검색 실험 설계 (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만 회 · seed 20260729 · 문서 클러스터(first, 95군집 / 군집당 2.64)

  • 측정 완료 · 핵심: 임베딩 축(M0 ↔ M1·M2·M3, C_doc 고정) · 후보 구성 축(C_docE_doc·F_doc·G_doc, 4모델 전부) · 리랭커 ON/OFF(A_docC_doc) · 리랭커 종류(R1 ↔ R2)

이 문서의 역할: 여기 적힌 조건이 하나라도 달라지면 같은 실험으로 비교할 수 없다. 조건이 헷갈릴 때 돌아오는 자리다.

어디부터 읽나

목적읽을 순서
처음 보면§0-1 (용어·코드·눈금) → §0-2 ①②③④
수치만 확인하러 왔으면검색 실험 결과 (4모델·BM25)
왜 그렇게 판단했나가 궁금하면아래 설명
  • 왜 그렇게 판단했나가 궁금하면 · 읽을 순서: 1부(무엇을 재나) → 2부(무엇을 비교하나) → 3부(측정이 성립하려면)

확정된 값은 검색 실험 결과 (4모델·BM25)가 정본이다.

목차 — 실제 본문 순서와 역할

찾는 것바로 갈 곳여기서 확인할 것
실험을 처음 이해하기§0-1 → §0-2용어, 파이프라인, 고정값, 조작값
수치를 비교해도 되는 조건인지 확인§3 · §5 · §9~§12아래 설명
모델·축 정의 확인§7 · §8M0~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 안에서의 델타로 읽는다.

실험 코드 읽는 법 — 이 문서 내내 나온다

코드
임베딩M0e5-small — 기준선(118M · 384D)
M1bge-m3 — 568M · 1024D
M2KURE-v1 — 0.6B · 1024D
M3gpl3 — LoRA-GPL 머지 e5-small 파생, 118M · 384D
후보 구성Adense만, 리랭커 없음
Cdense 20개 + 리랭커
Edense∪BM25 융합 상위 20 + 리랭커
Fdense∪BM25 융합 상위 30 + 리랭커
Gdense∪BM25 전부 + 리랭커
리랭커R1bge-reranker-v2-m3
R2dragonkue/bge-reranker-v2-m3-ko
접미사_doc대상 문서 1개 안에서만 검색. 모든 판정의 기준
엔진 갈래M0_exactM0를 정확 계산으로 만든 후보 캐시
M0_fastM0를 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 ↔ M1118M → 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의 “작은 쪽” 규칙을 우선해 M3 gpl3로 닫았다. BM25는 OFF 유지 — 조건을 충족한 것이 M0 위에서뿐이었기 때문이다 → §8-1


1부 · 무엇을 재나

1. 왜 다시 재나 — 조건이 충분히 고정돼야 판정이 선다

이 실험의 질문은 단순하다. 같은 정답지, 같은 문서 스코프, 같은 통계 기준에서 임베딩·후보 구성·리랭커를 바꿨을 때 종단 MRR@10이 충분히 오르는가.

여기서 충분하다는 말은 감상이 아니다. 기준은 코드로 고정한다.

  1. 차이의 paired bootstrap 95% CI 하한이 0보다 크다.
  2. 점추정이 기준선 대비 +0.03 이상이다.
  3. 어떤 유형도 −0.03 이하로 회귀하지 않는다.
  4. 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단계, 그리고 왜 스킬을 안 바꿨나

  1. 라벨러 — .harness-mem/goldenset-skill/rounds/r8/SKILL.md (재현율 0.863 / 정밀도 0.750)로 원문 대조 판정
  2. 독립 검증자 — VERIFY_SPEC.md 규격으로 라벨러가 고른 정답만 재검사

여기서는 절대 정확도보다 일관성이 중요하다. 증분을 더 엄격한 방법으로 라벨링하면 기존 정답과 잣대가 갈려, 기존 후보와 신규 후보가 서로 다른 기준으로 채점된다. 지금 고치려는 편향을 다른 편향으로 바꾸지 않기 위해 같은 스킬·같은 프롬프트를 그대로 썼다.

검증 실측 — 2단계가 설계대로 작동했다.

갈래문항검증 verdict
신규 문항85통과 77 · 수정 8
기존 문항 델타155통과 155 · 수정 0
BM25 델타118추가 0 · 제거 0
갈래유지제거추가보류
신규 문항120280
기존 문항 델타5001
BM25 델타4000

신규 문항의 최종 정답은 라벨러 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@5LLM에 실제로 넘어가는 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)952.64채택. 문서 상관을 보정한다
대상 문서 집합 (tuple)1291.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_000

docstring에 이유가 한 줄로 적혀 있다.

기준을 코드로 박는 이유: 수치를 보고 나서 기준을 고르면 어떤 결과든 정당화된다.

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. 동점 통과 시의 규칙

여러 구성이 통과하면 작은 쪽을 고른다. 인덱스 크기·질의 비용이 그만큼 낮기 때문이다.

이 규칙은 이번에 실제로 발동했다. 임베딩 후보가 모두 통과했고, 평균 성능·견고성·유형 안정성·모델 크기가 서로 다른 답을 가리켰기 때문이다.

결정 — M3 gpl3 채택 (사용자, 2026-07-31) 축이 갈렸을 때 무엇을 우선할지는 실측이 정하지 않는다. 사용자가 이 규칙을 우선했다.

#근거
1gpl3는 118M·384D로 KURE(0.6B·1024D)의 1/5
2유형별 최저값 gpl3 +0.0328 > KURE +0.0169 > bge −0.0100
3gpl3 단독(+0.0437) ≈ e5+BM25(+0.0430)인데 BM25 인덱스가 필요 없다 → §8-1

⚠️ 평균·견고성 축에서는 KURE가 앞섰다는 사실은 유지된다. 그래서 재판정 조건을 함께 남겼다 → 검색 실험 결과 (4모델·BM25)


2부 · 무엇을 비교하나

7. 임베딩 4종

코드모델파라미터차원프리픽스비고
M0intfloat/multilingual-e5-small118M384query: / passage: 필수기준선
M1BAAI/bge-m3568M1024없음대형 다국어. HF 캐시 4.3GB
M2nlpai-lab/KURE-v10.6B1024없음bge-m3의 한국어 튜닝판
M3e5-small + LoRA-GPL merged (gpl3)118M384query: / 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@10nDCG@10Recall@20
(a) 원본 e5 + 프리픽스0.64020.57640.7608
(b) gpl3 + 프리픽스0.70520.64470.8305
(c) gpl3 프리픽스 없음0.52190.48460.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 ★

M0M1M2M3
베이스 계열e5bgebgee5 (M0와 동일)
파라미터118M568M0.6B118M (동일)
차원38410241024384 (동일)
프리픽스 규약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/OFFA_docC_doc측정 완료R1 ON을 유지할지 설명한다
리랭커 종류R1 ↔ R2측정 완료R2로 바꿀지 설명한다
  • 후보 구성 · 비교쌍: C_docE_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 · gpl3gpl3 채택 (+0.0437)
2후보 구성gpl3 · 현행 리랭커 (R1)dense만 (C_doc) ↔ dense+BM25 (E·F·G_doc)dense만 유지 (+0.0007)
3리랭커 ON/OFFgpl3 · 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) 전부에서 측정했다는 뜻이다. ❌는 “안 쟀다” 또는 “잴 이유가 낮아 제외했다”는 뜻이며, 둘은 구분해서 읽어야 한다.

후보 구성리랭커 없음R1R2
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_docdense 20개 + R1기준선
E_docdense∪BM25 융합 상위 20 + R1예산 고정. BM25가 dense를 밀어낼 수 있다
F_docdense∪BM25 융합 상위 30 + R1후보를 30개까지 늘린 절충
G_docdense∪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만 회 · seed 20260729

조건이 하나 더 붙은 이유 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% CInDCG@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_id0건
드롭0건
pytest23건 통과 (청크 수 일치 / 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 min0.9999999
mean1.0

코사인이 1.0이면 입력 문자열이 바이트 단위로 같다는 전수 증명이다. 첫 청크만 맞고 중간이 틀린 경우도 잡을 수 있다.

11. 동일 엔진 — 검색 방식 덕에 이기지 않게 한다

세 임베딩이 같은 엔진을 써야 한다 Chroma는 doc_id 필터를 걸어도 전수 비교가 아니라 근사 탐색이라, 정확 계산과 대조하니 일부 문항에서 상위 20이 달랐다. 기존 M0 캐시는 근사 산출물이므로 새 모델만 정확 계산으로 만들면 새 모델이 임베딩 성능이 아니라 검색 방식 덕에 유리해진다.

M1·M2·M3 후보를 numpy 정확 계산으로 만들었다. 그래서 M0도 정확 계산 버전(M0_exact)을 한 벌 더 만들어 M3의 비교 상대로 삼았다.

캐시만든 방법누구의 비교 상대인가
M0_fastChroma 근사 탐색 (기존 캐시 계보)M1 · M2
M0_exactnumpy 정확 계산M3 (핵심 비교쌍 — 엔진을 맞춘다)

11-1. 결과적으로 이 게이트는 종단에서 무의미했다 — 그러나 사전에는 알 수 없었다

비교종단 MRR@10 차이
M0_exactM0_fast0.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.0s251문항 전건
  • 후보 수 · 비고: 후보 구성 축의 F_doc·G_doc은 §8-1의 정의를 따른다

인덱스 계보는 실제로 사고가 난 자리다 chromachroma_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.jsonlv7 정답 라벨 (정답 490건)
VM /srv/GPTPilot/goldenset/v7_20260731/골든셋 v7 스냅샷 · current 이동 완료
BM25 축 (§8-1)
out/verdict_bm25_v7.jsonBM25 판정 정본 — 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_docC_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

Footnotes

  1. 사전등록은 v6 시점에 이루어졌고, BM25 판정 스크립트명도 그 이력을 따라 GPTPilots_Project/scripts/verdict_bm25_v6.py로 남아 있다(커밋 4b4f155). 본문 기준은 모두 골든셋 v7이며, v7 라벨로 재확인한 임베딩 판정 수치는 소수 4자리까지 동일하다. 2