외부 채점기로 검증한 검색 라이브러리: BM25F, 그래프 동반 융합, SQLite 영속화
XGEN의 GraphRAG 검색 로직을 저장소 비종속 라이브러리 omnifuse로 추출한 뒤, 다른 오픈소스가 공개한 한국어 금융법령 코퍼스와 채점 스크립트, 공개 IR 데이터셋으로 랭킹과 효율을 측정하며 고쳤습니다. 데이터셋별 튜닝과 결과 자르기를 금지한 측정 계약 아래에서 채택한 변경과 기각한 변경을 정리합니다.
Key Results
- 0.797 → 0.851
- single-hop MRR@10한국 금융법령 4,417건·120질의, LLM 미사용, 제목 필드 4배 가중 BM25F 적용 전후
- 19 → 103 / 120
- multi-hop strict 정답같은 코퍼스의 조문 인용 질의, 1-hop 그래프 구조를 랭킹에 직접 융합 전후
- MRR@10 0.9083
- TREC-COVID 171,332문서50질의, 메모리 인덱스, p50 109.49 ms
2026년 6월, XGEN 온톨로지 스택의 검색 로직을 제품 코드 밖에서도 쓸 수 있도록 저장소 비종속 오픈소스 라이브러리 omnifuse로 추출했습니다. 벡터와 그래프를 한 번에 융합하는 원샷 GraphRAG 코어, 트리플·JSONL·CSV 데이터 로더, CI와 패키지 배포까지 하루 만에 갖췄습니다. 그런데 제품 안에서 검증된 로직이라고 해서 라이브러리로서도 좋은 랭킹과 효율을 낸다는 보장은 없었고, 그것을 잰 적도 없었습니다.
그래서 7월 한 달 동안 측정과 개선을 반복했습니다. 기준은 남이 만든 문항과 채점기였습니다. 다른 오픈소스 메모리 라이브러리가 저장소에 커밋해 둔 한국 금융법령 코퍼스와 그쪽의 IR 채점 스크립트를 그대로 가져오고, 공개 IR 데이터셋을 더해 LLM 없이 측정했습니다. 직접 만든 문항과 채점기를 쓰면 유리한 조건을 고를 여지가 생기기 때문입니다.
이 글은 그 과정에서 무엇이 부족했고, 어떤 근거로 고쳤으며, 무엇을 기각했는지 다룹니다. 수치가 오른 변경보다 수치가 올랐는데도 버린 변경이 더 많았고, 측정 도구 자체를 고친 일도 여러 번 있었습니다.
라이브러리와 측정 계약
omnifuse의 검색은 에이전트 루프 없이 한 번의 패스로 끝납니다. 질의를 어휘 점수로 랭킹하고, 강한 어휘 시드가 그래프에서 가리키는 이웃 문서를 같은 랭킹에 끼워 넣고, 선택적으로 밀집 임베딩 점수를 섞습니다.
질의 → BM25F(제목 × 4 + 본문) 랭킹
└─ 1-hop 그래프 동반 후보 (점수 = 융합 계수 × 시드 점수)
→ 단일 패스 융합 랭킹 (에이전트·LLM 루프 없음)
→ (선택) 밀집 e5-small 점수와 RRF 융합 → top-k비교가 의미를 가지려면 측정 조건부터 고정해야 했습니다. 입력은 고정본을 복사해 해시로 묶었고(원본 다운로더가 데이터셋 리비전을 고정하지 않았기 때문입니다), 채점기는 바이트 단위로 같은 파일을 SHA-256으로 확인해 썼습니다. K=10, 후보 상한 20, 워밍업 1회 뒤 반복 측정을 공통으로 두고, 시스템마다 새 워커 프로세스를 AB/BA 순서로 번갈아 띄웠습니다. 결과 JSON에는 코드 리비전, 작업 트리 변경분, 추적되지 않은 파일 목록, 입력 해시를 함께 남겼고, 실행 전후로 입력 19종의 구조와 해시를 점검하는 사전 검사를 실행했습니다.
금지 조건도 문서로 정했습니다. 데이터셋별 스위치, 질의나 문서 단위 규칙, 결과 목록 자르기, 벤치마크에서만 동작하는 지름길은 쓰지 않습니다. 아래에서 기각한 변경 대부분이 이 조건에 걸렸습니다.
금융법령 코퍼스에서 드러난 랭킹 결함 두 가지
코퍼스는 한국 금융법령 조문 4,417건, 질의는 정답 문서가 하나인 single-hop 120개와 조문 A와 A가 인용한 조문 B를 모두 찾아야 하는 multi-hop 120개입니다.
첫 번째 결함은 신호가 실린 자리였습니다. 조문 질의는 대개 "○○법 제N조(제목)"의 제목에 핵심어가 있는데, 제목과 본문이 같은 가중치로 섞여 본문 깊숙이 같은 단어를 여러 번 언급한 조문에 밀리고 있었습니다. 청크에 짧은 제목 필드를 두고 본문 대비 4배 가중하는 필드 가중 BM25(BM25F)를 넣자 single-hop MRR@10이 0.797에서 0.851이 됐습니다.
두 번째 결함은 그래프가 랭킹에 쓰이지 않는 구조였습니다. 그래프 관계는 LLM 프롬프트에만 들어가고 순위 계산에는 반영되지 않아, 인용을 따라가야 하는 multi-hop에서 두 조문을 모두 상위에 올린 문항이 120개 중 19개였습니다. 그래프 저장소 프로토콜에 이웃 ID 조회를 추가하고, 강한 어휘 시드가 가리키는 1-hop 이웃을 "융합 계수 × 시드 점수"로 같은 랭킹에 동반시키자 103/120이 됐고 multi-hop R@10은 0.9042였습니다. 인용 간선은 코퍼스의 조문 번호 값에서 만들었고, 이 코퍼스 전용 규칙은 두지 않았습니다.
동반 후보의 자리를 놓고도 실험이 있었습니다. 직접 매칭한 문서의 순위를 보호하려고 동반 후보를 꼬리에만 배치하는 판을 시험했더니, 당시 108/120이던 multi-hop strict가 97/120으로 떨어졌습니다. 더 이른 단계에서 교차 배치하거나 RRF로 섞는 방식은 그보다 나빴습니다. 동반 후보는 시드 점수에 비례해 랭킹 안에서 경쟁하게 두는 것으로 정했습니다.
데이터셋마다 다른 최적값과 기본값 하나
공개 데이터셋을 더하자 어휘 설정의 최적값이 서로 충돌했습니다. AutoRAG는 BM25의 k1이 낮아야, HotPotQA는 높아야 좋았습니다. PublicHealthQA는 문자 유니그램을, Allganize RAG-Eval은 바이그램만 쓰는 쪽을 원했습니다. 바이그램, 유니그램 혼합, 형태소 분석, 그 합집합, RRF 앙상블, 커버리지 가중, k1·b 스윕을 모두 시험했지만 어느 것이든 한 곳을 얻으면 다른 곳을 잃었습니다. 데이터셋마다 다른 설정을 고르는 것은 시험 라벨에 맞추는 일이라 기각했습니다.
형태소 분석기 도입도 같은 기준으로 봤습니다. 금융법령 코퍼스에서 의존성 없는 CJK 바이그램이 Kiwi 형태소 분석보다 MRR 0.851 대 0.820으로 앞섰고 5배 빨랐습니다. 새 의존성을 넣을 이유가 없어, 대신 규칙 기반 한국어 어간 추출을 직접 구현했고 어휘 트랙 평균 MRR이 0.829에서 0.840이 됐습니다. 어휘만으로 약한 데이터셋은 밀집 임베딩과의 하이브리드로 보완했습니다. 임베딩 모델은 실측으로 골랐습니다. bge-m3는 CPU에서 배치당 145초가 걸렸고 MiniLM 계열은 검색 품질이 무너져, 빠르고 강한 multilingual-e5-small을 택했습니다. Ko-StrategyQA는 어휘 0.6242에서 하이브리드 0.7780이 됐습니다.
IDF 지수(idf_pow)는 한때 1.5였습니다. 특정 개체명이 흔한 단어에 묻히는 현상을 줄이려고 올렸는데, FiQA에서 1.5가 0.2781로 1.0의 0.2929보다 낮게 나오면서 기본값을 의심하게 됐습니다. 1.0, 1.1, 1.2, 1.3, 1.5의 다섯 arm을 확장 9개 데이터셋에서 같은 실행으로 다시 계산했고(약 12시간 41분), 1.5는 FiQA에서 밀리고 9개 합산으로도 1.0 대비 -0.0104라 더 올리지 않았습니다. 기본값은 1.2로 유지했습니다.
한국어 질의에서는 조금 다른 문제가 나왔습니다. "~란 무엇인가", "~알려 줘" 같은 정의·요청 문형의 연산자만으로도 문서가 후보에 들어와 꼬리 후보가 늘었습니다. 불용어를 넓게 지우면 XPQA와 MIRACL이 떨어졌고, 어미 "-란"을 떼면 "임진왜란", "정유재란"이 잘못 분해됐고, 요청어 접두 매칭은 "설명회", "알려졌던" 같은 내용어를 해쳤습니다. 그래서 후보 허용(admission)과 채점(scoring)을 분리했습니다. 닫힌 부류의 질의 연산자는 채점 질의에 그대로 남기되, 주어가 되는 앵커 없이는 문서를 후보로 들이지 못하게 했습니다. 이 규칙으로 영향받는 한국어 공개셋의 해당 지표가 올랐고, 점검한 9개 한국어 공개셋에서 떨어진 지표는 없었습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 데이터셋별 BM25 설정 | 각 셋에서 최고점 | 시험 라벨에 맞춘 과적합 | 기각 |
| Kiwi 형태소 분석 도입 | 일부 한국어 셋에서 유리 | 금융법령 0.820 대 바이그램 0.851, 5배 느림, 새 의존성 | 기각, 규칙 기반 어간 추출로 대체 |
| idf_pow 1.5 유지 | 개체명 묻힘 완화 | FiQA 하락, 9개 합산 음수 | 기각, 1.2 유지 |
| 넓은 불용어 삭제 | 꼬리 후보 감소 | XPQA·MIRACL 하락, 내용어 손상 | 기각 |
| 후보 허용과 채점 분리 | 색인과 채점 질의 보존, 회귀 0 | 규칙이 하나 늘어남 | 채택 |
| 결과 목록 동적 절단 | 가변 분모 Precision 상승 | 다른 셋의 Recall 하락 | 기각 |
마지막 줄은 채점기의 성질에서 나온 유혹이었습니다. 공식 채점기의 Precision@10은 분모가 10이 아니라 반환한 결과 수라, 같은 적중이면 결과를 짧게 낼수록 값이 오릅니다. 제품의 동적 절단을 재사용하거나 점수 간격으로 자르는 규칙을 시험했지만, 동적 절단은 Ko-StrategyQA와 PublicHealthQA의 Recall을 떨어뜨렸고 간격 규칙은 엉뚱한 질의에서 보정되는 식이라 둘 다 넣지 않았습니다. 한국어 한 음절 어근의 조사 변형을 별칭으로 보완하는 시도도 있었습니다. 특정 문항을 보고 고른 좁은 규칙은 그 문항만 고쳤지만 선택 자체가 문항에 맞춘 것이었고, 제한을 걷은 일반 규칙은 2,269개 질의 중 46개의 순위를 바꿔 적중 1개를 얻고 3개를 잃어 구현하지 않았습니다.
구축 비용과 영속화
정적 인덱스는 구축 시점에 어휘 인덱스 전체를 즉시(eager) 만들고 있었습니다. NFCorpus 정적 적재가 1.276초였는데, 조회는 빠르지만 처음 만드는 비용이 컸습니다. 적재 시점에는 불변인 제목·본문 값만 스냅샷으로 잡고, BM25/BM25F는 첫 어휘 질의 때 RLock 아래에서 한 번만 만들도록 바꿨습니다. 적재는 0.0045초가 됐고, 대신 첫 100질의 라운드가 1.314초, 이후 반복 라운드는 0.045초였습니다. 비용을 없앤 것이 아니라 첫 사용 시점으로 옮긴 것이고, 그 비용이 측정에 보이도록 콜드 첫 라운드를 따로 기록했습니다. 변경 가능한 저장소와 피드백 저장소는 의미가 지연 구축에 의존하지 않도록 즉시 구축으로 남겼습니다.
메모리 전용이던 영속화는 네이티브 SQLite 백엔드로 만들었습니다. 디스크 빌더는 RAM에 전체 검색 인덱스를 들지 않고, 정확한 단어 빈도와 필드 길이를 순방향 전용(forward-only) 표현으로 씁니다. 처음에는 원시 포스팅 스트림을 BLOB 하나에 두고 질의어마다 blobopen으로 필요한 구간만 읽었는데, 다음 판에서 이 구조가 쓰기 중에 포스팅을 일시적으로 두 벌 들고 첫 질의가 훨씬 넓은 범위를 건드린다는 것을 확인해 64 KiB 블록으로 나눠 쓰도록 바꿨습니다. 텍스트는 원문 UTF-8과 zlib 중 작은 쪽으로 저장하고, 압축 수준과 페이지 크기는 데이터셋별로 고르지 않고 표준값(Z_BEST_SPEED, 4 KiB)을 썼습니다. 프로파일링에서 ASCII 토큰 분리 정규식이 병목으로 나와 ASCII 전용 빠른 경로를 넣었고, 128개 ASCII 코드 포인트 전부에서 결과가 같다는 계약 테스트를 추가했습니다.
이 과정에서 기각한 것들이 설계의 경계를 정했습니다. 가중치를 미리 계산해 담는 방식은 쓰기 중 RAM에 BM25F 인덱스를 통째로 들어 메모리 피크가 높았고, SQLite FTS5와 여러 랭킹 프로필의 합의 방식은 NFCorpus만 올리고 다른 셋과 아티팩트 크기를 악화시켰고, 쓰기 때 모든 가중치를 디코딩해 두는 방식은 메모리는 줄었지만 디스크 생성이 3.2초 안팎으로 늘었습니다.
불변 인덱스의 표현도 줄였습니다. 정적 저장소가 가변 저장소와 같은 양방향 포스팅을 들고 있었기 때문에, 순방향 전용 압축 포스팅 스냅샷을 도입했습니다. 3만 문서 진단에서 직렬화 상태가 4.4 MB에서 1.4 MB(-68.6%), 직렬화 시간이 72 ms에서 4 ms가 됐고, 대가로 빌드가 15.3% 늘고 콜드 질의 p50이 0.020 ms에서 0.134 ms가 됐습니다. 손상된 스냅샷은 읽지 않고 실패하게 했고, 이전 직렬화 상태는 계속 읽을 수 있게 했습니다.
질의 시점 디코딩도 손봤습니다. 1바이트 varint 전용 빠른 경로를 두고(다중 바이트·비정규·절단 인코딩은 따로 검증), 필드 평균과 k1+1은 인덱스를 열 때 한 번만 계산하고, BM25F는 포스팅마다 임시 리스트를 만들지 않고 바로 누산하게 했습니다. NFCorpus 질의 라운드가 584.46 ms에서 369.22 ms가 됐습니다. 포스팅 가중치를 미리 계산하는 프로토타입은 질의 라운드를 0.246배로 줄였지만 빌드가 2.03배, 아티팩트가 1.594배로 커져 기각했습니다. 빌드와 저장 효율을 질의 속도와 맞바꾸지 않는다는 원칙이었습니다.
마지막으로 콜드 스타트를 다뤘습니다. 150문서·16질의를 정해진 순서로 실행하는 QA 성능 계약(p95 100 ms 미만)을 콜드 조건으로 재현하자, 1~2필드짜리 불변 인덱스도 역방향 변경과 근거 추적을 위한 범용 경로를 타고 있어 p95 중앙값이 73.67 ms였고 4개 워커 중 3개만 기준을 통과했습니다. 기존의 빠른 경로 조건을 일반화했습니다.
not retain_reverse and no evidence and field_count <= 2이 조건을 만족하는 인덱스는 원시 문서 소비 경로를 재사용하고 어휘 사전을 적재 후 한 번만 만듭니다. 콜드 p95는 49.25 ms(-33.1%)로 4/4가 통과했고, 변경 후 14개 데이터셋 2,269개 질의의 상위 20 결과가 이전 판과 모두 같았습니다.
측정 도구를 먼저 고친 기록
최적화마다 이전 판과 상위 10 문서 ID, 점수의 float.hex 표현까지 비교해 랭킹이 비트 단위로 같은지 확인했습니다. 효율 개선이 랭킹을 몰래 바꾸지 않았다는 것을 보이는 최소한의 장치였습니다. 그런데 정작 자주 틀린 것은 측정 도구 쪽이었습니다.
- 메모리 샘플러가 샘플마다 Windows용 ctypes 구조체 클래스를 새로 만들어 느린 워커에 메타데이터가 쌓였습니다. 그 회차 리포트를 메모리 근거에서 폐기하고, 구조체를 모듈 범위로 올려 1만 번 호출에 working set 증가가 0인 것을 확인한 뒤 다시 쟀습니다.
- 생애 최대 RSS가 두 시스템 모두 약 66 MB에 찍혀 있었는데, 워커가 입력 바이트를 들고 JSON 전체를 디코딩한 뒤 정규화 사본을 하나 더 만들면서 생긴 파서 피크였습니다. 근소한 차이를 "사실상 같음"으로 넘기지 않고, 스트리밍 SHA-256 지문과 사본 없는 로드로 하니스를 고쳐 양쪽에 똑같이 적용한 뒤 다시 실행했습니다. 수정 뒤 omnifuse의 NFCorpus 생애 최대 RSS는 49.88 MB였습니다.
- TREC-COVID 메모리 비교가 한쪽은 RAM 인덱스, 다른 쪽은 디스크 SQLite였고 비교 문서에는 두 백엔드 설명이 뒤바뀌어 있었습니다. 문서를 정정하고 양쪽 모두 SQLite를 생성, 닫기, 재오픈, 질의하는 같은 백엔드 조건으로 다시 쟀습니다.
- Windows
time()의 해상도가 15.625 ms라 1 ms 미만 질의가 0 ms로 기록됐습니다. 순서가 고정된 단발 실행의 합계는 방향만 참고하고, 지연 수치는 단조 나노초 시계와 반복 AB/BA 측정에서만 인용했습니다. - 콜드 스타트는 앞선 테스트가 데워 둔 상태에서 재면 의미가 없어, trial마다 독립 Python 프로세스를 띄웠습니다.
- 입력 사전 검사의 첫 판은 파일 존재만 봐서 빈 객체
{}도 통과시켰고 일부 데이터셋을 빠뜨렸습니다. 구조와 비어 있지 않은 라벨까지 검사하도록 고쳤습니다. 비교 대상 저장소에 바이트코드가 써져 추적 파일이 바뀐 일도 있어, 그 체크아웃은 보존하고 새 클린 체크아웃으로 교체했습니다.
이런 일이 반복되면서 "결과가 기대와 다르면 코드보다 하니스를 먼저 본다"는 순서가 자리 잡았습니다. 지는 지표를 해석으로 덮지 않고, 측정 결함을 고친 뒤 같은 코호트를 다시 실행하는 쪽을 택했습니다.
결과
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| single-hop MRR@10 | 0.797 | 0.851 | 금융법령 4,417건·120질의, LLM 없음, 제목 4배 BM25F |
| multi-hop strict 정답 | 19/120 | 103/120 | 같은 코퍼스, 1-hop 그래프 동반 융합 |
| 어휘 트랙 평균 MRR | 0.829 | 0.840 | 규칙 기반 한국어 어간 추출 전후 |
| NFCorpus 정적 적재 | 1.276 s | 0.0045 s | 지연 구축, 첫 라운드 1.314 s · 반복 0.045 s |
| 정적 인덱스 직렬화 상태 | 4.4 MB | 1.4 MB | 3만 문서·30질의 진단, 빌드 +15.3%, top-10 동일 |
| NFCorpus SQLite 질의 라운드 | 584.46 ms | 369.22 ms | 7회 교대 실행 중앙값, 랭킹·아티팩트 동일 |
| QA 계약 콜드 p95 | 73.67 ms | 49.25 ms | 150문서·16질의, 독립 프로세스 4워커 중앙값 |
규모 검증으로 TREC-COVID(171,332문서, 50질의)를 측정했습니다. 메모리 인덱스에서 MRR@10 0.9083, 질의 p50 109.49 ms였고, 네이티브 SQLite에서는 MRR@10 0.9017, p50 895.14 ms, 아티팩트 약 184 MB였습니다. 두 판은 백엔드가 달라 서로 비교하지 않습니다. 이 규모에서 생애 최대 RSS는 입력 JSON 파싱이 지배해(약 1.8~2.1 GB) 우열의 근거로 쓰지 않았습니다.
회고와 남은 과제
돌아보면 이 작업에서 가장 오래 남은 것은 개별 수치보다 측정 계약이었습니다. 남의 코퍼스와 채점기를 쓰고, 데이터셋별 스위치와 결과 자르기를 금지하고, 모든 결과에 코드와 입력의 지문을 남기니 "이 개선이 일반적인가"를 판단할 근거가 생겼습니다. 특정 문항을 고치는 규칙이 수치를 올려도 버릴 수 있었던 것은 그 계약이 먼저 있었기 때문입니다.
남은 과제도 분명합니다. block-max WAND, SIMD 정수 압축 같은 질의 실행 최적화는 구현하지 않았고, 순수 Python 구현이라 PISA 같은 엔진과 같다고 주장하지 않습니다. 이 기간의 측정은 임베딩 없는 어휘·그래프 검색과 SQLite 영속화에 한정되며, 리랭커, LLM 종단 평가, PostgreSQL, 비동기 API는 별도 평가 대상으로 남겼습니다. 측정용 변경은 검증을 끝낸 뒤 릴리스 여부를 따로 판단하도록 커밋과 배포를 미뤘습니다.