검색 2차 실험 (임베딩·BM25·리랭커)
아카이브됨 — 새 설계·결과 문서로 대체됐다 (2026-07-29)
설계와 결과를 한 문서에 둔다. 계획과 결과가 나뉘어 있으면 “뭘 하려 했고 어떻게 됐나”를 두 번 찾아야 한다. 1차 판정 → 검색 실험 결과 (164문항) · 정답지를 고친 기록 → 정답지 편향 제거 (전체 풀링 라벨링) · 용어 → 개념 색인
결론 — 아무것도 안 바꾼다 (2026-07-29)
축 판정 왜 임베딩 ❌ e5-small 유지 KURE가 +0.0297 앞서지만 신뢰구간 하한이 −0.0042. 확실한 비용 vs 증명되지 않은 이득 → §8 리랭커 ✅ bge-reranker-v2-m3(R1) 유지한국어 튜닝판 R2는 세 채점 가정 어디서도 유의하게 진다 → §10 BM25 ⚠️ 미채택이나 아직 제대로 잰 적이 없다 변형 설계 결함 3건으로 기존 하이브리드 수치가 전부 무효. 진짜 하이브리드(G) 재측정 중 → §9 현행
e5-small + R1이 그대로 남는다. 단 §9는 열려 있다.
0. 세 줄 요약
- dense 단독으로는 새 임베딩이 크게 이긴다(+0.10). 그런데 리랭커를 켜면 그 이득이 대부분 흡수된다 — 종단 격차는 +0.0297이고 신뢰구간이 0을 품는다 → §7·§11.
- 정답지가 e5에 편향돼 있던 것을 라벨링으로 걷어내고 다시 재도 결론은 같았다. 편향 제거 후 KURE 이득이 +0.0068 → +0.0297로 4배 커졌지만, 164문항으로는 이 크기를 우연과 구분할 수 없다.
- 대신 다른 문이 열렸다. 리랭커 위에서 BM25를 잰다던 변형이 아무것도 재지 못하는 설계였고, 게다가 dense는 문서 안, BM25는 전체를 보고 있었다. e5가 못 찾은 17문항 중 11문항을 BM25가 건진다 → §9.
1부 · 무엇을 어떻게 실험했나
1. 왜 또 하나
1차에서 이렇게 끝났다 — dense 0.6402 / +BM25 0.6096 ❌ / +리랭커 0.7443 ✅ 채택. 세 가지가 남았다.
① BM25로 메우려던 계획이 사라졌다. docs/ADR.md ADR-006이 지금 임베딩을 고르며 약점을 이렇게 적어놨다.
대형 임베딩 모델 대비 한국어 고유명사·표 검색 품질 한계 → 메타데이터 필터·프리픽스 주입·(백로그) BM25 하이브리드로 보완
앞의 둘은 들어가 있고 세 번째가 1차에서 기각됐다. 약점을 메울 수단이 없어졌으니 엔진 자체를 볼 차례였다.
② 리랭커를 채택하면서 임베딩의 임무가 바뀌었다.
[예전] 질문 → 임베딩이 20개 찾고 순위도 매김 → 상위 5개
[지금] 질문 → 임베딩이 20개 찾음 → 리랭커가 줄세움 → 상위 5개
└ 찾기만 └ 줄세우기
리랭커는 후보 밖의 것을 데려올 수 없다. 20개 안에 정답이 없으면 끝이다.
| 임베딩을 바꿔서 얻는 것 | 리랭커가 대신해주나 | 값어치 |
|---|---|---|
| 정답을 20개 안에 넣는 능력 | ❌ 못 함 | 크다 |
| 20개 안에서 정답을 위로 올리는 능력 | ✅ 이미 함 | 작다 |
③ 실제로 못 찾아오고 있었다 — 이게 결정적. e5의 Recall@20이 0.8163이고, 정답이 20위 안에 하나도 없는 문항이 17개다. 저 17문항은 리랭커를 켜도 영원히 못 맞힌다(spec.md §10-17이 **“리랭커로 도달 불가능한 상한”**이라 부르는 그 문항들이다).
2. 실험 대상 ★
2-1. 임베딩 3종
| 코드 | 모델 | 크기 | 차원 | 프리픽스 | 비고 |
|---|---|---|---|---|---|
| M0 | intfloat/multilingual-e5-small | 118M | 384 | query: / passage: 필수 | 현행 운영 (ADR-006) |
| M1 | BAAI/bge-m3 | 568M | 1024 | 없음 | HF 캐시 4.3GB |
| M2 | nlpai-lab/KURE-v1 | 0.6B | 1024 | 없음 | bge-m3의 한국어 튜닝판 |
M1을 반드시 넣어야 했다. M2는 M1을 한국어로 추가 학습한 모델이라, M1 없이 M2만 돌리면 이긴 이유가 갈리지 않는다.
| 결과 | 해석 |
|---|---|
| M0→M1 크고 M1→M2 작다 | 계열이 관건 |
| M0→M1 작고 M1→M2 크다 | 한국어 튜닝이 관건 |
| M1→M2 ≤ 0 | 한국어 튜닝 불필요 |
왜 A.X-Encoder는 뺐나 — skt/A.X-Encoder-base는 팀 리서치 후보였으나 검색용으로 학습된 모델이 아니다(ModernBERT 기반 문서 이해·분류용, 모델 카드 예제가 fill-mask). 검색용 임베딩은 “질문과 정답 문단의 벡터가 가까워야 한다”를 따로 배우는데 A.X는 그 학습을 받은 적이 없어 낮게 나오는 게 정상이다. 그 숫자로 기각하면 팀원 제안을 잘못 기각하는 것이 된다. 남길 사유는 “A.X가 나쁘다”가 아니라 “검색 파인튜닝이 없어 비교 조건이 안 맞는다”.
2-2. 리랭커 2종 (크로스인코더 리랭커)
| 코드 | 모델 | 크기 | 비고 |
|---|---|---|---|
| R1 | BAAI/bge-reranker-v2-m3 | 568M | 현행 채택 (ADR-009) |
| R2 | dragonkue/bge-reranker-v2-m3-ko | 568M | R1의 한국어 튜닝판 |
임베딩 쪽과 구조가 똑같다 — 원본 vs 한국어 튜닝판. M1 vs M2의 답과 R1 vs R2의 답이 같은 방향인지도 볼거리였다.
2-3. 후보 구성 변형 — 리랭커에 무엇을 입력하는가
전부 _doc 스코프(대상 문서 안에서만)로 통일한 뒤의 정의다 — 그러지 않았을 때 무슨 일이 났는지는 §9-2.
| 코드 | 후보 구성 | 개수(실측) | 상태 |
|---|---|---|---|
| C | dense_doc 그대로 | 20.0 | 기준선 (현행 운영) |
| D | dense_doc을 RRF로 재정렬만 (집합 불변) | 20.0 | 폐기 — 리랭커 뒤에서 C와 동치. 회귀 감시용으로만 남긴다 → §9-1 |
| E | dense_doc ∪ bm25_doc 융합 상위 20 | 20.0 | BM25가 dense를 밀어낼 값어치가 있나 |
| F | 같은 융합 상위 30 | 29.8 | 예산을 늘리면 |
| G | dense_doc ∪ bm25_doc 전부 (RRF 미사용) | 35.2 | ★ 핵심. 빼지 않고 더하기만 한다 |
C → G가 답이다. 빼는 게 없으니 차이가 나면 원인이 하나뿐이다. E·F는 BM25를 더하면서 dense를 동시에 빼므로, 지면 *“BM25가 나쁜 건지 빠진 dense가 좋았던 건지”*를 가릴 수 없다.
G는 RRF를 아예 쓰지 않는다 — 순위 합치기를 RRF와 리랭커가 이중으로 할 이유가 없다.
bm25_doc 캐시 실측(문항당 20개): dense와 겹침 M0 4.8 · M1 7.2 · M2 5.3 → 합집합 M0 35.2 · M1 32.8 · M2 34.7.
BM25는 어휘 검색이라 임베딩과 무관해 한 번만 계산해 세 캐시에 공유한다.
3. 실험 구도 — 단계마다 뭘 고정하고 뭘 바꿨나
6.8.5 리랭커 없이 dense vs dense+BM25 임베딩 3 × BM25 on/off = 6조합
6.8.6 리랭커 얹고 R1 vs R2 임베딩 3 × 리랭커 2 = 6조합 (각 4변형)
6.8.7 채점 가정을 바꿔가며 편향 측정 상·하한·응축 3가정
6.8.8 정답지를 고침 (전체 풀링 라벨링) 골든셋 v4 → v5
6.8.9 진짜 하이브리드 (E·F·G) 추가 임베딩 3 × R1 × 6변형 ← 재측정 중
4. 실험 베이스라인 — 무엇을 기준으로 재나 ★ 측정 조건 정본
이 절이 측정 조건의 정본이다. 조건이 헷갈리면 여기로 돌아온다. 아래 값이 하나라도 달라지면 이전 수치와 비교할 수 없다.
4-1. 기준선 (이겨야 하는 상대)
| 구성 | M0 + R1 = e5-small + bge-reranker-v2-m3 |
| 성적 | 종단 MRR@10 = 0.7443 |
| 출처 | 1차 실험 C_doc 채택 구성 → 검색 실험 결과 (164문항) |
새 조합은 이 수치를 신뢰구간으로 이겨야 채택된다(§6). dense 단독(0.6402)이 아니라 리랭커까지 켠 상태가 기준선이라는 점이 중요하다 — 임베딩을 바꿔 얻는 이득 중 상당 부분을 리랭커가 이미 가져갔을 수 있기 때문이다.
4-2. 정답지 (무엇으로 채점하나)
| 골든셋 | 측정 당시 v4_20260729 → 최종 채점은 v5(6.8.8 라벨링 반영) |
| 문항 | 200문항 중 채점 성립 164문항 (제외: 모른다 15 · 반문 15 · 빈 정답 6) |
| 정답 청크 | v4 연인원 310 → v5 연인원 353 |
| 유형 (채점 164 기준) | 단일세부 90 · 후속 29 · 탐색비교 25 · 직답 20 |
| 상세 | 골든셋 · 골든셋 200문항 제작 |
정답지는 원래 e5가 만든 후보에서 뽑혔다
새 모델이 찾아온 옳은 청크가 정답지에 없어서 오답 처리된다. 이 편향을 상·하한으로 가둔 뒤 전체 풀링 라벨링으로 제거했다 → 정답지 편향 제거 (전체 풀링 라벨링)
4-3. 인덱스·검색 조건 (전 조합 동일)
| 항목 | 값 | 비고 |
|---|---|---|
| 인덱스 계보 | chroma_v2 (= VM /srv/GPTPilot/chroma) | 30,002청크 / 99문서 |
| 검색 범위 | 문서 한정 (_doc) | 프로덕션에 다른 경로가 없다 |
| 후보 수 | 20 (RERANK_CANDIDATE_K) | G만 합집합 전량 |
| 정규화 | normalize_embeddings=True | 전 모델 공통 |
| 질문셋 | 164문항 동일 | 조합마다 바꾸지 않는다 |
4-3-1. 왜 문서를 먼저 좁히나 — 그리고 이 수치의 전제
파이프라인이 2단이다.
① doc_cards 컬렉션(문서 100개 요약카드)으로 어느 문서 얘기인지 특정
② 그 문서의 청크 안에서만 검색 where={"doc_id": ...}
이유는 RFP 100개가 서로 너무 닮았기 때문이다. 전부 정부 제안요청서라 “사업기간은 계약일로부터 O개월” 같은 문장이 100개 문서에 다 있다. 문서를 안 좁히면 벡터 유사도상 엉뚱한 사업의 절이 위에 올라온다. 실측으로도 C_doc 0.7443 > C_full 0.7313이다.
이 실험은 ①이 항상 맞았다고 가정한다
골든셋의
대상_doc_id를 그대로 쓴다. 실서비스에서는 라우터가 문서를 틀릴 수 있으므로 **이 문서의 모든 수치는 “라우팅이 완벽할 때의 상한”**이다.검색 성능과 라우팅 성능을 분리해 재려는 의도된 설계지만, “실서비스 성능”으로 읽으면 안 된다.
4-4. 고정 vs 변수
| 항목 | |
|---|---|
| 절대 고정 | 청크 경계·chunk_id(§5-1) · 토크나이저(e5) · 위 인덱스/검색 조건 |
| 변수 | 임베딩 3 × 리랭커 2 × 후보 구성 |
4-5. 프리픽스 ⚠️ 통제 항목 중 제일 위험
E5는 passage: /query: 를 반드시 붙여야 하고 bge-m3·KURE는 붙이면 안 된다. 틀려도 에러가 안 나고 성능만 조용히 떨어진다.
함정이 하나 더 있었다 — src/index/chunk.py:58이 passage: [섹션명]\n 형태로 프리픽스 뒤에 섹션 이름을 같이 붙인다. 통째로 떼면 섹션 정보까지 날아가 새 모델만 정보를 덜 받는다.
| 모델 | 문서 입력 | 질문 입력 |
|---|---|---|
| M0 (e5) | passage: [섹션명]\n + 본문 | query: + 질문 |
| M1·M2 | [섹션명]\n + 본문 | 질문 그대로 |
각 인덱스 첫 청크의 실제 입력 문자열을 눈으로 확인하고 넘어갔다.
5. 이 실험이 성립하게 만든 장치 둘
5-1. 청크 경계를 고정했다 — chunk_id 게이트
chunk_id는 문서 내 위치 기반이다.
# src/index/chunk.py:321
"chunk_id": f"{doc_id}::{len(chunks):04d}" # 몇 번째 청크인가청크 경계가 밀리면 164문항 골든셋의 정답 chunk_id가 전부 다른 청크를 가리킨다. 에러 없이 조용히 어긋나는 종류다 → 청크와 chunk_id
그래서 임베딩을 바꿔도 청킹은 항상 e5 토크나이저(CHUNK_TOKENIZER_MODEL)로 한다. 안 쓰는 토크나이저로 자르는 게 이상해 보이지만 청킹 고정이 목적이라 이게 맞다. bge-m3·KURE가 8192토큰을 받는다고 청크를 키우는 건 다른 실험이다.
게이트 결과 — 통과.
| 항목 | 결과 |
|---|---|
| chunk_id 차집합 (M1 − M0 / M0 − M1) | 0건 / 0건 |
| 총 청크 | 30,002 = 30,002 |
| 문서 수 | 99 = 99 |
| 문서별 청크 수 불일치 | 0건 |
384차원·프리픽스 있음 → 1024차원·프리픽스 없음으로 통째로 바꿨는데 청크 경계가 하나도 밀리지 않았다. 여기서 어긋났다면 골든셋이 통째로 무효가 되고 실험을 다시 설계해야 했다 — 이 실험에서 가장 크게 걸려 있던 위험이 여기서 닫혔다. M2도 같은 게이트를 통과했다.
5-2. 검색은 한 번만 돌린다 — 후보 캐시
dense 검색과 BM25를 미리 돌려 chunk_id와 거리만 저장해두고, 변형 조합은 오프라인 계산으로 만든다. 그래서 변형을 추가해도 재검색이 필요 없다 — E·F·G를 지금 몇 분 만에 잴 수 있는 것도 이 덕분이다. 질문·청크 원문을 저장하지 않아 NDA 안전하다.
캐시 생성 자체도 **1,887초 → 136초(13.9배)**로 줄였다. 산출물은 1차와 비트 단위 동일하다.
세 모델이 같은 엔진을 써야 한다
Chroma는
doc_id필터를 걸어도 전수 비교가 아니라 근사 탐색이라, 정확 계산과 대조하니 164문항 중 **9문항(5.5%)**에서 상위 20이 달랐다. M0 캐시는 근사 산출물이므로 M1·M2만 정확 계산으로 만들면 새 모델이 임베딩 성능이 아니라 검색 방식 덕에 유리해진다. 전부 기본값fast로 갔다.
세부 → 후보 캐시
6. 지표와 승격 기준 — 측정 전에 확정했다
§1-②의 이유로 지표를 나눈다.
| 지표 | 무엇을 보나 | 역할 |
|---|---|---|
| Recall@20 | 정답이 후보 안에 들어왔나 | 진단 — 임베딩 고유 실력 |
| 종단 MRR@10 (리랭커 ON) | 사용자가 실제로 받는 순위 | 판정 ★ |
| 단독 MRR@10 (리랭커 OFF) | 리랭커 없을 때 순위 | 참고 |
| nDCG@10 · Recall@5 | 보조 | 회귀 감시 |
판정은 종단 성적으로 한다. 그게 실제로 돌아갈 구성이기 때문이다. 1차에서 프로덕션에 없는 경로를 재서 결론이 뒤집혔던 교훈을 그대로 적용한다.
승격 기준:
| 조건 | 값 | 왜 이 값인가 |
|---|---|---|
| 주 기준 | 종단 MRR@10 차이의 95% 신뢰구간 하한 > 0 | 아래 |
| 최소 폭 | 점추정 +0.03 이상 | 재인덱싱 한 번이면 되고 질의 비용은 리랭커 옆에서 무시할 수준. **“비용을 넘는가”보다 “노이즈가 아닌가”**가 관건 |
| 회귀 금지 | 어떤 유형도 −0.03 이하로 떨어지지 않을 것 | 평균이 오르며 한 유형이 망가지는 걸 막는다 (1차 BM25가 정확히 그랬다) |
신뢰구간을 쓰는 이유: 지난번에 이것 때문에 틀렸다. 36문항에서 +0.073이 나와 “확정”이라 적었는데 164문항에서 −0.031로 부호가 뒤집혔다. “평균이 올랐다”만으로는 또 같은 실수를 한다 → paired bootstrap 여러 구성이 통과하면 작은 쪽을 고른다.
2부 · 판정
7. 최종 성적표 ★ 값 정본
164문항 · C_doc · MRR@10 · 골든셋 v5(편향 제거 후) · 리랭커 R1/R2.
| 조합 | MRR@10 | 기준선 대비 | 95% CI (paired bootstrap 1만 회) | 판정 |
|---|---|---|---|---|
| e5-small + R1 (현행) | 0.7443 | 기준선 | — | ✅ 유지 |
| bge-m3 + R1 | 0.7640 | +0.0197 | [−0.0151, +0.0578] | ❌ |
| KURE-v1 + R1 | 0.7740 | +0.0297 | [−0.0042, +0.0654] | ❌ 아슬아슬하게 기각 |
| e5-small + R2 | 0.6805 | −0.0638 | [−0.1005, −0.0275] ✱ | ❌ |
| bge-m3 + R2 | 0.6736 | −0.0707 | [−0.1169, −0.0243] ✱ | ❌ |
| KURE-v1 + R2 | 0.6856 | −0.0587 | [−0.1035, −0.0140] ✱ | ❌ |
✱ = 95% 구간이 0을 포함하지 않음
7-1. 참고 — dense 단독 (리랭커 없이, 골든셋 v4 기준)
임베딩 자체의 실력. 판정용이 아니라 진단용이다(§6).
| 임베딩 | MRR@10 | +BM25 | nDCG@10 | Recall@5 | Recall@20 | 정답이 후보에 아예 없음 | |
|---|---|---|---|---|---|---|---|
| M0 | e5-small | 0.6402 | 0.6096 | 0.6061 | 0.6188 | 0.8163 | 17 |
| M1 | bge-m3 | 0.7417 | 0.6179 | 0.6973 | 0.7132 | 0.8557 | 14 |
| M2 | KURE-v1 | 0.7416 | 0.6491 | 0.7082 | 0.7335 | 0.8631 | 13 |
M1 ≈ M2 — dense 단계에서 한국어 튜닝의 이득은 없다. §2-1의 판정표대로 읽으면 **“계열이 관건”**이다. M0의 Recall@20 0.8163은 1차 실험값과 정확히 재현됐다(파이프라인 재현성 근거).
이 표의 +BM25 열은 “리랭커 없이, 전역 스코프 BM25로, RRF 순서를 그대로 쓴” 조건의 측정이다. 그 조건에서는 임베딩 3종 전부 순손해였고 dense가 강할수록 더 크게 진다(−0.031 → −0.124). 그 조건 안에서만 유효하다 — 스코프를 맞춘 하이브리드는 §9에서 다시 잰다.
8. 왜 임베딩을 안 바꾸나 — “차이가 없다”가 아니다
KURE는 +0.0297로 최소 폭(+0.03)에 사실상 닿았다. 그런데 95% 구간 하한이 −0.0042다. 164문항으로는 이 크기를 우연과 구분할 수 없다.
| 치러야 하는 것 (확실) | 얻는 것 (불확실) |
|---|---|
| 재인덱싱 28.5분 | MRR +0.0297 — 다만 구간이 0을 품는다 |
| 모델 5배 (118M → 0.6B) | |
| HF 캐시 4.3GB (VM 여유 7GB) |
**정직하게 쓰면 “교체해도 손해는 없지만 이득이 증명되지 않는다”**이다. “차이가 없다”가 아니다. 점추정은 계속 KURE가 앞섰고, 정답지 편향을 걷어낼수록 격차가 벌어졌다.
8-1. 재판정 조건 — 이 셋 중 하나면 다시 본다
- 골든셋이 300문항 이상으로 늘어난다 — 구간이 좁아져 +0.03이 유의해질 수 있다
- 다른 이유로 재인덱싱이 필요해진다 — 교체 비용이 사라지면 교환 조건 자체가 바뀐다
- Recall@20이 병목인 기능이 생긴다 — e5 0.8163 vs KURE 0.8631(§7-1)
9. BM25는 리랭커 위에서 한 번도 제대로 측정된 적이 없다 ★
"BM25 미채택"을 어휘 검색 전체를 기각한 것으로 읽으면 안 된다
리랭커를 켠 상태의 하이브리드 측정에 설계 결함이 셋 있었다(§9-1~9-3). 지금까지의 BM25 하이브리드 수치는 전부 무효다 → §9-5.
9-1. D가 무의미했던 이유
핵심 사실 하나에서 다 따라온다.
리랭커가 뒤에서 후보 전체를 다시 정렬한다. 따라서 앞단의 RRF는 순서가 아니라 **누가 후보에 들어오나(집합)**만 결정한다.
D는 dense 후보 20개를 RRF로 재정렬만 하고 집합은 그대로 둔다(if c in seen으로 BM25 전용 청크를 버린다). 리랭커가 어차피 다시 정렬하므로 D는 C와 수학적으로 같다.
C: [dense 20개] ───────────────────→ 리랭커 재정렬 → 결과
D: [dense 20개] → RRF로 순서만 바꿈 → 리랭커 재정렬 → 결과
↑ 여기서 바꾼 순서를 ↑ 여기서 지운다
개념적으로도 틀렸다 — D는 BM25를 후보 공급자가 아니라 리랭커로 쓰는 셈이라 하이브리드 검색이 아니다.
버린 근거였던 *“BM25 전용 청크는 본문이 없어 추가 Chroma 왕복이 필요하다 — 이득 대비 비용이 커 v1 범위 밖”*도 실측으로 무너졌다: 20개 일괄 조회 0.6ms. dense 검색 2.05초의 0.03%도 안 된다.
9-2. 그리고 검색 범위가 서로 달랐다 ★
두 번째 결함이 더 크다. dense와 BM25가 다른 범위를 보고 있었다.
| 검색 범위 | 대상 문서 밖 후보 | |
|---|---|---|
dense_doc | 대상 문서 안에서만 20개 | 0% |
bm25 (구) | 30,002청크 전체에서 20개 | 32.6% |
라벨링 규칙 **R1이 “대상 문서가 아니면 무조건 배제”**다. 즉 BM25 후보 20개 중 평균 6.5개는 정답일 수가 없는 청크였고, 그걸 그대로 리랭커에 먹이고 있었다.
그래서 지금까지의 “BM25 하이브리드” 측정은 하이브리드를 잰 게 아니라 “엉뚱한 문서의 청크를 섞으면 나빠지나”를 잰 것이다.
수정: BM25도 대상 문서 안에서만 돌도록 bm25_doc을 새로 계산해 세 캐시에 넣었다(§2-3).
교훈 — 두 검색기를 합칠 때는 검색 범위가 같은지 먼저 확인한다
dense는 문서 스코프인데 BM25는 전역이면, 합친 결과는 하이브리드가 아니라 잡음 주입이다. 실측에서 BM25 후보의 32.6%가 대상 문서 밖이었고, 그 상태로 “BM25는 손해”라는 결론이 두 번 나올 뻔했다.
9-3. 채점이 옛 라벨로 돌고 있었다
캐시 파일의 gold 필드가 골든셋 v4(정답 310개)로 박혀 있어 스크립트 로그 수치가 전부 옛 기준이었다. 세 캐시를 v5(353개)로 갱신했다. 이제 로그가 곧 v5 기준이다.
9-4. 버려온 것의 크기 (v5 라벨 기준 실측)
| 임베딩 | dense_doc가 정답을 하나도 못 찾은 문항 | 그중 BM25가 찾아준 문항 |
|---|---|---|
| M0 e5-small (현행) | 17 | 11 |
| M1 bge-m3 | 10 | 3 |
| M2 KURE-v1 | 9 | 3 |
e5가 못 찾은 17문항 중 11문항을 BM25가 건진다. §1-③의 “리랭커로 도달 불가능한 상한” 바로 그 영역이다.
9-5. 지금까지의 BM25 하이브리드 수치는 전부 무효다 — 재측정 중
세 결함이 겹쳐서, 리랭커 위에서 잰 BM25 수치 중 살아남는 것이 없다.
| 결함 | 무엇을 망쳤나 |
|---|---|
| ① D가 집합을 안 바꾼다 (§9-1) | 유일한 하이브리드 변형이 아무것도 재지 못했다 |
| ② dense는 문서 스코프, BM25는 전역 (§9-2) | E·F가 하이브리드가 아니라 잡음 주입을 쟀다 |
③ 캐시 gold가 v4 (§9-3) | 로그 수치가 옛 정답지 기준이었다 |
그래서 E·F의 중간 수치는 폐기한다. 범위 불일치 상태에서 나온 값이라 인용할 수 없다.
v5 라벨 + 문서 스코프 BM25로 3조합(M0/M1/M2 × R1 × 6변형)을 재측정 중이다. 결과가 나오면 이 절을 판정으로 고쳐 쓴다.
교훈 둘
① 리랭커가 있으면 앞단 융합은 순서가 아니라 집합만 정한다. 그러므로 리랭커가 있는 파이프라인에서 *“RRF로 재정렬한다”*는 변형은 아무것도 재지 못한다. 하이브리드를 실험하려면 후보 집합이 어떻게 달라지는가로 설계해야 하고, 비교군은 빼지 말고 더하기만 해야 원인을 특정할 수 있다.
② 두 검색기를 합칠 때는 검색 범위가 같은지 먼저 확인한다. 범위가 다르면 그건 하이브리드가 아니라 잡음 주입이다.
9-6. 지금 재는 것이 곧 그 코드의 답이다
# retrieve.py:39
use_bm25 = config.RETRIEVAL_USE_BM25 and doc_id is None문서가 특정되면 BM25를 아예 끈다. 볼트에 이 줄이 *“왜 죽은 코드인지 모르고 지나치면 안 되는 자리”*로 적혀 있었는데(검색 실험 결과 (164문항) §11), 지금 재는 것이 정확히 그 판단이 옳았는지다.
C → G가 이기면 이 줄은 틀린 판단이 되고, 지면 옳은 판단이었음이 처음으로 근거를 갖는다.
10. R2는 왜 지나 — 점수가 포화한다
R2가 M0·M1·M2 어디에 붙어도 R1보다 −0.06 ~ −0.08이다. 근소한 차이가 아니다.
원인을 실측으로 잡았다 — R2는 점수가 1.0으로 포화한다.
| 한 문항의 R2 점수 | |
|---|---|
| 후보 20개 중 고유 점수 | 10개뿐 |
| 상위 3개 점수 | 전부 정확히 1.0 |
점수가 다 같으면 순위를 매길 수 없다. 변별을 못 하는 것이다 → 지표 포화
10-1. C ≡ D와 그 0.001 어긋남
C와 D가 164문항 전건 같은 값이 나왔다. 그 이유는 §9-1이다 — D가 집합을 안 바꾸므로 리랭커 뒤에서 C와 동치다.
그런데 M0 × R2에서만 C_doc 0.6805 vs D_doc 0.6815로 0.001 어긋났다. 이건 위 동치성이 깨진 게 아니라, R2의 점수 포화가 얹힌 부수 현상이다 — 점수가 동점이면 안정 정렬이 입력 순서를 그대로 유지하므로, C와 D의 입력 순서 차이가 결과에 남는다. 164문항 중 1문항(g200:4406b63d)에서 정답 청크가 C는 3등, D는 2등이 됐다.
R1에서는 한 건도 발생하지 않았다. R2를 쓰면 안 되는 이유가 하나 더 나온 셈이다.
이 자리에서 더 큰 결함을 놓쳤다
C ≡ D를 관찰하고도 “동점 안정정렬 부작용”으로 설명이 끝나버려, 그 아래의 **“D는 애초에 C와 같다”**는 사실에 도달하지 못했다. 증상에 맞는 설명을 찾으면 탐색을 멈추게 된다.
11. 이득이 리랭커에 흡수된다 · 한국어 튜닝은 양쪽 다 꽝
dense 단독: M0 0.6402 → M2 0.7416 +0.10 ← 임베딩 실력 차이는 크다
+ 리랭커 R1: M0 0.7443 → M2 0.7740 +0.0297 ← 종단에서는 3분의 1 이하
§1-②가 나눠 적어둔 두 능력으로 읽으면 정확히 맞아떨어진다.
| 임베딩을 바꿔서 얻는 것 | 예상 | 실측 |
|---|---|---|
| 정답을 20개 안에 넣는 능력 | 리랭커가 못 함 → 크다 | Recall@20 0.8163 → 0.8631, 후보 밖 17 → 13 ✅ |
| 20개 안에서 위로 올리는 능력 | 리랭커가 이미 함 → 작다 | 종단 +0.0297에 그침 ✅ |
이건 실패가 아니라 “리랭커가 이미 그 몫을 하고 있다”는 유용한 결론이다. 그리고 §9가 바로 여기서 이어진다 — **리랭커가 못 하는 일(후보를 넓히는 것)**을 BM25가 할 수 있다면, 그건 임베딩 교체와 겹치지 않는 이득이다.
한국어 튜닝은 양쪽 다 이득이 없었다.
| 축 | 원본 → 한국어 튜닝판 | 결과 |
|---|---|---|
| 임베딩 | bge-m3 → KURE-v1 | 무승부 (0.7417 vs 0.7416, dense 단독) |
| 리랭커 | R1 → R2 | 크게 짐 (−0.06 ~ −0.08) |
§2-2에서 *“둘 다 미미하면 우연이 아니라 패턴”*이라고 미리 적어둔 자리다.
12. 측정 전에 적어둔 예상 — 채점
결과를 보고 말을 바꾸지 않으려고 먼저 적어뒀던 것들이다.
| # | 예상 | 결과 |
|---|---|---|
| 1 | Recall@20은 오른다 | ✅ 0.8163 → 0.8631 |
| 2 | 종단 MRR은 그보다 덜 오른다 | ✅ +0.10 → +0.0297 |
| 3 | M1이 이득의 대부분, M2는 조금 더한다 | ✅ M1≈M2 |
| 4 | BM25는 여전히, 더 크게 진다 | ⚠️ 순서 융합에서는 맞음. 집합 확장은 미측정 |
| 5 | R1 vs R2는 근소하다 | ❌ 크게 졌다 |
| 6 | 탐색비교·후속은 거의 안 오른다 | ✅ |
예상 2번을 적으며 **“그건 실패가 아니라 리랭커가 이미 그 몫을 한다는 유용한 결론”**이라고 써뒀는데, 실제로 그렇게 됐다.
3부 · 하다가 바로잡은 것
13. 검색과 재정렬이 서로 다른 질문을 보고 있었다 ★
리랭킹을 처음 돌렸을 때 0.7747이 나왔다. 기준선 대비 +0.03을 훌쩍 넘어 승격처럼 보였는데 가짜 상승이었다.
| 단계 | 무엇을 봤나 |
|---|---|
| 1단계 — dense 후보 캐시 | qid_query_200.jsonl (병합본) |
| 2단계 — 리랭킹 | goldenset_200.jsonl (원본) ← 여기가 틀렸다 |
두 파일은 후속 질문 29문항의 텍스트가 다르다.
원본: "그 사업의 안정화기간은?"
병합본: "한영대학교 …사업기간은 어떻게 되나요? 그 사업의 안정화기간은?"
검색은 맥락이 붙은 질문으로 후보를 뽑았는데, 리랭커는 “그 사업”이 뭔지 모르는 채로 그 29문항의 순서를 매겼다. 골든셋·정답지·채점 기준에는 문제가 없었다 — 잘못은 전적으로 실행 인자 쪽이다.
교훈 — 1단계(후보 캐시)와 2단계(리랭킹)는 반드시 같은 질문 파일을 봐야 한다
검색 실험 결과 (164문항) §8(인덱스 혼동)과 같은 조용한 실패다. 파일 이름이 둘 다 그럴듯하고, 문항 수도 맞고, 에러도 안 난다. 성적이 올라서 발견됐다. 내려갔으면 “새 임베딩이 별로네”로 넘어갔을 수도 있다.
13-1. 두 질문 파일의 관계
| 파일 | 무엇인가 | 필드 | 문항 |
|---|---|---|---|
goldenset_200.jsonl | 정본 골든셋 | qid / question / 유형 / 선행_턴 / 대상_doc_id / expected_answer_기준 | 200 |
qid_query_200.jsonl | 파생된 검색 실험용 질문 텍스트 | qid / query 둘뿐 (정답 정보 없음) | 164 |
rerank_labels_200.jsonl | 정답 라벨. VM 골든셋의 rerank_labels.jsonl과 gold 청크 전건 일치 | 164 |
goldenset_200.jsonl (200문항)
│ 선행_턴 + question 을 이어붙임 · − 모른다 15 − 반문 15 − 빈 정답 6
▼
qid_query_200.jsonl (164문항) ← 검색·리랭킹은 항상 이걸 본다
goldenset_200.jsonl을 검색 인자로 넣으면 문항 수 필터링과 선행 턴 병합이 둘 다 빠진다.
14. 그 밖에 바로잡은 것
| 무엇 | 어디에 |
|---|---|
| 정답지가 e5에 편향돼 새 모델을 과소평가하던 것 → 풀링으로 제거 | 정답지 편향 제거 (전체 풀링 라벨링) |
| 정답률 1.3%를 보고 “미채택”으로 판단했다가 철회한 경위 | 같은 문서 §7 |
| 캐시 생성 1,887초 → 136초 · Chroma 필터 검색이 근사라는 발견 | §5-2 · 후보 캐시 |
| ”맥은 MPS로 돈다”는 전제가 틀렸던 것 (실제로는 CPU, 49배 느림) | 검색 실험 결과 (164문항) §11-1 |
| BM25 하이브리드 변형의 설계 결함 3건 — 집합 불변 · 스코프 불일치 · 옛 라벨 채점 | §9-1 ~ §9-3 |
15. 관련 문서
| 문서 | 무엇을 보러 |
|---|---|
| 검색 실험 결과 (164문항) | 1차 판정 — 이 실험의 전제이자 기준선(0.7443) |
| 정답지 편향 제거 (전체 풀링 라벨링) | 채점 기준을 어떻게 고쳤나 (골든셋 v4 → v5) |
| 골든셋 · 골든셋 200문항 제작 | 164문항 정답지가 어떻게 만들어졌나 |
| 후보 캐시 | dense_doc/dense_full/bm25 구분, 검색을 한 번만 돌리는 법 |
| 임베딩 · Dense 검색 · 크로스인코더 리랭커 · BM25 · RRF · 하이브리드 검색 | 개념 |
코드: src/config.py · src/index/chunk.py · src/index/build_index.py · scripts/build_retrieval_cache.py · scripts/rerank_on_cache.py · scripts/rerank_bias_bounds.py · scripts/promote_decision.py
정본: Plans.md 6.8 · spec.md §9 · §10-17 · docs/ADR.md ADR-006 · ADR-009