검색 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. 세 줄 요약

  1. 검색의 **기본 엔진(임베딩)**을 바꿔본다. 지금 건 일부러 작은 걸 골랐고, 그 약점을 메우기로 했던 BM25가 1차에서 기각됐다.
  2. 엔진이 바뀌면 1차 결론 두 개가 다시 열린다 — BM25는 정말 쓸모없나, 리랭커 이득은 얼마나 남나. 그래서 셋을 한 판에 같이 잰다.
  3. 가장 큰 함정은 성능이 아니라 정답지다. 지금 정답지는 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문서
검색 범위문서 한정프로덕션에 다른 경로가 없다
후보 수20RERANK_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기준선
+ BM250.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종

모델크기차원프리픽스이 줄이 알려주는 것
M0intfloat/multilingual-e5-small118M384query:/passage:기준선 (현행)
M1BAAI/bge-m3568M1024없음계열을 바꾸면 얼마나 오르나
M2nlpai-lab/KURE-v10.6B1024없음거기에 한국어 튜닝을 얹으면

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없음
R1BAAI/bge-reranker-v2-m31차에서 쓴 것
R2dragonkue/bge-reranker-v2-m3-koR1의 한국어 추가 학습판

임베딩 쪽과 구조가 똑같다 — 원본과 한국어 튜닝판의 대결이다. 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 × 임베딩 36의미 있음 — BM25 판정
R1·R2 × BM25 off × 임베딩 36의미 있음 — 리랭커·임베딩 판정
R1·R2 × BM25 on × 임베딩 36확인용 — 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.md CRITICAL — 골든셋 작업은 호출이 광범위해 $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차에서 실제로 사고가 났다: chromachroma_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/offVM완료 — 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_rank3.65초← 진짜 병목
dense_full2.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에 근접)
GPUL4 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-small238.32분 06초
bge-m329.317분 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. 예상 — 결과를 보고 말 바꾸지 않으려고 먼저 적는다

  1. Recall@20은 오른다. ADR-006이 인정한 약점(한국어 고유명사·표)을 큰 모델이 더 잘 잡는다.
  2. 종단 MRR은 그보다 덜 오른다. 순위 개선분은 리랭커가 이미 먹었다.
  3. M1이 이득의 대부분을 가져가고 M2는 조금 더한다. KURE 벤치마크 패턴(+0.0075)대로면 그렇다.
  4. BM25는 여전히 진다. M1·M2에서 dense가 더 강해지므로 1차보다 더 크게 질 것이다.
  5. R1 vs R2는 근소하다. 리랭커 한국어 튜닝도 임베딩과 비슷한 폭일 가능성.
  6. 탐색비교·후속은 거의 안 오른다. 리랭커도 못 올렸다(+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