Fuseki 없이 지식 그래프 운영하기: PostgreSQL 테이블 3개로 옮기며 드러난 숨은 결합들
별도 JVM 트리플스토어인 Fuseki는 설정 하나가 어긋나면 그래프 계층 전체가 멈추고 폐쇄망 반입 부담도 컸습니다. 같은 빌드를 두 저장소에 이중 기록해 충실도를 대조한 뒤 Fuseki 쓰기를 기본 비활성화했고, 완료 판정·재시도·카운트·품질 지표에 숨어 있던 Fuseki 전제를 하나씩 걷어내며 검색을 라이브러리로 일원화했습니다.
Key Results
- 0.6초
- 그래프 조회(PostgreSQL)공공기관 22문서 컬렉션 시각화 조회(노드 500·엣지 643). 같은 컬렉션의 Fuseki COUNT 질의는 콜드 30초, 빌드 경합 시 87초 뒤 오류
- 4.2초 → 0.005초
- Fuseki 부재 시 호출 실패 비용트랜스포트 레벨 서킷 브레이커(TTL 60초 half-open) 적용, 두 번째 호출부터
- 10/10
- 골드 팩트 질의 정답1,636청크 컬렉션 10문항, Fuseki 정지 상태, PG 경로+검색 라이브러리 평균 6.6초. 기존 경로는 9/10·14.7초
XGEN의 온톨로지 그래프는 Apache Jena Fuseki에 RDF 트리플로 저장했습니다. 표준 SPARQL을 쓸 수 있어 설계상으로는 자연스러운 선택이었지만, 운영에서 보면 Fuseki는 별도 JVM 프로세스 하나가 더 있다는 뜻이었습니다. 한 개발 환경에서는 Fuseki의 데이터셋 이름과 애플리케이션 설정이 달라 모든 SPARQL 요청이 405로 거절됐고, 트리플 총계 0인 채로 그래프 계층이 통째로 멈춰 있었습니다. 같은 컬렉션의 트리플 수를 세는 질의는 콜드 상태에서 30초, 빌드와 경합하면 87초 뒤 오류가 났습니다.
2026년 8월에 그래프의 정본을 PostgreSQL 테이블 세 개로 옮겼습니다. 저장소를 고른 이유는 시리즈 글에서 다뤘으므로, 이 글은 옮기는 과정 자체를 다룹니다. 같은 빌드를 두 저장소에 동시에 써서 무엇을 비교했는지, 비교에서 "노이즈"로 넘긴 것이 왜 검색을 실패하게 했는지, Fuseki를 끄자 어떤 경로들이 멈췄는지, 그리고 검색을 라이브러리로 일원화하면서 응답 시간이 어떻게 바뀌었는지입니다.
그래프 계층이 별도 서버 하나에 묶여 있었습니다
[이전] 빌드 ──▶ Fuseki (SPARQL UPDATE) 조회·검색 ──▶ Fuseki (SPARQL, jena-text)
완료 판정 · 트리플 수 · 품질 점수도 Fuseki 에 질의
[전환기] 빌드 ──┬▶ Fuseki 조회 ──▶ PostgreSQL 우선, 없으면 Fuseki
└▶ PostgreSQL (이중 기록) 검색 ──▶ xgen-omnifuse + PG 어댑터, 실패 시 기존 경로
[이후] 빌드 ──▶ PostgreSQL 조회·검색 ──▶ PostgreSQL (+ Qdrant 벡터)
Fuseki 미러는 기본 비활성, 옛 컬렉션 읽기 폴백만목표는 Fuseki 없이도 기존 수준의 빌드 품질이 나오고, 검색도 같은 기준으로 바꾸는 것이었습니다. 제약은 두 가지였습니다. 기존 컬렉션은 Fuseki에만 그래프가 있으므로 한 번에 끊을 수 없었고, Fuseki만 쓰는 배포의 동작은 전환 중에도 바뀌면 안 됐습니다.
데이터 모델은 세 테이블로 정했습니다.
ontology_nodes (collection_id, uri, kind, label) UNIQUE(collection_id, uri)
ontology_edges (collection_id, subject_uri, predicate, object_uri,
predicate_uri, edge_kind, edge_weight)
ontology_node_chunks (collection_id, uri, chunk_id) 노드 → 원문 청크컬렉션마다 테이블을 만들지 않고 collection_id로 범위를 나눴습니다. 간선에는 주어·목적어·술어 방향 조회 인덱스와 중복을 막는 유니크 인덱스, 라벨에는 pg_trgm GIN 인덱스를 뒀습니다. jena-text(Lucene)가 하던 라벨 링킹은 라벨 ILIKE 부분일치로 바꿨습니다. 그래프 노드 라벨은 임베딩 대상이 아니어서 어휘 매칭이 오히려 정확하고, 의미 검색은 기존 Qdrant가 맡기 때문입니다. Fuseki 시각화 API와 같은 {nodes, edges} 응답 계약은 순수 SQL로 다시 구현했습니다.
같은 빌드를 두 저장소에 쓰고 비교했습니다
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 한 번에 교체 | 코드가 가장 단순 | 기존 컬렉션이 즉시 빈 그래프, 비교 기준이 없음 | 기각 |
| 이중 기록 + PG 우선 읽기 + Fuseki 폴백 | 같은 추출 결과를 두 저장소에서 대조, 기존 배포 동작 불변 | 쓰기 경로가 두 배, 두 저장소를 함께 운영 | 전환기에 채택 |
| 미러 기본 비활성 + 읽기 폴백 | Fuseki 없이 완주, 되돌리기는 설정 하나 | 모든 환경 재빌드 필요 | 대조 후 채택 |
이중 기록을 먼저 택한 이유는 비교 조건을 깨끗하게 만들기 위해서입니다. 같은 빌드가 같은 추출 결과를 두 저장소에 동시에 쓰므로, 차이가 나면 그것은 추출이 아니라 저장 충실도의 차이입니다.
작은 비정형·정형 컬렉션에서는 클래스 라벨 집합(13 = 13, 10 = 10), 인스턴스 라벨 집합(12 = 12, 50 = 50), instanceOf 간선(12/12, 72/72)이 같았습니다. subClassOf와 속성 노드는 Fuseki가 더 많았는데(subClassOf 10 대 5, 속성 18 대 9), PostgreSQL 쪽은 입력과 1:1이었습니다. Fuseki에는 OWL 생성기가 만든 이중언어 URI 트윈과 range 선언이 더 있을 뿐이라 지식은 같다고 판단했습니다. 인스턴스 식별은 PostgreSQL이 더 충실했습니다. Fuseki의 이름 기반 URI는 이름이 같은 서로 다른 개체를 합쳐 50개가 됐고, PostgreSQL은 72개를 그대로 뒀습니다.
공공기관 문서 22개(청크 1,636개) 컬렉션에서도 같은 경향이었습니다. 인스턴스 5,245 대 9,247, 관계 트리플 42,626 대 52,823(123.9%), 청크 근거 39,488 대 39,395(99.8% 일치)였습니다. 그래프 시각화 조회(노드 500, 엣지 643)는 PostgreSQL에서 0.6초였습니다.
합성 데이터로는 안 잡히던 결함도 실제 빌드에서 나왔습니다. 처음 재빌드한 컬렉션에서 끊긴 간선이 주어 쪽 4개, 목적어 쪽 12개였습니다. 합성 케이스는 인스턴스 끝점만 검사했는데, 실데이터에서는 LLM이 클래스 목록에 선언하지 않은 클래스를 domain이나 range로만 언급하거나 xsd:string을 객체 속성의 range로 지정해 존재하지 않는 클래스를 가리켰습니다. 끝점을 placeholder 노드로 실체화하자 0/0이 됐고, 22문서 재빌드에서도 0/0이었습니다.
노이즈로 넘긴 트윈이 사실은 다리였습니다
검색을 PostgreSQL 위로 옮기던 중, 정형 컬렉션에서 한국어로 질문하면 트리플 0개, 청크 0개로 "답변을 생성할 수 없습니다"가 나온다는 제보가 들어왔습니다. 새 경로가 기존보다 못하다는 평가였습니다. 로그와 테이블, 라이브러리 소스를 대조해 보니 원인이 네 개 겹쳐 있었습니다.
첫째, 검색 진입부가 벡터 결과가 0건이면 바로 기존 경로로 넘겼습니다. DB 행으로 만든 정형 그래프는 설계상 Qdrant 청크가 0개라, 그래프가 가장 강한 컬렉션에서 새 경로가 한 번도 실행되지 않았습니다. 둘째, 이 컬렉션의 클래스 라벨은 모두 영어였고 한국어 질문과 어휘가 겹치지 않았습니다. 기존 Fuseki 경로에서는 OWL 생성기의 이중언어 라벨 트윈이 이 간극을 메웠는데, PostgreSQL 빌드는 그 단계를 뺐습니다. 대조할 때 "스키마 트리플이 더 많을 뿐"이라고 판단했던 바로 그 트윈입니다. 셋째, 저장소 어댑터가 DB의 kind 값 concept를 그대로 넘기는데 검색 라이브러리는 kind == "class"일 때만 클래스 전수 열거를 했습니다. 영어로 질문했어도 클래스 열거는 실행되지 않는 잠복 결함이었습니다. 넷째, 기존 경로로 넘어간 뒤에도 질문 분석이 그래프 시드를 건너뛰어 최종 결과가 0/0이었습니다.
당일 수정(kind 매핑, 게이트 조건 변경, 클래스 카탈로그로 질문과 라벨을 잇는 언어 브리지)으로 트리플이 0에서 29개가 되고 실제 속성 근거로 답했지만, 이 수정은 같은 날 지시에 따라 원복했습니다. 최종 머지본에는 kind 매핑만 들어갔고, 이중언어 문제의 항구 해법(빌드 때 번역 라벨을 alias로 적재해 질의 시 LLM 호출 없이 잇기)은 후속 과제로 남겼습니다.
이 일에서 얻은 교훈은 대조 방법에 관한 것입니다. 저장 충실도를 비교할 때 한쪽에만 있는 트리플을 "지식은 같다"며 넘겼는데, 그 트리플을 누가 소비하는지는 확인하지 않았습니다. 대조의 기준은 저장된 사실의 집합만이 아니라 소비하는 쪽이 실제로 쓰는 것이어야 했습니다.
Fuseki를 끄자 멈춘 경로들
이중 기록 대조가 끝난 뒤 ONTOLOGY_FUSEKI_MIRROR 스위치를 넣고 기본값을 비활성으로 정했습니다. 비활성이면 재빌드 때의 CLEAR, 업로드 세 곳, Fuseki 전용 병합·개명 함수, 빌드 마무리 후처리, 적재 완료 마커, 스테이징 정리, 품질 탐지까지 쓰기 경로를 모두 건너뛰고 옛 컬렉션 읽기 폴백만 남깁니다. 스위치 하나로 되돌릴 수 있게 한 것이 롤백 경로입니다. 기본값을 바꾸자 Fuseki가 있다는 전제에 묶여 있던 경로가 차례로 드러났습니다.
- DB 적재 세션이 끝나지 않았습니다. 페이지는 PostgreSQL에 다 들어가는데 완료 판정이 Fuseki 커밋 마커에 묶여 있었습니다. 잡 행의 조건부 갱신(CAS)이 내구성 있는 마커를 대신하게 했고, 중단·실패 때의 스테이징 정리는 Fuseki가 없으면 성공으로 처리합니다.
- 빌드가 수십 분씩 늘어졌습니다. 개념 개명 루프가 개념마다 SPARQL UPDATE를 보내며 약 3.7초짜리 DNS 실패를 수백 번(실측 120회 이상 연속) 기다렸습니다. 연결 실패만 차단하고 60초 뒤 half-open으로 다시 시도하는 트랜스포트 수준 서킷 브레이커를 넣자, 첫 실패 4.2초 뒤로는 0.005초에 끝났습니다.
- Fuseki를 이틀째 정지한 실전 검증에서도 남은 곳이 있었습니다. 트리플 수를 세는 다섯 곳이 여전히 Fuseki를 먼저 물어, Fuseki가 살아 있으면 낡은 레거시 카운트가 PostgreSQL 실측을 덮고 죽어 있으면 호출마다 약 4초씩 기다렸습니다. 중단된 적재 세션 복구도 Fuseki 마커를 전제로 해 영영 복구되지 않았습니다. 미러가 비활성일 때는 PostgreSQL로 바로 가게 하고, 잡 행의 CAS 상태 전이 자체를 커밋 마커로 인정했습니다.
- PostgreSQL 쓰기 실패가 조용히 삼켜지고 있었습니다. 전용 예외로 올리고, 미러가 비활성이면 빌드를 실패로 처리합니다.
- 빌드 품질 배지가 빈 값으로 표시됐습니다. UI가 기대하는 리포트 계약(점수, 완결성 등)을 만드는 함수가 Fuseki 전제였기 때문입니다. SPARQL 지표를 SQL로 대응시킨 판을 같은 계약, 같은 점수 공식으로 만들었습니다(NamedIndividual은 instance로, 근거 공기는 청크 링크 공유로).
- DB 증분 재실행 뒤 갱신된 행의 옛 값과 새 값이 그래프에 함께 남았습니다. 행 적재가 append-only라 교체가 안 됐기 때문입니다. 정형 경로에만 (주어, 술어) 기존 간선을 지우고 넣는 교체 쓰기를 적용했고, 문서 빌드는 append를 유지했습니다.
관계 URI의 언어도 같은 부류였습니다. 한국어 관계명을 영어 URI로 번역하는 단계가 레거시 Fuseki 경로에만 연결돼 있어 PostgreSQL에는 한글과 영문이 섞여 들어갔습니다. 번역을 PostgreSQL 쓰기에 연결하고 첫 그룹 밖의 술어는 빌드 뒤 일괄 갱신하게 하자, 22문서 컬렉션 재빌드에서 술어 URI의 97%(5,305/5,458)가 영어가 됐습니다. 표시 라벨은 질문어와 매칭돼야 하므로 한국어를 유지했습니다.
검색은 라이브러리 하나로, 응답은 47초에서 9초대로
검색 알고리즘(라벨 링킹, 1홉 확장, 클래스 전수, HippoRAG 확장, MMR, adaptive top-k, 단일 합성)의 정본은 xgen-omnifuse 라이브러리로 옮기고, 플랫폼에는 저장소를 라이브러리 프로토콜 모양으로 보여 주는 어댑터만 남겼습니다. 같은 로직을 플랫폼과 라이브러리 두 곳에서 따로 유지하던 상태를 끝내기 위해서입니다. 처음 비교(22문서, 질문 6개)에서는 3승 1패 2무였고 튜닝으로 회귀를 없앴습니다. 청크 점수로 원시 코사인 대신 플랫폼의 순위를 넘겨야 MMR이 정답 청크를 근거 밖으로 밀어내지 않았고, 관계 상한을 100으로 넓히자 질문 개체가 그래프에 없을 때 무관한 관계가 프롬프트를 채워 환각이 났기 때문에 라이브러리 기본값 40을 유지했습니다.
DB 적재 경로를 종단 검증하다 검색 쪽 결함도 하나 나왔습니다. 가짜 RDB 두 페이지(5행)를 적재하고 값을 비교하는 질문을 하자, 합성 단계가 원본에 없는 수치를 두 값의 합으로 추정해 답했습니다. 클래스 시드의 이웃이 스키마 간선과 개체 이름뿐이고 실제 값 사실이 없었기 때문입니다. 문서 컬렉션에서는 청크 본문이 값을 보완해 가려져 있던 결함이었습니다. 클래스 노드의 이웃을 조회할 때 남은 상한 안에서 개체들의 실제 값 사실을 함께 싣게 하자 최댓값·최솟값 질문이 모두 맞았고, 22문서 컬렉션 회귀 3문항도 정답을 유지했습니다.
그다음 기존 Fuseki 검색 기계를 지웠습니다. SPARQL 시드 5종, Fuseki 1홉 확장, 토글, ReAct 잔재를 걷어내 검색 모듈이 1,165줄에서 712줄이 됐고, 구조는 "omnifuse, 실패하면 벡터" 한 갈래가 됐습니다.
머지 직전에는 검색이 47초 걸리는 문제가 남아 있었습니다. DB 자체는 11ms였고 병목은 왕복 수였습니다. HippoRAG 단계가 청크와 개체 조합마다 이웃 조회를 따로 불러 요청당 수백 번 왕복했습니다. 요청 수명 동안의 이웃 캐시와 일괄 2쿼리 프리페치로 바꾸자 그래프 융합 구간이 24초에서 약 1초가 됐습니다. 라벨 검색의 어절당 왕복은 OR 단일 쿼리와 LIKE 이스케이프로 합쳤습니다. 벡터 ID와 노드 ID가 달라 원래부터 항상 빈 조회였던 그래프 융합 옵션은 껐습니다. 결과는 9~11초였고, 남은 바닥은 LLM 합성 약 5초와 Qdrant 2~3초입니다.
성능 수정이 결과를 바꾸지 않았는지도 확인했습니다. 프리페치와 단건 조회를 노드별로 대조하니 60개 중 59개가 같았습니다. 다른 하나는 후보 338개가 상한 100을 넘는 클래스였는데, 기존 단건 경로도 ORDER BY 없이 임의 부분집합을 반환하던 곳이라 계약은 같다고 보고 정렬 결정화를 후속 과제로 남겼습니다. 라벨 검색은 5문항에서 결과 URI 집합이 완전히 같았고, 골든 E2E 4문항도 통과했습니다.
결과와 남은 과제
| 지표 | 이전(Fuseki 경로) | 이후(PostgreSQL) | 측정 조건 |
|---|---|---|---|
| 그래프 조회 | 콜드 30초, 경합 시 87초 뒤 오류 | 0.6초 | 22문서 컬렉션, PG는 시각화 조회(노드 500·엣지 643), Fuseki는 COUNT 질의 |
| 골드 팩트 10문항 | 9/10, 평균 14.7초 | 10/10, 평균 6.6초 | 1,636청크, Fuseki 정지, 같은 합성 모델 |
| Fuseki 부재 시 호출 실패 비용 | 매 호출 약 4.2초 | 0.005초 | 서킷 브레이커, 두 번째 호출부터 |
| 검색 응답 | 47초 | 9~11초 | 로컬 스택 검색 스모크, 머지 직전 |
| 검색 모듈 | 1,165줄 | 712줄 | SPARQL 시드·Fuseki 확장·ReAct 잔재 제거 |
| 미러 비활성 재빌드 | - | 완료, Fuseki 쓰기 0건 | 1,636청크, Fuseki 정지 이틀째, 끊긴 간선·라벨 없음·자기 타입 모두 0 |
그래프 조회의 두 수치는 같은 연산이 아니어서 배수로 읽지 않습니다. 의미가 있는 것은 뷰어 조회가 그래프 서버의 상태에 묶이지 않게 됐다는 점입니다. 골드 팩트 비교에서 기존 경로의 오답 하나는 원문 수치 70.4를 87.0으로 답한 경우였고, 기존 경로는 그래프 근거 없이 원샷 폴백으로 답하고 있었습니다.
머지 전에는 기존 코드를 백업 브랜치, 온톨로지 코드 아카이브, 전체 클론 세 가지로 남겼고, 머지 후 모든 환경에서 재빌드해야 한다는 전제를 공유했습니다. 레거시 컬렉션은 미빌드로 표시됩니다.
남은 과제도 있습니다. 한국어 질문과 영어 클래스 라벨 사이의 간극은 번역 라벨을 alias로 적재하는 방식으로 풀어야 합니다. 클래스 난립과 오버타이핑은 품질 리포트의 도메인 위반 936건으로 정량화만 된 상태입니다. 전량 재동기화에서 상류에서 지워진 행이 PostgreSQL에서 지워지지 않던 제한은 이후 DB 행 지문 동기화에서 다시 다뤘습니다. 전환 당시 세 테이블은 문서 서비스가 DDL을 스스로 만들어 코어 모델 레지스트리 밖에 있었는데, 이후 제품 라인에서는 코어 마이그레이션이 인덱스를 만들고 문서 서비스는 표가 없으면 런타임 DDL 없이 예외를 내도록 바뀌었습니다.
저장소를 바꾸는 작업의 대부분은 저장소 코드가 아니었습니다. 완료 판정, 재시도, 개수 집계, 품질 지표처럼 "그 서버가 있다"는 가정이 스며든 주변부를 찾아내는 일이었고, 그 가정은 서버를 실제로 끈 상태에서만 드러났습니다.