검색 실험 설계

무엇을 왜 어떻게 재는가. 판정과 성적은 검색 실험 결과가 정본이다. 용어가 막히면 → §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 임베딩M0e5-small — 지금 쓰는 것 (기준선)
M1bge-m3 — 5배 큰 모델
M2KURE-v1 — bge-m3의 한국어 튜닝판
축 2 리랭커R1bge-reranker-v2-m3 — 지금 쓰는 것 (기준선)
R2그것의 한국어 튜닝판
축 3 후보 구성Cdense 20개만 — 지금 쓰는 것 (기준선)
E·Fdense + BM25 섞어서 상위 20개 / 30개
Gdense 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
캐시 엔진세 임베딩 모두 fastChroma 필터 검색은 근사다. 엔진을 섞으면 검색 방식 덕에 이긴다 → §10
지표MRR@10(판정) · nDCG@10 · Recall@5·@20hit@5는 포화해서 못 쓴다 → §3
통계paired bootstrap 95% CI · 1만 회 · 시드 20260729평균만 보면 소표본에 속는다 → §4

② 무엇을 바꾸나 — 조작 변인

파이프라인 어디바꾸는 것몇 개상세
축 1② 후보 뽑기임베딩 M0 M1 M23§6
축 2③ 줄세우기리랭커 R1 R22§7
축 3② 후보 뽑기후보 구성 C E F G4§8

전체 조합 3 × 2 × 4 = 24. 한 판에 다 돌리면 수치는 나오지만 무엇 때문에 이겼는지를 못 가린다. 그래서 두 축을 고정하고 한 축만 움직여 3단계로 확정한다 → §8-1

③ 무엇과 무엇을 맞대나 — 비교쌍

같은 조건에서 딱 한 곳만 다르게 한 두 판을 맞댄다. 그래야 차이의 원인이 하나로 좁혀진다.

비교무엇이 달라지나답하는 질문
C ↔ G후보 20개 → 35개 (BM25 얹기)BM25를 더하면 나아지나 — 빼는 게 없어 원인이 하나뿐 ★
C ↔ E후보 20개 → 20개 (일부가 BM25로 교체)BM25가 dense 후보를 밀어낼 값어치가 있나
E ↔ G20개 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→3C → 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.03236 (지금 164)
+0.02약 531

그래서 재판정 조건의 **“골든셋 300문항 이상”**은 취향이 아니라 표본 해상도에서 나온 조건이다. 200문항을 만들었을 때 채점 성립이 164였으므로, 300문항이면 채점 성립이 240 안팎이 되어 +0.03 판정이 가능해진다.

운영 문서에서는 이 절을 기준표와 함께 읽는다. 기준은 여전히 CI 하한 > 0 + 점추정 +0.03이고, 달라진 것은 기준 자체가 아니라 그 기준을 현재 표본으로 얼마나 세밀하게 판정할 수 있는가에 대한 주석이다. 다음 재측정 때 표본이 늘면 같은 기준을 다시 적용한다.


2부 · 무엇을 비교하나

6. 임베딩 3종

코드모델크기차원프리픽스비고
M0intfloat/multilingual-e5-small118M384query: / passage: 필수현행 운영 (ADR-006)
M1BAAI/bge-m3568M1024없음HF 캐시 4.3GB
M2nlpai-lab/KURE-v10.6B1024없음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종 (크로스인코더 리랭커)

코드모델크기비고
R1BAAI/bge-reranker-v2-m3568M현행 채택 (ADR-009)
R2dragonkue/bge-reranker-v2-m3-ko568MR1의 한국어 튜닝판

임베딩 쪽과 구조가 똑같다 — 원본 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덧셈뿐이다.

코드후보 구성개수(실측)무엇을 재나
Adense_doc 그대로, 리랭커 없음20.0dense 단독 진단
Bdense_docbm25_doc 융합, 리랭커 없음20.0BM25를 리랭커 없이 섞었을 때
Cdense_doc 그대로 + 리랭커20.0기준선 (현행 운영)
Ddense_docbm25_doc 융합 상위 20 + 리랭커20.0옛 구현은 BM25 전용 청크를 버려 C와 동치 — 회귀 감시용
Edense_docbm25_doc 융합 상위 2020.0BM25가 dense를 밀어낼 값어치가 있나
F같은 융합 상위 3029.8예산을 늘리면
Gdense_docbm25_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후보 구성리랭커 R1C · E · F · G × 임베딩 3C 유지 — e5 G_doc +0.0222 (CI 하한 −0.0184) · bge −0.0035 · KURE −0.0127새로 계산
2리랭커1단계가 정한 후보(C)R1 · R2 × 임베딩 3R1 유지 — R2가 −0.06~−0.07로 유의 패배(점수 포화)기존 R2 측정 재사용
3임베딩후보 C + 리랭커 R1M0 · M1 · M2e5 유지 — bge +0.0197 · KURE +0.0297, 둘 다 CI가 0을 걸침1단계 산출물 재사용

전 축 현행 유지 (2026-07-29)

왜 이 순서인가. 후보 구성이 제일 아래층이다. 후보 집합이 바뀌면 리랭커가 줄세울 대상이 바뀌고, 임베딩 판정도 새 후보 전제 위에서 다시 열린다. 반대로 리랭커나 임베딩을 바꿔도 이미 뽑힌 후보 집합은 바뀌지 않는다.

세 단계 모두 판정은 한다. 다른 것은 점수 출처뿐이다. 실제 산출 파일 하나(rerank_m0_r1_v5.json 등)에 두 열이 같이 들어 있다.

임베딩C_docG_doc
e5-small0.73730.7595
bge-m30.76400.7606
KURE-v10.77400.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 1711
bge-m3 103
KURE-v1 93

한 임베딩에서만 재고 후보 구성을 정했다면 e5의 +0.0222만 보고 “G가 좋아 보인다”고 읽을 수 있었다. 하지만 세 임베딩을 같이 보면 방향이 갈린다.

측정 중 나온 C_doc 0.7373 vs 정본 0.7443 불일치의 원인은 정답지 갱신도 재실행 비결정성도 아니고, 리랭커 점수 동점 시 입력 순서였다(MRR 영향 ±0.007). 재려던 효과(+0.022)와 자릿수가 같아 e5 수치는 셋째 자리를 신뢰할 수 없다검색 실험 결과

8-1-1. 이 순서가 정당한가 — 그리고 안 잰 칸 ★

3단계로 좁히는 방식은 전수 조사가 아니다. 한 축을 승자로 고정하고 다음 축으로 넘어가므로, “고정한 축의 승자가 다른 조합에서도 승자다”라는 가정 위에 선다. 그 가정이 어디까지 검증됐는지 남긴다.

실제로 측정한 격자

후보 구성R1R2
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:58passage: [섹션명]\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

인덱스 계보는 실제로 사고가 난 자리다

chromachroma_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