검색 2차 실험 (임베딩·BM25·리랭커)

아카이브됨 — 새 설계·결과 문서로 대체됐다 (2026-07-29)

무효 사유: “1차·2차” 프레이밍을 버렸다. 하나의 실험이고, 그 과정에서 설계 결함을 네 번 잡아 고쳤을 뿐이다. 여기 변형 정의와 실험 순서(임베딩 → 리랭커 → 하이브리드)는 거꾸로였다.

대체 정본 → 검색 실험 설계 · 검색 실험 결과

설계와 결과를 한 문서에 둔다. 계획과 결과가 나뉘어 있으면 “뭘 하려 했고 어떻게 됐나”를 두 번 찾아야 한다. 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. 세 줄 요약

  1. dense 단독으로는 새 임베딩이 크게 이긴다(+0.10). 그런데 리랭커를 켜면 그 이득이 대부분 흡수된다 — 종단 격차는 +0.0297이고 신뢰구간이 0을 품는다 → §7·§11.
  2. 정답지가 e5에 편향돼 있던 것을 라벨링으로 걷어내고 다시 재도 결론은 같았다. 편향 제거 후 KURE 이득이 +0.0068 → +0.0297로 4배 커졌지만, 164문항으로는 이 크기를 우연과 구분할 수 없다.
  3. 대신 다른 문이 열렸다. 리랭커 위에서 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종

코드모델크기차원프리픽스비고
M0intfloat/multilingual-e5-small118M384query: / passage: 필수현행 운영 (ADR-006)
M1BAAI/bge-m3568M1024없음HF 캐시 4.3GB
M2nlpai-lab/KURE-v10.6B1024없음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종 (크로스인코더 리랭커)

코드모델크기비고
R1BAAI/bge-reranker-v2-m3568M현행 채택 (ADR-009)
R2dragonkue/bge-reranker-v2-m3-ko568MR1의 한국어 튜닝판

임베딩 쪽과 구조가 똑같다 — 원본 vs 한국어 튜닝판. M1 vs M2의 답과 R1 vs R2의 답이 같은 방향인지도 볼거리였다.

2-3. 후보 구성 변형 — 리랭커에 무엇을 입력하는가

전부 _doc 스코프(대상 문서 안에서만)로 통일한 뒤의 정의다 — 그러지 않았을 때 무슨 일이 났는지는 §9-2.

코드후보 구성개수(실측)상태
Cdense_doc 그대로20.0기준선 (현행 운영)
Ddense_docRRF재정렬만 (집합 불변)20.0폐기 — 리랭커 뒤에서 C와 동치. 회귀 감시용으로만 남긴다 → §9-1
Edense_docbm25_doc 융합 상위 2020.0BM25가 dense를 밀어낼 값어치가 있나
F같은 융합 상위 3029.8예산을 늘리면
Gdense_docbm25_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:58passage: [섹션명]\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 + R10.7640+0.0197[−0.0151, +0.0578]
KURE-v1 + R10.7740+0.0297[−0.0042, +0.0654]아슬아슬하게 기각
e5-small + R20.6805−0.0638[−0.1005, −0.0275] ✱
bge-m3 + R20.6736−0.0707[−0.1169, −0.0243] ✱
KURE-v1 + R20.6856−0.0587[−0.1035, −0.0140] ✱

✱ = 95% 구간이 0을 포함하지 않음

7-1. 참고 — dense 단독 (리랭커 없이, 골든셋 v4 기준)

임베딩 자체의 실력. 판정용이 아니라 진단용이다(§6).

임베딩MRR@10+BM25nDCG@10Recall@5Recall@20정답이 후보에 아예 없음
M0e5-small0.64020.60960.60610.61880.816317
M1bge-m30.74170.61790.69730.71320.855714
M2KURE-v10.74160.64910.70820.73350.863113

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. 재판정 조건 — 이 셋 중 하나면 다시 본다

  1. 골든셋이 300문항 이상으로 늘어난다 — 구간이 좁아져 +0.03이 유의해질 수 있다
  2. 다른 이유로 재인덱싱이 필요해진다 — 교체 비용이 사라지면 교환 조건 자체가 바뀐다
  3. 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 (현행)1711
M1 bge-m3103
M2 KURE-v193

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. 측정 전에 적어둔 예상 — 채점

결과를 보고 말을 바꾸지 않으려고 먼저 적어뒀던 것들이다.

#예상결과
1Recall@20은 오른다✅ 0.8163 → 0.8631
2종단 MRR은 그보다 덜 오른다✅ +0.10 → +0.0297
3M1이 이득의 대부분, M2는 조금 더한다✅ M1≈M2
4BM25는 여전히, 더 크게 진다⚠️ 순서 융합에서는 맞음. 집합 확장은 미측정
5R1 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