검색 실험 결과

그래서 무엇을 쓰기로 했나. 측정 조건은 검색 실험 설계가 정본이다. 용어가 막히면 → 개념 색인

최종 판정표 — 아무것도 바꾸지 않는다 (2026-07-29 · 측정 완료)

판정근거
후보 구성C (BM25 없음) 유지E·F·G 어느 것도 유의하지 않음 → §2
BM25미채택문서 스코프로 고쳐 재측정해도 세 임베딩 전부 CI가 0을 품는다 → §2
리랭커R1 bge-reranker-v2-m3 유지켠 것 자체가 +0.104. R2는 세 임베딩 전부 −0.06~−0.07로 유의하게 패배 → §3
임베딩e5-small 유지KURE +0.0297, CI [−0.0042, +0.0654] — 유의성 미달 → §4

프로덕션은 RETRIEVAL_USE_RERANKER = True · RETRIEVAL_USE_BM25 = False → §5

3단계 순서는 여기서 끝난다 — 1단계에서 후보가 C로 확정돼 리랭커·임베딩 판정의 전제가 그대로다. 즉 §3·§4는 재판정 없이 확정이다 → 검색 실험 설계 §8-1 · 재판정 조건은 §4-1

성적표 옆에 붙여 읽을 한계

C·D는 후보 집합이 동일한데 상위10 순서가 다른 문항이 e5 18건 / bge 2건 / KURE 0건이었다. 원인은 리랭커 점수 동점 시 입력 순서가 결과를 바꾸는 것이고, MRR 영향은 ±0.007이다. 재려는 효과(+0.022)와 같은 자릿수라 e5 수치는 셋째 자리를 신뢰할 수 없다. → §11-1


0. 세 줄 요약

  1. 리랭커가 이 실험의 유일한 승자다. dense 단독 0.6402 → 0.7443(+0.104), 손해 본 문항 유형이 하나도 없다.
  2. 임베딩을 키워도 그 이득이 리랭커에 흡수된다. dense 단독으로는 +0.10 차이가 나지만 종단에서는 +0.0297로 줄고, 신뢰구간이 0을 품는다. **“차이가 없다”가 아니라 “이득이 증명되지 않는다”**다.
  3. BM25도 미채택 — 설계 결함 2건을 고쳐 다시 재도 유의하지 않다. 다만 가설의 방향은 맞았다(약한 임베딩에서만 +) → §2. 그 결함을 안 잡았으면 틀린 근거로 같은 결론을 낼 뻔했다 → §10.

그리고 MRR@10 하나만 봤다면 BM25 판정이 뒤집힐 뻔했다. MRR로는 “유의차 없음”인데 nDCG@10·Recall@5로는 유의하게 나쁘다 — BM25 후보가 상위 5개를 잠식해 LLM에 넘어가는 컨텍스트를 떨어뜨린다 → §2-1-2. 판정 지표를 하나만 두면 반대 방향의 손해가 안 보인다.

0-1. 이 숫자의 범위 — 라우팅까지 잰 값이 아니다

MRR 0.7443의 전제

이 실험은 **“올바른 공고가 이미 정해진 상태”**에서의 청크 검색만 잰다.

순서무슨 일이 일어나나이 실험이 재나
어느 공고 얘기인지 정해진다안 잼
그 공고 안에서 정답 문단을 찾는다이것만 잰다 (C_doc MRR@10)

①은 두 가지 방식으로 일어난다

  • — 사용자가 목록에서 공고를 클릭 (doc_id가 화면에서 정해진다)
  • 챗 / CLI — 라우터가 질문 문장에서 추정

라우터가 못 잡으면 전체 검색으로 넘어가지 않고 되묻는다 (spec §12-3 폴백 금지). ①의 정확도는 0.769 / 표본 13건 — 사실상 미측정이다.


목차

무엇이 있나
§1 성적표6조합 × MRR × CI × 판정 — 값 정본
1부 · 판정
실험이 돈 순서
§2 BM25 하이브리드1단계 — 후보에 BM25를 더할까 → 미채택
§3 리랭커2단계 — 어느 리랭커가 줄세울까 → R1 유지
§4 임베딩3단계 — 어느 임베딩이 뽑을까 → e5 유지
§5 프로덕션 반영코드에 뭐가 켜져 있나
2부 · 바로잡은 것§6~§10인덱스 오용 · 질문 파일 불일치 · 정답지 편향 · 철회한 판단 · 설계 결함 2건
3부 · 남은 것§11~§13조용히 틀리는 실패 · 미해결 · 산출물

1부 · 판정

이 부의 순서 = 실험이 돌아간 순서

아래층부터 확정했다. 후보 구성이 바뀌면 그 위의 판정이 전부 다시 열리기 때문이다.

단계무엇을 정했나결과
1단계§2후보에 BM25를 더할까C 유지 (BM25 미채택)
2단계§3그 후보를 어느 리랭커가 줄세울까R1 유지
3단계§4그 후보를 어느 임베딩이 뽑을까e5 유지

1단계가 **C(현행)**로 끝나서 2·3단계의 전제가 안 바뀌었다 → 검색 실험 설계 §8-1

1. 성적표 ★ 값 정본

164문항 · C_doc · MRR@10 · 골든셋 v5 · paired bootstrap 1만 회. 기준선은 e5-small + R1. 승격 기준은 측정 전에 정해둔 CI 하한 > 0(검색 실험 설계 §5).

조합MRR@10기준선 대비95% CI판정
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을 포함하지 않음

e5의 셋째 자리는 과해석하지 않는다

C·D 동점 노이즈로 e5에서 MRR ±0.007이 움직인다. C_doc 0.7443과 새 실행 0.7373의 불일치도 이 동점 처리 문제가 원인으로 밝혀졌다. 판정은 바뀌지 않지만, e5 관련 숫자는 소수점 둘째 자리까지만 근거로 쓴다.

1-0. 변형 코드 읽는 법 — C_doc이 뭔가

C_doc = 문서 안에서 dense로 찾고 리랭커로 재정렬 = 지금 서비스가 도는 방식.

C _ doc          C   = 어떤 조합인가
↑    ↑           doc = 어디서 찾았나
변형  스코프        임베딩은 여기 안 담긴다 → `m0 C_doc`처럼 앞에 붙인다
조합A dense만 · C dense+리랭커 · G dense+BM25 전부+리랭커
스코프_doc 문서 안(프로덕션) · _full 3만 청크 전체(실험용)
임베딩M0 e5-small · M1 bge-m3 · M2 KURE-v1
조합 7종 전체 · dense와 e5의 차이
코드구성
Adense만
Bdense + BM25
Cdense + 리랭커
Ddense + BM25 + 리랭커
Edense ∪ BM25 융합 상위 20 + 리랭커
F같은 융합 상위 30 + 리랭커
Gdense ∪ BM25 전부(35.2개) + 리랭커
  • dense = “뜻으로 찾기”라는 방식
  • e5 = 그 벡터를 만드는 모델 이름

A_doc은 방식만 말하고 임베딩은 안 담는다. 0.6402는 정확히는 m0 A_doc — e5로, dense만, 문서 안에서, 리랭커 없이.

같은 표가 검색 실험 설계 §0 「실험 코드 읽는 법」에도 있다.

2. BM25 하이브리드 — 미채택 (재측정 완료)

설계 결함 둘(§10)을 고치고 **문서 스코프 BM25(bm25_doc)**로 다시 쟀다. C_doc · MRR@10 · 164문항 · 골든셋 v5 · 리랭커 R1.

이번 재측정이 새로 잰 것

예전 구현은 BM25 전용 청크의 본문을 가져오지 않아 후보를 넓히지 못했다. 이번에는 bm25_doc을 신설하고 변형 G로 BM25 전용 청크를 실제 후보에 더했다. 그 결과 e5에서만 소폭 이득, bge-m3·KURE에서는 손해로 최종 미채택됐다.

임베딩C (BM25 없음)G (dense + BM25 전부)Δ95% CI
e5-small (현행)0.73730.7595+0.0222[−0.0184, +0.0642]
bge-m30.76400.7606−0.0035[−0.0231, +0.0178]
KURE-v10.77400.7613−0.0127[−0.0339, +0.0087]

E(융합 상위 20)·F(상위 30)도 함께 쟀으나 어느 것도 유의하지 않았다.

세 임베딩 전부 구간이 0을 품는다 → 미채택. RETRIEVAL_USE_BM25 = False 유지.

e5의 C_doc 0.7373은 정본 0.7443과 다르지만, 원인은 미상이 아니라 동점 처리와 입력 순서 노이즈다. 이 노이즈가 있어도 CI가 0을 걸치므로 판정은 바뀌지 않는다.

2-1. 가설은 방향이 맞았다 — 크기가 모자랐을 뿐

가설은 *“약한 임베딩일수록 어휘 검색이 메울 게 많다”*였다(검색 실험 설계 §0-④).

dense가 정답을 하나도 못 찾은 문항그중 BM25 보유G의 Δ
e5-small1711+0.0222
bge-m3103−0.0035
KURE-v193−0.0127

메울 게 많은 e5에서만 +로 나왔고, 강한 임베딩에서는 음수다. 근거 숫자와 정확히 부합한다. 다만 크기가 유의성에 못 미친다 — 가설이 틀린 게 아니라 효과가 작다는 것이 답이다.

2-1-1. BM25는 정답을 후보에 넣어주긴 한다 — 상위로 못 올릴 뿐 ★

가설의 앞부분은 예측치까지 맞았다. 후보에 정답이 하나도 없던 문항 수:

C_docE_docF_docG_doc줄어든 수
e5-small171076−11
bge-m310777−3
KURE-v19966−3

e5의 17문항 중 정확히 11문항이 구제됐다. 예측치(§2-1의 “BM25 보유 11”)와 한 건도 어긋나지 않는다.

그런데 MRR은 유의하게 안 오른다. 즉 —

후보에 넣는 것과 상위에 올리는 것은 다른 문제다. BM25가 정답을 데려와도 리랭커가 그것을 상위 10위 안으로 올리지 못한다. 리랭커는 후보를 재정렬할 뿐, 어휘적으로만 맞는 청크의 의미적 관련성을 새로 만들어내지는 못한다.

검색 실험 설계가 말하는 *“리랭커로 도달 불가능한 상한”*은 후보 확장으로 낮출 수 있지만, 그것이 지표 개선으로 이어지지는 않는다는 것이 이번 실측의 답이다.

2-1-2. MRR만 보면 놓친다 — 다른 지표는 유의하게 나쁘다

MRR@10은 첫 정답의 순위만 본다. nDCG@10과 Recall@5는 상위권 전체의 질을 본다. 둘을 함께 보면 그림이 달라진다.

KURE-v1 기준, C_doc 대비:

지표G_doc Δ95% CI
MRR@10−0.0127[−0.0339, +0.0087]미달
nDCG@10−0.0364[−0.0622, −0.0101]유의
Recall@5−0.0492[−0.0869, −0.0126]유의

E·F도 같은 방향으로 유의하게 나쁘다.

BM25 후보가 상위 5개를 잠식해 LLM에 넘어가는 컨텍스트를 떨어뜨린다. MRR만 봤을 때는 “유의차 없음”이었는데, 실제 답변 품질에 직결되는 지표로는 명확한 손해다.

판정 지표를 하나만 두면 반대 방향 손해를 못 본다

MRR@10을 판정 지표로 삼은 것은 “LLM에 넘길 컨텍스트 맨 앞이 중요하다”는 이유였다. 하지만 컨텍스트는 상위 5개 전체다. MRR이 안 움직여도 nDCG·Recall이 무너질 수 있다. 이번엔 결론이 같은 방향이라 판정이 안 바뀌었지만, 반대였다면 잘못 채택했을 것이다.

2-1-3. 하이브리드를 켜면 임베딩 격차가 사라진다 ★

KURE-v1 − e5-small 격차를 후보 구성별로 보면:

후보 구성격차 (MRR@10)95% CI
C_doc (BM25 없음 = 기준선)+0.0367[−0.0018, +0.0763]미달
D_doc (집합은 C와 동일)+0.0297[−0.0042, +0.0654]미달
E_doc+0.0271[+0.0070, +0.0515]유의
F_doc+0.0073[−0.0007, +0.0185]미달
G_doc (하이브리드)+0.0018[−0.0006, +0.0050]미달

하이브리드에서는 격차가 20분의 1로 줄어든다. BM25가 약한 임베딩의 구멍을 메우니 어느 임베딩을 쓰든 비슷해지는 것이다 — §2-1의 가설과 같은 원리다.

즉 *“임베딩을 바꿀 돈으로 BM25를 켜라”*는 선택지가 성립할 뻔했다. 실제 답은 둘 다 안 켜는 것이다 — BM25는 §2-1-2대로 nDCG·Recall을 유의하게 떨어뜨리고, 임베딩 교체는 §4대로 유의하지 않다.

C_doc 격차 +0.0367과 D_doc 격차 +0.0297의 차이에 주의하라. 두 변형은 후보 집합이 완전히 같다. 동점 순서 하나로 헤드라인 숫자가 0.007 움직인 것이다 → §11-1

2-2. 죽은 코드가 아니라 근거 있는 분기였다

# src/query/retrieve.py:39
use_bm25 = config.RETRIEVAL_USE_BM25 and doc_id is None

문서가 특정되면 BM25를 끈다. 오래 *“왜 죽은 코드인지 모르고 지나치면 안 되는 자리”*로 남겨뒀는데, 이제 답이 나왔다 — 이 분기가 옳았음이 실증됐다.

문서를 좁힌 상태에서 BM25를 더해봐야 세 임베딩 어디서도 유의한 이득이 없다. 죽은 코드가 아니라 근거 있는 분기다.

3. 리랭커는 R1 — R2는 점수가 포화한다

결론: R1 유지, R2 기각. R2는 임베딩 3종 어디에 붙여도 R1 대비 −0.06~−0.07로 크게 졌고, CI가 0을 안 걸친다.

코드모델명성격
R1bge-reranker-v2-m3현행 리랭커
R2dragonkue/bge-reranker-v2-m3-koR1의 한국어 튜닝판

골든셋 v5 · C_doc · MRR@10 기준이다.

임베딩R2의 R1 대비 Δ95% CI유의 여부
e5-small−0.0638[−0.1005, −0.0275]유의 패배
bge-m3−0.0707[−0.1169, −0.0243]유의 패배
KURE-v1−0.0587[−0.1035, −0.0140]유의 패배

CI 상한까지 전부 음수다. “운이 나빴을 수도 있다”가 아니라 확실히 진 것이다.

R2 기각은 v5 라벨링 전에 이미 확정됐다 — 정답지를 어떻게 채점해도 졌기 때문이다. 그 경위는 §8-2.

3-0. 리랭커를 켠 것 자체가 이 실험 최대 이득

구성MRR@10기준선 대비
dense 단독 (A_doc)0.6402
+ BM25 (전역 스코프·RRF 순서)0.6096−0.031 ❌
+ 리랭커 (C_doc)0.7443+0.104 ✅

+0.104가 얼마나 큰가: 164문항 총점으로 17점이다. 정답이 6등에 있던 문항 20개가 1등으로 올라온 것과 같은 크기다.

유형별로도 손해 본 유형이 하나도 없다.

유형문항dense 단독+리랭커차이
단일세부900.69560.8322+0.137
직답200.52600.7146+0.189
탐색비교250.52670.5611+0.034
후속290.64490.6498+0.005

탐색비교·후속은 개선폭이 작다. 둘 다 문서를 여러 개 걸치거나 앞 대화에 의존하는 유형이라, 후보를 잘 정렬하는 것만으로는 한계가 있다 → §12

3-1. 왜 졌나 — 점수 포화

리랭커는 후보 20개에 각각 점수를 매기고, 그 점수 순서로 후보를 다시 줄세운다. 점수 포화는 여러 후보가 같은 최고점 근처에 몰려 순서를 가를 정보가 사라지는 상태다.

관찰한 것R2 실측
후보 20개 중 고유 점수10개뿐같은 점수를 받은 후보가 많다
상위 3개 점수전부 정확히 1.01~3위를 점수로 구분하지 못한다

점수가 같으면 순서를 못 정한다. R2는 후보를 다시 줄세우는 역할에서 변별 실패를 낸 것이다 → 지표 포화

3-2. 한국어 튜닝은 양쪽에서 이득이 없었다

임베딩 쪽도 한국어 튜닝판이 이기지 못했고, 리랭커 쪽은 크게 졌다. 한쪽이면 우연일 수 있지만 둘 다면 패턴이다.

원본한국어 튜닝판조건결과
임베딩M1 bge-m3 0.7417M2 KURE-v1 0.7416dense 단독 · 리랭커 없음무승부
리랭커R1 bge-reranker-v2-m3R2 dragonkue/bge-reranker-v2-m3-ko임베딩 3종 전부R2가 −0.06~−0.07로 유의 패배

4. 임베딩을 왜 안 바꾸나 — “차이가 없다”가 아니다

결론: M0 e5-small 유지. KURE-v1은 점추정으로는 앞섰지만, 종단 MRR +0.0297의 95% CI가 0을 걸쳐 이득이 증명되지 않았다.

후보 구성이 C로 확정됐으므로(§2) 이 판정은 전제가 그대로다 — 재판정 없이 확정.

코드이 절에서 뜻하는 것
M0e5-small — 현행 임베딩
M1bge-m3 — 큰 원본 임베딩
M2KURE-v1 — bge-m3의 한국어 튜닝 임베딩
R1bge-reranker-v2-m3 — 현행 리랭커
값의 조건
dense 단독임베딩만으로 후보를 뽑고 리랭커는 켜지 않은 진단값
+ 리랭커 R1현행 리랭커까지 켠 종단값. 승격 판정은 이 조건으로 한다
CI가 0을 걸침같은 164문항을 다시 뽑아 재면 이득이 0 이하일 수 있다는 뜻
임베딩dense 단독 (A_doc)+ 리랭커 R1 (C_doc)기준선 대비95% CI판정
M0 e5-small (현행)0.64020.7443기준선✅ 유지
M1 bge-m30.74170.7640+0.0197[−0.0151, +0.0578]
M2 KURE-v10.74160.7740+0.0297[−0.0042, +0.0654]

이 표에서 셋이 한 번에 보인다.

보이는 것
dense 단독 격차는 크다M0 0.6402 vs M1·M2 약 0.7417 → +0.10
리랭커를 켜면 3분의 1 이하로 줄어든다+0.10 → +0.0297
M1 ≈ M20.7417 vs 0.7416 — 한국어 튜닝이 dense 단계에서 무효

리랭커가 이미 그 몫을 하고 있다. 임베딩을 키워 얻는 것은 두 가지인데, 하나는 리랭커가 대신 못 하고 하나는 이미 하고 있다.

임베딩을 바꿔 얻는 것리랭커가 대신해주나실측
정답을 후보 안에 넣는 능력❌ 못 함Recall@20 0.8163 → 0.8631, 후보 밖 17 → 13
후보 안에서 위로 올리는 능력✅ 이미 함종단 +0.0297에 그침

KURE는 최소 폭(+0.03)에 사실상 닿았지만 95% 구간 하한이 −0.0042다. 164문항으로는 이 크기를 우연과 구분할 수 없다.

치러야 하는 것 (확실)얻는 것 (불확실)
재인덱싱 28.5분MRR +0.0297 — 구간이 0을 품는다
모델 5배 (118M → 0.6B)
HF 캐시 4.3GB (VM 여유 7GB)

정직하게 쓰면 “교체해도 손해는 없지만 이득이 증명되지 않는다” 이다. 점추정은 계속 KURE가 앞섰고, 정답지 편향을 걷어낼수록 격차가 벌어졌다(§8).

4-0. dense 단독 성적 — 임베딩 자체의 실력 (진단용, 골든셋 v4 기준)

임베딩 자체의 실력. 판정용이 아니다(검색 실험 설계 §3).

임베딩MRR@10nDCG@10Recall@5Recall@20정답이 후보에 아예 없음
M0e5-small0.64020.60610.61880.816317
M1bge-m30.74170.69730.71320.855714
M2KURE-v10.74160.70820.73350.863113

M1 ≈ M2 — dense 단계에서 한국어 튜닝의 이득은 없다. 검색 실험 설계 §6의 판정표대로 읽으면 **“계열이 관건”**이다.

4-1. 재판정 조건 — 이 셋 중 하나면 다시 본다

  1. 골든셋이 300문항 이상으로 늘어난다 — 구간이 좁아져 +0.03이 유의해질 수 있다
  2. 다른 이유로 재인덱싱이 필요해진다 — 교체 비용이 사라지면 교환 조건이 바뀐다
  3. Recall@20이 병목인 기능이 생긴다 — e5 0.8163 vs KURE 0.8631

5. 프로덕션 반영 상태

결론: 프로덕션은 리랭커만 켜고 BM25는 끈다. GPU가 없으면 리랭커를 포기하고 dense 단독으로 강등한다.

RETRIEVAL_USE_RERANKER = True · RETRIEVAL_USE_BM25 = False.

설정의미
RETRIEVAL_USE_RERANKERTruedense 후보 20개를 R1 리랭커로 다시 줄세운다
RETRIEVAL_USE_BM25False문서 안 검색에서는 BM25 후보 확장을 쓰지 않는다
RERANKER_REQUIRE_GPUTrueGPU가 없으면 리랭커를 켜지 않고 dense 단독으로 강등한다
지금:   질문 → 문서 좁히기 → 뜻으로 20개 찾기 → 리랭커가 재정렬 → 상위 5개 → LLM
                                                  ↑ 이것만 추가됐다

켜기 전 선결 과제 둘을 이렇게 해소했다.

과제상태무엇을 했나
동시성 락ask()가 동기 함수라 FastAPI 스레드풀에서 568M 모델이 중복 로드될 수 있었다(→ L4 OOM). 이중검사 락(모델·코퍼스 지문별 key)으로 로더를 1회만 태우고 MODEL_LOAD_TIMEOUT_S=300으로 유한 대기를 둬 초과 시 폴백 강등한다. 8스레드 Barrier 테스트로 로더 1회 호출 확인
지연시간아래
거짓양성 검사남음모른다·반문 30문항에서 없는 근거를 지어내지 않는지

“맥은 MPS로 돈다”는 전제가 틀렸다. _load_cross_encoderdevice="cuda" if use_cuda else "cpu"로 되어 있고 MPS 경로는 불안정해 일부러 배제한다. 맥에서는 CPU로 돈다.

환경문항당배수
VM L4 (GPU)0.27초
맥 CPU13.27초49×

문항당 13초는 대화형으로 쓸 수 없다. 그래서 RERANKER_REQUIRE_GPU=True 가드GPU가 없으면 dense 단독으로 자동 강등한다. 프로덕션은 VM(GPU 있음)이라 이 가드를 안 타지만, 맥에서 백엔드를 띄우는 워크플로가 실재해서 넣었다 — 가드가 없으면 그 경로에서 조용히 49배 느려진다.


2부 · 실험하며 바로잡은 것

여기부터는 결론이 아니라 경위다. 결론만 필요하면 1부에서 멈춰도 된다. 다만 아래 넷은 수치보다 오래 남을 기록이다 — 전부 “에러 없이 조용히 틀리는” 종류였다.

6. 인덱스를 잘못 쓰고 있었다

결론: 초기 측정은 틀린 인덱스를 봤고, 전부 chroma_v2로 통일했다. 정답 청크 2개가 빠진 상태라 조용히 성적이 왜곡될 수 있었다.

이름이 절에서 뜻하는 것
data/chroma초기 측정이 잘못 사용한 인덱스
chroma_v2골든셋 정답지가 기준으로 삼은 인덱스
chunk_id정답 청크를 식별하는 키. 양쪽 인덱스에 같은 값이 있어도 내용 완전성은 다를 수 있다

초기 측정은 data/chroma를 썼는데, 정답 청크 253개 중 251개만 들어 있었다. 정답지는 chroma_v2 기준으로 만들어진 것이었다.

chunk_id가 양쪽 다 존재해서 에러 없이 조용히 2문항이 빠진다. 전부 chroma_v2로 통일했다.

7. 검색과 재정렬이 서로 다른 질문을 보고 있었다 ★

결론: 0.7747은 가짜 상승이었다. 후보를 뽑은 질문 파일과 리랭커가 본 질문 파일이 달라, 같은 문항을 같은 조건으로 잰 비교가 아니었다.

파일이 절에서 뜻하는 것
qid_query_200.jsonl후속 질문 29문항에 선행 턴을 붙인 검색·리랭킹용 병합본
goldenset_200.jsonl골든셋 원본. 정답 정보와 원문 질문을 담지만, 이 실행의 리랭킹 입력으로 쓰면 안 됐다

리랭킹을 처음 돌렸을 때 0.7747이 나왔다. 기준선 대비 +0.03을 훌쩍 넘어 승격처럼 보였는데 가짜 상승이었다.

단계무엇을 봤나
1단계 — dense 후보 캐시qid_query_200.jsonl (병합본)
2단계 — 리랭킹goldenset_200.jsonl (원본) ← 여기가 틀렸다

두 파일은 후속 질문 29문항의 텍스트가 다르다(검색 실험 설계 §2-1).

원본:    "그 사업의 안정화기간은?"
병합본:  "한영대학교 …사업기간은 어떻게 되나요? 그 사업의 안정화기간은?"

검색은 맥락이 붙은 질문으로 후보를 뽑았는데, 리랭커는 “그 사업”이 뭔지 모르는 채로 그 29문항의 순서를 매겼다. 골든셋·정답지·채점 기준에는 문제가 없었다 — 잘못은 전적으로 실행 인자 쪽이다.

교훈 — 1단계(후보 캐시)와 2단계(리랭킹)는 반드시 같은 질문 파일을 봐야 한다

파일 이름이 둘 다 그럴듯하고, 문항 수도 맞고, 에러도 안 난다. 성적이 올라서 발견됐다. 내려갔으면 “새 임베딩이 별로네”로 넘어갔을 수도 있다.

8. 정답지가 새 모델에 불리했다 — 풀링으로 제거했다 ★

결론: v4 정답지는 e5 후보 중심이라 새 임베딩에 불리했고, v5 풀링으로 편향을 제거했다. 편향 제거 뒤에도 최종 판정은 현행 유지다.

용어이 절에서 뜻하는 것
v4e5 후보 위주로 만들어져 새 모델 후보의 미판정 비율이 높던 정답지
v5세 임베딩 후보를 합쳐 추가 판정한 뒤 만든 정답지
풀링여러 모델이 가져온 후보를 합집합으로 묶어 같은 정답지로 판정하는 방식
미판정 후보사람이 아직 정답/오답을 읽어보지 않은 후보. 그대로 두면 보통 오답처럼 세어진다

8-1. 무엇이 문제였나

정답 라벨은 e5가 뽑아온 후보만 사람이 판정해서 만들어졌다. 새 모델이 그 풀 밖에서 진짜 정답을 찾아오면 라벨이 없어 자동으로 오답이 된다.

리랭킹 상위 10개 기준 미판정 비율:

조합상위 10개 중 미판정
e5 + R1 (현행)0.7%
bge-m3 + R126.0%
KURE + R132.0%

새 모델이 사용자에게 보여줄 10개 중 3분의 1을 아무도 읽어본 적이 없었다. 잘할수록 손해를 보는 구조다.

v4 문제v5에서 바뀐 것
e5 상위10 미판정률 0.7%전 조합 0.0%
bge-m3 상위10 미판정률 26.0%전 조합 0.0%
KURE 상위10 미판정률 32.0%전 조합 0.0%

8-2. 먼저 구간에 가뒀다

결론: R2는 바로 기각할 수 있었지만, M1·M2는 정답지를 더 읽어야 했다. 미판정 후보 처리 방식에 따라 새 임베딩 점수가 크게 움직였기 때문이다.

전부 라벨링하기 전에 상·하한 채점으로 판정이 갈리는지부터 봤다.

정답지를 e5 후보로만 만들어서, 새 임베딩이 가져온 후보 중 아무도 O/X를 안 매긴 청크가 최대 32%였다(e5는 0.7%). 정답인지 모르는데 점수는 내야 하므로 셋 다 계산했다.

가정안 본 청크를
하한오답으로 침제일 짜게
응축순위에서 빼버림본 것끼리만 등수
상한정답으로 쳐줌제일 후하게

어느 가정으로 계산해도 결론이 같으면 라벨링이 필요 없고, 갈리면 라벨링을 해야 한다. 점수를 확정하는 장치가 아니라 *“정답지를 더 읽어야 하나”*를 가리는 장치다.

조합하한응축상한
e5 + R10.74430.74460.74610.0018
bge-m3 + R10.74120.75460.79160.0504
KURE + R10.75110.76390.80020.0491

e5의 폭이 0.0018로 사실상 없다. 정답지가 e5에 맞춰져 있다는 직접 증거이자, 기존 수치는 그대로 신뢰해도 된다는 뜻이다.

세 갈래 검정 결과 R2 조합은 어떤 가정으로도 져서 확정 기각됐고, M1·M2는 **“채택도 기각도 못 한다”**가 됐다. 그래서 읽기로 했다.

8-3. 전수 풀링 라벨링

결론: 세 임베딩 후보를 한 정답지로 합쳐 읽었다. 모델별 정답지를 따로 만들면 비교 기준이 달라지므로, 하나의 v5 판정 풀로 묶었다.

정답지는 하나로 둔다. 모델별로 만들면 서로 다른 시험이 되어 비교가 불가능해진다. 그래서 세 임베딩의 dense_doc 후보를 합집합으로 묶어 판정 풀을 넓히고, 기존 판정분은 재사용하고 증분만 판정했다(문항당 합집합 36.0개, 신규 대상 2,105개).

기존 파이프라인을 그대로 썼다 — 라벨러 rounds/r8/SKILL.md(v8) → 독립 검증자 VERIFY_SPEC.md. 200문항 골든셋이 만들어진 바로 그 흐름이다.

여기서는 절대 정확도보다 일관성이 중요하다

증분을 더 엄격한 방법으로 라벨링하면 기존 정답과 잣대가 갈려, e5(기존 라벨 위주)와 KURE(신규 라벨 위주)가 서로 다른 기준으로 채점된다. 지금 고치려는 편향을 다른 편향으로 바꾸는 꼴이다.

샤드별 전수 풀링 라벨링 결과
샤드문항라벨러 정답/판정검증 verdict추가제거보류
shard1437 / 530 (1.3%)통과 40 · 수정 3003
shard24213 / 525 (2.5%)통과 42000
shard33713 / 525 (2.5%)통과 36 · 수정 1101
shard4429 / 525 (1.7%)통과 41 · 수정 1001
16442 / 2,105 (2.0%)재작업 0105

독립 검증자 4명이 2,105개를 전부 재독해 추가 1건 · 제거 0건. 지어낸 chunk_id 0건.

shard1의 1.3%가 기존 판정 풀(4.6%)의 1/3.5라 “덜 고른 것 아닌가”를 의심했다 — R8(쪼개진 표 묶기) 적용 0건, 기존 정답과 번호가 인접한 후보 25개 중 정답 2개뿐. 그래서 재독시켰고 추가 0건으로 돌아왔다. 배제 사유 23개가 원문 인용과 함께 남아 있었다. 낮은 정답률은 태만이 아니라 실제 분포였다.

8-4. 편향이 사라졌다

결론: v5에서는 전 조합 상위10 미판정률이 0.0%가 됐다. 보정할 구간이 사라졌고, KURE 이득은 커졌지만 승격 기준은 넘지 못했다.

정답 청크    310 → 353     판정 풀   6,751 → 8,856 (문항당 41.2 → 54.0)
상위10 미판정률   전 조합 0.0%      하한 = 응축 = 상한   완전 일치

상·하한 구간이 한 점으로 붕괴했다. 보정할 여지 자체가 사라졌다 — 풀링이 성공했다는 증거다. 편향 제거 후 KURE의 실제 이득은 +0.0068 → +0.0297로 4배 커졌다. 그런데도 §1의 판정은 바뀌지 않았다.

보류 5건은 미반영 — 정답으로 치지 않는다. 오염보다 누락이 덜 해롭기 때문이다. 쟁점은 표기 정규화로, 가장 날카로운 건 g200:15de45f1(“계약일로부터 3개월”과 기준답변 “90일”을 같은 값으로 볼지).

9. “미채택” 판단을 한 번 철회했다 ★

결론: 정답률만 보고 임베딩 교체를 기각한 판단은 철회했다. 중요한 것은 추가 정답의 개수 비율이 아니라 MRR에 실제로 얼마나 반영되는지였다.

shard1 결과를 받고 이렇게 적었다.

실측 정답률이 1.3%뿐이니 하한이 거의 참값이고, 하한 기준 구간이 0을 품으니 임베딩 교체는 승격 근거가 없다.

틀렸다. 정답률만 보고 그 정답이 점수를 얼마나 움직이는지는 재지 않은 판단이었다.

조합반영 전7개 반영 후Δ
e5 + R10.74430.7443+0.0000
bge-m3 + R10.74120.7473+0.0061
KURE + R10.75110.7590+0.0078

7개뿐인데 KURE가 +0.0078 올랐다. 그 7개가 6문항에 붙었고, MRR은 정답 하나가 한 문항 점수를 0 → 0.5로 바꾼다. e5가 꿈쩍 안 한 건 예상대로다 — 기존 라벨이 애초에 e5 후보에서 나왔으니 더 얻을 게 없다.

교훈 — 정답률이 낮은 것과 지표에 영향이 없는 것은 다른 얘기다

2,105개 중 42개(2.0%)라는 개수 비율은 무시해도 될 크기처럼 보이지만, 그게 어느 문항 몇 등에 꽂히느냐에 따라 지표는 크게 움직인다. 판정은 개수 비율이 아니라 지표에 반영해서 봐야 한다.

덧붙여, 이때 표본으로 전수를 외삽해 약 +0.0297을 예측했는데 전수 결과가 정확히 그 값이었다. 예측 셋 중 둘(상승폭·경계에 걸림)은 맞았고 하나(경계를 넘음)는 틀렸다.

10. BM25 하이브리드 설계 결함 2건 ★

결론: 예전 BM25 하이브리드 측정은 두 결함 때문에 무효다. 하나는 후보 집합을 넓히지 못한 변형 D였고, 다른 하나는 dense와 BM25의 검색 범위가 달랐던 것이다.

코드이 절에서 뜻하는 것
dense_doc대상 문서 안에서 dense로 찾은 후보 20개
bm25 (구)30,002청크 전체에서 찾던 옛 BM25 후보
bm25_doc대상 문서 안에서만 다시 계산한 BM25 후보
D옛 구현에서는 BM25 전용 청크를 버려 C와 같은 집합이 된 변형

10-1. 유일한 하이브리드 변형이 아무것도 재지 못했다

핵심 사실 하나에서 다 따라온다.

리랭커가 뒤에서 후보 전체를 다시 정렬한다. 따라서 앞단의 RRF순서가 아니라 **누가 후보에 들어오나(집합)**만 결정한다.

변형 D는 dense 후보 20개를 RRF로 재정렬만 하고 집합은 그대로 뒀다(if c in seen으로 BM25 전용 청크를 버린다). 리랭커가 어차피 다시 정렬하므로 D는 C와 수학적으로 같다.

C와 D가 왜 리랭커 뒤에서 같아지는가
C:  [dense 20개] ───────────────────→ 리랭커 재정렬 → 결과
D:  [dense 20개] → RRF로 순서만 바꿈 → 리랭커 재정렬 → 결과
                     ↑ 여기서 바꾼 순서를   ↑ 여기서 지운다

개념적으로도 틀렸다 — D는 BM25를 후보 공급자가 아니라 리랭커로 쓰는 셈이라 하이브리드 검색이 아니다.

버린 근거였던 *“BM25 전용 청크는 본문이 없어 추가 Chroma 왕복이 필요하다”*도 실측으로 무너졌다: 20개 일괄 조회 0.6ms, dense 검색 2.05초의 0.03%도 안 된다.

이 자리에서 더 큰 결함을 놓쳤다. C ≡ D를 관찰하고도 **“R2 점수 동점 때문에 생긴 안정정렬 부작용”**으로 설명이 끝나버려, 그 아래의 **“D는 애초에 C와 같다”**는 사실에 도달하지 못했다. 증상에 맞는 설명을 찾으면 탐색을 멈추게 된다.

10-2. dense와 BM25의 검색 범위가 달랐다

두 번째 결함이 더 크다.

검색 범위대상 문서 후보
dense_doc대상 문서 안에서만 20개0%
bm25 (구)30,002청크 전체에서 20개32.6%

라벨링 규칙상 대상 문서가 아니면 무조건 배제다. 즉 BM25 후보 20개 중 평균 6.5개는 정답일 수가 없는 청크였고, 그걸 그대로 리랭커에 먹이고 있었다.

그래서 그 측정은 하이브리드를 잰 게 아니라 “엉뚱한 문서의 청크를 섞으면 나빠지나”를 잰 것이다.

수정: BM25도 대상 문서 안에서만 돌도록 bm25_doc을 새로 계산해 세 캐시에 넣었다(검색 실험 설계 §8).

교훈 — 두 검색기를 합칠 때는 검색 범위가 같은지 먼저 확인한다

dense는 문서 스코프인데 BM25는 전역이면, 합친 결과는 하이브리드가 아니라 잡음 주입이다. 그 상태로 “BM25는 손해”라는 결론이 두 번 나올 뻔했다.

10-3. 그래서 무효 처리한 것

E·F의 옛 값(M0 E 0.7277 / F 0.7321, M1 E 0.7257 / F 0.7173)은 범위 불일치 상태에서 나온 값이라 인용할 수 없다 — 무효. 채점에 쓰이던 캐시 gold 필드도 v4(310개)로 박혀 있어 v5(353개)로 갱신했다. 이전 로그 수치는 전부 v4 기준이었다.


3부 · 남은 것

11. 조용히 틀리는 실패가 반복된다

결론: 이 실험의 주요 실패는 에러가 아니라 그럴듯한 숫자로 나타났다. 그래서 검색 조건·질문 파일·정답지·후보 순서 같은 입력 전제를 게이트로 잠가야 한다.

이 실험에서 잡은 사고를 늘어놓으면 전부 같은 종류다.

사고왜 안 보였나
틀린 인덱스(§6)양쪽에 같은 chunk_id가 있어 에러가 안 난다
질문 파일 어긋남(§7)파일 이름이 그럴듯하고 문항 수도 맞다
정답지 편향(§8)라벨이 없으면 그냥 오답으로 세어진다
BM25 스코프 불일치(§10-2)후보 개수는 정확히 20개다

공통점: 실패가 예외를 던지지 않고 숫자로만 나타난다. 그래서 이 실험은 조건 하나하나에 게이트를 붙였다(검색 실험 설계 §9~§12).

11-1. 동점 노이즈 — 재려는 효과와 같은 크기다 ✅ 조치함

결론: 측정 코드의 후보 입력 순서를 통일해 동점 노이즈 원인을 제거했다. 기존 e5 수치는 재측정하지 않았으므로 소수점 둘째 자리까지만 근거로 쓴다.

용어이 절에서 뜻하는 것
C_doc문서 안에서 dense 후보 20개를 뽑고 리랭커로 재정렬한 현행 기준선
D_doc옛 구현에서 C와 후보 집합이 같았던 하이브리드 변형
동점 노이즈리랭커 점수가 같을 때 입력 순서가 최종 순위를 바꾸는 현상
MRR@10 차이 +0.0070C와 D의 후보 집합은 같았는데 순서만 달라져 생긴 e5 측정 잔차

C_docD_doc은 후보 집합이 완전히 같다(164/164 확인). 리랭커가 전부 재정렬하므로 결과도 같아야 한다. 그런데 상위 10위 순서가 다른 문항이 나왔다.

임베딩상위10 순서가 다른 문항MRR@10 차이
e5-small18+0.0070
bge-m320.0000
KURE-v100.0000

원인은 크로스인코더 점수의 동점이다. 점수가 같으면 안정 정렬이 입력 순서를 보존하므로, 같은 집합이라도 넣는 순서가 다르면 결과가 갈린다. e5 후보에서 특히 심한 것은 유사한 청크를 많이 물어와 점수가 자주 겹치기 때문으로 보인다.

같은 조건의 서로 다른 실행에서 C_doc0.7443과 0.7373으로 갈린 것도 이 때문이다 — GPU 실행 간 미세한 부동소수점 차이가 동점 순서를 뒤집는다.

이 노이즈(±0.007)는 이번 실험에서 재려던 효과와 같은 크기다.

KURE의 임베딩 이득이 +0.0297, e5의 하이브리드 이득이 +0.0222였다. 판정은 안 바뀐다(어느 조합도 유의하지 않다). 하지만 e5 관련 수치의 유효 정밀도는 소수점 둘째 자리까지로 봐야 한다. 셋째 자리를 근거로 쓰면 안 된다.

§3에서 “R2는 점수가 포화한다”고 한 것의 확장판이다. 처음엔 R2만의 문제로 봤는데, R1에서도 e5 후보에서는 18문항이나 발생한다. 정도의 차이일 뿐 같은 현상이다.

조치 — 순서 규약을 하나로 통일했다 (d1ad855, PR #29)

문제의 정체는 “리랭커가 비결정적”이 아니라 변형마다 후보를 넣는 순서가 달랐다는 것이다. C는 dense 순서, D·E·F는 RRF 순서로 넣고 있었다. RRF 순서는 리랭커가 덮어쓰므로 의미가 없는데도, 동점이 나는 순간 그 순서가 등수로 살아났다.

그래서 모든 변형을 dense 순서 → BM25 전용 청크는 BM25 순서 하나로 통일했다.

이전지금
변형 간 차이의 원인집합 + 순서집합 하나뿐
C vs D리랭커 뒤에서 대체로 수렴 (잔차 0.0070)리랭커 전에 이미 동일 — 구조적으로 보장

chunk_id 오름차순을 쓰지 않았다. 동점일 때 되돌아갈 자리는 dense 순위가 맞다 — 알파벳순보다 나은 사전 정보이고, 프로덕션의 입력 순서(dense 거리순)와도 같다. src/query/hybrid.pyrerank()는 건드리지 않았다. 프로덕션은 입력 순서가 이미 결정적이라 이 문제가 없다 — 측정 쪽 결함이었다.

tests/test_rerank_candidates.py 9건으로 규약을 잠갔다.

기존 수치는 다시 안 잰다

판정이 노이즈에 좌우되지 않았고(어느 조합도 유의하지 않다), 재측정에 GPU 시간이 든다. e5 관련 수치의 유효 정밀도가 소수점 둘째 자리라는 단서는 그대로 유효하다.

11-2. 승격 기준이 측정 해상도보다 낮다 — +0.03은 164문항으로 판정할 수 없다

paired bootstrap 95% CI의 반폭이 전 조합에서 ±0.035~0.046으로 거의 일정하다. 164문항이라는 표본이 만드는 고정 해상도다.

조합점추정CI반폭
bge + R1+0.0197[−0.0151, +0.0578]±0.036
KURE + R1+0.0297[−0.0042, +0.0654]±0.035
e5 R2−0.0638[−0.1005, −0.0275]±0.037
bge R2−0.0707[−0.1169, −0.0243]±0.046
KURE R2−0.0587[−0.1035, −0.0140]±0.045

승격 기준 두 개가 서로 충돌한다.

기준실제 요구
① CI 하한 > 0점추정 +0.036 이상
② 점추정 +0.03 이상①보다 느슨

②를 통과해도 ①에서 막히는 구간(+0.030~+0.036)이 생긴다. 이번에 KURE가 +0.0297로 정확히 그 구간에 떨어졌다.

측정 전에 기준을 정한 것은 옳았으나, 그 기준을 이 표본으로 판정할 수 있는지는 확인하지 않았다. 사전에 계산했다면 **“+0.03은 164문항으로 판정 불가”**가 나왔을 것이다.

단, 결론 자체가 틀린 것은 아니다. bge +0.0197은 해상도 안에서도 미달이고, R2는 −0.06으로 크게 벗어나 확실히 기각이다. 애매한 것은 KURE 하나뿐이다.

잡고 싶은 차이필요한 채점 문항
+0.03236 (지금 164)
+0.02약 531

재판정 조건의 **“골든셋 300문항↑“**은 이 숫자가 근거다. 200문항으로 늘렸을 때 채점 성립이 164였으므로, 300문항이면 240 안팎이 되어 +0.03을 판정할 수 있다. 즉 **“300이면 좋겠다”가 아니라 “+0.03을 판정하려면 236이 필요하다”**가 정확한 설명이다.

12. 미해결

결론: 남은 일은 성능 채택 문제가 아니라 운영 검증·데이터 반영·라벨 확인이다. 현행 유지 판정은 바뀌지 않는다.

남은 항목현재 상태다시 봐야 하는 조건
동점 노이즈 ±0.007조치함. 기존 e5 수치는 소수점 둘째 자리까지만 사용GPU 재측정 예산을 쓰기로 할 때
거짓양성 검사모른다·반문 30문항 미검사출시 전 답변 안전성 확인
탐색비교·후속리랭커 개선폭이 작음(+0.034 / +0.005)여러 문서를 걸치는 검색을 별도 설계할 때
후보 밖 정답dense만으로 e5 17 / KURE 9Recall@20이 병목인 기능이 생길 때
라벨링 보류 5건정답으로 반영하지 않음사람이 표기 정규화를 결정할 때
v5 VM 반영VM current가 아직 v4_20260729운영 데이터 교체 작업 때
  • 동점 노이즈 ±0.007조치함. 변형 순서 규약을 통일해 차이의 원인을 후보 집합 하나로 좁혔다 (d1ad855) → §11-1. 단 기존 수치는 재측정하지 않았다 — e5 수치는 여전히 소수점 둘째 자리까지만 유효
  • 거짓양성 미검사 — 모른다·반문 30문항에서 없는 근거를 지어내지 않는지 아직 안 봤다
  • 탐색비교·후속이 안 오른다 — 리랭커로 +0.034 / +0.005뿐. 여러 문서를 걸치는 검색은 별도 접근이 필요하다
  • 정답이 후보에 아예 없는 문항 — dense만으로는 e5 17 / KURE 9. BM25를 더하면 e5 17→6까지 줄지만 MRR로는 안 이어진다(§2-1-1). 후보에 넣는 것과 상위로 올리는 것은 다른 문제다
  • 라벨링 보류 5건 — 사람 확인 대상 (§8-4)
  • v5가 아직 VM에 안 올라갔다currentv4_20260729를 가리킨다

12-1. 산출물

산출물역할
data/promote_decision_v5.md골든셋 v5 기준 최종 승격/미승격 판단 기록
scripts/add_bm25_doc.py전역 BM25가 아니라 문서 스코프 bm25_doc 후보를 추가하는 스크립트
scripts/merge_pooled_labels.py풀링 라벨을 병합해 v5 판정 풀을 만드는 스크립트
ADR-010미신설. 현행 유지라 기존 결정 재확인으로 충분

13. 관련 문서

문서무엇을 보러
검색 실험 설계측정 조건 정본 — 파이프라인·정답지·지표·실험 대상·통제
골든셋 · 골든셋 200문항 제작정답지가 어떻게 만들어졌나
쿼리 파이프라인검색이 전체 흐름에서 어디인가
상·하한 채점 · paired bootstrap · 후보 캐시 · 지표 포화이 실험이 쓴 방법
개념 색인용어

코드: scripts/build_retrieval_cache.py · scripts/rerank_on_cache.py · scripts/rerank_bias_bounds.py · src/eval/label_bias.py · src/query/retrieve.py 정본: Plans.md 6.7~6.8 · docs/ADR.md ADR-009