대용량 SPARQL 쿼리가 가능한 qlever 설치와 사용법

JeongYun Lee·2025년 5월 22일
0

Developing

목록 보기
6/7

현재 개발 중인 시스템에서 대용량 ttl 파일을 적재하고 쿼리하기 위한 엔진으로 'qlever'를 테스트 중이다. 독일의 Freiburg 대학에서 개발한 오픈소스이고, 현재는 상업용 서비스를 개발중이라고 한다. 이전에 blazegraph도 잠깐 테스트해봤는데, qlever가 검색 속도 측면에서는 확실히 빠른 것 같다. 다만, 여전히 깃헙의 업데이트와 이슈들이 많은 아직 활성중인(?) 시스템인 것 같다는 느낌과 OS별로 구축과 각종 에러...혹은 이슈들이 많은 것 같긴 하다.

작년에 이미 연구실 선배가 테스트를 했고, 블로그에 자세히 기록을 남겨줘서 나름 수월하게 진행하긴 했지만, 그 사이에 또 다른 이슈들이 있는 건지, 내가 잘못한건지... 추가적인 이슈사항들이 있어서 기록을 남긴다.

테스트는 qlever-control을 위주로 진행했다. qlever-control의 qlever의 거의 모든 기능을 파이썬 기반으로 실행할 수 있도록 하는 일종의 라이브러리이고, qlever에서 공식적으로 개발했다. 테스트 환경은 MacOS이고, M2와 M1-max에서 성공했다.

🧩 설정

우선 qlever-control 레포를 clone해도 되고, 혹은 새로운 폴더를 하나 생성해서 아래 순서대로 진행해도 된다.

(1) Qleverfile

뒤에 아무 확장장도 없이 그냥 'Qleverfile'이라는 이름의 파일을 생성하고 아래 내용을 작성하면 된다.

[data]
NAME         = test
DESCRIPTION  = test data

[index]
INPUT_FILES     = *.ttl
CAT_INPUT_FILES = cat ${INPUT_FILES}
SETTINGS_JSON   = { "languages-internal": [], "prefixes-external": [""], "locale": { "language": "en", "country": "US", "ignore-punctuation": true }, "ascii-prefixes-only": true, "num-triples-per-batch": 500000, "parallel-parsing" : false}
STXXL_MEMORY    = 10G

[server]
PORT               = 7000
ACCESS_TOKEN       = ${data:NAME}
MEMORY_FOR_QUERIES = 10G
CACHE_MAX_SIZE     = 6G

[runtime]
SYSTEM = docker
IMAGE  = docker.io/adfreiburg/qlever:latest

[ui]

다른 부분은 거의 수정하지 않았고, 세부 내용은 이 블로그를 참고했다. 수정한 부분은 [ui] 부분인데, 기존에는 UI_CONFIG = test라는 내용을 아래 적어줬지만, qlever ui를 실행하면 An error occured while getting and parsing the config file (CommandError: Backend test does not exist) 이런 에러가 발생했다. 내용을 보면, ui가 'test'라는 이름의 backend를 찾지 못한다는 문제이다. 이름도 바꿔보고, UI_CONFIG = { "backend": { "host": "localhost", "port": 7000 } } 이런식으로도 작성해줬지만 전부 실패해서 결국 해당 부분을 삭제하고 인덱스 생성, 시작, ui시작 까지 진행했다.

진행하다가 메모리 에러로 추정되는 이슈가 있어서 num-triples-per-batch 부분을 100K로 수정했고, STXXL_MEMORY도 15G로 수정했다. 본인 환경에 적합하게 수정해줄 필요가 있는 것 같다.

(2) Qleverfile-ui.yml

위에서 [ui]부분을 삭제했기 때문에 해당 파일을 추가로 작성해줘야 한다.

config:
  backend:
    name: Default
    slug: default
    baseUrl: http://localhost:7000
    apiToken: ''
    isDefault: true
    maxDefault: '100'
    filteredLanguage: en
    dynamicSuggestions: '2'
    defaultModeTimeout: '5.0'
    mixedModeTimeout: '1.0'
    supportedKeywords: as,ask,base,bind,by,construct,contains-entity,contains-word,data,delete,describe,distinct,filter,from,graph,group,has-predicate,having,insert,internal,keywords,limit,minus,named,not,offset,optional,optional,order,prefix,select,service,sort,textlimit,union,using,values,where,with
    supportedFunctions: asc, desc, avg, values, score, text, count, sample, min, max, average, concat, group_concat, langMatches, lang, regex, sum, dist, contains, str, strlen, substr, strstarts, strends, strbefore, strafter, contains, year, month, day, rand, abs, ceil, floor, round, log, exp, sqrt, sin, cos, tan, if, coalesce, bound, concat, replace, encode_for_uri, isiri, isblank, isliteral, isNumeric, bound
    suggestedPrefixes: |-
      @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
      @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
      @prefix skos: <http://www.w3.org/2004/02/skos/core#> .
      @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
  examples: []

baseURL은 backend인 7000포트로 설정해줬고, supportedKeywords와 Functions는 UI에서 쿼리 작성시 인식할 수 있는 내용들로 Claude를 통해서 작성했다. 필요에 따라서 수정, 추가하면 된다.

필자는 Claude의 추천으로 자동완성을 위한 쿼리 템플릿 부분도 아래와 같이 추가해줬다.

config:
  backend:
    name: Default
    slug: default
    sortKey: 1
    baseUrl: http://localhost:7000
    apiToken: ''
    isDefault: true
    isNoSlugMode: 'False'
    maxDefault: '100'
    filteredLanguage: en
    dynamicSuggestions: '2'
    defaultModeTimeout: '5.0'
    mixedModeTimeout: '1.0'
    suggestSubjects: |-
      %PREFIXES%
      # IF CURRENT_WORD_EMPTY #

      %WARMUP_QUERY_1%

      # ELSE #

      SELECT ?qleverui_entity (SAMPLE(?name) AS ?qleverui_name) (SAMPLE(?alias) AS ?qleverui_alias) (SAMPLE(?count) AS ?qleverui_count) WHERE {
        { %WARMUP_QUERY_2% }
        # IF !CURRENT_WORD_EMPTY #
        FILTER (REGEX(?alias, "^\"%CURRENT_WORD%") || REGEX(?alias, "^<%CURRENT_WORD%"))
        # ENDIF #
      } GROUP BY ?qleverui_entity ORDER BY DESC(?qleverui_count)

      # ENDIF #
    suggestPredicates: |-
      %PREFIXES%
      # IF !CURRENT_SUBJECT_VARIABLE #

      SELECT ?qleverui_entity
                    (MIN(?name) as ?qleverui_name)
                    (MIN(?alias) as ?qleverui_alias)
                    (SAMPLE(?count_2) as ?qleverui_count)
                    ?qleverui_reversed WHERE {

        { { SELECT ?qleverui_entity (COUNT(?qleverui_tmp) AS ?count_2)
          WHERE { %CURRENT_SUBJECT% ?qleverui_entity ?qleverui_tmp  }
          GROUP BY ?qleverui_entity }
        BIND (0 AS ?qleverui_reversed) }
        UNION
        { { SELECT ?qleverui_entity (COUNT(?qleverui_tmp) AS ?count_2)
          WHERE { ?qleverui_tmp ?qleverui_entity %CURRENT_SUBJECT%  }
          GROUP BY ?qleverui_entity }
          BIND (1 AS ?qleverui_reversed) }
        { %WARMUP_QUERY_5% }
        # IF !CURRENT_WORD_EMPTY #
        FILTER REGEX(?alias, "%CURRENT_WORD%", "i")
        # ENDIF #
      } GROUP BY ?qleverui_entity ?qleverui_reversed ORDER BY DESC(?qleverui_count)

      # ENDIF #
      # IF CONNECTED_TRIPLES_EMPTY AND CURRENT_SUBJECT_VARIABLE #

      SELECT ?qleverui_entity
                    (MIN(?name) as ?qleverui_name)
                    (MIN(?alias) as ?qleverui_alias)
                    (SAMPLE(?count_1) as ?qleverui_count) WHERE {
        { %WARMUP_QUERY_4% }
        # IF !CURRENT_WORD_EMPTY #
        FILTER REGEX(?alias, "%CURRENT_WORD%", "i")
        # ENDIF #
      } GROUP BY ?qleverui_entity ORDER BY DESC(?qleverui_count)

      # ENDIF #

      # IF !CONNECTED_TRIPLES_EMPTY AND CURRENT_SUBJECT_VARIABLE #

      SELECT ?qleverui_entity
                    (MIN(?name) as ?qleverui_name)
                    (MIN(?alias) as ?qleverui_alias)
                    (SAMPLE(?count_2) as ?qleverui_count) WHERE {
        { SELECT ?qleverui_entity (COUNT(?qleverui_entity) AS ?count_2)
          WHERE { %CONNECTED_TRIPLES% %CURRENT_SUBJECT% ql:has-predicate ?qleverui_entity }
          GROUP BY ?qleverui_entity }
        { %WARMUP_QUERY_5% }
        # IF !CURRENT_WORD_EMPTY #
        FILTER REGEX(?alias, "%CURRENT_WORD%", "i")
        # ENDIF #
      } GROUP BY ?qleverui_entity ORDER BY DESC(?qleverui_count)

      # ENDIF #
    suggestObjects: |-
      %PREFIXES%
      SELECT ?qleverui_entity
                    (MIN(?name) AS ?qleverui_name)
                    (MIN(?alias) AS ?qleverui_alias)
                    (MAX(?count_1) AS ?qleverui_count) WHERE {
        {

          { SELECT ?qleverui_entity ?name ?alias ?count_1 WHERE {
            { SELECT ?qleverui_entity (COUNT(?qleverui_entity) AS ?count_1) WHERE {
              %CONNECTED_TRIPLES% %CURRENT_SUBJECT% %CURRENT_PREDICATE% ?qleverui_entity .
            } GROUP BY ?qleverui_entity }
            { %WARMUP_QUERY_3% }
            # IF !CURRENT_WORD_EMPTY #
            FILTER (REGEX(?alias, "^\"%CURRENT_WORD%") || REGEX(?alias, "^<%CURRENT_WORD%"))
            # ENDIF #
          } }

        } UNION {

          { SELECT ?qleverui_entity ?name ?alias ?count_1 WHERE {
            { SELECT ?qleverui_entity (COUNT(?qleverui_entity) AS ?count_1) WHERE {
              %CONNECTED_TRIPLES% %CURRENT_SUBJECT% %CURRENT_PREDICATE% ?qleverui_entity
            } GROUP BY ?qleverui_entity }
            %ENTITY_NAME_AND_ALIAS_PATTERN_DEFAULT%
            # IF !CURRENT_WORD_EMPTY #
            FILTER (REGEX(?alias, "^\"%CURRENT_WORD%") || REGEX(?alias, "^<%CURRENT_WORD%"))
            # ENDIF #
          } }

        }
      } GROUP BY ?qleverui_entity ORDER BY DESC(?qleverui_count)
    subjectName: ''
    alternativeSubjectName: ''
    predicateName: ''
    alternativePredicateName: ''
    objectName: ''
    alternativeObjectName: ''
    replacePredicates: ''
    supportedKeywords: as,ask,base,bind,by,construct,contains-entity,contains-word,data,delete,describe,distinct,filter,from,graph,group,has-predicate,having,insert,internal,keywords,limit,minus,named,not,offset,optional,optional,order,prefix,select,service,sort,textlimit,union,using,values,where,with
    supportedFunctions: asc, desc, avg, values, score, text, count, sample, min, max,
      average, concat, group_concat, langMatches, lang, regex, sum, dist, contains,
      str, strlen, substr, strstarts, strends, strbefore, strafter, contains, year,
      month, day, rand, abs, ceil, floor, round, log, exp, sqrt, sin, cos, tan, if,
      coalesce, bound, concat, replace, encode_for_uri, isiri, isblank, isliteral,
      isNumeric, bound
    supportedPredicateSuggestions: ''
    suggestPrefixnamesForPredicates: 'True'
    fillPrefixes: 'True'
    filterEntities: 'False'
    suggestedPrefixes: |-
      @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
      @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
      @prefix skos: <http://www.w3.org/2004/02/skos/core#> .
      @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
    suggestionEntityVariable: ?qleverui_entity
    suggestionNameVariable: ?qleverui_name
    suggestionAltNameVariable: ?qleverui_alias
    suggestionReversedVariable: ?qleverui_reversed
    frequentPredicates: ''
    frequentPatternsWithoutOrder: ''
    entityNameAndAliasPattern: BIND(?qleverui_entity AS ?name) BIND(?qleverui_entity
      AS ?alias)
    entityScorePattern: '{ SELECT ?qleverui_entity (COUNT(?qleverui_tmp) AS ?count)
      WHERE { ?qleverui_entity ql:has-predicate ?qleverui_tmp } GROUP BY ?qleverui_entity
      }'
    predicateNameAndAliasPatternWithoutContext: BIND(?qleverui_entity AS ?name) BIND(?qleverui_entity
      AS ?alias)
    predicateNameAndAliasPatternWithContext: BIND(?qleverui_entity AS ?name) BIND(?qleverui_entity
      AS ?alias)
    entityNameAndAliasPatternDefault: BIND(?qleverui_entity AS ?name) BIND(?qleverui_entity
      AS ?alias)
    predicateNameAndAliasPatternWithoutContextDefault: BIND(?qleverui_entity AS ?name)
      BIND(?qleverui_entity AS ?alias)
    predicateNameAndAliasPatternWithContextDefault: BIND(?qleverui_entity AS ?name)
      BIND(?qleverui_entity AS ?alias)
    warmupQuery1: |-
      SELECT ?qleverui_entity (SAMPLE(?name) AS ?qleverui_name) (SAMPLE(?alias) AS ?qleverui_alias) (SAMPLE(?count) AS ?qleverui_count) WHERE {
        { SELECT ?qleverui_entity ?name ?alias ?count WHERE {
          %ENTITY_SCORE_PATTERN%
          %ENTITY_NAME_AND_ALIAS_PATTERN% }
        ORDER BY ?qleverui_entity }
      } GROUP BY ?qleverui_entity ORDER BY DESC(?qleverui_count)
    warmupQuery2: |-
      SELECT ?qleverui_entity ?name ?alias ?count WHERE {
        %ENTITY_SCORE_PATTERN%
        %ENTITY_NAME_AND_ALIAS_PATTERN%
      } ORDER BY ?alias
    warmupQuery3: |-
      SELECT ?qleverui_entity ?name ?alias ?count WHERE {
        %ENTITY_SCORE_PATTERN%
        %ENTITY_NAME_AND_ALIAS_PATTERN%
      } ORDER BY ?qleverui_entity
    warmupQuery4: |-
      SELECT ?qleverui_entity ?name ?alias ?count_1 WHERE {
        { { SELECT ?qleverui_entity (COUNT(?qleverui_entity) AS ?count_1) WHERE { ?x ql:has-predicate ?qleverui_entity } GROUP BY ?qleverui_entity }
          %PREDICATE_NAME_AND_ALIAS_PATTERN_WITHOUT_CONTEXT% .
          FILTER (?qleverui_entity != <QLever-internal-function/langtag>)
        } UNION {
          { SELECT ?qleverui_entity (COUNT(?qleverui_entity) AS ?count_1) WHERE { ?x ql:has-predicate ?qleverui_entity } GROUP BY ?qleverui_entity }
          %PREDICATE_NAME_AND_ALIAS_PATTERN_WITHOUT_CONTEXT_DEFAULT% .
          FILTER (?qleverui_entity != <QLever-internal-function/langtag>)
        } }
    warmupQuery5: |-
      SELECT ?qleverui_entity ?name ?alias ?count_1 WHERE {
        { { SELECT ?qleverui_entity (COUNT(?qleverui_entity) AS ?count_1) WHERE { ?x ql:has-predicate ?qleverui_entity } GROUP BY ?qleverui_entity }
          %PREDICATE_NAME_AND_ALIAS_PATTERN_WITH_CONTEXT% .
          FILTER (?qleverui_entity != <QLever-internal-function/langtag>)
        } UNION {
          { SELECT ?qleverui_entity (COUNT(?qleverui_entity) AS ?count_1) WHERE { ?x ql:has-predicate ?qleverui_entity } GROUP BY ?qleverui_entity }
          %PREDICATE_NAME_AND_ALIAS_PATTERN_WITH_CONTEXT_DEFAULT% .
          FILTER (?qleverui_entity != <QLever-internal-function/langtag>)
        } }
    suggestSubjectsContextInsensitive: |-
      %PREFIXES%
      # IF CURRENT_WORD_EMPTY #

      %WARMUP_QUERY_1%

      # ELSE #

      SELECT ?qleverui_entity (SAMPLE(?name) AS ?qleverui_name) (SAMPLE(?alias) AS ?qleverui_alias) (SAMPLE(?count) AS ?qleverui_count) WHERE {
        { %WARMUP_QUERY_2% }
        # IF !CURRENT_WORD_EMPTY #
        FILTER (REGEX(?alias, "^\"%CURRENT_WORD%") || REGEX(?alias, "^<%CURRENT_WORD%"))
        # ENDIF #
      } GROUP BY ?qleverui_entity ORDER BY DESC(?qleverui_count)

      # ENDIF #
    suggestPredicatesContextInsensitive: |-
      %PREFIXES%

      SELECT ?qleverui_entity
                    (MIN(?name) as ?qleverui_name)
                    (MIN(?alias) as ?qleverui_alias)
                    (SAMPLE(?count_1) as ?qleverui_count) WHERE {
        { %WARMUP_QUERY_4% }
        # IF !CURRENT_WORD_EMPTY #
        FILTER REGEX(?alias, "%CURRENT_WORD%", "i")
        # ENDIF #
      } GROUP BY ?qleverui_entity ORDER BY DESC(?qleverui_count)
    suggestObjectsContextInsensitive: |-
      %PREFIXES%
      # IF CURRENT_WORD_EMPTY #

      %WARMUP_QUERY_1%

      # ELSE #

      SELECT ?qleverui_entity (SAMPLE(?name) AS ?qleverui_name) (SAMPLE(?alias) AS ?qleverui_alias) (SAMPLE(?count) AS ?qleverui_count) WHERE {
        { %WARMUP_QUERY_2% }
        # IF !CURRENT_WORD_EMPTY #
        FILTER (REGEX(?alias, "^\"%CURRENT_WORD%") || REGEX(?alias, "^<%CURRENT_WORD%"))
        # ENDIF #
      } GROUP BY ?qleverui_entity ORDER BY DESC(?qleverui_count)

      # ENDIF #
    mapViewBaseURL: ''
  examples: []

(3) 업로드 데이터

업로드할 데이터는 Qleverfile과 같은 경로에 뒀다. 업로드할 파일 형식은 기본적으로 .ttl을 사용했는데, ttl.gz나 tar.gz와 같은 압축 파일도 정상적으로 업로드 됐다. 다만, 이때는 Qleverfile에서 CAT_INPUT_FILES 부분을 zcat ${INPUT_FILES}으로 수정해야 정상적으로 실행됐다.

🧩 실행

(1) 가상환경 설정

파이썬 가상환경을 만들고 접속해서 pip install qlever를 입력한다.

# 1. generate python bubble
python -m venv env

# 2. activate
source env/bin/activate  # mac
env\Scripts\activate.bat  # window

(2) 인덱스 생성

위에 설정 부분을 마친 뒤, qlever index를 실행하면 파일들의 인덱스가 생성된다. M1-max (memory 32GB) 기준으로 1.6GB 정도의 데이터를 업로드하는데 15분 정도 소요됐다.

인덱스를 생성한 뒤 qlever status를 입력하면 인덱스 정보를 확인할 수 있다.

(3) 시작

이제 생성한 인덱스를 기반으로 qlever 서버를 docker 기반으로 실행해야 한다. qlever start를 입력하면 자동으로 docker 빌드가 되고, 7000포트가 열린다. 다만, localhost:7000으로 접속하면 계속 'Unknown path'라고 떠서 처음에는 뭔가 잘못됐나...했다. 그러나 터미널 상에서 다음과 같이 쿼리를 하면 정상적으로 실행되는 걸 확인했다.

curl -X POST http://localhost:7000/ \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "query=SELECT * WHERE { ?s ?p ?o } LIMIT 10"

(4) qlever ui

API를 만드는 과정에서는 백엔드 서버만 열어주면 되지만, qlever는 간단한 쿼리를 할 수 있는 UI를 제공하고 있다. qlever ui를 실행하면 docker에 8176포트가 열리고 다음과 같이 테스트 할 수 있다.

profile
궁금한 건 많지만, 천천히 알아가는 중입니다

0개의 댓글