온톨로지 엔진의 이식성: 커밋 258개 격차를 파일 단위로 맞추고 출력 동일성으로 라이브러리를 검증하기
같은 온톨로지 기능을 고객 배포 라인과 다른 서비스에 반복해서 싣는 비용을 줄이려고, 고객 라인을 파일 단위와 3-way 병합으로 제품 라인에 맞추고 빌드 로직을 환경 의존 없는 라이브러리로 옮겼습니다. 동일성은 실코퍼스 14,924청크의 출력 대조로 증명했고, 남은 연동 코드 약 12,000줄은 호스트가 네 가지 포트만 구현하면 되는 어댑터 계층으로 분리하자고 제안했습니다.
Key Results
- 14,924청크 diff 0
- 라이브러리 이식 출력 동일성원본 서비스 함수와 라이브러리를 같은 프로세스에서 같은 실코퍼스로 대조, 개념 27,109·개체 166,525·관계 2,544·값 2,625 일치
- 0 → 852
- 고객 라인 그래프 노드 수SDK가 WITH로 시작하는 조회 결과를 버리던 결함을 SDK 1.34로 올려 해소한 전후, 로컬 검수 컬렉션 실측
XGEN의 온톨로지 빌드와 GraphRAG 검색은 문서 서비스 안에서 자랐습니다. 같은 기능을 은행 고객의 배포 라인에 싣고, 이후 다른 서비스에도 도입하려고 하자 기능을 만드는 비용과 별개로 옮기는 비용이 반복해서 들기 시작했습니다. 고객 배포 라인은 제품 기본 라인보다 온톨로지 커밋이 258개 뒤처져 있었고, 그 사이에 끼워 넣은 고객 전용 변형 하나는 9월 중순 고객 배포 라인 개발 환경의 PostgreSQL 표를 통째로 지우는 사고의 경로가 됐습니다.
이 글은 2026년 9월부터 10월까지 같은 온톨로지 엔진을 성격이 다른 환경에 싣기 위해 한 일을 다룹니다. 고객 배포 라인을 제품 기본 라인과 맞춘 방법, 빌드 로직을 라이브러리로 추출하면서 "원본과 같다"를 증명한 방법, 그리고 라이브러리 밖에 남은 연동 코드를 어떤 경계로 나누자고 제안했는지입니다. 세 작업은 결국 "어디까지가 엔진이고 어디부터가 환경인가"라는 같은 질문에 대한 답이었습니다.
같은 기능이 세 곳에서 다른 속도로 자라고 있었습니다
제품 기본 라인 (문서 서비스)
service/ontology + controller/ontology 약 19,000줄, HTTP 33경로
├─ 알고리즘: 결정론 추출, 후처리, 증분·감분, 적재 규칙 플랫폼 결합 0
└─ 연동: 잡 상태, 기준선, 저장, HTTP, 설정 SDK·설정 서비스 결합 집중
고객 배포 라인
제품 라인의 과거 판 + 고객 전용 축
├─ 에이전트 온톨로지(관리 자산 그래프) 3,902줄
├─ CSV → PostgreSQL 표 적재, text-to-SQL 채널 526줄 + 114줄
└─ 자동 빌드 게이트
라이브러리
xgen-ontology-build (빌드) xgen-omnifuse (검색)계측해 보면 플랫폼 결합(SDK, 앱 컨테이너, 설정 서비스)은 컨트롤러 두 파일(93건)과 LLM 프로바이더(6건), 파이프라인(5건), 자동 백필, 컬렉션 설정에 몰려 있었고 알고리즘 파일에는 결합이 없었습니다. 그런데 이 구조를 다른 환경으로 옮기는 단위는 파일이 아니라 저장소 전체였습니다. 고객 라인에 기능을 싣는 일은 매번 문서 서비스를 통째로 맞추는 일이었고, 다른 서비스가 온톨로지를 쓰려면 잡 관리, 증분 빌드, 저장, HTTP 제공 코드를 새로 작성해야 했습니다.
고객 환경의 제약도 있었습니다. 폐쇄망이라 구성 요소를 새로 들이기 어렵고, 고객 라인은 제품 라인(SDK 2.x)보다 오래된 플랫폼 SDK(1.33·1.34)를 썼습니다. 이 SDK는 부팅할 때 모델 정의에 없는 컬럼을 지우는 스키마 동기화를 하므로, 모델 하나를 잘못 옮기면 데이터가 사라질 수 있었습니다.
격차를 재 보니 검색은 같고 빌드가 벌어져 있었습니다
먼저 어디가 얼마나 다른지 쟀습니다. 로컬 워크트리는 ref 신선도가 제각각이라 쓰지 않고 원격 브랜치끼리 git diff --numstat으로 비교했습니다. 결과는 예상과 달랐습니다. 검색 어댑터 파일은 두 라인이 바이트 단위로 같았고, 엔드포인트도 모델 선택용 경로 하나만 달랐습니다. 채널 융합이나 로컬 PPR 같은 검색 개선은 이미 체리픽으로 넘어가 있었습니다.
벌어진 쪽은 빌드였습니다. 그래프 저장 계층이 +503줄(이름 키 색인, 이름 변형 접기, 흔한 보통명사 제거), 결정론 추출기가 +454줄(표를 행 사실로, 표 밖 글을 명사구로), 파이프라인이 +264줄(문서 삭제 감분, 잡별 토큰 집계) 앞서 있었습니다. 제목 기준으로 제품 라인에만 있는 온톨로지 커밋이 258개였습니다.
고객 전용 변형 중 하나는 따로 봐야 했습니다. 빌드할 때마다 CSV를 운영 DB의 컬렉션별 표로 적재하던 경로입니다. 제품 라인은 6월에 이 경로를 지웠습니다. 당시 검색이 이 표를 조회하는 도구를 더는 호출하지 않아 순수한 부작용이었고, 컬렉션을 지울 때 표를 정리하는 경로도 없었기 때문입니다. 고객 라인에서는 이 경로가 살아 있었고, 9월 중순 여기서 심각한 결함이 드러났습니다. 표를 정리하는 함수가 컬렉션 이름에서 영숫자가 아닌 글자를 지워 접두사를 만들었는데, 한글 이름에서는 빈 문자열이 나왔습니다. 조건은 LIKE '__%'가 되고, _는 한 글자 와일드카드라 public 스키마의 모든 표가 정리 대상에 들어가는 구조였습니다. 이 경로를 실행 경로에 올린 것은 같은 배포에 들어간 자동 백필이었습니다. 온톨로지가 없는 컬렉션을 차례로 빌드하는 작업이 한글 이름의 CSV 컬렉션에 닿으면서 결함이 실행됐습니다.
이 일을 겪은 뒤 처음 세운 이식 계획은 보수적이었습니다. 빌드만 선별해 옮기고 자동화는 켜지 않는다는 것이었습니다. 이 계획은 곧 바뀌었습니다. 결함의 원인이 제품 라인을 따르지 않고 고객 변형을 끼워 넣은 데 있다면, 변형을 지키며 일부만 옮기는 것은 같은 종류의 위험을 남기는 일이었습니다. 결정은 컬렉션 온톨로지를 제품 라인 판으로 통째로 바꾸고, 에이전트 온톨로지만 고객 전용으로 남기는 것이었습니다.
체리픽 대신 파일 단위로, 두 번째는 3-way로 맞췄습니다
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 커밋 258개 체리픽 | 이력이 그대로 남음 | 고객 전용 파일과 계속 충돌, 중간 상태가 빌드되지 않는 구간이 김 | 기각 |
| 파일 단위 동기화 | 결과가 제품 라인 판과 같아짐, 서사형 주석과 죽은 코드도 함께 정리 | 가져온 파일 안에 고객 훅을 다시 심어야 함 | 1차 이식에 채택 |
| 기준점 3-way 병합 | 양쪽이 이후에 고친 내용을 모두 반영 | 기준점 관리가 필요, 정의만 떨어지는 사고 가능 | 2차 동기화에 채택 |
1차 이식에서는 고객 전용 파일 목록을 먼저 고정하고, 나머지 온톨로지 모듈 23개와 엔드포인트 3개는 제품 라인 판을 그대로 가져왔습니다. CSV→PostgreSQL 경로 파일 두 개는 지웠고, 에이전트 온톨로지 파일 5개는 건드리지 않았습니다. 에이전트 온톨로지가 부르는 공용 함수의 시그니처가 제품 판에도 그대로 있는지는 함수별로 확인했습니다.
자동 백필은 데이터를 보고 판단했습니다. 고객 배포 라인 검수 환경의 실데이터로 백필 대상 선별을 드라이런하니 미빌드 컬렉션 13개, 약 5만 청크가 기본 빌드 대상으로 잡혔습니다. LLM은 쓰지 않지만 CPU와 DB 부하가 생기는 양이라 고객 라인에서는 백필을 빼고 필요할 때 수동으로 재빌드하기로 했습니다. 제품 라인은 계속 켜 둡니다. 배포 전에는 사라지는 컬럼 0, API 시그니처와 응답 키 손실 0, DROP·TRUNCATE 0건을 확인하고 DB 덤프를 떴습니다.
10월에는 2차 동기화가 필요했습니다. 그사이 제품 라인에 관계 출처 표, 관계 라벨 컬럼, 관계 형성 재설계, 재빌드 보관, DB 행 지문 동기화가 들어갔고, 고객 라인도 같은 파일 일부를 따로 고쳤습니다. 이번에는 9월 이식 시점을 기준점으로 git merge-file 3-way 병합을 했습니다. 문서 서비스 29개 파일 중 그대로 받을 것 11개, 고객 판을 유지할 것 6개, 양쪽이 바꾼 것 9개로 나뉘었고, 고객 전용 파일 5개는 그대로 뒀습니다. 충돌 12곳은 모두 고객 쪽 변경을 제품 쪽이 이미 대체한 경우였습니다.
3-way 병합에도 함정이 있었습니다. 프론트엔드에서 고객 전용 타입과 상수의 정의가 떨어지고 사용처만 남아, 온톨로지 탭이 llmKey is not defined로 실패했습니다. 빌드 산출물을 띄워 Playwright로 실제 페이지를 연 검증에서 잡혔습니다. SDK 차이도 있었습니다. SDK 1.33은 유일 인덱스 표식(3-튜플)을 몰라 표 생성이 실패해서, 모델에는 2-튜플만 선언하고 유일 인덱스는 기동 때 런타임 제약으로 만들게 했습니다.
검수에서 찾은 조용한 공백: WITH로 시작하는 조회
1차 이식을 검수하다 가장 큰 결함을 찾았습니다. 고객 라인의 그래프 뷰와 그래프 기반 검색 확장이 이식 전부터 조용히 비어 있었습니다. 앞선 검증 기록에는 "그래프 검색 정상"이라고 적혀 있었는데, 다시 보니 답은 나왔지만 그래프 확장은 빈 채였습니다. 벡터 검색만으로도 답이 나왔기 때문에 정상으로 보였던 것입니다.
증상은 세 곳에서 나타났습니다. 시각화 조회가 None을 반환해 Fuseki 폴백으로 빠지고 빈 그래프를 그렸고, 개체 이름 목록이 비었고, 검색의 개체→청크 확장이 비었습니다. 세 조회의 공통점을 찾아보니 모두 WITH로 시작하는 CTE였습니다. SDK 1.33의 쿼리 실행 함수는 SQL이 SELECT로 시작할 때만 결과를 fetch하고, 그 밖의 문장은 예외 없이 빈 목록을 반환했습니다. 제품 라인은 SDK 2.x라 이 문제가 없었습니다.
해결책은 SDK를 올리는 것이었지만 폐쇄망 고객 라인에서 SDK 버전업은 가볍지 않습니다. 다음 버전 1.34의 휠을 받아 diff를 떠 보니 바뀐 것은 이 수정(커서 description으로 결과 유무 판정, fetch 뒤 commit)과 캐시 버전 API뿐이었고 의존성도 같았습니다. 같은 서비스의 다른 쿼리 실행 호출은 AST로 훑어 모두 SELECT로 시작하거나 결과 행이 없는 DML임을 확인한 뒤 1.34로 올렸습니다. 검수 컬렉션의 그래프 노드는 0에서 852개(간선 1,156개), 개체→청크 링크는 0에서 14개, 개체 이름은 0에서 300개가 됐습니다.
검수에서는 연결부 결함도 나왔습니다. 문서 삭제 감분 호출이 빠져 있었고, 그래프 후보 청크 함수의 import 경로가 옛 고객 라인부터 틀려 있었고, 컬렉션 복제가 이름 키를 복사하지 않았습니다. 복제가 빌드 기준선을 옮기지 않아 복제본이 백필에서 전 청크를 다시 빌드하는 문제(노드 859 → 2,426)도 있어, 원본의 최근 완료 잡을 새 잡 ID로 복사하게 했습니다. 배포 후 UI 전수 검수에서는 모델 호출이 실패한 청크까지 처리됨으로 기록해 다시는 시도하지 않던 추출기, 그리고 Fuseki가 없는 환경에서 아무것도 고치지 않고 빈 Fuseki 기준 점수로 품질 표시를 덮던 품질 자동 정리를 찾아 두 라인에 함께 고쳤습니다.
라이브러리 추출: 단위 테스트가 아니라 같은 입력의 출력으로 증명했습니다
고객 라인을 맞추는 일은 같은 코드를 한 번 더 옮기는 일이었습니다. 반복을 줄이려면 빌드 로직 자체가 환경과 분리돼 있어야 했습니다. 9월 중순 라이브러리는 0.5에 머물러 이름 정제와 Hearst 패턴, 머리명사 계층만 갖고 있었고, 제품 라인의 기본 빌드가 9월 초 LLM 0회 결정론 추출로 바뀐 내용은 없었습니다.
경계는 세 가지로 정했습니다. 라이브러리는 로직만 담고 컬렉션, PostgreSQL, Qdrant 의존은 뺍니다. 잡과 세션 부기는 라이브러리 밖에 두고 단계별 progress 콜백만 제공합니다. PostgreSQL 저장은 DB-API 커넥션을 주입받는 선택 extra로 분리합니다. 업로드 머리말처럼 제품 문구에 묶인 규칙은 정규식을 주입받는 인자로 뺐습니다. 이 경계로 결정론 추출기, 후처리, 술어 거버넌스, 증분 확장, 적재 정규화, 번역, 용어 사전, PostgreSQL 그래프 백엔드를 0.6~0.8 세 릴리스에 나눠 옮기고 0.7과 0.8을 PyPI에 게시했습니다.
옮긴 코드가 원본과 같다는 것은 단위 테스트로 증명하기 어렵습니다. 테스트는 작성자가 예상한 경우만 확인하기 때문입니다. 그래서 원본 서비스 모듈을 shim으로 같은 프로세스에 import하고, 같은 실코퍼스를 양쪽에 넣어 출력을 비교했습니다.
| 대조 항목 | 결과 | 조건 |
|---|---|---|
| 결정론 추출 청크별 사실 | diff 0 | 실코퍼스 14,924청크 전량 |
| Hearst 패턴 추출 | 5,885 = 5,885 | 같은 코퍼스 |
| 최종 개념·개체·관계·값 | 27,109 · 166,525 · 2,544 · 2,625 동일 | 같은 코퍼스 |
| 후처리(계층·관련·술어 병합) | subClassOf 4,146 · 관련 5,285 · 병합 12 동일 | 같은 16K 노드 그래프 |
| 적재 정규화 | 노드 16,400 · 간선 10,522 · 속성 282 집합 동일 | 원본 직렬화 함수와 비교 |
차이는 한 건이었습니다. 이름 조각 접기 101건 중 1건이 동률이었는데, 원본은 set 순회 순서에 따라 결과가 달라지는 코드였습니다. 라이브러리는 철자 순서로 고정했습니다. 대조 과정에서 오래된 동작 테스트의 기대값 2건이 현행 원본과 다르다는 것도 나왔는데, 실코퍼스 대조가 현재 동작을 보여 주므로 테스트 쪽을 고쳤습니다.
이 방식은 이후 릴리스에서도 유지했습니다. 9월 말 감분(문서 삭제를 그래프에서 걷어내기)을 옮길 때는 원본이 고아 개념을 한 번만 판정해 잔재가 남을 수 있다는 것을 발견했고, 라이브러리는 고정점까지 반복하게 하되 이 차이를 변경 이력에 의도된 차이로 적었습니다. 10월 초 0.13·0.14에서는 원본과 라이브러리를 기본 빌드, 표, 관계 형성, 후처리·저장소 네 영역으로 나눠 동일·의도된 차이·누락·불일치로 분류했고, 버리는 PostgreSQL 컨테이너에 제품 코어 모델 정의로 표를 만든 뒤 쓰기, 읽기, 확장, 감분, 삭제를 왕복시켰습니다. 이 왕복에서 감분 뒤 간선 가중치가 줄지 않던 결함을 찾아 고쳤습니다.
정본 저장소도 개인 계정에서 조직 저장소로 옮겼습니다. 개인 레포가 정본이면 개인 토큰 만료와 개인 계정에 배포가 묶이기 때문입니다. 나중에 확인해 보니 제품 라인의 파일 저장소 지식 계층이 이미 이 라이브러리를 정확한 버전으로 고정해 쓰고 있었고, 10월에는 패키지 이름을 xgen_ontology_build로 정리하면서 옛 이름은 두 릴리스 동안 재내보내기 껍데기로만 남겼습니다.
남은 연동 코드: 호스트가 네 가지만 주게 하는 어댑터
빌드와 검색 라이브러리는 환경 독립이 됐지만 그것만으로는 다른 서비스가 온톨로지를 쓸 수 없었습니다. 잡 관리, 증분 기준선, 원자적 발행, HTTP 제공은 여전히 문서 서비스 안에 있었습니다. 연동 코드 약 19,000줄 중 약 7,000줄은 이미 라이브러리로 옮겨진 이전 세대 구현이라 옮길 대상은 약 12,000줄이었습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 서비스마다 연동 코드 재작성 | 각 환경에 맞춘 구현 | 같은 잡·증분·발행 로직을 환경 수만큼 유지 | 기각 |
| 라이브러리에 잡·저장·HTTP 포함 | 설치 한 번으로 끝 | 알고리즘 라이브러리가 특정 저장소와 프레임워크에 묶임 | 기각 |
| 별도 어댑터 계층 | 라이브러리 무변경, 환경 차이는 포트 구현으로 흡수 | 계층 하나와 적합성 테스트를 새로 유지 | 제안 |
제안한 구조에서 호스트가 주는 것은 네 가지입니다. 청크가 어디 있는지(ChunkSource), 그래프를 어디에 쓰는지(GraphStore), 모델이 무엇인지(LLM·Embedder), 누가 볼 수 있는지(ScopeResolver)입니다. 코어의 빌드·검색·그래프 조회 서비스는 두 라이브러리와 포트 인터페이스만 import하고, 특정 저장소나 검색 엔진 이름이 코어에 나오면 설계 위반으로 봅니다. 환경별 코드는 extras로 격리하고, 호스트의 포트 구현이 ID 안정성, 범위 적용, 정렬 결정성 같은 계약을 지키는지 검사하는 적합성 테스트 묶음을 함께 배포합니다.
호스트가 주는 것 어댑터 코어 표면
ChunkSource (청크 어디) ─┐ BuildService 잡 상태·증분/감분·원자적 발행 Python API
GraphStore (그래프 어디) ├▶ SearchService 범위·채널 융합·PPR·근거 구성 FastAPI 라우터
LLM/Embedder (모델 무엇) │ GraphReadService 시각화·탐색·변경 이력 독립 서비스 + OpenAPI
ScopeResolver (누가 봄) ─┘ CLI표면을 네 가지로 둔 이유는 조사에서 나왔습니다. 예시로 살펴본 한 사내 서비스는 제품 경로에 파이썬 런타임이 없었고 청크 원천도 Qdrant가 아니었습니다. 파이썬이 없는 호스트가 흔하다면 HTTP 사이드카와 생성 클라이언트가 기본 배포 형태여야 하고, 청크 ID가 벡터 포인트 ID와 같다는 가정도 성립하지 않으니 청크 ID 규칙은 어댑터가 소유해야 합니다. 고객 라인의 text-to-SQL 채널 같은 환경 전용 기능은 본체에 넣지 않고 추가 근거 훅 하나로 열어 두기로 했습니다.
추진 단계마다 완료 기준도 정했습니다. 1단계 골격은 기존 재현 하니스로 문서 서비스와 근거 목록이 같은지, 4단계 XGEN 전환은 제품 라인 회귀 50문항이 같은지로 판단합니다. 라이브러리 추출에서 쓴 "같은 입력, 같은 출력" 원칙을 어댑터에도 그대로 적용한 것입니다.
결과와 남은 과제
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 고객 라인 그래프 노드 | 0 | 852 (간선 1,156) | 로컬 검수 컬렉션, SDK 1.33 → 1.34 |
| 개체→청크 확장 링크 | 0 | 14 | 같은 컬렉션 |
| 기존 컬렉션 그래프 표시 | 0 (SDK 1.33 기준) | 노드 1,264 · 관계 1,045 | 고객 라인 검수 환경 배포 후, 파드 안 직접 호출 |
| 검색 후보 중 그래프 확장 | 비어 있음 | 79개 중 27개 | 배포 후 같은 컬렉션 질문 1건 |
| 라이브러리 이식 출력 차이 | - | 청크 사실 diff 0 | 실코퍼스 14,924청크, 같은 프로세스 대조 |
| 2차 동기화 후 DB 동기화 7가지 변경 | - | 제품 라인과 같은 결과 | Oracle 샘플 6개 테이블 1,610행, 문서 서비스 테스트 369 통과 |
배포 후에는 시험 컬렉션으로 업로드 자동 빌드, 두 번째 업로드 증분, 문서 삭제 감분, 복제, DB 적재, 검색까지 종단 검증하고 시험 데이터를 모두 지웠습니다. 표 수가 배포 전 덤프와 같은지도 확인했습니다.
남은 과제도 분명합니다. 어댑터는 제안 단계이고 저장소 위치, 두 번째 검증 호스트, 검증용 모델이 정해지면 1단계부터 착수합니다. 라이브러리의 저장소 백엔드는 임시 DB와 버리는 컨테이너에서는 왕복했지만, 공유 환경의 실제 컬렉션 표에 물려 불러오기·확장·감분을 왕복한 기록은 아직 없습니다. 원본의 1회 판정과 라이브러리의 고정점 반복처럼 일부러 다르게 둔 규칙을 원본에도 반영할지는 별도 판단으로 남겼습니다. 고객 라인의 2차 동기화 묶음은 기록 시점 기준으로 리뷰와 머지를 기다리고 있습니다.
돌아보면 이식 비용은 코드 양보다 경계가 어디에 그어져 있는지에서 나왔습니다. "답이 나온다"는 관찰이 "그래프가 쓰이고 있다"는 뜻은 아니었고, "원본과 같다"는 주장은 같은 입력을 넣어 출력을 대조할 때 비로소 검증할 수 있었습니다.