검색 실험 설계
무엇을 왜 어떻게 재는가. 판정과 성적은 검색 실험 결과가 정본이다. 용어가 막히면 → §0 · 개념 색인 · 정답지가 어떻게 만들어졌나 → 골든셋
이 문서의 역할
여기 적힌 조건이 하나라도 달라지면 이전 수치와 비교할 수 없다. 조건이 헷갈릴 때 돌아오는 자리다.
어디부터 읽나
처음 보면 → §0 (용어·코드·눈금) → §0-1 ①②③④ 로 끝. 여기까지가 실험의 전부다. 조건을 확인하러 왔으면 → §0-1 ① 표 → 해당 절 왜 이렇게 설계했나가 궁금하면 → 1부(무엇을 재나) → 2부(무엇을 비교하나) → 3부(측정이 성립하려면)
현재 읽을 때의 상태 — 실험 종료 (2026-07-29)
후보 구성·리랭커·임베딩 세 축 모두 측정이 끝났고, 판정은 전 축 현행 유지다. 확정된 값은 검색 실험 결과가 정본이다. 이 문서는 무엇을 왜 그렇게 쟀는지를 남긴 설계서이며, 판정 결과는 각 절의 콜아웃에 표시했다.
0. 읽기 전에 — 이 실험이 뭐냐
한 문장
질문을 던졌을 때 정답이 적힌 문단을 몇 등으로 찾아오는가를 재고, 지금 쓰는 검색 부품 셋(임베딩 · 리랭커 · 후보 구성)을 바꿀 값어치가 있는지 판정한다.
실제로 무슨 일이 일어나나 — 질문 하나를 끝까지 따라가기
질문 하나가 검색 파이프라인을 통과하는 흐름
질문 "한영대학교 정보시스템 구축사업의 사업기간은?"
│
│ ① 어느 문서 얘기? 문서 100개 중 1개로 좁힌다
▼ (골든셋이 정답 문서를 알려주므로 이 단계는 항상 맞다고 친다)
문서 1개 = 청크(문단) 약 300개
│
│ ② 그중 20개만 뽑는다 ← 축 1 임베딩 · 축 3 후보 구성이 여기
▼
후보 20개 (정답이 이 안에 없으면 이후 단계가 무슨 짓을 해도 끝)
│
│ ③ 20개를 다시 줄세운다 ← 축 2 리랭커가 여기
▼
1등 … 20등
│
│ ④ 상위 5개만 LLM에게 준다
▼
답변
★ 채점: 정답 문단이 ③에서 몇 등이었나 → 1등이면 1.0, 2등이면 0.5, 4등이면 0.25
모르면 못 읽는 단어 8개
| 단어 | 한 줄 뜻 |
|---|---|
| 청크 | 문서를 잘라 놓은 문단 하나. 검색의 최소 단위 (RFP 100개 → 30,002개) |
| 임베딩 | 문장을 숫자 벡터로 바꾸는 모델. 뜻이 비슷하면 벡터가 가깝다 → 이걸로 후보를 뽑는다 |
| dense | 위 임베딩(벡터)으로 하는 검색. “의미가 비슷한 것” |
| BM25 | 단어가 겹치는 걸로 하는 옛날 방식 검색. “계약보증금” 같은 고유명사·숫자에 강하다 |
| 하이브리드 | dense와 BM25를 둘 다 써서 후보를 모으는 것 → 축 3 |
| 리랭커 | 뽑아온 후보 20개를 질문과 하나씩 붙여 보고 다시 줄세우는 모델. 정확하지만 느려서 20개만 본다 |
| 골든셋 / 정답지 | 사람(과 Claude)이 미리 판정해 둔 “이 질문의 정답은 이 청크” 목록. 채점 기준 |
| MRR@10 | 이 실험의 판정 지표. 정답이 몇 등인지의 평균 → 아래 눈금 참조 |
숫자 읽는 눈금 — MRR 0.74가 좋은 건가
1.00 항상 1등 0.74 ← 지금 여기 0.50 평균 2등
0.33 평균 3등 0.10 평균 10등 0.00 10등 안에 아예 없음
0.7443 = 대충 “질문 10개 중 67개는 정답이 1등, 나머지는 24등쯤”. 나쁘지 않은 수준이라
남은 이득이 작다 — 이 실험에서 뭘 바꿔도 +0.03 넘기기가 어려웠던 이유다.
실험 코드 읽는 법 — 이 문서 내내 나온다
| 축 | 코드 | 뜻 |
|---|---|---|
| 축 1 임베딩 | M0 | e5-small — 지금 쓰는 것 (기준선) |
M1 | bge-m3 — 5배 큰 모델 | |
M2 | KURE-v1 — bge-m3의 한국어 튜닝판 | |
| 축 2 리랭커 | R1 | bge-reranker-v2-m3 — 지금 쓰는 것 (기준선) |
R2 | 그것의 한국어 튜닝판 | |
| 축 3 후보 구성 | C | dense 20개만 — 지금 쓰는 것 (기준선) |
E·F | dense + BM25 섞어서 상위 20개 / 30개 | |
G | dense 20개 + BM25 20개 전부 (겹침 빼면 35개) | |
| 접미사 | _doc | 문서 1개 안에서만 검색 (프로덕션 경로) |
_full | 문서 100개 전체에서 검색 (대체 경로) |
기준선 =
M0+R1+C_doc= 지금 돌아가는 그것. 모든 비교는 이것과 맞댄다.
0-1. 실험 설계 한눈에
① 무엇을 고정했나 — 통제 변인
하나라도 움직이면 이전 수치와 비교할 수 없다.
| 고정한 것 | 값 | 왜 고정하나 |
|---|---|---|
| 청크 경계 | e5 토크나이저 (CHUNK_TOKENIZER_MODEL) | chunk_id(청크 번호)가 문서 내 위치 기반이라, 경계가 밀리면 골든셋 정답이 전부 다른 청크를 가리킨다 → §9 |
| 정답지 | 골든셋 v5 · 164문항 · 정답 353개 | 라벨이 늘면 점수가 오른다. 조합마다 다르면 비교가 아니다 → §2 |
| 질문 텍스트 | qid_query_200.jsonl (후속 29문항은 선행 턴 병합본) | 검색과 재정렬이 같은 질문을 봐야 한다 — 실제로 어긋난 적이 있다 → §2-1 |
| 검색 범위 | 대상 문서 안 (doc_id 필터) | RFP 100개가 서로 닮아 안 좁히면 엉뚱한 사업이 올라온다 → §1 |
| 캐시 엔진 | 세 임베딩 모두 fast | Chroma 필터 검색은 근사다. 엔진을 섞으면 검색 방식 덕에 이긴다 → §10 |
| 지표 | MRR@10(판정) · nDCG@10 · Recall@5·@20 | hit@5는 포화해서 못 쓴다 → §3 |
| 통계 | paired bootstrap 95% CI · 1만 회 · 시드 20260729 | 평균만 보면 소표본에 속는다 → §4 |
② 무엇을 바꾸나 — 조작 변인
| 축 | 파이프라인 어디 | 바꾸는 것 | 몇 개 | 상세 |
|---|---|---|---|---|
| 축 1 | ② 후보 뽑기 | 임베딩 M0 M1 M2 | 3 | §6 |
| 축 2 | ③ 줄세우기 | 리랭커 R1 R2 | 2 | §7 |
| 축 3 | ② 후보 뽑기 | 후보 구성 C E F G | 4 | §8 |
전체 조합 3 × 2 × 4 = 24. 한 판에 다 돌리면 수치는 나오지만 무엇 때문에 이겼는지를 못 가린다. 그래서 두 축을 고정하고 한 축만 움직여 3단계로 확정한다 → §8-1
③ 무엇과 무엇을 맞대나 — 비교쌍
같은 조건에서 딱 한 곳만 다르게 한 두 판을 맞댄다. 그래야 차이의 원인이 하나로 좁혀진다.
| 비교 | 무엇이 달라지나 | 답하는 질문 |
|---|---|---|
| C ↔ G | 후보 20개 → 35개 (BM25 얹기) | BM25를 더하면 나아지나 — 빼는 게 없어 원인이 하나뿐 ★ |
| C ↔ E | 후보 20개 → 20개 (일부가 BM25로 교체) | BM25가 dense 후보를 밀어낼 값어치가 있나 |
| E ↔ G | 20개 vs 35개 (둘 다 BM25 포함) | E가 졌다면 BM25 탓인가, dense를 뺀 탓인가 |
| R1 ↔ R2 | 줄세우는 모델만 교체 | 한국어 튜닝 리랭커가 나은가 |
| M0 ↔ M2 | 후보 뽑는 모델만 교체 | 임베딩을 바꿀 값어치가 있나 ★ |
| M1 ↔ M2 | 원본 vs 한국어 튜닝판 | 이득이 모델 계열 덕인가 한국어 튜닝 덕인가 |
④ 가설과 판정 기준 — 측정 전에 정한다
판정 기준을 말로 풀면
“기준선보다 확실히 낫고(운이 아니고), 그 차이가 바꿀 만큼 크다” 두 가지를 동시에 요구한다.
- 확실히 낫다 = paired bootstrap 95% CI 하한 > 0 — 164문항을 다시 뽑아 재도 이기는가. CI가
[−0.004, +0.065]처럼 0을 걸치면 “졌을 수도 있다”는 뜻이라 채택하지 않는다.- 바꿀 만큼 크다 = MRR +0.03 이상. 재인덱싱·모델 교체를 감수할 최소선.
| 실험 | 가설 | 왜 그렇게 봤나 (근거 숫자) | 판정 기준 | 지금 |
|---|---|---|---|---|
| 임베딩 교체 | 큰 모델이 후보 품질을 올려 Recall 상한을 뚫는다 | e5 Recall@20 0.8163, 17문항은 정답이 후보에 아예 없다 — 리랭커로는 못 고침 | 기준선 대비 MRR@10 개선의 95% CI 하한 > 0 + 점추정 +0.03 | ❌ 가설과 다름 — Recall은 올랐으나(0.8631) 종단 +0.0297, CI 하한 −0.0042 |
| 리랭커 R2 | 한국어 튜닝판이 한국어 RFP에서 낫다 | 임베딩 쪽 KURE와 같은 구조(원본 → 한국어 추가학습) | 동일 | ❌ 가설과 다름 — −0.06~−0.07로 크게 패배 (점수 포화) |
| BM25 하이브리드 | dense가 놓친 고유명사·숫자·법령명을 어휘 검색이 건진다 | dense가 정답을 하나도 못 찾은 문항 중 BM25가 갖고 있던 것 — e5 17→11 · bge 10→3 · KURE 9→3 | C → G 개선이 유의해야 채택 | ❌ 가설과 다름 — e5만 +0.0222(CI 하한 −0.0184), bge −0.0035 · KURE −0.0127 |
BM25 가설은 한 번 잘못 기각됐다
“순손해(−0.031)“로 기각했는데, 그 측정은 ① BM25가 찾은 청크 본문을 안 가져와 후보 집합을 넓히지 못했고 ② dense는 문서 안인데 BM25만 전역이었다. 즉 “어휘 검색이 쓸모없다”를 검증한 적이 없다. 정확한 결론은 **“후보를 안 넓히는 반쪽 BM25 구현은 쓸모를 보이지 못했다”**다.
재측정 완료 — 그래도 미채택 (2026-07-29)
bm25_doc신설 + 변형 G로 반쪽 구현을 메워 다시 쟀다. e5만 0.7373 → 0.7595(+0.0222), 그러나 CI[−0.0184, +0.0642]가 0을 걸치고 bge −0.0035 · KURE −0.0127로 나머지 둘은 손해다 → 미채택 확정. 즉 반쪽 구현을 고쳐서 제대로 재봐도 결론은 같았다. → §8-1
1부 · 무엇을 재나
1. 파이프라인 — 왜 문서를 먼저 좁히나
검색은 2단이다.
① doc_cards 컬렉션(문서 100개 요약카드)으로 어느 문서 얘기인지 특정
② 그 문서의 청크 안에서만 검색 where={"doc_id": ...}
이유는 RFP 100개가 서로 너무 닮았기 때문이다. 전부 정부 제안요청서라 “사업기간은 계약일로부터 O개월” 같은 문장이 100개 문서에 다 있다. 문서를 안 좁히면 벡터 유사도상 엉뚱한 사업의 절이 위로 올라온다.
실측으로도 문서를 좁힌 쪽이 낫다 — C_doc 0.7443 > C_full 0.7313.
표기 규칙: 접미사 _doc = 대상 문서 안에서만(프로덕션 경로. 모든 판정의 기준) / _full = 100개 문서 30,002청크 전체(문서를 못 특정했을 때의 대체 경로) → 후보 캐시
전제 — ①이 항상 맞았다고 가정한다
골든셋의
대상_doc_id를 그대로 쓴다. 실서비스에서는 라우터가 문서를 틀릴 수 있으므로 **모든 수치는 “라우팅이 완벽할 때의 상한”**이다.검색 성능과 라우팅 성능을 분리해 재려는 의도된 설계지만, “실서비스 성능”으로 읽으면 안 된다.
2. 정답지 — 골든셋 v5
이 섹션에서 확인할 것
정답지는 점수의 기준선이다. 문항 수, 제외 사유, 정답 청크 수, 판정 풀이 바뀌면 같은 실험 결과처럼 보여도 비교할 수 없다.
| 전체 | 200문항 |
| 채점 성립 | 164문항 (제외: 모른다 15 · 반문 15 · 빈 정답 6) |
| 정답 청크 | 연인원 353 |
| 판정 풀 | 8,856 (문항당 54.0) |
| 유형 (164 기준) | 단일세부 90 · 후속 29 · 탐색비교 25 · 직답 20 |
| 만들어진 경위 | 골든셋 · 골든셋 200문항 제작 |
v5는 아직 로컬 산출물 단계다
VM
/srv/GPTPilot/goldenset/current는 **아직v4_20260729**를 가리킨다. v5는 정답 청크가 310 → 353으로 늘어난 판이고, 어떻게 늘렸는지는 검색 실험 결과 §8에 있다.
| v5를 읽을 때 헷갈리는 지점 | 읽는 법 |
|---|---|
| 전체는 200문항인데 채점은 164문항 | 모른다 15 · 반문 15 · 빈 정답 6은 검색 순위로 채점할 수 없어 제외 |
| 정답 청크가 353개 | 한 문항에 정답 청크가 여러 개 있을 수 있어 문항 수보다 많다 |
| 판정 풀이 8,856개 | 후보로 올라올 수 있는 청크를 넓게 읽고 정답/오답을 판정한 전체 풀 |
2-1. 질문 파일 — 이걸 틀리면 조용히 망한다
검색·리랭킹은 **항상 qid_query_200.jsonl**을 본다.
6.8.6에서 실제로 전체 측정이 무효가 됐다
첫 실행에서 검색은
qid_query_200.jsonl을 봤는데, 리랭킹에는goldenset_200.jsonl원본을 넣었다. 그러면 후속 29문항의 선행 턴 병합이 빠져 검색과 재정렬이 서로 다른 질문을 보게 된다. 파일 이름도 문항 수도 그럴듯해서 에러가 나지 않는다. 결과 숫자만 조용히 틀어진다.
| 파일 | 무엇인가 | 필드 | 문항 |
|---|---|---|---|
goldenset_200.jsonl | 정본 골든셋 | qid / question / 유형 / 선행_턴 / 대상_doc_id / expected_answer_기준 | 200 |
qid_query_200.jsonl | 파생된 검색 실험용 질문 텍스트 | qid / query 둘뿐 (정답 정보 없음) | 164 |
rerank_labels_200.jsonl | 정답 라벨 | 164 |
goldenset_200.jsonl (200문항)
│ 선행_턴 + question 을 이어붙임 · − 모른다 15 − 반문 15 − 빈 정답 6
▼
qid_query_200.jsonl (164문항) ← 검색·리랭킹은 항상 이걸 본다
| 실행 단계 | 반드시 넣을 파일 | 틀리면 생기는 일 |
|---|---|---|
| 후보 캐시 생성 | qid_query_200.jsonl | 후속 질문 문맥 없이 후보를 뽑거나, 채점 제외 문항이 섞인다 |
| 리랭킹 | qid_query_200.jsonl | 검색과 재정렬이 서로 다른 질문을 본다 |
| 채점 | rerank_labels_200.jsonl | 정답 라벨 기준이 검색 질문과 분리된다 |
후속 29문항은 선행 턴을 앞에 이어붙인 병합본이다(“그 사업의 안정화기간은?” → “한영대학교 …사업기간은 어떻게 되나요? 그 사업의 안정화기간은?”).
goldenset_200.jsonl을 검색 인자로 넣으면 문항 수 필터링과 선행 턴 병합이 둘 다 빠진다 — 실제로 사고가 났다 → 검색 실험 결과 §7
3. 지표
| 지표 | 무엇을 보나 | 역할 |
|---|---|---|
| MRR@10 (리랭커 ON) | 정답이 몇 등인가 | 판정 ★ |
| Recall@20 | 정답이 후보 안에 들어왔나 | 진단 — 임베딩 고유 실력 |
| MRR@10 (리랭커 OFF) | 리랭커 없을 때 순위 | 참고 |
| nDCG@10 · Recall@5 | 보조 | 회귀 감시 |
판정은 종단(리랭커 켠) 성적으로 한다. 그게 실제로 돌아갈 구성이기 때문이다.
Recall@20을 같이 보는 이유는 종단이 안 올랐을 때 원인을 구분하려고다.
| Recall@20 | 종단 MRR | 해석 |
|---|---|---|
| ↑ | ↑ | 더 데려왔고 성적으로 이어졌다 |
| ↑ | → | 더 데려왔는데 리랭커가 못 올렸다 → 리랭커 쪽 문제 |
| → | → | 키워도 못 찾는 문항이다 → 청킹·질의 쪽 문제 |
3-1. hit@5를 안 쓰는 이유 — 포화
예전 42문항 시절에는 hit@5를 썼는데 1.000으로 포화돼 변별력이 없었다. 문서범위 검색이라 doc_hit@5는 정의상 1.0이라 200문항에서도 여전히 무의미하다.
포화를 만났을 때 조건을 바꾸면 안 된다 — 실제로 그 실수를 한 적이 있다(포화를 풀려고 문서 필터를 껐다가, 프로덕션에 없는 경로에서 측정하고 결론이 뒤집혔다). 더 민감한 지표로 내려가는 것이 맞는 대응이다 → 지표 포화
4. 통계 — paired bootstrap
paired bootstrap을 쓰는 이유
같은 164문항에서 기준선과 도전안을 나란히 비교한다. 문항마다 난이도가 다르므로, 단순 평균 두 개를 따로 보는 것보다 문항별 차이를 다시 뽑아 보는 쪽이 맞다.
164문항을 복원추출해 문항별 차이 d_i = 도전점수(i) − 기준점수(i)를 리샘플한다. 1만 회, 시드 20260729 고정.
“평균이 올랐다”만으로 판정하지 않는다. 36문항에서 +0.073이 나와 “확정”이라 적었다가 164문항에서 −0.031로 부호가 뒤집힌 적이 있다. 자세히 → paired bootstrap
| 평균만 봤을 때 | bootstrap으로 보는 것 |
|---|---|
| 이번 표본에서 올랐나 | 164문항을 다시 뽑아 재도 이길 가능성이 높은가 |
| 한 숫자로 끝남 | 차이의 95% 신뢰구간을 냄 |
| 소표본 노이즈에 취약 | CI가 0을 걸치면 채택하지 않음 |
5. 승격 기준 — 측정 전에 확정한다
기준선은 e5-small + R1 (종단 MRR@10 = 0.7443).
판정은 여기서 끝난다
후보, 리랭커, 임베딩 중 무엇을 바꾸든 아래 세 조건을 모두 통과해야 한다. 점추정이 좋아 보여도 95% CI가 0을 걸치면 채택하지 않는다.
| 조건 | 값 | 왜 이 값인가 |
|---|---|---|
| 주 기준 | 차이의 95% 신뢰구간 하한 > 0 | 소표본 오판을 막는다 |
| 최소 폭 | 점추정 +0.03 이상 | 재인덱싱 한 번이면 되고 질의 비용은 리랭커 옆에서 무시할 수준. **“비용을 넘는가”보다 “노이즈가 아닌가”**가 관건 |
| 회귀 금지 | 어떤 유형도 −0.03 이하로 떨어지지 않을 것 | 평균이 오르며 한 유형이 망가지는 걸 막는다 |
여러 구성이 통과하면 작은 쪽을 고른다. 단, +0.03을 이 표본으로 판정할 수 있는지는 사전에 따로 확인했어야 했다 → 2-2. 승격 기준과 측정 해상도 — +0.03은 164문항으로 판정할 수 없다
측정 해상도 요약 — 왜 300문항↑ 재판정 조건이 붙었나
이번 164문항 표본에서 paired bootstrap 95% CI 반폭은 전 조합이 거의 ±0.035~0.046에 묶였다. 이 해상도에서는 최소 폭 기준 +0.03이 독립적으로 작동하지 않는다. CI 하한 > 0을 함께 통과하려면 점추정이 대략 +0.036 이상이어야 한다.
| 조합 | 점추정 | 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 |
따라서 +0.030~+0.036 구간은 최소 폭 기준은 넘지만 CI 하한에서 막히는 회색지대다. 이번에 KURE가 +0.0297로 그 경계 바로 아래에 걸렸다. 결론이 뒤집힌 것은 아니다. bge +0.0197은 해상도 안에서도 미달이고, R2는 −0.06대라 확실히 기각이다. 애매한 것은 KURE 하나뿐이다.
| 잡고 싶은 차이 | 필요한 채점 문항 |
|---|---|
| +0.03 | 약 236 (지금 164) |
| +0.02 | 약 531 |
그래서 재판정 조건의 **“골든셋 300문항 이상”**은 취향이 아니라 표본 해상도에서 나온 조건이다. 200문항을 만들었을 때 채점 성립이 164였으므로, 300문항이면 채점 성립이 240 안팎이 되어 +0.03 판정이 가능해진다.
운영 문서에서는 이 절을 기준표와 함께 읽는다. 기준은 여전히 CI 하한 > 0 + 점추정 +0.03이고, 달라진 것은 기준 자체가 아니라 그 기준을 현재 표본으로 얼마나 세밀하게 판정할 수 있는가에 대한 주석이다. 다음 재측정 때 표본이 늘면 같은 기준을 다시 적용한다.
2부 · 무엇을 비교하나
6. 임베딩 3종
| 코드 | 모델 | 크기 | 차원 | 프리픽스 | 비고 |
|---|---|---|---|---|---|
| M0 | intfloat/multilingual-e5-small | 118M | 384 | query: / passage: 필수 | 현행 운영 (ADR-006) |
| M1 | BAAI/bge-m3 | 568M | 1024 | 없음 | HF 캐시 4.3GB |
| M2 | nlpai-lab/KURE-v1 | 0.6B | 1024 | 없음 | bge-m3의 한국어 튜닝판 |
M1을 반드시 넣어야 했다. M2는 M1을 한국어로 추가 학습한 모델이라, M1 없이 M2만 돌리면 이긴 이유가 갈리지 않는다.
| 결과 | 해석 |
|---|---|
| M0→M1 크고 M1→M2 작다 | 계열이 관건 |
| M0→M1 작고 M1→M2 크다 | 한국어 튜닝이 관건 |
| M1→M2 ≤ 0 | 한국어 튜닝 불필요 |
왜 A.X-Encoder는 뺐나 — skt/A.X-Encoder-base는 팀 리서치 후보였으나 검색용으로 학습된 모델이 아니다(ModernBERT 기반 문서 이해·분류용, 모델 카드 예제가 fill-mask). 검색용 임베딩은 “질문과 정답 문단의 벡터가 가까워야 한다”를 따로 배우는데 A.X는 그 학습을 받은 적이 없어 낮게 나오는 게 정상이다. 남길 사유는 “A.X가 나쁘다”가 아니라 “검색 파인튜닝이 없어 비교 조건이 안 맞는다”.
7. 리랭커 2종 (크로스인코더 리랭커)
| 코드 | 모델 | 크기 | 비고 |
|---|---|---|---|
| R1 | BAAI/bge-reranker-v2-m3 | 568M | 현행 채택 (ADR-009) |
| R2 | dragonkue/bge-reranker-v2-m3-ko | 568M | R1의 한국어 튜닝판 |
임베딩 쪽과 구조가 똑같다 — 원본 vs 한국어 튜닝판. M1 vs M2의 답과 R1 vs R2의 답이 같은 방향인지도 볼거리였다.
8. 후보 구성 변형 — 리랭커에 무엇을 입력하는가 ★
전부 _doc 스코프(대상 문서 안에서만)로 통일한 뒤의 정의다.
BM25 미채택의 범위
기존 구현은 BM25가 찾은 청크의 본문을 가져오지 않아 후보를 넓히지 못했다(
retrieve.py). “추가 Chroma 왕복이 필요해 v1 범위 밖”이라는 판단이 있었지만, 본문 조회 실측은 20개 0.6ms였다. 그래서 이전 측정의 정확한 의미는 **“후보를 안 넓히는 BM25 구현은 효과가 없었다”**이지, **“어휘 검색 자체가 쓸모없다”**가 아니다.G변형은 이 반쪽 구현을 메우기 위해 BM25 전용 청크를 실제 후보에 더한다.
그림으로 — 문서 하나에서 dense가 20개, BM25가 20개를 뽑아왔고 그중 5개가 겹친다고 하자(실측 4.8).
C/E/F/G 후보 구성이 어떻게 다른지
dense 20개 겹침 5 BM25 20개
┌──────────────┬───────────┬──────────────┐
│ 15 │ 5 │ 15 │ ← 합쳐서 서로 다른 청크 35개
└──────────────┴───────────┴──────────────┘
C ████████████████████ dense 20개 그대로 (BM25 안 씀)
E ██████████████░░░░░░ 섞어서 상위 20개만 (dense 일부가 밀려남 ✂)
F ██████████████░░░░░░░░░░░░░░░░ 섞어서 상위 30개 (조금 덜 밀려남)
G ████████████████████░░░░░░░░░░░░░░░ 35개 전부 (빼는 게 없다 ★)
██ dense 유래 ░░ BM25가 추가한 것
E·F는 자리가 정해져 있어 BM25를 넣는 만큼 dense를 빼야 한다. G만 덧셈뿐이다.
| 코드 | 후보 구성 | 개수(실측) | 무엇을 재나 |
|---|---|---|---|
| A | dense_doc 그대로, 리랭커 없음 | 20.0 | dense 단독 진단 |
| B | dense_doc ∪ bm25_doc 융합, 리랭커 없음 | 20.0 | BM25를 리랭커 없이 섞었을 때 |
| C | dense_doc 그대로 + 리랭커 | 20.0 | 기준선 (현행 운영) |
| D | dense_doc ∪ bm25_doc 융합 상위 20 + 리랭커 | 20.0 | 옛 구현은 BM25 전용 청크를 버려 C와 동치 — 회귀 감시용 |
| E | dense_doc ∪ bm25_doc 융합 상위 20 | 20.0 | BM25가 dense를 밀어낼 값어치가 있나 |
| F | 같은 융합 상위 30 | 29.8 | 예산을 늘리면 |
| G | dense_doc ∪ bm25_doc 전부 (RRF 미사용) | 35.2 | ★ 핵심. 빼지 않고 더하기만 한다 |
C → G가 답이다. 빼는 게 없으니 차이가 나면 원인이 하나뿐이다. E·F는 BM25를 더하면서 dense를 동시에 빼므로, 지면 *“BM25가 나쁜 건지 빠진 dense가 좋았던 건지”*를 가릴 수 없다.
G는 RRF를 아예 쓰지 않는다 — 순위 합치기를 RRF와 리랭커가 이중으로 할 이유가 없다. 리랭커가 있으면 앞단 융합은 순서가 아니라 후보 집합만 정한다 → 검색 실험 결과 §10
bm25_doc 캐시 실측(문항당 20개): dense와 겹침 M0 4.8 · M1 7.2 · M2 5.3 → 합집합 M0 35.2 · M1 32.8 · M2 34.7.
BM25는 어휘 검색이라 임베딩과 무관해 한 번만 계산해 세 캐시에 공유한다.
8-1. 실험 순서 — 한 번에 한 축만 움직인다 ★
축이 셋이라 조합이 후보 구성 5 × 리랭커 2 × 임베딩 3 = 30이다. 한 판에 다 돌리면 수치는 나오지만 무엇 때문에 이겼는지를 못 가린다. 그래서 아래처럼 한 축씩 확정했다.
| 단계 | 무엇을 정하나 | 고정한 것 | 변수 | 결과 | 점수는 어디서 |
|---|---|---|---|---|---|
| 1 | 후보 구성 | 리랭커 R1 | C · E · F · G × 임베딩 3 | C 유지 — e5 G_doc +0.0222 (CI 하한 −0.0184) · bge −0.0035 · KURE −0.0127 | 새로 계산 |
| 2 | 리랭커 | 1단계가 정한 후보(C) | R1 · R2 × 임베딩 3 | R1 유지 — R2가 −0.06~−0.07로 유의 패배(점수 포화) | 기존 R2 측정 재사용 |
| 3 | 임베딩 | 후보 C + 리랭커 R1 | M0 · M1 · M2 | e5 유지 — bge +0.0197 · KURE +0.0297, 둘 다 CI가 0을 걸침 | 1단계 산출물 재사용 |
→ 전 축 현행 유지 (2026-07-29)
왜 이 순서인가. 후보 구성이 제일 아래층이다. 후보 집합이 바뀌면 리랭커가 줄세울 대상이 바뀌고, 임베딩 판정도 새 후보 전제 위에서 다시 열린다. 반대로 리랭커나 임베딩을 바꿔도 이미 뽑힌 후보 집합은 바뀌지 않는다.
세 단계 모두 판정은 한다. 다른 것은 점수 출처뿐이다. 실제 산출 파일 하나(rerank_m0_r1_v5.json 등)에 두 열이 같이 들어 있다.
| 임베딩 | C_doc | G_doc |
|---|---|---|
| e5-small | 0.7373 | 0.7595 |
| bge-m3 | 0.7640 | 0.7606 |
| KURE-v1 | 0.7740 | 0.7613 |
1단계에서 C가 이겼으므로 3단계는 왼쪽 열끼리 비교했다. G가 이겼다면 오른쪽 열끼리 비교했을 것이고, 그 값도 이미 있다. 3단계는 GPU를 다시 돌리는 실행이 아니라 이미 나온 승자 후보 열의 판정이다.
왜 각 단계에서 임베딩 3종을 다 돌리나. BM25 효과가 임베딩마다 방향이 갈리기 때문이다. BM25는 임베딩 모델을 바꾸는 기능이 아니라 후보 집합을 넓히는 기능이므로, dense가 이미 잘 찾는 모델과 놓친 것이 많은 모델에서 효과가 다르게 나온다.
| 임베딩 | C → G |
|---|---|
| e5-small | +0.0222 (이득) |
| bge-m3 | −0.0035 (손해) |
| KURE-v1 | −0.0127 (손해) |
| dense가 정답을 하나도 못 찾은 문항 | 그중 BM25가 갖고 있던 것 |
|---|---|
| e5 17 | 11 |
| bge-m3 10 | 3 |
| KURE-v1 9 | 3 |
한 임베딩에서만 재고 후보 구성을 정했다면 e5의 +0.0222만 보고 “G가 좋아 보인다”고 읽을 수 있었다. 하지만 세 임베딩을 같이 보면 방향이 갈린다.
측정 중 나온 C_doc 0.7373 vs 정본 0.7443 불일치의 원인은 정답지 갱신도 재실행 비결정성도 아니고, 리랭커 점수 동점 시 입력 순서였다(MRR 영향 ±0.007). 재려던 효과(+0.022)와 자릿수가 같아 e5 수치는 셋째 자리를 신뢰할 수 없다 → 검색 실험 결과
8-1-1. 이 순서가 정당한가 — 그리고 안 잰 칸 ★
3단계로 좁히는 방식은 전수 조사가 아니다. 한 축을 승자로 고정하고 다음 축으로 넘어가므로, “고정한 축의 승자가 다른 조합에서도 승자다”라는 가정 위에 선다. 그 가정이 어디까지 검증됐는지 남긴다.
실제로 측정한 격자
| 후보 구성 | R1 | R2 |
|---|---|---|
| C (BM25 없음) | ✅ | ✅ |
| D | ✅ | ✅ |
| E | ✅ | ❌ |
| F | ✅ | ❌ |
| G (BM25 포함) | ✅ | ❌ |
각 ✅는 임베딩 3종 전부. 전체 30칸(5 후보 × 2 리랭커 × 3 임베딩) 중 21칸 측정 · 9칸 미측정.
미측정 9칸은 E·F·G × R2 × 임베딩 3이다. 즉 “BM25로 넓힌 후보를 한국어 리랭커(R2)가 다루면?”은 재지 않았다. 근거는 ① 1단계에서 G도 졌고 ② R2도 −0.06~−0.07로 크게 졌다는 점이다. 다만 이것은 가정이지 측정이 아니다. 닫으려면 리랭커 3회(G × R2 × 임베딩 3) ≈ 5분이면 된다. 캐시가 있어 재검색은 필요 없다.
이번 실험에서는 1단계에서 C가 이겼다. C는 2·3단계가 원래 서 있던 전제와 같으므로 기존 판정이 그대로 재현됐다. 안 잰 9칸은 남아 있지만, 채택 경로가 C로 닫혔기 때문에 이번 운영 결론에는 직접 쓰이지 않았다. 다음 실험에서 G나 E·F가 이기면 그 순간 E·F·G × R2 × 임베딩 3은 다시 열린다.
8-2. BM25는 문서를 좁혔을 때만 의미가 있다
BM25를 전체 코퍼스에 돌리면 사업명 토큰이 상위를 독식한다. RFP는 표지·머리말·각 절에 사업명이 반복돼서, 질문에 사업명이 들어가면 그 사업의 아무 절이나 위로 올라온다. 어떤 절이 답인지는 전혀 가려주지 못한다.
문서를 이미 좁힌 상태에서는 사업명이 모든 후보에 똑같이 들어 있어 변별에 기여하지 않고, 나머지 어휘(고유명사·표기·수치)로 순위가 갈린다. 그래서 하이브리드는 문서 스코프에서만 성립한다 — §8의 변형이 전부 _doc인 이유다.
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}" # 몇 번째 청크인가청크 경계가 밀리면 164문항 정답지가 통째로 다른 청크를 가리킨다. 에러 없이 조용히 어긋나는 종류다 → 청크와 chunk_id
그래서 임베딩을 바꿔도 청킹은 항상 e5 토크나이저(CHUNK_TOKENIZER_MODEL)로 한다. 안 쓰는 토크나이저로 자르는 게 이상해 보이지만 청킹 고정이 목적이라 이게 맞다. bge-m3·KURE가 8192토큰을 받는다고 청크를 키우는 건 다른 실험이다.
게이트 — 재인덱싱 직후 chunk_id 집합이 완전히 일치하는지 확인하고, 하나라도 다르면 중단한다.
| 항목 | M1 결과 |
|---|---|
| chunk_id 차집합 (M1 − M0 / M0 − M1) | 0건 / 0건 |
| 총 청크 | 30,002 = 30,002 |
| 문서 수 | 99 = 99 |
384차원·프리픽스 있음 → 1024차원·프리픽스 없음으로 통째로 바꿨는데 청크 경계가 하나도 밀리지 않았다. M2도 같은 게이트를 통과했다.
10. 검색은 한 번만 돌린다 — 후보 캐시
dense 검색과 BM25를 미리 돌려 chunk_id와 거리만 저장해두고, 변형 조합은 오프라인 계산으로 만든다. 그래서 변형을 추가해도 재검색이 필요 없다. 질문·청크 원문을 저장하지 않아 NDA 안전하다.
캐시 생성 자체도 **1,887초 → 136초(13.9배)**로 줄였다(dense 경로는 건드리지 않아 산출물이 비트 단위 동일).
캐시
gold도 통제 대상이다기존 캐시에
gold가 v4 정답지(310개) 기준으로 박혀 있었다. v5 정답지는 353개이므로, 채점 캐시는 반드시 v5로 갱신해야 한다. 정답지 버전이 섞이면 같은 후보를 두고도 다른 시험을 치르게 된다.
세 임베딩이 같은 엔진을 써야 한다
Chroma는
doc_id필터를 걸어도 전수 비교가 아니라 근사 탐색이라, 정확 계산과 대조하니 164문항 중 **9문항(5.5%)**에서 상위 20이 달랐다. M0 캐시는 근사 산출물이므로 M1·M2만 정확 계산으로 만들면 새 모델이 임베딩 성능이 아니라 검색 방식 덕에 유리해진다. 전부 기본값 **fast**로 간다.
세부 → 후보 캐시
11. 프리픽스 ⚠️ 통제 항목 중 제일 위험
E5는 passage: /query: 를 반드시 붙여야 하고 bge-m3·KURE는 붙이면 안 된다. 틀려도 에러가 안 나고 성능만 조용히 떨어진다.
함정이 하나 더 있다 — src/index/chunk.py:58이 passage: [섹션명]\n 형태로 프리픽스 뒤에 섹션 이름을 같이 붙인다. 통째로 떼면 섹션 정보까지 날아가 새 모델만 정보를 덜 받는다.
여기서 틀리면 실험이 조용히 망한다
프리픽스 오류는 예외를 던지지 않는다. 모델별 입력 문자열을 눈으로 확인하지 않으면, 새 모델이 나빠진 것이 아니라 새 모델만 섹션 정보를 덜 받은 상태를 재게 된다.
| 모델 | 문서 입력 | 질문 입력 |
|---|---|---|
| M0 (e5) | passage: [섹션명]\n + 본문 | query: + 질문 |
| M1·M2 | [섹션명]\n + 본문 | 질문 그대로 |
각 인덱스 첫 청크의 실제 입력 문자열을 눈으로 확인하고 넘어간다.
12. 인덱스·검색 조건 (전 조합 동일)
| 항목 | 값 | 비고 |
|---|---|---|
| 인덱스 계보 | chroma_v2 (= VM /srv/GPTPilot/chroma) | 30,002청크 / 99문서 |
| 검색 범위 | 문서 한정 (_doc) | 프로덕션에 다른 경로가 없다 |
| 후보 수 | 20 (RERANK_CANDIDATE_K) | G만 합집합 전량 |
| 정규화 | normalize_embeddings=True | 전 모델 공통 |
| 질문셋 | 164문항 동일 | 조합마다 바꾸지 않는다 |
| 리랭커 실행 | VM L4 GPU | 맥 CPU는 49배 느리다 → 검색 실험 결과 §5 |
인덱스 계보는 실제로 사고가 난 자리다
chroma와chroma_v2에 같은 chunk_id가 존재해서, 틀린 인덱스를 써도 에러가 나지 않고 조용히 다른 수치가 나왔다. → 검색 실험 결과 §11
13. 관련 문서
| 문서 | 무엇을 보러 |
|---|---|
| 검색 실험 결과 | 판정·성적 정본 · 실험하며 바로잡은 것 |
| 골든셋 · 골든셋 200문항 제작 | 정답지가 어떻게 만들어졌나 |
| 후보 캐시 | dense_doc/dense_full/bm25_doc 구분 |
| 쿼리 파이프라인 | 검색이 전체 흐름에서 어디인가 |
| 개념 색인 | MRR · nDCG · BM25 · RRF · 하이브리드 검색 · 임베딩 등 |
코드: src/config.py · src/index/chunk.py · src/index/build_index.py · src/query/retrieve.py · scripts/build_retrieval_cache.py · scripts/rerank_on_cache.py
정본: Plans.md 6.7~6.8 · spec.md §9 · §10-17 · docs/ADR.md ADR-006 · ADR-009