검색 2차 실험 계획 (임베딩·BM25·리랭커)
아카이브됨 — ** 검색 2차 실험 (임베딩·BM25·리랭커)**로 계획·결과가 합쳐졌다 (2026-07-29)
같은 실험인데 계획과 결과가 나뉘어 있어 두 문서를 오가야 했다. 하나로 합치고 이쪽을 내렸다. §0-1 실험 베이스라인(측정 조건 정본)은 합친 문서 §4로 옮겼다. 사료로서 보존하되 현행 상태로 읽지 말 것 — 아래 진행 상황·일정·미정 항목은 전부 결과로 대체됐다.
이 문서 하나만 읽으면 실험을 그대로 실행할 수 있게 쓴다. 1차 결과 → 검색 실험 결과 (164문항) · 용어가 막히면 → 개념 색인
진행 상황 (2026-07-29)
완료 — 인덱싱 처리량 실측(§12) · 프리픽스 모델별 분리(§10-1) · 후보 캐시 최적화(§11-1) · M0 캐시 parity(§8) · M1·M2 인덱싱 + chunk_id 게이트 통과 + 캐시 생성(11단계 2~4) · BM25 판정(5단계) · 리랭커 채점(6단계) · 상·하한 채점 + paired bootstrap(7단계, §9·§13) · 전수 증분 라벨링(8단계, 골든셋 v5) · 리랭커 프로덕션 반영(→ 검색 실험 결과 (164문항) §11)
판정 완료 — 임베딩 교체 미채택(e5 유지) · R2 기각(R1 유지) 다시 열린 것 — BM25. 지금까지의 기각은 후보 확장 효과를 안 잰 반쪽 실험이었다(§5-1)
판정과 성적 정본 → 검색 2차 실험 결과 (임베딩 교체) · 정답지를 고친 기록 → 정답지 편향 제거 (전체 풀링 라벨링) 이 문서는 설계·조건(§0-1)을 든다.
M0 parity: 로컬
chroma_v2↔ VM chunk_id 집합 완전 일치(차집합 0/0), 1차 캐시 고유 id 3,592개 전부 존재 → M0 캐시 재사용 확정, 재측정 불필요 M1 게이트: M1 ↔ M0 chunk_id 차집합 양방향 0건(30,002 = 30,002) → 정답지 그대로 사용 확정(§8)
0. 세 줄 요약
- 검색의 **기본 엔진(임베딩)**을 바꿔본다. 지금 건 일부러 작은 걸 골랐고, 그 약점을 메우기로 했던 BM25가 1차에서 기각됐다.
- 엔진이 바뀌면 1차 결론 두 개가 다시 열린다 — BM25는 정말 쓸모없나, 리랭커 이득은 얼마나 남나. 그래서 셋을 한 판에 같이 잰다.
- 가장 큰 함정은 성능이 아니라 정답지다. 지금 정답지는 e5가 뽑은 후보에서 만들어져 새 모델에 구조적으로 불리하다. → §9
0-1. 실험 베이스라인 — 무엇을 기준으로 재나 ★
이 절이 측정 조건의 정본이다. 뒤에서 조건이 헷갈리면 여기로 돌아온다. 아래 값이 하나라도 달라지면 이전 수치와 비교할 수 없다.
기준선 (이겨야 하는 상대)
| 구성 | M0 + R1 = e5-small 임베딩 + bge-reranker-v2-m3 리랭커 |
| 성적 | 종단 MRR@10 = 0.7443 |
| 출처 | 1차 실험 C_doc 채택 구성 → 검색 실험 결과 (164문항) |
새 조합은 이 수치를 신뢰구간으로 이겨야 채택된다 (§13). dense 단독(0.6402)이 아니라 리랭커까지 켠 상태가 기준선이라는 점이 중요하다 — 임베딩을 바꿔서 얻는 이득 중 상당 부분을 리랭커가 이미 가져갔을 수 있기 때문이다.
정답지 (무엇으로 채점하나)
| 골든셋 | 측정 당시 v4_20260729 → 최종 채점은 v5(§9 라벨링 반영). 200문항 중 채점 성립 164문항 |
| 제외 | 모른다 15 · 반문 15 · 빈 정답 6 (= 36문항) |
| 정답 청크 | v4 연인원 310 / 고유 253 → v5 연인원 353 |
| 유형 | 단일세부 90 · 후속 30 · 탐색비교 25 · 직답 25 |
| 검증 | 170문항 완료 |
| 상세 | 골든셋 |
정답지는 e5가 만든 후보에서 뽑혔다
새 모델이 찾아온 옳은 청크가 정답지에 없어서 오답 처리될 수 있다. 이 편향을 전부 라벨링으로 메우지 않고 상·하한으로 가둔다 → §9
인덱스·검색 조건 (전 조합 동일)
| 항목 | 값 | 비고 |
|---|---|---|
| 인덱스 계보 | chroma_v2 (= VM /srv/GPTPilot/chroma) | 30,002청크 / 99문서 |
| 검색 범위 | 문서 한정 | 프로덕션에 다른 경로가 없다 |
| 후보 수 | 20 | RERANK_CANDIDATE_K |
| 정규화 | normalize_embeddings=True | 전 모델 공통 |
| 질문셋 | 164문항 동일 | 조합마다 바꾸지 않는다 |
고정 vs 변수
| 항목 | |
|---|---|
| 절대 고정 | 청킹 경계·chunk_id(§8·§10-2) · 토크나이저(e5) · 위 인덱스/검색 조건 |
| 변수 (3축) | 임베딩 3 × 리랭커 2 × BM25 on/off = 12조합 (§6) |
청킹을 고정하는 이유가 실험 설계의 핵심이다 — chunk_id가 위치 기반이라
청크 경계가 밀리면 164문항 정답지가 통째로 무효가 된다 → 청크와 chunk_id
1부 · 왜 또 하나
1. 1차에서 나온 것
164문항, 베이스는 e5-small.
| 구성 | MRR@10 | |
|---|---|---|
| dense 단독 (현행) | 0.6402 | 기준선 |
| + BM25 | 0.6096 | ❌ 미채택 |
| + 리랭커 | 0.7443 | ✅ 채택 |
2. 그런데 세 가지가 남았다
2-1. BM25로 메우려던 계획이 사라졌다
docs/ADR.md ADR-006이 지금 임베딩을 고르면서 약점을 이렇게 적어놨다.
대형 임베딩 모델 대비 한국어 고유명사·표 검색 품질 한계 → 메타데이터 필터·프리픽스 주입·(백로그) BM25 하이브리드로 보완
앞의 둘은 이미 들어가 있고, 세 번째가 1차에서 기각됐다. 약점을 메우겠다던 수단이 없어졌으니 엔진 자체를 볼 차례다.
2-2. 리랭커를 채택하면서 임베딩의 임무가 바뀌었다
[예전] 질문 → 임베딩이 20개 찾고 순위도 매김 → 상위 5개
└ 찾기 + 줄세우기를 다 함
[지금] 질문 → 임베딩이 20개 찾음 → 리랭커가 줄세움 → 상위 5개
└ 찾기만 └ 줄세우기
리랭커는 후보 밖의 것을 데려올 수 없다. 20개 안에 정답이 없으면 끝이다.
| 임베딩을 바꿔서 얻는 것 | 리랭커가 대신해주나 | 값어치 |
|---|---|---|
| 정답을 20개 안에 넣는 능력 | ❌ 못 함 | 크다 |
| 20개 안에서 정답을 위로 올리는 능력 | ✅ 이미 함 | 작다 |
2-3. 실제로 못 찾아오고 있다 — 이게 결정적
지금 임베딩이 정답을 후보 20개 안에 얼마나 담아오는지 재봤다.
| e5 Recall@20 (문서범위) | 0.8163 |
| 정답을 20위 안에 다 담은 문항 | 118 / 164 |
| 정답이 20위 안에 하나도 없는 문항 | 17 / 164 |
정답의 18%가 후보 밖에 있다. 그리고 저 17문항은 리랭커를 켜도 영원히 못 맞힌다. 임베딩을 바꿔야만 손댈 수 있는 영역이고, 그게 이 실험의 명분이다.
2부 · 무엇과 무엇을 비교하나
3. 임베딩 3종
| 모델 | 크기 | 차원 | 프리픽스 | 이 줄이 알려주는 것 | |
|---|---|---|---|---|---|
| M0 | intfloat/multilingual-e5-small | 118M | 384 | query:/passage: | 기준선 (현행) |
| M1 | BAAI/bge-m3 | 568M | 1024 | 없음 | 계열을 바꾸면 얼마나 오르나 |
| M2 | nlpai-lab/KURE-v1 | 0.6B | 1024 | 없음 | 거기에 한국어 튜닝을 얹으면 |
M1을 반드시 넣어야 하는 이유: M2(KURE)는 M1(bge-m3)을 한국어로 추가 학습한 모델이다. M1 없이 M2만 돌리면 이긴 이유가 갈리지 않는다.
| 결과 | 해석 |
|---|---|
| M0→M1 크고, M1→M2 작다 | 계열이 관건. 한국어 튜닝은 덤 |
| M0→M1 작고, M1→M2 크다 | 한국어 튜닝이 관건 |
| 둘 다 크다 | 누적 효과 |
| M1→M2 ≤ 0 | 한국어 튜닝 불필요 |
참고: KURE 팀이 한국어 검색 8종으로 잰 값은 bge-m3 0.6872 → KURE 0.6947로 +0.0075뿐이다. 반면 계열 차이(e5-large 0.6637 → bge-m3 0.6872)는 +0.0235로 세 배다. 우리 데이터에서도 같은 패턴이면 M1이 이득의 대부분을 가져간다는 뜻이 된다.
왜 A.X-Encoder는 뺐나
skt/A.X-Encoder-base는 팀 리서치 후보였으나 검색용으로 학습된 모델이 아니다.
ModernBERT 기반 문서 이해·분류용 베이스이고, 모델 카드 예제가 fill-mask다.
검색용 임베딩은 *“질문과 정답 문단의 벡터가 가까워야 한다”*를 따로 배우는데, A.X는 그 학습을 받은 적이 없다. 그냥 쓰면 낮게 나오는 게 정상이라, 그 숫자로 “이 모델이 나쁘다”고 적으면 팀원 제안을 잘못 기각하게 된다.
남길 사유: “A.X가 나쁘다”가 아니라 “검색 파인튜닝이 없어 비교 조건이 안 맞는다”. 넣으려면 우리 데이터로 유사도 학습을 먼저 해야 하고, 그건 8/1 마감 안에 불가능하다.
4. 리랭커 2종
1차에서는 켜냐/끄냐만 봤다. 리랭커 모델끼리는 비교한 적이 없다.
| 모델 | 비고 | |
|---|---|---|
| R0 | 없음 | |
| R1 | BAAI/bge-reranker-v2-m3 | 1차에서 쓴 것 |
| R2 | dragonkue/bge-reranker-v2-m3-ko | R1의 한국어 추가 학습판 |
임베딩 쪽과 구조가 똑같다 — 원본과 한국어 튜닝판의 대결이다. M1 vs M2의 답과 R1 vs R2의 답이 같은 방향인지도 볼거리다. 둘 다 “한국어 튜닝은 미미하다”로 나오면 그건 우연이 아니라 패턴이다.
5. BM25를 왜 다시 보나
1차에서 BM25는 졌다(−0.031). 그런데 진 이유가 “dense가 이미 강해서”일 수 있다.
두 검색기를 합치는 건 실력이 비슷할 때 의미가 있다(하이브리드 검색). 베이스가 바뀌면 그 균형이 달라진다.
| 상황 | BM25 기여 |
|---|---|
| dense가 훨씬 강함 | 노이즈만 섞임 → 손해 (1차가 이랬을 가능성) |
| dense가 비슷하거나 약함 | 보완이 성립 → 이득 가능 |
중복이 아니라 필요한 확인이다. 다만 §6에서 보듯 리랭커를 켜면 이 축은 접힌다.
5-1. ⚠️ 그 “접힌다”가 틀렸다 — 재측정 중
if c in seen으로 BM25 전용 청크를 버렸기 때문에 접힌 것이지, 구조적 사실이 아니었다.
청크를 살리면 BM25가 후보 집합을 바꾸므로 리랭커가 지울 수 없다.
e5가 못 찾은 17문항 중 11문항을 BM25가 건진다 → 검색 2차 실험 결과 (임베딩 교체) §3
6. 격자 — 18조합, 그중 12개만 의미 있다
3 임베딩 × 2 BM25 × 3 리랭커 = 18
그런데 1차에서 리랭커를 켜면 BM25가 무효임이 증명됐다.
BM25는 후보 집합을 못 바꾼다 (BM25 단독 발굴 청크는 본문이 없어 버림)
→ 순서만 바꾼다
→ 리랭커가 후보 전체를 자기 점수로 재정렬 = 그 순서를 지움
→ 결과 동일 (1차 164문항 전건 일치 확인)
이건 임베딩과 무관한 구조적 사실이라 M1·M2에서도 성립한다.
| 묶음 | 조합 | 성격 |
|---|---|---|
| R0 × BM25 off/on × 임베딩 3 | 6 | 의미 있음 — BM25 판정 |
| R1·R2 × BM25 off × 임베딩 3 | 6 | 의미 있음 — 리랭커·임베딩 판정 |
| R1·R2 × BM25 on × 임베딩 3 | 6 | 확인용 — off와 같아야 정상 |
18개 다 돌린다(리랭킹은 조합당 45초라 15분 더 들 뿐). 다만 보고할 때 **“의미 있는 12 + 확인용 6”**으로 나눠 쓴다.
확인용 6개가 off와 다르게 나오면 그건 결과가 아니라 버그 신호다. 후보 추림이나 융합 순서가 1차와 달라졌다는 뜻이므로 그 자리에서 멈추고 원인을 찾는다.
3부 · 어떻게 재나
7. 지표 — 하나로 판정하지 않는다
§2-2의 이유로 지표를 나눈다.
| 지표 | 무엇을 보나 | 역할 |
|---|---|---|
| Recall@20 | 정답이 후보 20개 안에 들어왔나 | 진단 — 임베딩 고유 실력 |
| 종단 MRR@10 (리랭커 ON) | 사용자가 실제로 받는 순위 | 판정 ★ |
| 단독 MRR@10 (리랭커 OFF) | 리랭커 없을 때 순위 | 참고 |
| nDCG@10 · Recall@5 | 보조 | 회귀 감시 |
판정은 종단 성적으로 한다. 그게 실제로 돌아갈 구성이기 때문이다. 1차에서 프로덕션에 없는 경로를 재서 결론이 뒤집혔던 교훈(검색 실험 계획 (164문항) §15)을 그대로 적용한다.
Recall@20을 왜 같이 보나: 종단이 안 올랐을 때 이유를 구분하려고.
| Recall@20 | 종단 MRR | 해석 |
|---|---|---|
| ↑ | ↑ | 더 데려왔고 성적으로 이어졌다 → 채택 |
| ↑ | → | 더 데려왔는데 리랭커가 못 올렸다 → 리랭커 쪽 문제 |
| → | → | 키워도 못 찾는 문항이다 → 청킹·질의 쪽 문제 |
8. 골든셋은 안 깨진다
chunk_id는 청킹에서 나온다. 임베딩은 그 청크를 벡터로 바꾸는 단계라 번호에 관여하지 않는다.
원문 → 청킹 → chunk_id 부여 → 임베딩 → 벡터DB
└ 여기서 결정 └ 여기만 바꾼다
# src/index/chunk.py:321
"chunk_id": f"{doc_id}::{len(chunks):04d}" # 몇 번째 청크인가164문항 정답지를 그대로 쓴다.
단, 깨지는 조건이 하나 있다
청킹 코드나 설정이 바뀌면 번호가 밀리고 정답지 전체가 다른 청크를 가리키게 된다. 에러가 안 나고 조용히 어긋나는 종류다.
게이트: 재인덱싱 직후
chunk_id집합이 현행chroma_v2와 완전히 일치하는지 확인. 하나라도 다르면 그 자리에서 중단한다.
✅ 실증됨 — M1(bge-m3) 게이트 통과 (6.8.3)
계획이 아니라 결과다. M1 인덱스와 M0 인덱스의 chunk_id를 대조했다.
| 항목 | 결과 |
|---|---|
| chunk_id 차집합 (M1 − M0 / M0 − M1) | 0건 / 0건 |
| 총 청크 | 30,002 = 30,002 |
| 문서 수 | 99 = 99 |
| 문서별 청크 수 불일치 | 0건 |
임베딩을 384차원·프리픽스 있음 → 1024차원·프리픽스 없음으로 통째로 바꿨는데
청크 경계가 하나도 밀리지 않았다. §10-1(embed 직전에만 프리픽스 치환)과
§10-2(청킹은 e5 기준 고정, CHUNK_TOKENIZER_MODEL로 토크나이저 영구 고정) 설계가
의도대로 동작했다는 증거다.
여기서 어긋났다면 164문항 골든셋이 통째로 무효가 되고 2차 실험을 다시 설계해야 했다. 이 실험에서 가장 크게 걸려 있던 위험이 여기서 닫혔다.
M1 캐시(6.8.3): 164문항, 고유 chunk_id 3,305개 전부 인덱스에 존재(드롭 0),
engine=fast / model=BAAI/bge-m3, 소요 249초.
M0 캐시 재사용 적격(6.8.2): 로컬 chroma_v2 ↔ VM /srv/GPTPilot/chroma 차집합 0/0,
1차 캐시의 고유 id 3,592개가 양쪽에 전부 존재. macOS(NFD) ↔ Linux(NFC) 표기 차이도 대조했으나
결과와 무관했다(only_a_raw_nfd_sensitive: 0).
9. 정답지가 새 모델에 불리하다 ★ 최대 위험
정답지를 어떻게 만들었는지 보면 문제가 보인다.
질문 → e5로 후보 20 ∪ BM25로 후보 20 → 사람이 그중에서 정답 고르기
└───── 이 풀 밖의 청크는 애초에 후보에 못 올랐다 ─────┘
문서 하나가 청크 280개(중앙값)인데 들여다본 건 문항당 40개, 즉 **문서의 14%**다.
새 모델이 나머지 86%에서 진짜 정답을 찾아오면, 라벨이 없어서 오답으로 세어진다. 잘할수록 손해를 본다.
이미 목격한 적 있다 — RFID 문항에서
::0024가 정답인데 후보 풀에::0023과::0025만 있어 라벨이 안 붙었다(골든셋).
다루는 법 — 전부 라벨링하지 않고 상·하한으로 가둔다
두 번 채점한다.
| 라벨 없는 청크를 | 누구에게 유리 | |
|---|---|---|
| 하한 (비관) | 전부 오답 취급 | 새 모델에 불리 |
| 상한 (낙관) | 전부 정답 취급 | 새 모델에 유리 |
하한에서도 새 모델이 이긴다 → 라벨링 없이 채택 확정
상한에서도 새 모델이 진다 → 라벨링 없이 기각 확정
구간이 걸친다 → 이때만 라벨링
라벨링이 필요해져도 전부 하지 않는다. 판정을 뒤집을 수 있는 것만 읽으면 된다 — 새 모델 상위 10위 안 + 기존 정답보다 위 + 라벨 없음. 문항당 0~2건 수준이다.
라벨링은 Claude가 원문을 직접 읽고 한다. 팀 OpenAI API는 쓰지 않는다. (
CLAUDE.mdCRITICAL — 골든셋 작업은 호출이 광범위해 $20 팀 한도를 빠르게 소진)
✅ 실측됨 — 구간이 걸쳤다 (6.8.7)
이 절의 우려가 실제로 판정을 막았다. 새 모델은 리랭킹 상위 10개 중 26~32%가 미판정이고 (M0는 0.7%), 그래서 상·하한 폭이 M0 0.0018 vs 새 모델 0.05로 갈렸다.
세 갈래 판정 결과: m1_r1·m2_r1만 “라벨링 필요”, R2 조합 셋은 낙관 검정에서도 져서 기각.
수치와 신뢰구간은 검색 2차 실험 결과 (임베딩 교체) §7이 정본이다.
개념 → 상·하한 채점 · paired bootstrap
✅ 라벨링으로 제거했다 (6.8.8)
이 절의 위험은 실재했고, 라벨을 늘려 없앴다. 세 임베딩의 후보를 합집합으로 묶어 2,105개를 증분 판정했다.
| 신규 정답 | 42 / 2,105 = 2.0% · 독립 검증 추가 1 · 제거 0 |
| 상위10 미판정률 | M0 0.7% / M1 26.0% / M2 32.0% → 전 조합 0.0% |
| 편향 제거 후 KURE 이득 | +0.0068 → +0.0297 (4배) |
전 과정과 교훈(정답률이 낮다고 영향이 없는 게 아니다) → 정답지 편향 제거 (전체 풀링 라벨링)
10. 통제 항목 — 하나라도 놓치면 무효
10-1. 프리픽스 ⚠️ 제일 위험
E5는 passage: /query: 를 반드시 붙여야 하고, bge-m3·KURE는 붙이면 안 된다.
틀려도 에러가 안 나고 성능만 조용히 떨어진다(spec.md §190이 경고하는 실패 모드).
함정이 하나 더 있다.
# src/index/chunk.py:58
return f"{E5_PASSAGE_PREFIX}[{label}]\n" # → "passage: [섹션명]\n"passage: 뒤에 섹션 이름이 같이 붙어 있다. 통째로 떼면 섹션 정보까지 날아가
새 모델만 정보를 덜 받는다 — 실험을 기존 모델 쪽으로 기울이는 실수다.
| 모델 | 문서 입력 | 질문 입력 |
|---|---|---|
| M0 (e5) | passage: [섹션명]\n + 본문 | query: + 질문 |
| M1·M2 | [섹션명]\n + 본문 | 질문 그대로 |
각 인덱스 첫 청크의 실제 입력 문자열을 눈으로 확인하고 넘어간다.
10-2. 청킹은 절대 건드리지 않는다
bge-m3·KURE는 8192토큰까지 받으니 “청크를 키우면 더 좋지 않나” 싶지만 그건 다른 실험이다. 같이 바꾸면 무엇이 효과의 원인인지 알 수 없고, §8대로 정답지가 깨진다.
512토큰 분할 검증도 e5 토크나이저 기준 그대로 유지한다. 안 쓰는 토크나이저로 자르는 게 이상해 보이지만 청킹 고정이 목적이라 이게 맞다.
10-3. 나머지 고정
질문셋·검색 범위·후보 수·정규화·인덱스 계보 → §0-1이 정본이다. 값을 고칠 일이 생기면 여기가 아니라 §0-1에서 고친다.
통제 관점에서 덧붙일 것 하나 — 위 다섯 중 하나라도 조합마다 달라지면 비교가 무효다.
특히 인덱스 계보는 1차에서 실제로 사고가 났다: chroma와 chroma_v2에 같은 id가 존재해
틀린 인덱스를 써도 에러가 나지 않고 조용히 다른 수치가 나왔다
→ 검색 실험 결과 (164문항) §8
4부 · 실행
11. 순서
| 단계 | 작업 | 어디서 | 시간 |
|---|---|---|---|
| 0 | ✅ 인덱싱 처리량 실측 (bge-m3, 청크 200개) | VM | 완료 → §12 |
| 1 | ✅ 프리픽스 모델별 설정 분리 (§10-1) + 육안 확인 | 코드 | 완료 |
| 1b | ✅ 후보 캐시 생성 최적화 (아래 §11-1) | 코드 | 완료 — 1,887초 → 136초 |
| 2 | ✅ M1(bge-m3) 인덱싱 → chunk_id 게이트 (§8) | VM | 완료 — 게이트 통과(차집합 0/0) |
| 3 | ✅ M1 후보 캐시 → 인덱스·가중치 삭제 | VM | 완료 — 249초 (드롭 0) |
| 4 | ✅ M2(KURE) 반복 | VM | 완료 — 게이트 통과 |
| 5 | ✅ BM25 판정 — 임베딩 3 × on/off | 로컬 | 완료 — 3종 전부 순손해, 최종 기각 |
| 6 | ✅ 리랭커 채점 — 임베딩 3 × R1·R2 × BM25 on/off | VM | 완료 — R1 유지, 승격 후보 0.7511 |
| 7 | ✅ 상·하한 채점 → 라벨링 필요 판정 (§9) | 로컬 | 완료 — m1_r1·m2_r1은 라벨링 필요, 나머지 기각 |
| 8 | ✅ 추가 라벨링 (전체 풀링 증분, 전수 2,105개) | 로컬 | 완료 — 신규 정답 42(2.0%), 검증 추가 1·제거 0 → 골든셋 v5 |
| 9 | ✅ 판정 (§13) | 임베딩 교체 미채택 — e5 유지 |
VM 점유 75분 / 로컬 81분 (8단계 제외).
- M0 캐시는 이미 있다 —
data/retrieval_cache_200.json(1차 산출물,chroma_v2기준)을 그대로 쓴다 - 5·7단계가 1분인 이유: 1차에서 만든 캐시 방식 덕이다 → 후보 캐시
- 0·1·2단계를 건너뛰지 않는다. 1·2는 “틀려도 에러가 안 나는” 종류라 사후 발견이 어렵고, 0은 나머지 추정의 유일한 근거다
총 시간은 왜 안 줄었나
1b(최적화 코드 작성)가 40분 붙어서 합계는 2.5시간 그대로다. 줄어든 건 VM 점유 시간 117분 → 75분. 제약이 걸린 자원이 VM이라(8/4 초기화 + GPU 독점) 여기를 줄인 게 의미가 있고, 최적화 코드는 이번 한 번 쓰고 버리는 게 아니라 이후 실험에 계속 남는다.
11-1. 후보 캐시 생성 최적화 (1b단계) — ✅ 완료
1,887초 → 136초 (13.9배). 164문항 산출물은 1차와 비트 단위 동일.
병목을 처음에 잘못 짚었다
계획 단계에서는 “문항마다
doc_id필터로 3만 청크를 전수 스캔하는 것”이 원인이라고 봤다. 프로파일링 결과 그 구간은 문항당 0.16초, 전체의 3%였다.
문항당 실측:
| 구간 | 소요 | |
|---|---|---|
bm25_rank | 3.65초 | ← 진짜 병목 |
dense_full | 2.05초 | |
dense_doc (문서 필터) | 0.16초 | 의심했던 곳 |
bm25_rank가 호출마다 28k 청크 재조회 + 토큰캐시 재파싱 + 코퍼스 지문 재해싱 +
28k 토큰열 JSON 재직렬화를 하고 있었다. 준비 작업을 1회로 접어 0.13초가 됐다.
dense 경로는 건드리지 않았다 — 그래서 산출물이 동일하다.
정확계산 경로는 --engine exact로 opt-in만 열어뒀다.
검증: 164문항 전량 dense_full·dense_doc·bm25 전 필드 chunk_id 순서 일치, 거리 최대차 0.0.
⚠️ 파생 발견 — Chroma 필터 검색은 근사다
정확 계산 경로와 대조하니 **164문항 중 9문항(5.5%)**에서 상위 20이 달랐다.
Chroma는 doc_id 필터를 걸어도 전수 비교가 아니라 근사 탐색이라 더 가까운 청크를 놓친다.
실측 예: 정확 계산이 거리 0.087415짜리를 찾았는데 Chroma는 못 찾았다.
세 모델이 같은 엔진을 써야 한다
M0 캐시는 근사 검색 산출물이다. M1·M2만 정확 계산으로 만들면 새 모델이 임베딩 성능이 아니라 검색 방식 덕에 유리해진다. 5.5% 순서 차이는 MRR을 흔들 수 있는 크기다. 기본값(
fast)이 1차를 그대로 재현하므로 전부fast로 간다 — M0 재생성 불필요.
개념 정리 → 후보 캐시
12. 자원 제약
| VM 여유 디스크 | 7.4GB (96GB 중 93% 사용) |
| bge-m3(M1) HF 캐시 | 4.3GB — 실측 (사전 추정 2.3GB의 약 2배) |
| KURE-v1(M2) HF 캐시 | 2.2GB — M1의 절반. 같은 계열인데도 차이가 크다 |
| 다운로드 직후 여유 | 3.2GB (bge-m3 기준, 중단 기준 3GB에 근접) |
| GPU | L4 23GB — 여유 (팀원 프로세스 2개 5.3GB 공유 중) |
| 기한 | 8/4 VM 초기화 — 리랭커는 GPU 필요, 그전에 끝내야 함 |
순차는 권장이 아니라 필수다
대형 모델 하나가 여유의 **57%**를 먹는다. 2종을 동시에 두면 디스크가 터진다. 한 모델씩 다운로드 → 인덱싱 → 캐시 저장 → 즉시 삭제. 삭제 후 7.4GB로 원복되는 것을 6.8.0에서 실측 확인했다.
bge-reranker-v2-m3(2.2GB)와 e5-small(471MB)은 계속 쓰므로 지우지 않는다.
캐시는 chunk_id와 거리만 담아 모델당 수 MB다.
인덱싱 처리량 — 실측 (6.8.0, NVIDIA L4, 청크 200개, 워밍업 분리)
| 모델 | 초당 청크 | 30,002청크 환산 |
|---|---|---|
| e5-small | 238.3 | 2분 06초 |
| bge-m3 | 29.3 | 17분 05초 |
bge-m3가 8.1배 느리다. batch 64는 32보다 오히려 11% 느려서 인덱싱은 batch 32로 간다.
"모델당 15분" 추정은 폐기
실측해보니 그 값은 사실상 bge-m3급 수치였다. e5급에 적용하면 일정이 7배 부풀려진다 → 모델별 산정으로 전환. 위 수치는 임베딩 순전파만이고 추출·청킹·Chroma 쓰기, 콜드 모델 로드(bge-m3 45초)는 별도다.
환산값을 실제 소요로 읽지 말 것
위 표는 임베딩 순전파만 잰 값이라 Chroma 쓰기가 빠져 있다. 실제 총소요는 훨씬 길다 — 인덱싱 중 GPU 0%인 채 CPU 100%로 sqlite에 쓰는 구간이 관측됐다. M2(KURE) 실측은 18~21청크/초로, 30,002청크에 약 25~30분이다. 즉 병목이 GPU가 아니라 쓰기 쪽이라, 모델을 빠른 걸로 바꿔도 그만큼 줄지 않는다.
API 비용 $0 — 전부 오픈소스 가중치를 우리 GPU에서 돌린다. 검색 단계까지만 재므로 LLM이 낄 자리가 없다.
13. 승격 기준 — 측정 전에 확정한다
기준선은 M0 + R1 (1차 채택 구성, 종단 MRR@10 = 0.7443).
| 조건 | 값 | 왜 이 값인가 |
|---|---|---|
| 주 기준 | 종단 MRR@10 차이의 95% 신뢰구간 하한 > 0 | 아래 참조 |
| 최소 폭 | 점추정 +0.03 이상 | 재인덱싱 한 번이면 되고, 질의 시 임베딩 비용 증가는 리랭커(270ms) 옆에서 무시할 수준. “비용을 넘는가”보다 **“노이즈가 아닌가”**가 관건 |
| 회귀 금지 | 어떤 유형도 −0.03 이하로 떨어지지 않을 것 | 평균이 오르며 한 유형이 망가지는 걸 막는다 (1차 BM25가 정확히 그랬다) |
신뢰구간을 쓰는 이유
지난번에 이것 때문에 틀렸다. 36문항에서 +0.073이 나와 “확정”이라고 적었는데 164문항에서 −0.031로 부호가 뒤집혔다 → 검색 실험 결과 (164문항) §5.
문항별 차이를 재표본추출(paired bootstrap)해서 차이가 0을 넘는다고 말할 수 있는지를 본다. “평균이 올랐다”만으로는 또 같은 실수를 한다.
여러 구성이 통과하면 작은 쪽을 고른다. M1과 M2가 0.02 이내면 M1이다.
5부 · 미리 적어두는 예상과 리스크
14. 예상 — 결과를 보고 말 바꾸지 않으려고 먼저 적는다
- Recall@20은 오른다. ADR-006이 인정한 약점(한국어 고유명사·표)을 큰 모델이 더 잘 잡는다.
- 종단 MRR은 그보다 덜 오른다. 순위 개선분은 리랭커가 이미 먹었다.
- M1이 이득의 대부분을 가져가고 M2는 조금 더한다. KURE 벤치마크 패턴(+0.0075)대로면 그렇다.
- BM25는 여전히 진다. M1·M2에서 dense가 더 강해지므로 1차보다 더 크게 질 것이다.
- R1 vs R2는 근소하다. 리랭커 한국어 튜닝도 임베딩과 비슷한 폭일 가능성.
- 탐색비교·후속은 거의 안 오른다. 리랭커도 못 올렸다(+0.034 / +0.005). 여러 문서를 걸치는 문제라 임베딩으로 풀릴 성질이 아니다.
2번이 맞으면 채택 기준을 못 넘을 수 있다. 그건 실패가 아니라 **“리랭커가 이미 그 몫을 하고 있다”**는 유용한 결론이다. 그때는 임베딩을 그대로 두고 리랭커만 켜는 게 정답이 된다.
15. 리스크
| 리스크 | 대응 |
|---|---|
| 정답지 편향 (§9) | 상·하한 먼저, 갈릴 때만 라벨링 |
| 프리픽스 실수 (§10-1) | 모델별 설정 + 실제 입력 문자열 육안 확인 |
| chunk_id 밀림 (§8) | 인덱싱 직후 집합 일치 게이트 — M1에서 통과, 골든셋 유효 확정(§8). M2도 같은 게이트를 태운다 |
| VM 디스크 (§12) | 한 모델씩 처리 후 삭제 |
| 8/4 VM 초기화 | 리랭커(GPU)를 그전에 완료 |
| 소표본 오판 (§13) | paired bootstrap 신뢰구간 |
| 확인용 6조합 불일치 (§6) | 결과가 아니라 버그 신호로 다룬다 |
16. 관련 문서
| 문서 | 무엇을 보러 |
|---|---|
| 검색 2차 실험 결과 (임베딩 교체) | 이 계획의 실측 결과 — 값 정본 |
| 검색 실험 결과 (164문항) | 1차 판정 — 이 실험의 전제이자 기준선 |
| 검색 실험 계획 (164문항) | 측정 설계 원칙 (§15 교훈) |
| 골든셋 | 164문항 정답지가 어떻게 만들어졌나 |
| 임베딩 · Dense 검색 · 크로스인코더 리랭커 · BM25 | 개념 |
| index | 폴더 전체 목차 |
코드: src/config.py:32 · src/index/chunk.py:58 · src/index/build_index.py · scripts/build_retrieval_cache.py · scripts/rerank_on_cache.py · scripts/promote_decision.py
정본: Plans.md 6.8 · spec.md §9 · docs/ADR.md ADR-006