검색 실험 설계 (4모델 · BM25)
무엇을 왜 어떻게 재는가. 판정과 성적은 검색 실험 결과 (4모델·BM25)가 정본이다. 용어가 막히면 → §0 · 개념 색인 · 정답지가 어떻게 만들어졌나 → 골든셋
0. 3분 요약 — 설계의 결론은 조건 고정과 네 축 분리다
| 읽을 것 | 핵심 |
|---|---|
| 결론 | 아래 설명 |
| 공통 조건 | 아래 설명 |
| 무엇이 바뀌지 않나 | 아래 설명 |
| 측정 완료 | 아래 설명 |
| 결과 해석 경계 | 아래 설명 |
-
결론 · 핵심: 이 문서는 무엇을 왜 어떻게 재는가를 골든셋 v7 기준으로 고정한다. 판정과 성적의 정본은 검색 실험 결과 (4모델·BM25)다.
-
무엇이 바뀌지 않나 · 핵심: 청크 경계, 문서 스코프, top-k, 프리픽스, 지표, 문서 클러스터 통계는 비교가 성립하도록 고정한다.
-
결과 해석 경계 · 핵심: 구간 이질성 해석과 최종 성적은 결과 문서가 정본이다 → 검색 실험 결과 (4모델·BM25)
-
공통 조건 · 핵심: 골든셋 v7 · 251문항 · 정답 490건 · 풀 구성 4모델 + BM25(문서 스코프) ·
_doc검색 · top-k 후보 20 → 리랭크 후 10 · paired bootstrap 95% CI 1만 회 · seed20260729· 문서 클러스터(first, 95군집 / 군집당 2.64) -
측정 완료 · 핵심: 임베딩 축(M0 ↔ M1·M2·M3,
C_doc고정) · 후보 구성 축(C_doc↔E_doc·F_doc·G_doc, 4모델 전부) · 리랭커 ON/OFF(A_doc↔C_doc) · 리랭커 종류(R1 ↔ R2)
이 문서의 역할: 여기 적힌 조건이 하나라도 달라지면 같은 실험으로 비교할 수 없다. 조건이 헷갈릴 때 돌아오는 자리다.
어디부터 읽나
| 목적 | 읽을 순서 |
|---|---|
| 처음 보면 | §0-1 (용어·코드·눈금) → §0-2 ①②③④ |
| 수치만 확인하러 왔으면 | 검색 실험 결과 (4모델·BM25) |
| 왜 그렇게 판단했나가 궁금하면 | 아래 설명 |
- 왜 그렇게 판단했나가 궁금하면 · 읽을 순서: 1부(무엇을 재나) → 2부(무엇을 비교하나) → 3부(측정이 성립하려면)
확정된 값은 검색 실험 결과 (4모델·BM25)가 정본이다.
목차 — 실제 본문 순서와 역할
| 찾는 것 | 바로 갈 곳 | 여기서 확인할 것 |
|---|---|---|
| 실험을 처음 이해하기 | §0-1 → §0-2 | 용어, 파이프라인, 고정값, 조작값 |
| 수치를 비교해도 되는 조건인지 확인 | §3 · §5 · §9~§12 | 아래 설명 |
| 모델·축 정의 확인 | §7 · §8 | M0~M3, 실험 순서, A/C/E/F/G_doc, R1/R2 |
| 최종 판정값 확인 | 검색 실험 결과 (4모델·BM25) | 아래 설명 |
- 수치를 비교해도 되는 조건인지 확인 · 여기서 확인할 것: 정답지, 통계, 청크/프리픽스/엔진 게이트
- 최종 판정값 확인 · 여기서 확인할 것: 이 문서는 조건 정본이고, 값 정본은 결과 문서
| 부 | 절 | 역할 |
|---|---|---|
| 도입 | §0~§0-2 | 전체 실험을 한 화면으로 압축 |
| 1부 · 무엇을 재나 | §1~§6 | 재측정 이유, 정답지, 지표, 통계, 승격 기준 |
| 2부 · 무엇을 비교하나 | §7~§8 | 아래 설명 |
| 3부 · 측정이 성립하려면 | §9~§13 | 청크, 프리픽스, 엔진, 인덱스, 실패 방지 |
| 부록 | §14~§15 | 산출물과 관련 문서 |
- 2부 · 무엇을 비교하나 · 역할: 임베딩 4종, 실험 순서, 후보 구성, 리랭커 축, 측정 격자
0-1. 읽기 전에 — 이 실험은 정답 문단의 순위를 잰다
한 문장
질문을 던졌을 때 정답이 적힌 문단을 몇 등으로 찾아오는가를 재고, 임베딩·후보 구성·리랭커 조건을 바꿀 값어치가 있는지 판정한다. 공통 시험지는 골든셋 v7 251문항, 정답 490건이다.
실제로 무슨 일이 일어나나 — 질문 하나를 끝까지 따라가기
질문 하나가 검색 파이프라인을 통과하는 흐름
질문 "한영대학교 정보시스템 구축사업의 사업기간은?"
│
│ ① 어느 문서 얘기? 문서 100개 중 1개로 좁힌다
▼ (골든셋이 정답 문서를 알려주므로 이 단계는 항상 맞다고 친다)
문서 1개 = 청크(문단) 약 300개
│
│ ② 후보를 뽑는다 ← 임베딩 4종과 BM25 후보 구성이 여기서 갈린다
▼
후보 20개 또는 그 이상
│
│ ③ 다시 줄세운다 ← 리랭커 R1/R2 또는 OFF 축
▼
1등 … 20등
│
│ ④ 상위 5개만 LLM에게 준다
▼
답변
★ 채점: 정답 문단이 ③에서 몇 등이었나 → 1등이면 1.0, 2등이면 0.5, 4등이면 0.25
범위 한계 — 라우팅은 재지 않는다
이 실험은 대상 문서가 이미 정해진 상태에서 그 문서 안의 청크 순위만 잰다. 100개 문서 중 무엇을 볼지 고르는 라우팅 단계는 범위 밖이다. 따라서 모든 수치는 라우팅이 완벽할 때의 검색 상한이지, 실서비스 전체 성능이 아니다.
모르면 못 읽는 단어 10개
| 단어 | 한 줄 뜻 |
|---|---|
| 청크 | 문서를 잘라 놓은 문단 하나. 검색의 최소 단위 (RFP 100개 → 30,002개) |
| 임베딩 | 문장을 숫자 벡터로 바꾸는 모델. 뜻이 비슷하면 벡터가 가깝다 |
| dense | 임베딩 벡터로 하는 검색. “의미가 비슷한 것” |
| BM25 | 단어가 실제로 겹치는 정도로 하는 검색. 문서 스코프(bm25_doc)에서만 비교한다 |
| 리랭커 | |
| 골든셋 / 정답지 | 미리 판정해 둔 “이 질문의 정답은 이 청크” 목록. 채점 기준 |
| MRR@10 | 이 실험의 판정 지표. 첫 정답이 몇 등인지의 평균 |
| LoRA | 아래 설명 |
| 문서 클러스터 부트스트랩 | 아래 설명 |
| 사전등록 | 수치를 보기 전에 판정 기준을 코드에 박아두는 것 → §6 |
- LoRA · 한 줄 뜻: 큰 모델 전체를 다시 학습하지 않고 작은 보조 행렬만 얹어 튜닝하는 방식. M3가 그것
- 문서 클러스터 부트스트랩 · 한 줄 뜻: 재표집 단위를 문항이 아니라 첫 대상 문서로 두는 통계 보정 → §5
숫자 읽는 눈금 — MRR 0.70이 좋은 건가
1.00 항상 1등 0.70 ← 이 실험의 기준선 근처 0.50 평균 2등
0.33 평균 3등 0.10 평균 10등 0.00 10등 안에 아예 없음
0.7041 = 대충 “질문 10개 중 6개쯤은 정답이 1등, 나머지는 2~4등쯤”. 이 문서의 비교는 모두 같은 골든셋 v7 안에서의 델타로 읽는다.
실험 코드 읽는 법 — 이 문서 내내 나온다
| 축 | 코드 | 뜻 |
|---|---|---|
| 임베딩 | M0 | e5-small — 기준선(118M · 384D) |
M1 | bge-m3 — 568M · 1024D | |
M2 | KURE-v1 — 0.6B · 1024D | |
M3 | gpl3 — LoRA-GPL 머지 e5-small 파생, 118M · 384D | |
| 후보 구성 | A | dense만, 리랭커 없음 |
C | dense 20개 + 리랭커 | |
E | dense∪BM25 융합 상위 20 + 리랭커 | |
F | dense∪BM25 융합 상위 30 + 리랭커 | |
G | dense∪BM25 전부 + 리랭커 | |
| 리랭커 | R1 | bge-reranker-v2-m3 |
R2 | dragonkue/bge-reranker-v2-m3-ko | |
| 접미사 | _doc | 대상 문서 1개 안에서만 검색. 모든 판정의 기준 |
| 엔진 갈래 | M0_exact | M0를 정확 계산으로 만든 후보 캐시 |
M0_fast | M0를 Chroma 근사 탐색으로 만든 후보 캐시 → §11 |
기준선 =
M0+R1+C_doc. 완료된 임베딩·후보 구성 비교는 이 기준선과 맞댄다.
0-2. 실험 설계 한눈에 — 고정값과 조작값만 보면 된다
① 무엇을 고정했나 — 통제 변인
하나라도 움직이면 같은 실험으로 비교할 수 없다.
| 고정한 것 | 값 | 왜 고정하나 |
|---|---|---|
| 청크 경계 | e5 토크나이저 (CHUNK_TOKENIZER_MODEL) | 아래 설명 |
| 정답지 | 골든셋 v7 · 300문항 준비 · 채점 성립 251 · 정답 490 | 아래 설명 |
| 검색 범위 | 대상 문서 안 (_doc, doc_id 필터) | 아래 설명 |
| top-k | 후보 20 또는 후보 구성별 정의값 → 리랭크 후 10 | 아래 설명 |
| 프리픽스 | 모델 계열별 규약 (e5 계열은 passage:/query:) | 아래 설명 |
| 지표 | MRR@10(판정) · nDCG@10 · Recall@5 · Recall@20 | 아래 설명 |
| 통계 | 아래 설명 | 문항 단위로 재표집하면 CI가 실제보다 좁아진다 → §5 |
- 청크 경계 · 왜 고정하나:
chunk_id가 문서 내 위치 기반이라, 경계가 밀리면 정답지가 전부 다른 청크를 가리킨다 → §9 - 정답지 · 왜 고정하나: 라벨이 늘면 점수가 오른다. 모델마다 다르면 비교가 아니다 → §3
- 검색 범위 · 왜 고정하나: 이 실험은 라우팅 후 청크 순위만 잰다 → §2
- top-k · 왜 고정하나: 구성별 후보 예산을 명시해야 델타 원인을 읽을 수 있다
- 프리픽스 · 왜 고정하나: 틀려도 에러가 안 나고 성능만 떨어진다 → §10
- 지표 · 왜 고정하나: MRR 하나만 두면 반대 방향 손해가 안 보인다 → §4
- 통계 · 값: paired bootstrap 95% CI · 1만 회 · seed
20260729· 문서 클러스터(first)
② 무엇을 바꾸나 — 조작 변인
이 표는 어떤 축만 움직였는지 확인하려고 본다.
| 축 | 비교쌍 | 상태 |
|---|---|---|
| 임베딩 | M0 ↔ M1·M2·M3 (dense만 (C_doc) · 현행 리랭커 고정) | 측정 완료 |
| 후보 구성 | dense만 (C_doc) ↔ BM25 변형 (E_doc/F_doc/G_doc) | 측정 완료 |
| 리랭커 ON/OFF | 리랭커 없음 (A_doc) ↔ dense만 (C_doc) | 측정 완료 — gpl3에서 R1 ON 유지 |
| 리랭커 종류 | 현행 리랭커 ↔ 한국어판 리랭커 | 측정 완료 — R2 전 모델 미채택 |
측정 완료 축의 값 정본은 결과 문서에 있다. 이 문서는 비교 조건과 판정 기준을 고정한다.
③ 무엇과 무엇을 맞대나 — 비교쌍
같은 조건에서 딱 한 곳만 다르게 한 두 판을 맞댄다. 그래야 차이의 원인이 하나로 좁혀진다.
| 비교 | 무엇이 달라지나 | 답하는 질문 | 용도 |
|---|---|---|---|
| M0_exact ↔ M3 | 아래 설명 | RFP 도메인 파인튜닝이 값어치가 있나 | 핵심 |
| M0_fast ↔ M1 | 118M → 568M, 384D → 1024D | 모델을 키우면 나아지나 | 임베딩 비교 |
| M0_fast ↔ M2 | 위와 같되 한국어 튜닝판 | 한국어 특화가 나은가 | 임베딩 비교 |
| C_doc ↔ E/F/G_doc | 후보에 문서 스코프 BM25를 얼마나 섞나 | 아래 설명 | 후보 구성 비교 |
| A_doc ↔ C_doc | 리랭커 OFF ↔ R1 ON | 아래 설명 | gpl3에서 ON 유의 채택 |
| R1 ↔ R2 | 리랭커 모델만 교체 | 한국어 튜닝 리랭커가 나은가 | R2 전 모델 유의 패배 |
| M0_exact ↔ M0_fast | 후보 캐시를 만든 검색 엔진만 | 아래 설명 | 게이트 검증 → §11 |
- M0_exact ↔ M3 · 무엇이 달라지나: 같은 계열·같은 크기·같은 차원에서 도메인 학습만
- C_doc ↔ E/F/G_doc · 답하는 질문: dense 후보에 어휘 후보를 더하면 나아지나
- A_doc ↔ C_doc · 답하는 질문: 리랭커가 종단 순위를 얼마나 바꾸나
- M0_exact ↔ M0_fast · 답하는 질문: 근사 탐색이 성적에 영향을 주나
M0 ↔ M3가 왜 핵심인가M1·M2는 기준선과 모델 크기·차원·학습 데이터·프리픽스 규약이 전부 다르다. 이겨도 *“큰 덕인가, 한국어 덕인가, 학습 데이터 덕인가”*가 갈리지 않는다. M3는 M0와 같은 베이스 모델에 LoRA만 얹은 것이라, 이기면 원인이 도메인 학습 하나로 좁혀진다.
④ 가설과 판정 기준 — 측정 전에 정한다
판정 기준을 말로 풀면
“기준선보다 확실히 낫고(운이 아니고), 그 차이가 바꿀 만큼 크고, 어느 유형도 망가지지 않는다”를 동시에 요구한다. BM25 후보 구성은 여기에 부수 지표 유의 음수 없음을 하나 더 붙인다.
| 실험 | 가설 | 판정 기준 | 상태 |
|---|---|---|---|
| M3 도메인 파인튜닝 | 아래 설명 | 아래 설명 | 측정 완료 |
| M1 대형 모델 | 모델을 키우면 후보 품질이 오른다 | 동일 | 측정 완료 |
| M2 한국어 튜닝 | 한국어 튜닝이 RFP 검색에 이득을 준다 | 동일 | 측정 완료 |
| BM25 후보 구성 | 아래 설명 | 위 3조건 + 부수 지표 유의 음수 없음 | 측정 완료 |
| 리랭커 ON/OFF | 아래 설명 | 아래 설명 | 측정 완료 — M0·M3 채택 |
| 리랭커 종류 | 아래 설명 | 아래 설명 | 측정 완료 — 반증됨(전 모델 유의 패배) |
- M3 도메인 파인튜닝 · 가설: RFP로 학습했으니 이 도메인의 후보 품질이 오른다
- M3 도메인 파인튜닝 · 판정 기준: CI 하한 > 0 + 점추정 ≥ +0.03 + 유형 회귀 없음
- BM25 후보 구성 · 가설: dense 후보에 문서 스코프 어휘 후보를 더하면 놓친 정답을 회수한다
- 리랭커 ON/OFF · 가설: dense 후보만 둔 순위보다 R1 리랭킹이 종단 성능을 올린다
- 리랭커 ON/OFF · 판정 기준: 승격 3조건 + 부수 지표 유의 음수 없음
- 리랭커 종류 · 판정 기준: 승격 3조건 + 부수 지표 유의 음수 없음
- 리랭커 종류 · 가설: R2가 R1보다 한국어 RFP 순위에 낫다
가설 하나는 실험 도중 실측으로 반증됐다
라벨링 단계에서 *“M3 고유 후보 1,020건 중 정답이 5건(0.49%)뿐이니 gpl3의 이득은 새 정답 발굴이 아니라 순위 상승”*이라는 해석을 세웠다. 순위 덤프를 분해해 보니 이득은 recall에서 온다. 경위는 검색 실험 결과 (4모델·BM25)가 정본이다.
채택은 gpl3 확정 (사용자 결정 2026-07-31)
승격 판정은 “올릴 수 있다”를 말하고, 채택은 “무엇을 올릴지”다. 평균 성능·견고성·유형 안정성·모델 크기가 서로 다른 답을 가리켰고, 사용자가 §6-3의 “작은 쪽” 규칙을 우선해
M3gpl3로 닫았다. BM25는 OFF 유지 — 조건을 충족한 것이M0위에서뿐이었기 때문이다 → §8-1
1부 · 무엇을 재나
1. 왜 다시 재나 — 조건이 충분히 고정돼야 판정이 선다
이 실험의 질문은 단순하다. 같은 정답지, 같은 문서 스코프, 같은 통계 기준에서 임베딩·후보 구성·리랭커를 바꿨을 때 종단 MRR@10이 충분히 오르는가.
여기서 충분하다는 말은 감상이 아니다. 기준은 코드로 고정한다.
- 차이의 paired bootstrap 95% CI 하한이 0보다 크다.
- 점추정이 기준선 대비 +0.03 이상이다.
- 어떤 유형도 −0.03 이하로 회귀하지 않는다.
- BM25 후보 구성은 부수 지표(nDCG@10 · Recall@5 · Recall@20)에 유의 음수가 없어야 한다.
이 기준이 있으려면 정답지와 후보 풀이 먼저 닫혀야 한다. 그래서 골든셋 v7은 4모델 dense 후보와 문서 스코프 BM25 후보를 모두 반영한 단일 정답지로 둔다.
2. 파이프라인 — 왜 문서를 먼저 좁히나
실험 파이프라인은 라우팅과 검색을 분리한다.
질문
→ 대상 문서 확정(골든셋의 대상_doc_id 사용)
→ 대상 문서 안에서 후보 청크 검색
→ 리랭킹
→ 상위 청크 채점
전제 — 대상 문서 선택이 항상 맞았다고 가정한다
골든셋의
대상_doc_id를 그대로 쓴다. 실서비스에서는 라우터가 문서를 틀릴 수 있으므로 모든 수치는 “라우팅이 완벽할 때의 상한”이다.이 실험은 대상 문서 안의 청크 순위를 재는 실험이다. 100개 문서 중 무엇을 볼지 고르는 라우팅 품질은 별도 실험 범위다.
BM25도 같은 이유로 _doc만 쓴다. 전역 BM25는 대상 문서 밖 후보를 섞기 때문에
정답일 수 없는 청크가 평가 후보에 들어온다 → §8-3
3. 정답지 — 골든셋 v7
이 섹션에서 확인할 것
정답지는 점수의 기준선이다. 문항 수, 제외 사유, 정답 청크 수, 판정 풀을 만든 후보 구성이 바뀌면 같은 실험 결과처럼 보여도 비교할 수 없다.
| 전체 | 300문항 |
| 라벨링 대상 (검색 4유형) | 254문항 |
| 채점 성립 | 251문항 (gold가 빈 3문항 제외) |
| 정답 청크 | 490건 |
| 판정 풀 구성 | 4모델 + BM25(문서 스코프) |
| 유형 (251 기준) | 단일세부 134 · 후속 45 · 탐색비교 37 · 직답 35 |
| 스냅샷 | VM /srv/GPTPilot/goldenset/v7_20260731/ · current 이동 완료 · 권한 r--r--r-- |
| 만들어진 경위 | 골든셋 · 골든셋 200문항 제작 |
| 헷갈리는 지점 | 읽는 법 |
|---|---|
| 전체는 300문항인데 채점은 251문항 | 아래 설명 |
| gold가 빈 3문항을 왜 빼나 | 아래 설명 |
| 정답 청크가 490건 | 한 문항에 정답 청크가 여러 개 있을 수 있어 문항 수보다 많다 |
| 판정 풀이 왜 4모델 + BM25인가 | 아래 설명 |
- 전체는 300문항인데 채점은 251문항 · 읽는 법: 모른다·반문 유형은 검색 순위로 채점할 수 없어 애초에 라벨링 대상이 아니다(254). 거기서 gold가 빈 3문항을 더 뺀다
- gold가 빈 3문항을 왜 빼나 · 읽는 법: 남기면 모든 구성의 절대 수치가 함께 낮아진다. 델타에는 영향이 없지만 절대값 해석이 망가진다
- 판정 풀이 왜 4모델 + BM25인가 · 읽는 법: 새 모델이나 새 후보 구성이 데려온 청크를 판정하지 않으면, 그 청크는 실제 정답이어도 오답으로 세어진다
3-1. 왜 정답지를 4모델 + BM25 기준으로 만들어야 했나 ★
여기가 이 실험에서 제일 조용히 망하기 쉬운 자리다.
판정 풀에 없는 후보를 실험 구성만 데려오면 이렇게 된다.
새 구성만 데려온 후보 → 사람이 판정한 적이 없음 → 라벨 없음 → 채점기가 오답으로 셈
즉 새 구성이 진짜 정답을 찾아올수록 점수가 깎인다. 잘할수록 손해를 보는 구조다. 그래서 정답지는 모델 4종의 dense 후보와 문서 스코프 BM25 후보를 모두 반영한 단일 판정 풀로 둔다.
3-2. 두 갈래로 채웠다
문항 성격이 달라 채우는 방법도 갈렸다.
| 갈래 | 대상 | 무엇을 판정했나 | 규모 |
|---|---|---|---|
| 신규 문항 | 새로 들어온 검색 문항 | 4모델 합집합 후보 전건 | 아래 설명 |
| 기존 문항 델타 | 기존 검색 문항 | 아래 설명 | M3 고유 598건 |
| BM25 델타 | 문서 스코프 BM25 전용 후보 | BM25 전용 미판정 후보 전건 | 734건 |
- 신규 문항 · 규모: 후보 4,215개 · 문항당 평균 49.6
- 기존 문항 델타 · 무엇을 판정했나: M3 고유 신규 후보만 (기존 정답은 맥락으로 동봉)
왜 기존 문항은 전건이 아니라 델타인가. 이미 판정된 후보를 다시 읽으면 비용만 들고, 더 나쁘게는 잣대가 미세하게 달라져 기존 라벨과 어긋난다. 그래서 새 구성이 새로 데려온 후보만 판정 대상으로 뽑았다.
M3 고유 후보는 최종적으로 1,020건이었고, 그중 정답으로 채택된 것은 5건 = 0.49%다.
이 0.49%를 어떻게 읽어야 하나 — 함정이 있다
이 숫자는 “4모델 중 M3만 데려온 후보의 정답률”이다. 판정이 재는 것은 “M0 대비 M3”다. 기준이 다르다. M0가 놓친 정답 중 M1·M2도 함께 회수한 것은 이 0.49%에 안 잡힌다. 이 둘을 같은 얘기로 읽었다가 가설을 하나 잘못 세웠다 → 검색 실험 결과 (4모델·BM25)
3-3. 라벨링 파이프라인 — 2단계, 그리고 왜 스킬을 안 바꿨나
- 라벨러 —
.harness-mem/goldenset-skill/rounds/r8/SKILL.md(재현율 0.863 / 정밀도 0.750)로 원문 대조 판정 - 독립 검증자 —
VERIFY_SPEC.md규격으로 라벨러가 고른 정답만 재검사
여기서는 절대 정확도보다 일관성이 중요하다. 증분을 더 엄격한 방법으로 라벨링하면 기존 정답과 잣대가 갈려, 기존 후보와 신규 후보가 서로 다른 기준으로 채점된다. 지금 고치려는 편향을 다른 편향으로 바꾸지 않기 위해 같은 스킬·같은 프롬프트를 그대로 썼다.
검증 실측 — 2단계가 설계대로 작동했다.
| 갈래 | 문항 | 검증 verdict |
|---|---|---|
| 신규 문항 | 85 | 통과 77 · 수정 8 |
| 기존 문항 델타 | 155 | 통과 155 · 수정 0 |
| BM25 델타 | 118 | 추가 0 · 제거 0 |
| 갈래 | 유지 | 제거 | 추가 | 보류 |
|---|---|---|---|---|
| 신규 문항 | 120 | 2 | 8 | 0 |
| 기존 문항 델타 | 5 | 0 | 0 | 1 |
| BM25 델타 | 4 | 0 | 0 | 0 |
신규 문항의 최종 정답은 라벨러 122건 → 검증 후 128건이 됐다. BM25 델타는 734건을 전건 라벨링했고, 정답 4건(0.54%)으로 닫혔다.
팀 OpenAI API 미사용
골든셋 판정·라벨링은 전부 Claude가 직접 수행했다(CLAUDE.md CRITICAL · spec §10-18). 문항 수 × 지표 수만큼 호출이 발생해 팀 $20 한도를 빠르게 소진하기 때문이다.
judge=gpt-5-nano경로는 골든셋에 한 번도 돌리지 않았다.
3-4. 병합은 집합 연산으로 한다
기존 라벨 ∪ M3 델타 추가분 ∪ 신규 문항 라벨 ∪ BM25 델타 라벨
↓ 집합 연산 — 리스트 append 금지
data/goldenset_v7/rerank_labels_full.jsonl → VM 스냅샷 v7_20260731
리스트로 이으면 조용히 틀어진다
같은 chunk_id가 두 번 들어가면 그 문항의 nDCG·Recall 분모가 부풀고 채점이 에러 없이 틀어진다. 병합은 반드시 집합 연산으로 한다. 경위는 검색 실험 결과 (4모델·BM25)가 정본이다.
3-5. BM25 축을 열면서 정답지가 한 단계 더 갔다 — v7
BM25를 조작 변인으로 두는 순간 §3-1과 똑같은 구조의 사고가 열린다. BM25 전용 후보는 4모델 dense 합집합 풀에 없다. 판정된 적이 없으니 그대로 두면 전부 오답으로 세어진다.
실측으로 크기를 먼저 쟀다.
| 항목 | 값 |
|---|---|
| BM25 전용 후보 | 2,819건 |
| 그중 판정 이력 없음 | 734건 = 26.0% (기존 문항 중 118문항에 걸침) |
| dense 대조군 미판정률 | M0 0.56% · M1 0.72% · M2 0.82% · M3 0.00% |
| 변형별 미판정률(리랭커 입력 기준) | C_doc 0.6% → E_doc 3.3% → F_doc 6.4% → G_doc 8.6% |
dense는 전부 1% 미만인데 BM25만 26%다. 그리고 판정 대상 변형(G_doc)이 가장 큰 페널티를 받는다.
원인은 스코프 변경이다. 이번 BM25는 문서 스코프라 대상 문서 안에서 새로 올라온 청크가 판정 대상에 없었다.
조치: 734건 전건 라벨링(라벨러 8명 병렬) → 독립 검증 2명 → 정답 4건(0.54%), 추가·제거 0. 리드가 별도로 자동 재현율 감사(기준답변 변별 토큰 60% 이상을 담고도 기각된 후보 탐색)를 돌려 0건 확인.
| 골든셋 v7 | |
|---|---|
| 문항 (300 / 254 / 채점 성립 251) | 동일 — 불변 |
| 정답 청크 | 490 |
| 판정 풀 구성 | 4모델 + BM25(문서 스코프) |
여기서도 라벨러·검증 규격을 바꾸지 않았다
§3-3과 같은 이유다. 증분을 더 엄격하게 라벨링하면 기존 정답과 잣대가 갈린다. 다만 이번엔 갈리는 축이 모델이 아니라 후보 구성이다. BM25 전용 후보만 더 엄격하게 보면 BM25가 구조적으로 불리해진다.
골든셋 v7은 BM25 델타를 얹은 별도 정본이다. 그리고 v7 라벨로 임베딩 판정을 재확인해도 세 승격이 소수 4자리까지 같다 — 신규 정답 4건이 전부 BM25 전용이라
C_doc후보에 들어오지 않기 때문이다.
3-6. 질문 파일 — 이걸 틀리면 조용히 망한다
검색·리랭킹은 항상 같은 질문 텍스트를 봐야 한다.
실제로 전체 측정이 무효가 된 실패 유형
검색은 병합본을 봤는데, 리랭킹에는 골든셋 원본을 넣으면 후속 유형의 선행 턴 병합이 빠져 검색과 재정렬이 서로 다른 질문을 보게 된다. 파일 이름도 문항 수도 그럴듯해서 에러가 나지 않는다. 결과 숫자만 조용히 틀어진다.
후속 유형은 선행 턴을 앞에 이어붙인 병합본이다.
원본: "그 사업의 안정화기간은?"
병합본: "한영대학교 …사업기간은 어떻게 되나요? 그 사업의 안정화기간은?"
후속 유형은 45문항이다. 이 실수는 유형 하나에만 국한되지 않고 종단 성적 전체를 흔든다.
| 실행 단계 | 틀리면 생기는 일 |
|---|---|
| 후보 캐시 생성 | 후속 질문 문맥 없이 후보를 뽑는다 |
| 리랭킹 | 검색과 재정렬이 서로 다른 질문을 본다 |
| 채점 | 정답 라벨 기준이 검색 질문과 분리된다 |
4. 지표
| 지표 | 무엇을 보나 | 역할 |
|---|---|---|
| MRR@10 (리랭커 ON) | 첫 정답이 몇 등인가 | 판정 ★ |
| nDCG@10 | 상위권 전체의 질 | 회귀 감시 |
| Recall@5 | LLM에 실제로 넘어가는 5개의 질 | 회귀 감시 |
| Recall@20 | 정답이 후보 안에 들어왔나 | 아래 설명 |
- Recall@20 · 역할: 진단 — 임베딩 고유 실력 · 리랭커가 손댈 수 없는 상한
판정은 종단 성적으로 한다. 그게 실제로 돌아갈 구성에 가장 가깝기 때문이다. 리랭커 ON/OFF 축은 별도로 측정 중이므로, OFF 값으로 임베딩 승격을 판정하지 않는다.
리랭커 OFF 값을 임베딩 승격 판정에 쓰지 마라 12.2a에서 gpl3가 dense 단독으로 +0.0650 [+0.0193, +0.1134]를 냈다. 이 값은 승격 판정이 아니다. dense 단독은 후보 회수력을 보는 진단값이고, 최종 판정은 리랭커를 통과한 종단 순위로 한다. 이 주의사항은
verdict_12_7.py의해석_주의배열에 산출물로 박아 뒀다.
4-1. Recall@20을 같이 보는 이유
종단이 안 올랐을 때 원인을 구분하려고다.
| Recall@20 | 종단 MRR | 해석 |
|---|---|---|
| ↑ | ↑ | 더 데려왔고 성적으로 이어졌다 |
| ↑ | → | 더 데려왔는데 리랭커가 못 올렸다 → 리랭커 쪽 문제 |
| → | → | 키워도 못 찾는 문항이다 → 청킹·질의 쪽 문제 |
이 진단표는 이번 실험에서 실제로 쓰였다. gpl3의 이득이 어디서 오는지 가른 것이 Recall@20과 순위 덤프였다 → 검색 실험 결과 (4모델·BM25)
4-2. 판정 지표를 하나만 두면 반대 방향 손해를 못 본다
MRR@10이 올라도 nDCG@10이나 Recall@5가 유의하게 떨어지면 상위 5칸을 잠식한 것이다. LLM에 넘어가는 컨텍스트가 나빠지면, 첫 정답 순위가 조금 오른 것만으로 채택하면 안 된다.
그래서 BM25를 잴 때는 부수 지표 회귀를 판정 규격 안에 넣었다. 채택 조건은 MRR 기준 3조건 + 부수 지표 유의 음수 없음이다.
verdict_12_7.py와 BM25 판정 스크립트는 ndcg@10, recall@5, recall@20을 함께 낸다.
Recall@20은 --dump-ranked가 남긴 순위 목록에서 계산한다. 순위는 chunk_id뿐이라 NDA에도 안전하다.
ranked가 없으면 중단한다. 조용히 0으로 채우면 모든 모델이 똑같이 0이 되어 차이가 사라진다.
5. 통계 — 문서 클러스터 paired bootstrap
paired bootstrap을 쓰는 이유 같은 251문항에서 기준선과 도전안을 나란히 비교한다. 문항마다 난이도가 다르므로, 단순 평균 두 개를 따로 보는 것보다 문항별 차이를 다시 뽑아 보는 쪽이 맞다.
251문항을 복원추출해 문항별 차이 d_i = 도전점수(i) − 기준점수(i)를 리샘플한다.
1만 회, 시드 20260729 고정.
“평균이 올랐다”만으로 판정하지 않는다. CI 하한이 0을 걸치면 이겼다고 말하지 않는다.
5-1. 왜 문항 단위가 아니라 문서 단위인가 ★
같은 문서에서 뽑은 문항들은 독립이 아니다. 한 RFP에서 3~4문항이 나오는데, 그 문서의 청킹이 잘 됐으면 문항 셋이 같이 잘 나오고 못 됐으면 같이 못 나온다.
문항 단위로 재표집하면 유효표본을 과대평가해 CI가 실제보다 좁아진다 — 유의성을 부풀리는 방향의 오차다.
그래서 재표집 단위를 문항이 아니라 문서로 둔다.
# scripts/verdict_12_7.py : build_clusters()
군집 키 = 그 문항의 첫 대상 문서(_norm_doc_id 경유)
키가 없는 문항이 하나라도 있으면 → SystemExit로 중단키가 없으면 왜 중단하나 부트스트랩이 키 없는 문항을 조용히 버린다. 분모가 줄었는데 에러는 안 난다. 이 실험에서 반복적으로 잡힌 실패 유형(§13의 “조용히 틀리는 실패”)과 정확히 같은 계열이라 하드페일로 막았다.
5-2. 군집 키를 뭘로 잡을지도 실측으로 정했다
| 키 | 군집 수 | 군집당 문항 | 왜 이걸 쓰나 / 안 쓰나 |
|---|---|---|---|
첫 대상 문서 (first) | 95 | 2.64 | 채택. 문서 상관을 보정한다 |
대상 문서 집합 (tuple) | 129 | 1.97 | 군집이 잘게 쪼개져 보정 효과가 약해진다 |
보정 효과가 약해지는 쪽을 고르면 CI가 좁아지고 유의성이 부풀려진다.
그러니 여기서 tuple을 고르는 것은 자기에게 유리한 선택이 된다. first를 기본값으로 박았다.
5-3. 클러스터 보정이 실제로 일한다 — 실측
M3 ↔ M0_exact · MRR@10 · 1만 회 · seed 20260729:
| 재표집 단위 | 95% CI | 폭 |
|---|---|---|
| 문항 단위 | [+0.0121, +0.0776] | 0.0655 |
| 문서 클러스터 (first, 95군집) | [+0.0097, +0.0807] | 0.0710 |
| 문서집합 클러스터 (tuple, 129군집) | [+0.0102, +0.0799] | 0.0697 |
- 클러스터 보정이 CI를 8.4% 넓혔다. 방향이 맞고 크기도 문서당 2.64문항에 부합한다
- 어느 재표집 단위를 써도 하한이 양수로 유지된다
- 남는 반박: 문서 클러스터가 상관의 유일한 축인가. 같은 발주기관·같은 문서 유형의 문항도 상관이 있을 수 있다 → 미측정
6. 승격 기준 — 측정 전에 코드로 고정한다
기준선은 M0 e5-small + R1 + C_doc.1
판정은 여기서 끝난다 아래 조건을 모두 통과해야 한다. 점추정이 좋아 보여도 95% CI가 0을 걸치면 채택하지 않는다.
| 조건 | 값 | 왜 이 값인가 |
|---|---|---|
| 주 기준 | 차이의 95% 신뢰구간 하한 > 0 | 소표본 오판을 막는다 |
| 최소 폭 | 점추정 +0.03 이상 | 재인덱싱·모델 교체를 감수할 최소선 |
| 회귀 금지 | 어떤 유형도 −0.03 이하로 떨어지지 않을 것 | 아래 설명 |
| 부수 지표 | 아래 설명 | MRR 상승이 상위 컨텍스트 훼손을 가릴 수 있다 |
- 회귀 금지 · 왜 이 값인가: 평균이 오르며 한 유형이 망가지는 걸 막는다
- 부수 지표 · 값: BM25 축은 nDCG@10 · Recall@5 · Recall@20에 유의 음수 없음
6-1. 왜 코드에 박았나 — 사전등록
# scripts/verdict_12_7.py
PROMOTE_MIN_DELTA = 0.03
REGRESSION_LIMIT = -0.03
SEED = 20260729
N_RESAMPLES = 10_000docstring에 이유가 한 줄로 적혀 있다.
기준을 코드로 박는 이유: 수치를 보고 나서 기준을 고르면 어떤 결과든 정당화된다.
promote_verdict()는 조건을 각각 bool로 내고 all()로 묶는다.
판정자가 “이번엔 이 조건이 좀 애매하니까”라고 끼어들 자리가 없다.
판정 스크립트 출처는 축별로 분리한다. 임베딩은 scripts/verdict_12_7.py, BM25는 scripts/verdict_bm25_v6.py(커밋 4b4f155), 리랭커는 scripts/verdict_r2_v7.py(커밋 ccfa8f9)다.
판정 지표는 MRR@10, 부수 지표는 nDCG@10 · Recall@5 · Recall@20이다.
6-2. 회귀 검사는 유형별 평균차로 한다
# per_type_deltas() — 평균이 오르며 한 유형이 망가지는 것을 여기서 잡는다
유형별 평균차(t) = mean( 도전점수(q) − 기준점수(q) ) for q in 유형 t골든셋 v7의 유형별 문항 수는 단일세부 134 · 후속 45 · 탐색비교 37 · 직답 35다. 직답 35문항은 표본이 작아 평균차의 변동이 크지만, 회귀 한계 −0.03은 CI가 아니라 점추정 기준이라 표본 크기와 무관하게 적용된다. 이건 의도된 보수성이다. 작은 유형에서 무너지는 것도 잡아야 하기 때문이다.
6-3. 동점 통과 시의 규칙
여러 구성이 통과하면 작은 쪽을 고른다. 인덱스 크기·질의 비용이 그만큼 낮기 때문이다.
이 규칙은 이번에 실제로 발동했다. 임베딩 후보가 모두 통과했고, 평균 성능·견고성·유형 안정성·모델 크기가 서로 다른 답을 가리켰기 때문이다.
결정 —
M3gpl3 채택 (사용자, 2026-07-31) 축이 갈렸을 때 무엇을 우선할지는 실측이 정하지 않는다. 사용자가 이 규칙을 우선했다.
# 근거 1 gpl3는 118M·384D로 KURE(0.6B·1024D)의 1/5 2 유형별 최저값 gpl3 +0.0328 > KURE +0.0169 > bge −0.0100 3 gpl3 단독(+0.0437) ≈ e5+BM25(+0.0430)인데 BM25 인덱스가 필요 없다 → §8-1 ⚠️ 평균·견고성 축에서는 KURE가 앞섰다는 사실은 유지된다. 그래서 재판정 조건을 함께 남겼다 → 검색 실험 결과 (4모델·BM25)
2부 · 무엇을 비교하나
7. 임베딩 4종
| 코드 | 모델 | 파라미터 | 차원 | 프리픽스 | 비고 |
|---|---|---|---|---|---|
| M0 | intfloat/multilingual-e5-small | 118M | 384 | query: / passage: 필수 | 기준선 |
| M1 | BAAI/bge-m3 | 568M | 1024 | 없음 | 대형 다국어. HF 캐시 4.3GB |
| M2 | nlpai-lab/KURE-v1 | 0.6B | 1024 | 없음 | bge-m3의 한국어 튜닝판 |
| M3 | e5-small + LoRA-GPL merged (gpl3) | 118M | 384 | query: / passage: 필수 | 도메인 파인튜닝 |
M1을 계속 넣어야 한다. M2는 M1을 한국어로 추가 학습한 모델이라, M1 없이 M2만 돌리면 이긴 이유가 갈리지 않는다.
| 결과 | 해석 |
|---|---|
| M0→M1 크고 M1→M2 작다 | 계열이 관건 |
| M0→M1 작고 M1→M2 크다 | 한국어 튜닝이 관건 |
| M1→M2 ≤ 0 | 한국어 튜닝 불필요 |
7-1. 새 후보 gpl3 — 먼저 병합 상태부터 검증했다
팀에서 e5-small을 RFP 도메인에 GPL(Generative Pseudo Labeling)로 파인튜닝한
LoRA 어댑터가 나왔다(intfloat-multilingual-e5-small-LoRA-gpl3.pt).
받은 체크포인트를 그대로 믿지 않았다. 어댑터가 실제로 병합됐는지, 무엇이 바뀌었는지부터 확인했다.
| 확인 항목 | 결과 | 무엇을 뜻하나 |
|---|---|---|
| state_dict 키 | 199개, missing / unexpected 0 | 베이스 구조와 완전히 호환된다 |
| 단어 임베딩 | (250037, 384) — 베이스와 동일 | 어휘·차원이 안 바뀌었다 |
| 변경된 텐서 | 72개 / 12개 레이어 (어텐션 q·k·v·output, FFN intermediate·output) | 아래 설명 |
| 임베딩 층·풀러 | 변경 없음 | → e5의 프리픽스 규약을 그대로 써야 한다 |
- 변경된 텐서 · 무엇을 뜻하나: 실제로 학습된 가중치가 얹혔다
마지막 줄이 실험 설계에 직접 영향을 줬다. 임베딩 층이 안 바뀌었다는 것은 입력 문자열 관례가 베이스와 같아야 한다는 뜻이고, 그래서 §10의 프리픽스 게이트가 M3에도 그대로 걸린다.
7-2. 프리픽스 여부를 실측으로 확정했다 (12.2a)
학습 시 query:/passage:를 붙였는지가 문서로 남아 있지 않았다. 그래서 세 조건을 다 돌렸다.
리랭커 OFF · dense 단독:
| 조건 | MRR@10 | nDCG@10 | Recall@20 |
|---|---|---|---|
| (a) 원본 e5 + 프리픽스 | 0.6402 | 0.5764 | 0.7608 |
| (b) gpl3 + 프리픽스 | 0.7052 | 0.6447 | 0.8305 |
| (c) gpl3 프리픽스 없음 | 0.5219 | 0.4846 | 0.7225 |
- (b) − (c) = +0.1833 — 프리픽스를 떼면 원본보다도 나쁘다. gpl3는 e5 관례를 유지한 채 학습됐다
- (b) − (a) = +0.0650, CI [+0.0193, +0.1134] — nDCG +0.0684 · Recall@20 +0.0697도 전부 CI 하한 > 0
EMBEDDING_M3_USE_E5_PREFIX = True확정
이 +0.0650은 승격 판정이 아니다 리랭커 OFF의 dense 단독값이다. 판정은 종단으로만 한다(§4). 이 주의문은
verdict_12_7.json의해석_주의에 산출물로 박혀 있다.
재현 검증 통과 — 조건 (a)가 M0 실측 MRR@10 0.6402를 소수점 넷째 자리까지 재현했다. dense_doc 경로가 원본과 동일함이 확인된 것이다.
7-3. 핵심 비교쌍은 M0 ↔ M3 ★
| M0 | M1 | M2 | M3 | |
|---|---|---|---|---|
| 베이스 계열 | e5 | bge | bge | e5 (M0와 동일) |
| 파라미터 | 118M | 568M | 0.6B | 118M (동일) |
| 차원 | 384 | 1024 | 1024 | 384 (동일) |
| 프리픽스 규약 | e5식 | 없음 | 없음 | e5식 (동일) |
| M0와 다른 점 | — | 크기·계열·차원·프리픽스 | 위 + 한국어 학습 | 도메인 학습 하나 |
M1·M2가 이겨도 이유가 안 갈린다. 크기 덕인지, 계열 덕인지, 한국어 덕인지 셋이 뭉쳐 있다. M3가 이기면 이유가 하나뿐이다 — RFP 도메인 학습.
그래서 COMPARE_PAIRS에서 M3만 핵심으로 태그돼 있고, M1·M2는 임베딩 비교쌍이다.
COMPARE_PAIRS = [
("핵심", "M3", "M0_exact"),
("임베딩", "M1", "M0_fast"),
("임베딩", "M2", "M0_fast"),
]기준선이 M3에 대해서만 M0_exact인 이유는 §11에 있다. M3 후보를 정확 계산으로 만들었으므로
비교 상대도 정확 계산이어야 “검색 방식 덕에 이긴 것”을 배제할 수 있다.
8. 측정 축 — 후보 구성과 리랭커를 분리한다
이번 문서가 설명하는 축은 네 개다. 네 축 모두 골든셋 v7 단일 기준으로 닫혔다.
| 축 | 비교쌍 | 상태 | 이 문서에서 하는 일 |
|---|---|---|---|
| 임베딩 | M0 ↔ M1·M2·M3 (C_doc·R1 고정) | 측정 완료 | 모델 차이를 설명한다 |
| 후보 구성 | 아래 설명 | 측정 완료 | BM25를 얼마나 섞을지 설명한다 |
| 리랭커 ON/OFF | A_doc ↔ C_doc | 측정 완료 | R1 ON을 유지할지 설명한다 |
| 리랭커 종류 | R1 ↔ R2 | 측정 완료 | R2로 바꿀지 설명한다 |
- 후보 구성 · 비교쌍:
C_doc↔E_doc·F_doc·G_doc(4모델 전부)
리랭커를 통제 변인에서 조작 변인으로 옮긴 이유는 후보 구성 축을 다시 연 이유와 같다. R2 기각은 이전 3모델 정답지 라벨링 전에 확정됐고, 정답지는 그 뒤 두 번 바뀌었으며, 채택 모델 gpl3는 당시 존재하지도 않았다. 즉 현행 정답지·채택 모델 위에서 리랭커 축을 잰 적이 없었다. BM25 축을 문서 스코프 v7로 다시 잰 것처럼, 리랭커 ON/OFF와 R1/R2도 같은 기준에서 닫아야 한다.
8-0. 실험 순서 — 축이 어떻게 열리고 닫혔나
전수 조사가 아니다. 아래층 축을 먼저 닫고, 그 결론이 불충분하다고 드러나면 다시 여는 방식으로 진행했다.
| 단계 | 무엇을 정하나 | 고정한 것 | 맞댄 것 | 결과 |
|---|---|---|---|---|
| 1 | 임베딩 | dense만 (C_doc) · 현행 리랭커 (R1) | e5 ↔ bge · KURE · gpl3 | gpl3 채택 (+0.0437) |
| 2 | 후보 구성 | gpl3 · 현행 리랭커 (R1) | dense만 (C_doc) ↔ dense+BM25 (E·F·G_doc) | dense만 유지 (+0.0007) |
| 3 | 리랭커 ON/OFF | gpl3 · dense만 (C_doc) | 리랭커 없음 (A_doc) ↔ 현행 리랭커 (R1) | 켠다 (+0.0651) |
| 4 | 리랭커 종류 | gpl3 · dense만 (C_doc) | 현행 (R1) ↔ 한국어판 (R2) | 현행 R1 유지 |
2단계의 +0.0007은 gpl3 위에서 BM25를 섞어도 이득이 없다는 뜻이다. 4단계에서 R2는 전 모델 CI 상한까지 음수였다.
왜 이전 판정을 상속하지 않았나
| 축 | 이전 판정 | 왜 다시 열었나 |
|---|---|---|
| 임베딩 | 현행 유지 | 기각의 정체가 “효과 없음”이 아니라 표본 해상도 부족이었다 → §6-3 |
| 후보 구성 (BM25) | 미채택 | 임베딩과 같은 표본에서 같은 모양으로 기각됐다. 유리한 축만 다시 재면 안 된다 |
| 리랭커 | R1 유지 | R2 기각은 이전 정답지 라벨링 전에 확정됐고, 정답지가 그 뒤 두 번 바뀌었으며, 채택 모델 gpl3는 당시 없었다 |
왜 각 축에서 4모델을 전부 돌렸나
효과의 방향이 임베딩마다 갈리기 때문이다. 한 모델에서만 쟀다면 반대 결론이 났다.
| 임베딩 | BM25를 섞으면 | 리랭커를 켜면 |
|---|---|---|
| e5-small | +0.0430 (채택 조건 충족) | +0.1169 |
| gpl3 (채택) | +0.0007 (이득 없음) | +0.0651 |
| KURE-v1 | −0.0027 | +0.0301 (CI가 0을 걸침) |
| bge-m3 | +0.0016 | +0.0195 (CI가 0을 걸침) |
BM25는 e5 위에서만 조건을 충족하고, 리랭커 이득은 임베딩이 강해질수록 줄어 KURE·bge에서는 유의성을 잃는다 → §8-1 · §8-2
8-0-1. 측정 격자 — 잰 칸과 안 잰 칸
아래 표의 ✅는 해당 칸을 4모델(M0·M1·M2·M3) 전부에서 측정했다는 뜻이다. ❌는 “안 쟀다” 또는 “잴 이유가 낮아 제외했다”는 뜻이며, 둘은 구분해서 읽어야 한다.
| 후보 구성 | 리랭커 없음 | R1 | R2 |
|---|---|---|---|
A_doc (dense 후보 20, 리랭커 OFF) | ✅ | — | — |
B_doc (dense+BM25, 리랭커 OFF) | ❌ | — | — |
C_doc (dense 후보 20) | — | ✅ | ✅ |
D_doc (전역 BM25 후보) | — | ❌ | ❌ |
E_doc (문서 BM25 융합 상위 20) | — | ✅ | ❌ |
F_doc (문서 BM25 융합 상위 30) | — | ✅ | ❌ |
G_doc (문서 BM25 전부) | — | ✅ | ❌ |
| 라우팅 | ❌ | ❌ | ❌ |
B_doc은 프로덕션 구성이 아니라 제외했다. 리랭커 OFF 값은 진단값일 수는 있어도 승격 판정 경로가 아니다.
D_doc은 전역 BM25 후보의 32.6%가 대상 문서 밖이라 정답일 수 없었고, 문서 스코프 G_doc이 대체했다(§8-3).
라우팅은 100개 문서 중 어느 문서를 볼지 고르는 단계라서, 대상 문서가 이미 정해졌다고 두는 이 실험의 범위 밖이다.
이 축소법은 전수 조사가 아니다. 한 축에서 이긴 값이 다른 축 조합에서도 이긴다는 가정 위에 선다.
예를 들어 G_doc+R2는 안 쟀으므로, R2가 BM25 후보를 R1과 다르게 다룰 가능성은 배제되지 않았다.
다만 R2는 C_doc 기준 전 모델에서 MRR@10 CI 상한까지 음수로 졌다(최고 상한도 −0.0359, §8-2).
따라서 E_doc·F_doc·G_doc에 R2를 다시 붙여 얻을 실익은 낮다고 판단했다.
이 말은 “잴 이유가 전혀 없다”가 아니라, 안 잰 칸과 현재 결론에 쓰인 칸을 구분해 둔다는 뜻이다.
8-1. BM25 축 — 문서 스코프 v7로 재측정했다
BM25는 문서 스코프(bm25_doc)에서만 의미가 있다. dense 후보와 BM25 후보를 섞는 방식은 세 가지다.
| 변형 | 후보 구성 | 의미 |
|---|---|---|
C_doc | dense 20개 + R1 | 기준선 |
E_doc | dense∪BM25 융합 상위 20 + R1 | 예산 고정. BM25가 dense를 밀어낼 수 있다 |
F_doc | dense∪BM25 융합 상위 30 + R1 | 후보를 30개까지 늘린 절충 |
G_doc | dense∪BM25 전부 + R1 | 더하기만 하는 상한. 원인 분리의 정본 |
측정 규격 — 임베딩 축과 같은 잣대에 한 항을 더했다.
| 항목 | 값 |
|---|---|
| 대상 | 임베딩 4종 전부(M0·M1·M2·M3) × 변형 E_doc·F_doc·G_doc = 12칸 |
| 문항 | 골든셋 v7 251문항 · 정답 490건 |
| 조건 | 아래 설명 |
| 채택 조건 | 승격 3조건 + ④ 부수 지표(nDCG@10·Recall@5·Recall@20)에 유의 음수 없음 |
| 사전등록 | BM25 판정 스크립트 — 수치를 보기 전에 커밋(4b4f155)1 |
- 조건 · 값:
bm25_doc(문서 스코프) · 리랭커 R1 ON · 문서 클러스터 95군집 · 1만 회 · seed20260729
조건이 하나 더 붙은 이유 BM25 후보가 상위 5개를 잠식하면 MRR만으로는 손해를 못 본다. 그래서 이번엔 그 검사를 판정 규격 안에 조건으로 넣었다.
결과 요약 — 값 정본은 검색 실험 결과 (4모델·BM25)다.
| 모델 | C_doc → G_doc MRR@10 Δ | 95% CI | 판정 |
|---|---|---|---|
| M0 e5-small | +0.0430 | [+0.0032, +0.0869] | 4조건 충족 |
| M1 bge-m3 | +0.0016 | [−0.0141, +0.0182] | 미채택 |
| M2 KURE-v1 | −0.0027 | [−0.0256, +0.0202] | 미채택 |
| M3 gpl3 | +0.0007 | [−0.0163, +0.0202] | 미채택 |
설계상의 결론은 “BM25 OFF 유지”다 BM25의 이득은 dense가 약할 때의 보완이고, 승격 후보 세 모델 위에서는 남지 않는다. 채택 모델이 gpl3(§6-3)이므로 이 축은 켜지지 않는다. 그리고 gpl3 단독 +0.0437 ≈ e5+BM25 +0.0430이라는 대조가 §6-3의 채택 근거 3번이 됐다.
8-2. 리랭커 축 — R1 ON 유지, R2 미채택
리랭커 축은 두 질문을 분리한다. 첫째, 리랭커를 켤 것인가. 둘째, 켠다면 R1을 R2로 바꿀 것인가.
| 항목 | 값 |
|---|---|
| 문항 | 골든셋 v7 251문항 · 정답 490건 |
| 조건 | 문서 스코프 _doc · 문서 클러스터 95군집 · 1만 회 · seed 20260729 |
| 판정 스크립트 | 아래 설명 |
| 채택 조건 | 승격 3조건 + 부수 지표 유의 음수 없음 |
- 판정 스크립트 · 값:
scripts/verdict_r2_v7.py— 커밋ccfa8f9에서 규격 고정,dc44275에서A_doc축 추가
리랭커 종류 — R2 ↔ R1, C_doc 고정.
| 모델 | MRR@10 Δ | 95% CI | nDCG@10 Δ | Recall@5 Δ | 판정 |
|---|---|---|---|---|---|
| M1 bge-m3 | −0.0897 | [−0.1176, −0.0618] | −0.0694 | −0.0715 | ❌ 미채택 |
| M2 KURE-v1 | −0.0891 | [−0.1168, −0.0614] | −0.0704 | −0.0652 | ❌ 미채택 |
| M3 gpl3 | −0.0885 | [−0.1199, −0.0578] | −0.0662 | −0.0379 | ❌ 미채택 |
| M0 e5-small | −0.0611 | [−0.0877, −0.0359] | −0.0450 | −0.0165 | ❌ 미채택 |
가설 반증 — R2가 R1보다 한국어 RFP에 낫다는 가설은 닫혔다 전 모델에서 MRR@10 CI 상한까지 음수다. 따라서 리랭커 종류는 R1 유지로 확정한다. 특히 채택 모델 gpl3 위에서 처음 잰 R2 비교도 같은 결론이다. 이전 판에는 gpl3가 없어 대조값 자체가 없었으므로, 이 줄은 재측정이 아니라 최초 측정이다.
리랭커 ON/OFF — C_doc − A_doc, 챌린저 = 리랭커 ON.
| 모델 | MRR@10 Δ (C−A) | 95% CI | 판정 |
|---|---|---|---|
| M0 e5-small | +0.1169 | [+0.0695, +0.1633] | ✅ 채택 |
| M3 gpl3 (채택 모델) | +0.0651 | [+0.0203, +0.1108] | ✅ 채택 |
| M2 KURE-v1 | +0.0301 | [−0.0153, +0.0781] | ❌ |
| M1 bge-m3 | +0.0195 | [−0.0216, +0.0611] | ❌ |
새 발견 — 임베딩이 강해질수록 리랭커의 이득이 줄어든다 e5에서 +0.1169이던 것이 gpl3에서 +0.0651로 절반이 되고, bge·KURE에서는 유의성을 잃는다(CI가 0을 걸침). 리랭커가 대신하던 일을 임베딩이 직접 하게 되는 것이다.
채택 결정에는 영향이 없다. gpl3는 리랭커 ON이 유의하게 이기므로 R1 ON 유지다. 다만 bge·KURE를 골랐다면 리랭커를 끄는 것이 선택지가 될 수 있었다. 리랭커는 질의마다 비용이 들기 때문에, 이 조건은 채택 모델 재판정 조건으로 남긴다.
8-3. BM25는 문서를 좁혔을 때만 의미가 있다 (전제)
재측정은 bm25_doc(문서 스코프) 기준으로만 했다. 전역 BM25는 이번 실험의 비교 대상이 아니다.
BM25를 전체 코퍼스에 돌리면 사업명 토큰이 상위를 독식한다. RFP는 표지·머리말·각 절에 사업명이 반복돼서, 질문에 사업명이 들어가면 그 사업의 아무 절이나 위로 올라온다. 어떤 절이 답인지는 전혀 가려주지 못한다.
문서를 이미 좁힌 상태에서는 사업명이 모든 후보에 똑같이 들어 있어 변별에 기여하지 않고, 나머지 어휘(고유명사·표기·수치)로 순위가 갈린다.
전역 BM25 후보 20개 중 평균 6.5개(32.6%)가 대상 문서 밖 = 정답일 수가 없는 청크였다.
그 측정은 하이브리드를 잰 게 아니라 “엉뚱한 문서의 청크를 섞으면 나빠지나”를 잰 것이었다.
문서 스코프 G_doc이 그 자리를 대체한다.
스코프 변경이 미판정 편향의 직접 원인이다 기존 판정 풀의 어휘 후보는 전역 BM25 계열이었다. 이번은 문서 스코프다. 대상 문서 안에서 새로 올라온 청크는 판정받은 적이 없다 → BM25 전용 후보의 26.0%가 미판정. 그대로 쟀으면 전부 오답으로 세어져 BM25가 구조적으로 진다. 조치와 실측은 §3-5에 있다. 스코프를 고치면 정답지도 따라와야 한다 — 이번에 배운 것이다.
8-4. 안 재는 것 — 안 쟀다와 잴 이유가 없다를 구분한다
| 항목 | 상태 | 근거 |
|---|---|---|
B_doc (dense+BM25, 리랭커 OFF) | 잴 이유가 없다 | 아래 설명 |
D_doc (전역 BM25 + 리랭커) | 잴 이유가 없다 | 아래 설명 |
| 라우팅 | 안 쟀다 | 아래 설명 |
B_doc(dense+BM25, 리랭커 OFF) · 근거: 프로덕션 구성이 아니다. 리랭커 OFF 값은 진단값일 수는 있어도 승격 판정에 쓰지 않는다D_doc(전역 BM25 + 리랭커) · 근거: 전역 BM25는 후보의 32.6%가 대상 문서 밖이라 정답일 수가 없다. 문서 스코프G_doc이 대체한다- 라우팅 · 근거: 이 실험은 대상 문서가 이미 정해진 상태에서 그 안의 청크 순위만 잰다. 100개 문서 중 무엇을 볼지 고르는 단계는 범위 밖이다
3부 · 측정이 성립하려면
3부의 읽는 법 여기부터는 성능 수치가 아니라 측정이 성립하기 위한 게이트다. 하나라도 실패하면 실험은 에러를 내지 않고도 조용히 틀릴 수 있다.
| 게이트 | 막는 실패 |
|---|---|
| §9 chunk_id 동결 | 정답지가 다른 청크를 가리키는 실패 |
| §10 프리픽스 | 모델 입력 형식이 달라지는 실패 |
| §11 동일 엔진 | 검색 방식 덕에 새 모델이 이기는 실패 |
| §12 인덱스·검색 조건 | 다른 인덱스나 검색 범위를 재는 실패 |
9. 청크 경계를 동결한다 — chunk_id 게이트
chunk_id는 문서 내 위치 기반이다.
# src/index/chunk.py:321
"chunk_id": f"{doc_id}::{len(chunks):04d}" # 몇 번째 청크인가청크 경계가 밀리면 251문항 정답지가 통째로 다른 청크를 가리킨다. 에러 없이 조용히 어긋나는 종류다 → 청크와 chunk_id
그래서 임베딩을 바꿔도 청킹은 항상 e5 토크나이저(CHUNK_TOKENIZER_MODEL)로 한다.
안 쓰는 토크나이저로 자르는 게 이상해 보이지만 청킹 고정이 목적이라 이게 맞다.
bge-m3·KURE가 8192토큰을 받는다고 청크를 키우는 건 다른 실험이다.
게이트 — 모델 간 chunk_id 차집합이 0건이 아니면 중단한다.
이번 실험에서는 후보 풀 생성이 전체 재인덱싱 없이 대상 문서 청크만 모델별로 임베딩하는 방식이라 게이트가 더 중요해졌다. 인덱스에 없는 chunk_id가 하나라도 나오면 드롭 하드페일로 세웠다.
| 항목 | 결과 |
|---|---|
| 후보 풀 생성 범위 | 32문서 / 10,643청크 (신규 문항 대상 27문서 9,323청크에서 확대) |
| 인덱스에 없는 chunk_id | 0건 |
| 드롭 | 0건 |
| pytest | 23건 통과 (청크 수 일치 / e5가 chroma_v2와 chunk_id 순서·거리 동일 / 중복 0건) |
범위를 27문서 → 32문서로 넓힌 이유: 새로 붙인 기존 문항 5건이 기존 캐시에 없어 그대로 두면 재채점 자체가 불가능했기 때문이다.
왜 드롭을 하드페일로 막았나 이 프로젝트에서 에러 없이 후보·라벨이 줄어드는 실패가 3건 확인됐다(메모리
silent-data-drop). id 조회 코드는 드롭 건수를 세야 한다. 세지 않으면 분모가 줄어든 채로 점수가 나온다.
10. 프리픽스 ⚠️ 통제 항목 중 제일 위험
E5 계열(M0·M3)은 passage: /query: 를 반드시 붙여야 하고 bge-m3·KURE(M1·M2)는 붙이면 안 된다.
틀려도 에러가 안 나고 성능만 조용히 떨어진다.
함정이 하나 더 있다. 프리픽스 뒤에 섹션 정보가 같이 붙는다.
# src/index/chunk.py:48
def build_passage_prefix(meta, section):
return f"passage: [사업명: {...} / 발주기관: {...} / 섹션: {section}]\n"통째로 떼면 섹션 정보까지 날아가 새 모델만 정보를 덜 받는다.
| 모델 | 문서 입력 | 질문 입력 |
|---|---|---|
| M0 · M3 (e5 계열) | passage: [사업명…/섹션…]\n + 본문 | query: + 질문 |
| M1 · M2 | [사업명…/섹션…]\n + 본문 | 질문 그대로 |
여기서 틀리면 실험이 조용히 망한다 프리픽스 오류는 예외를 던지지 않는다. 모델별 입력 문자열을 확인하지 않으면, 새 모델이 나빠진 것이 아니라 새 모델만 섹션 정보를 덜 받은 상태를 재게 된다.
10-1. 육안 확인 대신 전수 복원 검사를 썼다
표본 확인 대신 전수 복원 검사를 썼다.
인덱싱에 실제로 들어간 입력 문자열을 되짚어 재구성
→ 그 문자열을 다시 임베딩
→ 저장된 벡터와 코사인 유사도를 전건 비교
| 지표 | 값 |
|---|---|
embed_text_복원_cos min | 0.9999999 |
| mean | 1.0 |
코사인이 1.0이면 입력 문자열이 바이트 단위로 같다는 전수 증명이다. 첫 청크만 맞고 중간이 틀린 경우도 잡을 수 있다.
11. 동일 엔진 — 검색 방식 덕에 이기지 않게 한다
세 임베딩이 같은 엔진을 써야 한다 Chroma는
doc_id필터를 걸어도 전수 비교가 아니라 근사 탐색이라, 정확 계산과 대조하니 일부 문항에서 상위 20이 달랐다. 기존 M0 캐시는 근사 산출물이므로 새 모델만 정확 계산으로 만들면 새 모델이 임베딩 성능이 아니라 검색 방식 덕에 유리해진다.
M1·M2·M3 후보를 numpy 정확 계산으로 만들었다.
그래서 M0도 정확 계산 버전(M0_exact)을 한 벌 더 만들어 M3의 비교 상대로 삼았다.
| 캐시 | 만든 방법 | 누구의 비교 상대인가 |
|---|---|---|
M0_fast | Chroma 근사 탐색 (기존 캐시 계보) | M1 · M2 |
M0_exact | numpy 정확 계산 | M3 (핵심 비교쌍 — 엔진을 맞춘다) |
11-1. 결과적으로 이 게이트는 종단에서 무의미했다 — 그러나 사전에는 알 수 없었다
| 비교 | 종단 MRR@10 차이 |
|---|---|
M0_exact ↔ M0_fast | 0.000000 (251문항 전건) |
후보 집합이 다른 문항이 있었지만, 교체된 청크가 전부 비정답이고 상위권 밖이었다. 리랭커가 후보 20개를 전량 재정렬하므로 dense 엔진 차이가 종단 지표에 남지 않는다.
그래도 두 벌 만든 것이 낭비는 아니다 §11의 게이트는 dense 단독 측정에서만 구속력이 있고, 리랭커 종단에서는 씻긴다. 이것이 이번 실험이 새로 알아낸 사실이다. 그런데 그 사실은 측정으로만 알 수 있었다. 사전에는 “유리한 편향이 생길 위험”을 배제할 수 없었다. 게이트를 안 걸고 나중에 “씻겼을 것”이라 말하는 것과, 걸고 나서 “씻겼다”고 말하는 것은 다르다.
경위는 검색 실험 결과 (4모델·BM25)가 정본이다.
12. 인덱스·검색 조건 (전 조합 동일)
| 항목 | 값 | 비고 |
|---|---|---|
| 인덱스 계보 | chroma_v2 (= VM /srv/GPTPilot/chroma) | 30,002청크 / 99문서 |
| 검색 범위 | 문서 한정 (_doc) | 프로덕션에 다른 경로가 없다 |
| 후보 수 | top-k 후보 20 → 리랭크 후 10 | 아래 설명 |
| 리랭크 후 | 상위 10으로 채점 | |
| 정규화 | normalize_embeddings=True | 전 모델 공통 |
| 질문셋 | 251문항 동일 | 모델마다 바꾸지 않는다 |
| 리랭커 실행 | VM L4 GPU | 맥 CPU는 49배 느리다 |
| 리랭킹 소요 | M0 24.8s · M1 34.2s · M2 31.7s · M3 28.0s | 251문항 전건 |
- 후보 수 · 비고: 후보 구성 축의
F_doc·G_doc은 §8-1의 정의를 따른다
인덱스 계보는 실제로 사고가 난 자리다
chroma와chroma_v2에 같은 chunk_id가 존재해서, 틀린 인덱스를 써도 에러가 나지 않고 조용히 다른 수치가 나왔다.
리랭커 로드 안전장치 리랭커·임베딩 모델은 로컬 경로를 강제한다(
HF_HUB_OFFLINE). 네트워크에서 다른 리비전을 받아오면 같은 모델 이름으로 다른 가중치를 재게 된다. CUDA 실배치도 로그로 확인한다. 조용히 CPU로 떨어지면 49배 느려지는 것 말고는 티가 안 난다.
13. 이 실험이 경계하는 실패 유형
이 프로젝트에서 잡힌 사고를 늘어놓으면 전부 같은 종류다.
| 사고 | 왜 안 보였나 |
|---|---|
| 틀린 인덱스 | 양쪽에 같은 chunk_id가 있어 에러가 안 난다 |
| 질문 파일 어긋남 | 파일 이름이 그럴듯하고 문항 수도 맞다 |
| 정답지 편향 | 라벨이 없으면 그냥 오답으로 세어진다 |
| BM25 스코프 불일치 | 후보 개수는 정확히 20개다 |
| 정답지에 새 모델 미반영 (이번에 막은 것) | M3 고유 후보가 전부 오답으로 세어진다 |
| 병합 시 중복 chunk_id (이번에 잡은 것) | 분모만 조용히 부푼다 |
| 정답지에 새 후보 구성 미반영 (이번에 막은 것) | 아래 설명 |
- 정답지에 새 후보 구성 미반영 (이번에 막은 것) · 왜 안 보였나: BM25 전용 후보의 26.0%가 오답으로 세어진다 → §3-5
공통점: 실패가 예외를 던지지 않고 숫자로만 나타난다. 그래서 이 실험은 조건마다 게이트를 붙이고, 게이트 실패를 하드페일로 만들었다.
14. 산출물
| 파일 | 내용 |
|---|---|
scripts/verdict_12_7.py | 사전등록된 임베딩 판정 규격 (구조 회귀 테스트 포함) |
out/verdict_12_7_v7.json | 임베딩 판정의 v7 라벨 재확인 (세 승격 유지) |
out/rebuttal_12_7.md | 반박 대비 논점 정리 (구간별 재판정·이득 분해) |
data/goldenset_v7/rerank_labels_full.jsonl | v7 정답 라벨 (정답 490건) |
VM /srv/GPTPilot/goldenset/v7_20260731/ | 골든셋 v7 스냅샷 · current 이동 완료 |
| BM25 축 (§8-1) | |
out/verdict_bm25_v7.json | BM25 판정 정본 — 4모델 × E/F/G_doc |
out/bm25_unjudged_audit_v6.json | 미판정 편향 감사 (BM25 전용 2,819건 / 미판정 734건) |
out/rescore_v6_bm25/ | BM25 변형 리랭킹 순위 덤프 |
| 리랭커 축 (§8-2) | |
scripts/verdict_r2_v7.py | 아래 설명 |
out/verdict_r2_v7.json | 리랭커 판정 정본 — R2 전 모델 미채택 · R1 ON은 M0·M3에서 채택 |
scripts/verdict_r2_v7.py· 내용: 사전등록된 리랭커 판정 규격 —A_doc↔C_doc, R1 ↔ R2 · 커밋ccfa8f9규격 고정,dc44275에서A_doc축 추가
15. 관련 문서
| 문서 | 무엇을 보러 |
|---|---|
| 검색 실험 결과 (4모델·BM25) | 판정·성적 정본 · 실험하며 바로잡은 것 |
| 골든셋 · 골든셋 200문항 제작 | 정답지가 어떻게 만들어졌나 |
| 후보 캐시 | dense_doc / bm25_doc 구분 |
| 쿼리 파이프라인 | 검색이 전체 흐름에서 어디인가 |
| 개념 색인 | MRR · nDCG · BM25 · paired bootstrap · 지표 포화 등 |
코드: src/config.py · src/index/chunk.py · src/query/retrieve.py · src/eval/label_bias.py · scripts/build_pool_delta.py · scripts/verdict_12_7.py · scripts/verdict_bm25_v6.py · scripts/verdict_r2_v7.py
정본: Plans.md Phase 12 (12.2a~12.8) · spec.md §2 · §10-19 · docs/ADR.md ADR-010 · ADR-011