검색 개선
아카이브됨 — 2부 BM25 판정은 무효다 (2026-07-29)
검색(리트리벌)을 여태 어떻게 고쳐왔는지의 시간순 이력(1부)과, BM25 하이브리드 도입 여부를 판정한 실험 정본(2부), 그리고 미해결 과제와 원칙(3부).
현재 모습 → 쿼리 파이프라인 · 남은 후보 → 쿼리 파이프라인 §잔여 백로그 · 개념 배경 → 쿼리 파이프라인
1부 · 변경 이력
| 날짜 | 커밋 | 변경 |
|---|---|---|
| 07-15 | e85fd4e | src/query/retrieve.py 골격 생성 |
| 07-15 | 9be7c5d | 최초 구현 — e5 dense top-k + doc_id 필터, 폴백 금지 |
| 07-15 | 6019d47 | LangSmith run_type=retriever 트레이싱 |
| 07-24 | 14d018f | Parent-Document — 쪼개진 큰 표를 컨텍스트에서 복원 |
| 07-25 | 6866d9c | LCEL 전환 (결과 계약 불변) |
| 07-27 | 4684beb | 메타 헤더 — 발주기관·금액·마감을 컨텍스트에 부착 (#16) |
| 07-27 | 0e734b9 | 하이브리드·리랭커 구현 머지 (PR #22, 플래그 OFF) |
1. 최초 구현 — dense 단독 + 폴백 금지
9be7c5d · 07-15
검색 자체가 없던 상태에서 spec §6-2의 최소 경로를 세웠다. query: prefix 임베딩(e5)으로 chunks 컬렉션 top-k, 문서가 특정되면 where={"doc_id": ...} 메타 필터.
필터 후 0청크면 빈 목록을 반환한다. 필터를 풀고 전체 재검색하는 폴백은 금지(spec §12-3) — “다른 문서 내용을 그 문서 답인 척” 하는 오염을 차단하기 위해서다. 고정 응답은 호출부(CLI·라우터) 책임으로 분리했다.
함께 format_context가 히트마다 [출처: 사업명 / 섹션] 라벨을 붙여 근거 표기의 기반을 만들었다.
src/query/retrieve.py:16 · :59
2. 관측 가능성 — LangSmith
6019d47 · 07-15
검색이 어떤 청크를 몇 개, 어떤 distance로 돌려주는지 볼 수단이 없어 품질 논의가 감이었다. traced() 지연 데코레이터로 retrieve를 retriever span으로 기록했다.
NDA 가드(ADR-007)로 hide_inputs/hide_outputs 코드 강제 마스킹 — 스칼라 메타만 통과. (이후 07-25 사용자 지시로 LANGSMITH_MASK_TRACES=False 전환.)
검색 로직 자체는 +2줄 무변경. 이후 개선들이 “측정 후 판단”이 되게 한 토대다.
3. Parent-Document — 쪼개진 표 복원
14d018f · 07-24
CHUNK_SIZE를 넘는 큰 표가 여러 조각으로 쪼개지면 검색엔 조각 하나만 걸려 답변이 표의 절반만 반영됐다. 당시 표 청크 5,411개 중 **2,758개(51%)**가 쪼개진 표였다.
2조각 이상으로 쪼개진 표에만 원본 전체를 parent_text 메타로 동봉했다. 검색 단위는 조각 그대로(정밀도 손실 없음), format_context가 렌더할 때만 조각 대신 표 전체를 넣고 같은 parent_text 중복 히트는 1회만 삽입한다.
넓은 질문·좁은 질문 양쪽에서 의도대로 작동했고 기존 296테스트 전건 통과.
src/query/retrieve.py:59 · src/index/chunk.py
4. LCEL 전환
6866d9c · 07-25
raw Chroma 호출이라 LangSmith 트레이스가 커스텀 함수 덩어리로만 보였고, LC 생태계 컴포넌트를 쓸 수 없었다. src/query/lc.py를 신설해 E5Embeddings·build_retriever·doc_to_hit로 나누고 retrieve()를 VectorStoreRetriever.invoke로 교체했다.
핵심 제약은 결과·생성 계약 불변 — 실인덱스 top-5·doc_id 필터 파리티 확인, 347테스트 통과. 리팩터이지 품질 변경이 아니다.
부작용으로 as_retriever(similarity) 경로가 점수를 안 돌려줘 distance=None이 됐다(소비처가 관측 요약뿐이라 수용).
5. 문서 메타 헤더 부착
4684beb · 07-27 · #16
CSV 확정값(금액·마감일·기관)이 청크 metadata에 이미 인덱싱돼 있는데 format_context가 라벨에 사업명·섹션만 적어 생성 LLM이 볼 수 없었다 → 복합 질문에서 예산·마감일 누락.
히트를 doc_id로 묶어 문서당 1회 [문서 정보(출처: data_list.csv) — …] 헤더를 붙였다. 금액은 3.8억원(380,000,000원) 억 단위 병기로 자릿수 오독을 막고, 헤더 출처를 명시해 LLM이 무관한 본문 섹션을 출처로 인용하는 것도 차단했다.
실측 40건:
| 이전 | 이후 | |
|---|---|---|
| 컨텍스트에 사업금액 포함 | 21/60 | 57/60 |
| 입찰마감 포함 | 0/60 | 51/60 |
마감일은 본문에 아예 없어 기존엔 답변이 불가능했다. 비용은 프롬프트 +23토큰(+4.2%), 턴당 +$0.000006. 재인덱싱 불필요, LLM 추가 호출 0회.
src/query/retrieve.py:42 _doc_header · :31 _format_amount
6. 하이브리드 검색 + 리랭커 — 구현 머지
0e734b9 (PR #22) · 07-27 · 구현 머지, 플래그 OFF
계기 — 실험 6.1의 6개 변형 전부에서 hit@5가 1.000 포화. 검색을 뭘 바꿔도 효과를 잴 수 없었다. 원인은 청킹이 모든 청크에 사업명+발주기관 접두를 붙여 문서 단위 적중이 항상 성공하기 때문. 문서는 늘 맞히니 문서 지표는 죽은 지표였다.
1단계 — 자부터 만들기. 청크 단위 순위 지표 신설: MRR@10·nDCG@10·Recall@5 (src/eval/ir_metrics.py).
2단계 — 검색 변형. src/query/hybrid.py 신설 — RRF 융합, kiwipiepy 형태소 토큰화 한국어 BM25, 크로스인코더 리랭커 bge-reranker-v2-m3(실패 시 원순위 폴백). (융합 방식·토큰화 상세와 도입 배경·가설 → 2부 §1·§9)
배선. retrieve()가 config 플래그로 분기하되 기본값 둘 다 False → 기존 dense 경로와 결과가 바이트 단위로 동일하도록 조기 반환으로 고정(회귀 테스트로 못 박음). doc_id 필터 시 BM25는 건너뛴다 — 후보가 한 문서로 좁혀져 어휘 검색 이득이 없고 BM25 인덱스가 메타 필터를 미지원. 리랭커는 적용한다.
이 설계 근거는 실측으로 뒤집혔다 (2026-07-29)
“좁혀지면 어휘 검색 이득이 없다”는 구현 당시 추정이었고, 2부 §16이 정반대를 측정했다 — 문서 한정 시 오히려 MRR +0.073으로 승격 기준 통과. 전체 코퍼스에서는 +0.002로 기각. 원인은 사업명 토큰의 이중 역할이다. 전체 검색에서는 사업명이 노이즈로 퍼지지만, 문서가 한정되면 그 노이즈가 사라지고 고유번호·코드 매칭 이득만 남는다. 실질 산출물:
src/query/retrieve.py:36의and doc_id is None조건이 거꾸로 걸려 있다.
403테스트 통과. 측정 결과 → 2부 (BM25 완료 / 리랭커 미착수, 계획 → 2부 §18-1). 사전등록 설계서는 _archive/검색 실험 구도
⚠️ 2026-07-29 갱신 — 아래 2부의 BM25 결론은 164문항에서 뒤집혔다.
+0.073은 36문항 소표본 노이즈였고, 164문항 실측은 문서 한정 −0.031 / 전체 −0.036으로 양쪽 모두 손해다. 리랭커는 +0.104로 채택됐다. 최신 판정 → 검색 실험 결과 (164문항) 2부는 “그때 어떻게 판단했나”의 기록으로 남긴다.
2부 · BM25 하이브리드 실험
dense 단독 검색에 **BM25(단어 매칭)**를 섞으면 정답 순위가 오르는지 검증한 기록. 실험 기준·평가셋·함정도 여기 함께 정리한다. 관련 → 검색 실험 골든셋 40문항 · 사전등록 설계서(아카이브) → 검색 실험 구도
한눈에
결론이 두 번 바뀌었다. 코드도 데이터도 그대로고, 검색 범위 조건 하나만 바꿨을 때 뒤집혔다.
| 차수 | 조건 | 결과 | 판정 |
|---|---|---|---|
| 1차 | 전체 코퍼스에서 검색 | +0.002 | 🔴 기각 |
| 2차 | 대상 문서로 좁혀서 검색 | +0.073 | 🟢 승격 기준 통과 |
| 3차 | 표 청크만 검색 | −0.100 | 🔴 역효과 |
핵심 산출물: 프로덕션 코드가 BM25를 정확히 거꾸로 켜고 있다. 효과 있는 조건에서 끄고, 효과 없는 조건에서 켠다 → §16
배경과 설계
1. 왜 했나
우리 검색은 dense 단독이다. 질문과 청크를 각각 벡터로 만들어 뜻이 가까운 것을 찾는다.
임베딩은 로컬 intfloat/multilingual-e5-small (ADR-006).
그 ADR에 트레이드오프가 이렇게 적혀 있었다:
대형 임베딩 모델 대비 한국어 고유명사·표 검색 품질 한계 → 메타데이터 필터·프리픽스 주입·(백로그) BM25 하이브리드로 보완
즉 BM25 도입 근거는 처음부터 **“표와 고유명사에서 dense가 약할 것”**이라는 예상이었다.
우리 코퍼스에서 이게 중요한 이유: 인덱스 30,002청크 중 63.3%가 표 청크다. RFP의 핵심 정보(배점표, 요구사항 목록, 장비 규격, 제출서류)가 거의 다 표 안에 있다.
희석 가설: 표 청크는 | 항목 | 값 | 비고 | 형태로 셀 수십 개가 한 청크에 들어간다.
512토큰 벡터 하나에 그게 다 뭉개지므로 CDR-003, ISO 4373 같은 핵심 토큰의 신호가 희석된다.
BM25는 그 글자가 있으면 바로 점수를 준다.
2. 가설
| # | 가설 | 참이면 |
|---|---|---|
| H1 | dense는 표 안의 고유번호·코드를 잘 못 찾는다 | 어휘형 질문에서 dense 재현율이 낮다 |
| H2 | BM25가 그 구간을 메운다 | BM25가 dense가 놓친 청크를 잡는다 |
| H3 | 둘을 합치면 정답 순위가 올라간다 | 하이브리드 MRR@10 > dense MRR@10 |
H3이 본 가설. H1·H2는 그 이유를 설명하는 하위 가설.
3. 베이스라인 — 무엇과 비교하는가
베이스라인은 언제나 “지금 프로덕션이 실제로 하는 것”이다. 절대 점수는 의미 없다.
| 항목 | 현행 |
|---|---|
| 검색 방식 | dense 단독 (벡터 유사도) |
| 임베딩 | multilingual-e5-small, query:/passage: 프리픽스 |
| 인덱스 | Chroma chunks — 30,002청크 / 99문서 / 표 63.3% |
| top-k | RETRIEVAL_TOP_K = 5 (후보 폭 RERANK_CANDIDATE_K = 20) |
| BM25 | RETRIEVAL_USE_BM25 = False |
| 리랭커 | RETRIEVAL_USE_RERANKER = False — 이 실험 당시. 이후 채택되어 True로 켰다 → 검색 실험 결과 (164문항) §11 |
4. 실험 조건 — 반드시 명시할 두 축
결과를 적을 때 아래 두 축을 항상 함께 적는다. 안 적으면 그 숫자는 해석이 불가능하다.
축 A — 검색 범위
| 조건 | 내용 | 실전에서 언제 |
|---|---|---|
| 전체 코퍼스 | 99개 문서 30,002청크 전부 | 라우터가 문서를 못 정했을 때 |
| 문서 한정 | 대상 문서의 청크만 (doc_id 필터) | 라우터가 문서를 정했을 때 |
⚠️ 이 축을 안 밝히면 결과가 뒤집힌다. 실측: 전체 +0.002 / 문서 한정 +0.073 — 같은 코드, 같은 문항.
축 B — 문서를 어떻게 정했는가
| 방식 | 의미 | 해석 |
|---|---|---|
| 정답 문서 주입 | 골든셋의 target_doc_id를 그대로 씀 | 상한값. 라우터 100% 정확 가정 |
| 라우터 실측 | 실제 라우터가 고른 문서 | 실전 기대값 |
정답 문서를 주입해 잰 이득은 **
이득 × 라우터 정확도**로 할인해야 실전 수치가 된다.
5. 지표
주 지표 — MRR@10: 첫 정답 청크가 몇 등인가. 1등=1.0, 2등=0.5, 5등=0.2, 10위 밖=0.
실전에서 상위 5개만 LLM에 넘긴다. 20등에 있는 정답은 없는 것과 같다.
보조: nDCG@10(정답 여러 개일 때 위쪽 쏠림), Recall@5, doc_hit@5.
⚠️ 포화 지표 주의
| 지표 | 상태 |
|---|---|
hit@5 (문서 단위) | 1.000 포화 — 변별력 없음 |
hit@5 (청크 단위) | 포화 아님. 기존 문항 dense MRR 0.527 |
포화됐다고 조건을 바꾸지 마라. 포화는 “그 조건에서 잘 되고 있다”는 신호다. 변별력이 필요하면 더 어려운 문항이나 더 민감한 지표를 쓴다. 조건은 실전 그대로 둔다. — 1차 실험이 정확히 이걸 어겨서 틀렸다.
6. 평가셋
| 항목 | 내용 |
|---|---|
| 정본 | data/rerank_labels.jsonl — 사람이 직접 라벨링한 40문항 |
| 구성 | 기존 30 + 어휘형 10 |
| 빈 정답 | 4건 (기존 2, 어휘형 2) — 어느 방식도 득점 불가라 제외 |
| 가용 | 36문항 (기존 28 + 어휘형 8) |
40 / 36 / 35 — 세 숫자의 관계 (2026-07-29 확정)
문서마다 다른 수가 나와 혼선이 있었다. 셋 다 같은 40에서 나오며, 뺀 기준이 다를 뿐이다.
수 의미 계산 40 사람이 라벨링한 전수 — 데이터셋 크기 기존 30 + 어휘형 10 36 이 실험의 모수 — 채점이 성립하는 문항 40 − 미검출 4 (기존 2·어휘형 2) 35 검색 실험 골든셋 40문항의 “측정 대상” 36 − 검색 대상 아님 1 (기존 구간 #21) 미검출 4건(#10 #25 #31 #32)은 정답 청크가 비어 있어 어떤 방식으로도 MRR 0이다. 넣으면 변별에 기여하지 않고 분모만 키워 개선폭을 희석시킨다(+0.073 → +0.066). 그래서 채점에서 뺀다. #21은 본문에 근거가 없고
data_list.csv에만 있는 문항(라우터direct_meta몫)이라 검색 실험 대상 자체가 아니다.
두 구간은 반드시 분리해서 본다
| 구간 | 성격 | 정답 청크 중 표 비율 |
|---|---|---|
| 어휘형 (8) | 표 안의 고유 코드·번호 — CDR-003, ISO 4373, CC인증 등급, 배점 | 8/10 (80%) |
| 기존 (28) | 일반적인 사업 내용 질문 | 19/63 (30%) |
전체 평균만 보면 8문항짜리 개선 신호가 28문항에 희석된다. 구간별 표를 주 결과로, 전체 평균은 참고로.
편향 검사 통과 — 후보 풀이 dense ∪ BM25 합집합에서 뽑혀 어느 검색기에도 유리하지 않다.
7. 실행 환경 — 틀리면 조용히 망한다
🔴
CHROMA_DIR_OVERRIDE=data/chroma_v2없이 돌리면 결과가 전부 틀린다.
chunk_id는 위치 기반(doc_id::순번)이라 인덱스가 다르면 같은 id에 다른 본문이 들어간다.
| 인덱스 | 청크 | gold id 매칭 | gold 본문 일치 |
|---|---|---|---|
data/chroma (기본값) | 28,293 | 56/56 (100%) | 0/56 (0%) |
data/chroma_v2 ✅ | 30,002 | 56/56 (100%) | 56/56 (100%) |
id가 100% 맞아서 에러 없이 돌아간다. 본문만 전부 다르다. 가장 위험한 실패 모드.
| 위치 | 인덱스 | 라벨·풀 | GPU |
|---|---|---|---|
| 로컬 | data/chroma_v2 | 있음 | 없음 |
| VM | /srv/GPTPilot/chroma (동일 확인) | 없음 — 올려야 함 | L4 23GB |
골든셋 정본: /srv/GPTPilot/goldenset/current/ (버전 관리 + SHA256)
8. 승격 기준
측정 전에 확정한다. 결과를 보고 바꾸지 않는다.
| # | 기준 |
|---|---|
| 1 | 베이스라인 대비 MRR@10 +0.05 이상 |
| 2 | 기존 문항 hit@5 = 1.0 유지 (회귀 없음) |
| 3 | 조건(축 A·B)이 실전과 동일할 것 |
| 4 | 정답 문서 주입으로 쟀다면 라우터 정확도로 할인한 값이 기준을 넘을 것 |
하나라도 미달이면 미채택으로 기록하고 플래그는 OFF로 남긴다. 여러 방식이 통과하고 차이가 0.02 이내면 더 단순한 쪽.
3·4번은 1차 실험이 틀린 뒤 추가했다. 조건이 실전과 다르면 1번을 아무리 엄격하게 지켜도 무의미하다.
9. 합치는 방식 — RRF
두 검색기의 점수는 척도가 달라 직접 못 섞는다(dense는 코사인 유사도, BM25는 열린 점수). 그래서 등수만 쓴다.
점수(청크) = Σ 1 / (60 + 그 검색기에서의 등수)
60은 원논문 권장값(RRF_K). 한 검색기의 1등이 다른 검색기의 합의를 짓밟지 못하게 한다.
가중치 튜닝이 필요 없다는 게 RRF를 고른 이유.
BM25 구현: kiwipiepy 형태소 토큰화 → rank_bm25.BM25Okapi (k1=1.5, b=0.75).
토큰 캐시 data/bm25_tokens.json(31MB), 코퍼스 지문 불일치 시 자동 재생성.
실패 시 빈 목록 → dense 단독 강등 (spec §12).
실험 경과
10. 1차 — 전체 코퍼스 → 기각
| 구간 | 문항 | dense | 하이브리드 | Δ |
|---|---|---|---|---|
| 어휘형 | 8 | 0.750 | 0.771 | +0.021 |
| 기존 | 28 | 0.527 | 0.524 | −0.003 |
| 전체 | 36 | 0.577 | 0.579 | +0.002 |
승격 기준 +0.05 대비 1/25 수준. “BM25 기각, 리랭커로 전환”이라고 결론냈다.
문항별로 보면 크게 돕는 사례(HDIMS 6등→1등, KUSF 2등→1등)와 크게 해치는 사례 (한영대 4등→10등, 울산 BIT 20등→25등)가 상쇄되고 있었다.
11. 전환점 — “실험이 잘못된 것 아니냐”
“표는 BM25가 잘 가져와야 하는데 안 되는 거면, 구조적 문제거나 실험이 잘못된 것 아니냐”
이 지적을 받고 재검증했다. 실험이 잘못돼 있었다.
토큰화는 멀쩡했다
먼저 kiwi 토큰화를 의심했는데 아니었다. DDoS, CC, ISO, 등급 전부 온전히 살아 있다.
하이픈 분해도, 표 마크업 오염도 없었다.
진짜 원인 — 사업명이 상위를 독식
네팔 ISO 4373 — 정답 청크가 59등이었던 이유:
| 내용 | 매칭 토큰 | |
|---|---|---|
| 질문 | 네팔·수자원·관리·정보·화·사업·Pilot·시스템·구축·용역 + 수문·관측·기자재·ISO·표준 | 28개 |
| 정답 청크 (255자) | | 관련요구사항 | ISO 4373, ISO 11655 | | 6개 → 59등 |
| BM25 1등 | 같은 문서 ## 1. 과업개요 / 용역명: 네팔 수자원관리 정보화사업 Pilot... | 사업명 10토큰 |
DDoS CC인증 — 정답 청크 518등. 1등은 다른 문서의 | 요구사항 분류 | 보안 요구사항 |.
우리 골든셋 질문은 전부 “〈사업명 10토큰〉에서 〈핵심어 1~2토큰〉은?” 형식이다. BM25는 매칭 토큰의 합산 점수라, 사업명을 반복하는 개요·표지가 정답을 항상 이긴다.
조건별 BM25 단독 MRR
| 조건 | MRR |
|---|---|
| A. 전체 코퍼스 (1차 조건) | 0.349 |
| B. 문서 한정 | 0.533 (+0.184) |
| C. 사업명 토큰 제거 | 0.126 (−0.223) |
메커니즘 — 사업명 토큰이 두 일을 겸한다
C가 붕괴한 게 핵심 단서다. “사업명이 정답을 잡아먹는다”는 단순한 설명은 틀렸다.
| 역할 | 효과 |
|---|---|
| 올바른 문서로 좁히기 | 필수 — 없으면 99개 문서에서 엉뚱한 청크가 올라옴 (C 붕괴) |
| 문서 안에서 청크 고르기 | 방해 — 개요·표지가 정답 표를 밀어냄 (네팔 1등이 과업개요) |
한 신호가 두 일을 겸하는데 방향이 반대라 상쇄된다. 문서를 외부에서 좁혀주면 첫 역할이 필요 없어지고 충돌이 풀린다.
12. 2차 — 문서 한정 → 통과
| 방식 | 전체 36 | 어휘형 8 | 기존 28 |
|---|---|---|---|
| dense 단독 | 0.577 | 0.750 | 0.527 |
| BM25 단독 | 0.532 | 0.701 | 0.484 |
| 하이브리드 | 0.650 | 0.854 | 0.591 |
| Δ vs dense | +0.073 | +0.104 | +0.064 |
승격 기준 +0.050 ██████████████████████████
1차 전체 코퍼스 +0.002 █
2차 문서 한정 +0.073 ██████████████████████████████████████
승격 기준 통과. 어휘형·기존 두 구간이 각각 단독으로도 넘는다.
같은 BM25, 같은 RRF, 같은 36문항. 바꾼 건 검색 범위 하나뿐이다.
BM25가 dense를 대체하는 게 아니라, 둘이 서로 다른 걸 보기 때문에 합쳤을 때 이득이 난다. 전체 코퍼스에서는 BM25가 너무 약해(0.349 vs 0.577) 합쳐도 끌어올릴 힘이 없었던 것.
13. 3차 — “표만 검색하면?” → 역효과
BM25를 표 전문 검색기로 쓰면, 개요·표지 같은 텍스트 청크가 방해하지 못하지 않을까?
발상은 타당했지만 결과는 반대였다.
| 조건 | BM25 전체 | BM25 표만 | 하이브리드 전체 | 하이브리드 표만 |
|---|---|---|---|---|
| 전체 코퍼스 | 0.346 | 0.190 | 0.582 | 0.485 |
| 문서 한정 | 0.532 | 0.296 | 0.650 | 0.477 |
전부 나빠진다. 표만 쓰는 하이브리드는 베이스라인(0.577)보다도 아래다.
왜 안 되나
① 정답 청크의 대부분이 표가 아니다
| 구간 | 정답 청크 | 표 | 표 아님 |
|---|---|---|---|
| 기존 (28문항) | 63개 | 19개 (30%) | 44개 (70%) |
| 어휘형 (8문항) | 10개 | 8개 (80%) | 2개 |
표만 뒤지면 기존 문항 정답의 70%를 아예 못 찾는다 → 기존 구간 0.484 → 0.211로 반토막.
② 표만 남겨도 사업명 문제가 안 사라진다
어휘형(정답의 80%가 표)에서도 0.701 → 0.593으로 떨어진다.
사업개요·표지가 표 형태인 경우가 많기 때문이다 —
네팔 문서에서 BM25 2등이 | 사 업 명 | 네팔 수자원관리 정보화사업 | 이런 표였다.
텍스트 청크만 걷어내도 방해꾼은 그대로 남는다.
핵심은 범위를 좁히는 것이지, 대상을 표로 거르는 게 아니다. 좁히면 사업명이 무력화되고(모든 청크에 똑같이 있으니), 거르면 정답을 잃는다.
14. 전체 매트릭스 — 36문항 MRR
| # | 범위 | 방식 | 전체 | 어휘형(8) | 기존(28) |
|---|---|---|---|---|---|
| ① | 전체 | dense (베이스라인) | 0.576 | 0.750 | 0.527 |
| ② | 전체 | BM25 | 0.346 | 0.278 | 0.365 |
| ③ | 전체 | BM25 표만 | 0.190 | 0.282 | 0.164 |
| ④ | 전체 | 하이브리드 | 0.582 | 0.754 | 0.533 |
| ⑤ | 전체 | 하이브리드 표만 | 0.485 | 0.754 | 0.408 |
| ⑥ | 문서 | dense | 0.577 | 0.750 | 0.527 |
| ⑦ | 문서 | BM25 | 0.532 | 0.701 | 0.484 |
| ⑧ | 문서 | BM25 표만 | 0.296 | 0.593 | 0.211 |
| ⑨ | 문서 | 하이브리드 | 0.650 🏆 | 0.854 | 0.591 |
| ⑩ | 문서 | 하이브리드 표만 | 0.477 | 0.830 | 0.376 |
15. 가설 최종 판정
| 가설 | 전체 코퍼스 | 문서 한정 |
|---|---|---|
| H1 dense가 표에서 약하다 | 기각 (어휘형 9/10 적중) | 기각 유지 |
| H2 BM25가 구멍을 메운다 | 거의 기각 | 성립 — 단독 0.532로 dense와 대등 |
| H3 합치면 순위가 오른다 | 기각 (+0.002) | 성립 (+0.073) |
결론과 다음
16. 🔴 프로덕션 코드가 거꾸로 켜져 있다
# src/query/retrieve.py:36
use_bm25 = config.RETRIEVAL_USE_BM25 and doc_id is None
# ↑ 문서 지정되면 BM25를 끈다주석의 근거: “후보가 한 문서로 좁혀져 어휘 검색 이득이 사라진다” (구현 경위 → 1부 §6)
실측은 정반대다. 좁혀져야 이득이 생긴다. BM25가 유일하게 잘 작동하는 조건에서 꺼두고, 안 되는 조건에서만 켜도록 돼 있다.
→ and doc_id is None 조건을 뒤집는 것이 이 실험의 실질적 산출물.
17. 남은 한계
| # | 한계 | 왜 중요한가 |
|---|---|---|
| 1 | doc_id를 골든셋 정답 문서로 줬다 | 라우터 100% 가정. 실전값 = +0.073 × 라우터 정확도 |
| 2 | 승격 기준 2번(기존 hit@5 = 1.0) 미확인 | 두 조건 다 만족해야 승격 |
| 3 | 손으로 짠 예비 측정 | 정식 러너로 재현 필요 |
| 4 | 36문항 (어휘형은 8문항) | 통계적으로 얇다 |
라우터 정확도별 실전 기대값
| 라우터 정확도 | 실전 Δ | 판정 |
|---|---|---|
| 100% | +0.073 | 통과 |
| 70% | +0.051 | 아슬아슬 |
| 50% | +0.037 | 미달 |
18. 다음 할 일
| 순서 | 할 일 | 비고 |
|---|---|---|
| 1 | 라우터 문서 판정 정확도 측정 | 이 숫자에 따라 코드 수정 정당성이 갈림 |
| 2 | retrieve.py:36의 and doc_id is None 뒤집기 | 이 실험의 실질적 산출물 |
| 3 | 정식 러너로 재현 | run_rerank_experiments.py를 문서 한정 조건으로 |
| 4 | 승격 기준 2번 확인 | 기존 hit@5 = 1.0 유지되는지 |
| 5 | 리랭커(C·D)는 그 다음 | bge-reranker-v2-m3 568M, VM L4. OpenAI 안 씀 |
18-1. 리랭커 — 미착수 계획 (구 검색 실험 구도.md에서 이관)
BM25만 측정을 끝냈고 리랭커는 아직 아무 판정도 없다. 설계 문서를 _archive/로 내리면서 아직 유효한 부분만 여기로 옮긴다.
모델 축은 변형 표와 분리해서 돈다. 후보를 고정한 채 점수만 다시 매기면 되기 때문이다. 표에 곱하면 14변형이 되지만, 분리하면 이렇게 끝난다:
검색 1회 → 후보 20개 확정 ← 모델 바꿔도 불변
↓ 이 20개를 모델별로 재배열
문항 × 후보 20 = 모델당 700쌍
| 모델 | 역할 |
|---|---|
BAAI/bge-reranker-v2-m3 | 기준선 (현행) |
upskyy/ko-reranker-8k | 한국어 파인튜닝본 |
둘 다 2.27GB · 최대 8194토큰 · Apache-2.0. ko-reranker-8k는 모델 카드상 v2-m3를 한국어 데이터로 파인튜닝한 것이라 구조·토크나이저·길이가 같고 변수가 “한국어 학습” 하나뿐인 통제된 비교가 된다. 코드는 RERANK_MODEL 한 줄만 바뀐다.
탈락시킨 셋과 이유 — Dongjin-kr/ko-reranker(최대 514), bge-reranker-base(514 + 저성능), jina-reranker-v2(CC-BY-NC 비상업).
514 토큰이 왜 탈락 사유인가 — 리랭커 입력 길이 실측:
| 입력 | 중앙값 | 512 초과 |
|---|---|---|
| 청크 본문 (현행) | 148 | 0% |
parent_text (표 전문, 개선 후보) | 454 | 46% |
지금 입력이면 514 모델도 무해하다. 그러나 표 전문을 넣는 개선을 하면 절반이 잘린다. 실패 사례 상당수가 표 문항이라 그 길을 막을 수 없다.
모델 교체 기준: 동일 후보 위에서 MRR@10 +0.03 이상이면 교체, 미만이면 v2-m3 유지. 이 축은 A~D 승격 판정에는 넣지 않는다.
구현은 src/eval/ir_metrics.py (순수 함수 + stdlib).
단순한 쪽 우대 원칙 — 리랭커는 공짜가 아니다. 568M 모델을 매 질의마다 20개 청크에 돌리는 비용·지연·GPU 메모리를 0.02 차이로 정당화할 수 없다.
19. 부산물 — 진짜 문제는 따로 있다
기존 28문항의 dense MRR이 0.527이다. 정답이 상위 20에 아예 없는 경우가 곳곳에 있다.
- 봉화군 통합연계 — 정답 6개 중 하나가 15등
- GIS 데이터품질 — 20위권 밖
- 도시계획위원회 발주기관 — dense가 2개 다 놓침
hit@5 = 1.000 포화는 문서 단위 얘기였다. 청크 단위로는 절반 수준이다.
개선 여지가 크게 남아 있고, 하이브리드(+0.073)로도 0.650까지밖에 못 올린다.
3부 · 미해결과 원칙
미해결 — 검색 품질을 갉아먹는 것
1. 요구사항 표의 고유번호 유실 — 요구사항 청크 10,342개 중 3,252개(31%)에 SFR-03 같은 번호가 없다. 세로형 키-값 표가 500자에서 잘리며 ID 행이 앞 청크에 남는 구조다. 07-27 청킹 수정으로 45% → 31%까지 줄었으나 여전히 최대 손상. 상세 → 인덱스 데이터 손상 진단
2. 실험 러너의 doc_id 정규화 누락 — scripts/run_experiments.py:125에 _norm_doc_id가 빠졌다. 골든셋 doc_id는 .hwp 확장자를 달고 있는데 인덱스는 make_doc_id() 출력이라 직접 비교하면 0건 매칭. src/eval/run_eval.py:199에는 정규화가 있다. 미수정.
3. 플래그 ON 전 선결 — 동시성 — src/api/server.py의 ask()가 동기 함수라 FastAPI 스레드풀 동시 요청 시 모듈 캐시(리랭커 568M 모델·BM25 인덱스)가 중복 로드될 수 있다 → L4 GPU OOM 위험. 락 필요.
관통하는 원칙
검색 단위와 렌더 단위를 분리한다. Parent-Document(1부 §3)·메타 헤더(1부 §5) 모두 인덱스는 안 건드리고 format_context만 고쳐 재인덱싱 0회로 해결했다.
결과 계약 불변을 테스트로 못 박고 바꾼다. LCEL 전환(1부 §4)의 파리티 확인, 하이브리드(1부 §6)의 플래그 OFF 동일성 회귀 가드.
자가 죽었으면 검색보다 자부터 고친다. hit@5 포화 → 청크 단위 지표 신설(1부 §6).
추정은 실측으로 검증한다. 실패 원인 분석이 두 번 뒤집힌 기록 → 검색 실험 골든셋 40문항 교훈 A-⑤
실험에서 배운 것
측정 편의를 위해 실전 조건을 바꾸면, 그 실험은 실전에 대해 아무 말도 못 한다.
doc_id 필터를 끈 이유가 *“지표가 포화돼 변별력이 없어서”*였다.
포화는 그 조건에서 검색이 잘 된다는 신호지, 조건을 버리라는 뜻이 아니다.
포화를 피하려면 더 어려운 문항이나 더 민감한 지표를 써야 한다.
그리고 “승격 기준을 측정 전에 확정한다”는 규칙을 지키느라 조건 설정 자체는 검토하지 않았다. 기준을 고정하는 것과 조건을 검증하는 것은 별개다. 전자만 지키면 엄격하게 잘못된 답이 나온다.
관련
- 검색 실험 구도 — 사전등록 설계서. 아카이브됨(
_archive/), 유효 잔여분은 2부 §18-1로 이관 - 검색 실험 골든셋 40문항 — 평가셋 제작 과정
- 인덱스 데이터 손상 진단 —
chroma_v2재인덱싱 배경 - 골든셋 — 확장셋 (별도 트랙)
- 쿼리 파이프라인 · 쿼리 파이프라인 개선안 · 쿼리 파이프라인 · 검색 품질 개선 (BM25·리랭커)
- 코드:
src/query/hybrid.py,src/query/retrieve.py,scripts/run_rerank_experiments.py - 정본:
Plans.md6.5/6.7,spec.md§6-2,docs/ADR.mdADR-006