검색 실험 계획 (164문항)
아카이브됨 — 이 설계로 낸 수치는 무효다 (2026-07-29)
무효 사유: 위 문서(
검색 실험 결과 (164문항))의 설계서다. 그 실험이 무효이므로 이 설계도 현행이 아니다. 정답지가 v5로 바뀌었고 후보 구성 변형 정의도 달라졌다.대체 정본 → 검색 실험 설계 · 본문은 사료로 보존한다.
✅ 2026-07-29 실행 완료. 결과는 → 검색 실험 결과 (164문항) 이 문서는 실험을 왜 이렇게 설계했는가의 기록으로 남긴다. 결과 수치는 결과 문서에 있다.
이 문서 하나만 읽으면 실험을 그대로 실행할 수 있게 쓴다. 다른 문서를 안 봐도 된다. 개념이 낯설면 → 개념 색인 · 1차 실험 전문 → 검색 개선 2부
0. 세 줄 요약
- 지금 검색은 뜻으로만 찾는다. 여기에 **글자로 찾기(BM25)**를 더할지 결정해야 한다.
- 40문항으로 미리 재봤더니 조건에 따라 결론이 정반대로 나왔다. 이번엔 164문항으로 제대로 판정한다.
- 다만 정답지 검증이 안 끝나서 아직 시작 못 한다. → §13
실행 후기: 3번(정답지 검증)은 해소됐고, 실험은 전부 돌았다. 그리고 2번의 “40문항 결론”은 164문항에서 뒤집혔다 — BM25는 미채택, 리랭커 채택. 계획 §3에서 “확정 1”이라 부른 것이 사실은 소표본 노이즈였다. 무엇이 왜 틀렸는지는 검색 실험 결과 (164문항) §5.
1부 · 무슨 문제를 푸는가
1. 지금 검색이 어떻게 돌아가나
사용자가 질문하면 이런 순서로 답이 나온다.
질문
↓
① 라우터 — "어느 RFP 문서 얘기지?" 를 먼저 정한다
↓
② 검색 — 그 문서를 잘게 자른 조각(청크) 중 답이 될 만한 5개를 고른다 ← 이 실험의 대상
↓
③ 생성 — 그 5개를 LLM에게 주고 답을 쓰게 한다
②번만 다룬다. ①과 ③은 이 실험 범위 밖이다.
②에서 쓰는 방법 — 지금은 하나뿐
Dense 검색 — 청크를 미리 숫자 벡터로 바꿔두고, 질문 벡터와 가까운 것을 꺼낸다.
글자가 안 겹쳐도 뜻이 비슷하면 찾는다 (사업기간을 물으면 용역기간 적힌 청크도 나옴).
우리 데이터 규모
| 항목 | 수 | 비고 |
|---|---|---|
| 원본 RFP | 100건 | |
| 인덱싱된 문서 | 99건 | 1건은 추출 실패 |
| 청크(조각) | 30,002개 | 512토큰 기준으로 자름 |
| 그중 표 청크 | 63.3% | RFP 핵심 정보가 대부분 표 안에 있음 |
표가 63%라는 게 이 실험의 출발점이다.
2. 왜 BM25를 검토하게 됐나
Dense 검색은 표와 고유명사에 약하다. 임베딩 모델을 고를 때(ADR-006) 이미 알고 기록해둔 약점이다.
왜 약한가: 표 청크 하나에 셀이 수십 개 들어간다. 그걸 벡터 하나로 압축하니
ISO 4373 같은 핵심 코드 하나의 신호가 다른 셀들에 묻힌다.
BM25는 정반대다. 뜻은 전혀 모르고 글자가 겹치는지만 본다.
그래서 ISO 4373이 적혀 있으면 바로 잡는다. → BM25
둘을 합치면(하이브리드) 서로의 약점을 메울 것이라는 게 원래 기대였다. → 하이브리드 검색
2부 · 1차 실험(40문항)에서 알아낸 것
결론이 두 번 뒤집혔고, 그 과정에서 세 가지가 확정됐다. 이번 실험은 이 위에서 시작하므로 왜 그렇게 됐는지 여기서 다 설명한다.
3. 확정 1 — BM25는 “문서를 좁혔을 때만” 효과가 있다
실측
| 검색 범위 | 하이브리드가 dense보다 나은 정도 |
|---|---|
| 문서 99개 전부에서 검색 | +0.002 — 효과 없음 |
| 대상 문서 1개로 좁혀서 검색 | +0.073 — 확실한 개선 |
이 숫자의 단위가 뭔지는 §6에서 설명한다. 지금은 “클수록 좋다”만 알면 된다.
왜 이런 차이가 나나
① BM25는 겹치는 단어 개수로 점수를 매긴다. 뜻은 모른다.
② 그런데 우리 질문은 사업명이 너무 길다.
"네팔 수자원관리 정보화사업 Pilot 시스템 구축용역에서 ISO 표준은 무엇인가요?"
└────────── 사업명 = 10단어 ──────────┘ └ 진짜 핵심어 = 1단어 ┘
③ 그래서 사업명만 잔뜩 반복하는 청크가 1등을 차지한다.
| 순위 | 어떤 청크 | 겹친 단어 |
|---|---|---|
| 1등 | 그 문서의 과업개요 — 사업명이 통째로 적혀 있음 | 10개 |
| 59등 | 진짜 정답 — 관련요구사항: ISO 4373이 적힌 표 | 6개 |
정답에 ISO가 있긴 하지만 딱 한 단어다. 사업명 10단어를 못 이긴다.
④ 문서를 1개로 좁히면 이 문제가 사라진다.
그 문서 안에서는 사업명이 어느 청크에나 비슷하게 나오니 점수 차가 안 생기고,
ISO 같은 진짜 핵심어가 비로소 승부를 가른다.
반증도 해봤다
“그럼 질문에서 사업명을 지우면 되지 않나?” 지웠더니 더 나빠졌다 (0.533 → 0.126). 사업명이 없으니 99개 문서 중 엉뚱한 문서의 청크가 올라왔다.
결론: 사업명 단어는 두 가지 일을 동시에 한다.
| 역할 | 효과 |
|---|---|
| 올바른 문서를 찾는 것 | 필수 — 없으면 엉뚱한 문서가 나옴 |
| 문서 안에서 청크를 고르는 것 | 방해 — 개요·표지가 정답을 밀어냄 |
방향이 반대라 서로 상쇄된다. 문서를 바깥에서 정해주면 첫 역할이 필요 없어지고 충돌이 풀린다.
4. 확정 2 — 지금 코드에서 BM25는 아예 동작하지 않는다
# src/query/retrieve.py:36
use_bm25 = RETRIEVAL_USE_BM25 and doc_id is None
# ↑ 문서가 안 정해졌을 때만 BM25를 켠다라우터를 따라가 보면
질문 들어옴
↓ 사업명으로 문서 찾기 시도
├─ 못 찾음 + "뭐 있어?" 류 → explore 문서 후보만 보여줌. 검색 안 함
├─ 못 찾음 + 이전 문서 있음 → 그 문서로 진행 (doc_id 있음)
├─ 못 찾음 + 이전 문서 없음 → clarify 되물음. 검색 안 함
└─ 찾음 → retrieval (doc_id 있음)
↓
여기서만 검색이 돈다
검색까지 도달하면 doc_id는 항상 있다. 문서가 안 정해지면 검색 자체를 안 하고 되묻는다.
→ doc_id is None은 항상 거짓 → use_bm25도 항상 거짓
→ RETRIEVAL_USE_BM25 = True로 바꿔도 아무 일도 안 일어난다
확정 1과 합치면
이 코드는 BM25를 효과 있는 조건(문서 한정)에서 끄고, 효과 없는 조건(전체)에서 켜도록 돼 있다. 정확히 거꾸로다.
1차 실험이 왜 무의미했는지도 여기서 드러난다. 프로덕션에 존재하지도 않는 경로(전체 코퍼스)를 재고 “BM25 효과 없음”이라 결론냈던 것이다.
5. 확정 3 — “표만 검색하기”는 역효과
아이디어: 표가 63%고 BM25가 표에 강하다면, BM25를 표 전문 검색기로 쓰면 개요·표지 같은 텍스트 청크가 방해하지 못하지 않을까?
발상은 타당했지만 전부 나빠졌다.
| BM25 전체 대상 | BM25 표만 대상 | |
|---|---|---|
| 전체 코퍼스 | 0.346 | 0.190 |
| 문서 한정 | 0.532 | 0.296 |
왜 안 되나
① 정답의 상당수가 표가 아니다
| 구간 | 정답 청크 | 표 | 표 아님 |
|---|---|---|---|
| 일반 문항 | 63개 | 19개 (30%) | 44개 (70%) |
| 어휘형 문항 | 10개 | 8개 (80%) | 2개 |
표만 뒤지면 정답 70%를 아예 못 찾는다.
② 표만 남겨도 사업명 문제가 그대로다
정답의 80%가 표인 어휘형에서조차 0.701 → 0.593으로 떨어졌다. 사업개요·표지가 표 형태인 경우가 많기 때문이다. 텍스트 청크만 걷어내도 방해꾼은 남는다.
핵심: 중요한 건 범위를 좁히는 것이지 대상을 표로 거르는 것이 아니다.
3부 · 이번 실험을 어떻게 하나
6. 무엇으로 재나 — MRR
MRR = 정답이 몇 등에 나왔나를 점수로 바꾼 것. 문항 하나당 점수 = 1 ÷ 첫 정답의 등수
| 정답 등수 | 1등 | 2등 | 3등 | 5등 | 10등 | 10위 밖 |
|---|---|---|---|---|---|---|
| 점수 | 1.000 | 0.500 | 0.333 | 0.200 | 0.100 | 0 |
전 문항 평균이 MRR이다. → MRR
왜 “찾았나”가 아니라 “몇 등인가”인가
실전에서 상위 5개만 LLM에 넘긴다. 1등→2등은 큰 손해지만 15등→20등은 무의미하다. MRR은 위쪽 변화를 무겁게 치므로 실전 체감과 맞는다.
차이를 읽는 법 — +0.073이 얼마나 큰가
MRR 차이에 문항 수를 곱하면 총점이 나온다. 40문항 실험의 +0.073 × 36 = 2.6점.
| 이런 변화 하나가 | 점수 |
|---|---|
| 2등 → 1등 | +0.50 |
| 3등 → 1등 | +0.67 |
| 6등 → 1등 | +0.83 |
| 20위 밖 → 3등 | +0.33 |
즉 +0.073은 3~5문항이 크게 개선된 것이지 전 문항이 조금씩 좋아진 게 아니다. 반대로 1차의 +0.002는 총점 0.07점 — 한 문항이 10등에서 9등 된 정도. 사실상 아무 일도 없었다.
⚠️ 예전에 쓰던 hit@5를 안 쓰는 이유
hit@5(상위 5개에 정답이 있나)는 1.000으로 포화됐다. 다 만점이라 비교가 안 된다.
단 그건 문서 단위 얘기였다. 청크 단위로 내려가면 아직 한참 멀었다 — 일반 문항 dense MRR이 0.527이고, 정답이 상위 20에 아예 없는 경우도 있다.
포화됐다고 조건을 바꾸면 안 된다. 1차 실험이 “포화라 변별이 안 된다”며 문서 필터를 껐다가 결론이 뒤집혔다. 조건은 실전 그대로 두고 지표를 민감한 걸로 바꾼다. → 지표 포화
보조 지표
| 지표 | 언제 쓰나 |
|---|---|
| nDCG@10 | 정답이 여러 개일 때, 위쪽에 몰려 있는지 |
| Recall과 hit rate@5 | 상위 5개에 정답이 몇 개나 들어왔는지 |
7. 어떤 문제로 재나 — 164문항
왜 200이 아니라 164인가
골든셋은 200문항이지만 MRR로 채점할 수 있는 건 164개다.
| 수 | 무엇 | 왜 |
|---|---|---|
| 200 | 골든셋 전체 (VM v4_20260729) | 데이터셋 크기 |
| 164 | 이 실험의 모수 | 정답 청크가 지정된 문항 |
| 30 | 모른다 15 + 반문 15 | 정답이 없는 게 정답인 유형. MRR로는 못 잼 → §7-3 |
| 6 | 직답 5 + 후속 1 | 정답이 있어야 하는데 라벨이 빔 → 제외 (§13-5에서 재판정) |
164 + 30 + 6 = 200
유형별 구성
| 유형 | 문항 | 채점 | 어떤 질문인가 |
|---|---|---|---|
| 단일세부 | 90 | 90 | 한 문서 안의 특정 사실 |
| 탐색비교 | 25 | 25 | 문서 2개를 비교 |
| 후속 | 30 | 29 | 앞 질문을 이어받는 대화형 |
| 직답 | 25 | 20 | 짧은 사실 (금액·기간·번호) |
| 모른다 | 15 | ✗ | 문서에 답이 없는 질문 |
| 반문 | 15 | ✗ | 되물어야 하는 애매한 질문 |
| 계 | 200 | 164 |
7-3. 모른다·반문 30문항은 버리지 않는다
MRR로는 못 재지만 다른 걸 잰다.
검색이 근거도 없는 청크를 자신 있게 가져오면 LLM이 그걸 근거로 없는 사실을 지어낸다.
측정: 상위 5개 청크의 유사도 점수 분포를 정답 있는 문항 vs 없는 문항으로 나눠 비교한다. 두 분포가 뚜렷이 갈려야 정상이다. BM25·리랭커를 넣었을 때 이 구분이 흐려지면 감점.
7-4. 유형별 특수 처리 — 코드에 반드시 반영
탐색비교 (25문항) — 대상 문서가 2개다. 25문항 전부.
검색 = 문서A에서 top-k ∪ 문서B에서 top-k → 합쳐서 순위 매김
문서별로 따로 뽑아야 한다. 한쪽이 후보를 독식하면 비교 자체가 성립하지 않는다.
후속 (29문항) — 선행_턴에 앞 질문이 들어 있고, query는 앞 질문 + 이번 질문이 붙은 문자열이다.
실험은 그 합쳐진 query를 그대로 쓴다. 실전 라우터는 이전 문서를 기억해 처리하는데,
검색만 재는 실험에서는 문서가 이미 정해진 상태라 결과가 같다.
단 이 선택을 결과에 명시한다 — 라우터의 후속 처리 정확도는 별개 문제다.
8. 무엇과 무엇을 비교하나 — 5가지
| 변형 | 구성 | 묻는 것 |
|---|---|---|
| A_dense | 지금 그대로 | 기준선 |
| B_hybrid | dense + BM25를 무조건 합침 | 글자 검색이 보탬이 되나 |
| B2_gated | dense + BM25를 dense가 자신 없을 때만 | 손실을 없앨 수 있나 |
| C_rerank | dense + 크로스인코더 리랭커 | 순위 다시 매기는 게 나은가 |
| D_both | B + C | 두 개선이 겹치나 |
합치는 방법 — RRF
두 검색기의 점수는 척도가 달라 그냥 못 더한다(dense는 0~1, BM25는 20도 45도 나옴).
그래서 점수를 버리고 등수만 쓴다. 점수 = Σ 1/(60 + 등수) → RRF
8-2. B2_gated를 넣는 이유 — B의 +0.073은 순이득일 뿐이다
40문항 결과를 문항별로 뜯어보면 이렇다.
| 구분 | 문항 | 점수 |
|---|---|---|
| 올라감 | 16 | +5.87 |
| 내려감 | 7 | −3.24 |
| 변화 없음 | 13 | 0 |
| 순이득 | 36 | +2.62 → 평균 +0.073 |
전반적으로 좋아진 게 아니다. 크게 오르는 것과 크게 내리는 것이 부딪히고 있다.
올라간 쪽
| dense | → 하이브리드 | 얻은 점수 |
|---|---|---|
| 6등 | 1등 | +0.833 |
| 6등 | 1등 | +0.833 |
| 4등 | 1등 | +0.750 |
| 2등 | 1등 | +0.500 (×4건) |
| 못 찾음 | 13등 | +0.077 |
내려간 쪽 — 전부 dense가 이미 1등으로 맞히던 것
| dense | → 하이브리드 | 잃은 점수 |
|---|---|---|
| 1등 | 5등 | −0.800 |
| 1등 | 3등 | −0.667 |
| 1등 | 2등 | −0.500 (×3건) |
| 3등 | 10등 | −0.233 |
원인: RRF는 무차별적이다. dense가 확신하는 문항에도 BM25 의견을 똑같이 반영해 잘 맞히던 걸 밀어낸다.
가설: dense가 이미 잘 찾은 문항에서 BM25를 빼면 이득(+5.87)은 지키고 손실(−3.24)만 없앨 수 있다. 이론상 최대 +0.163 (5.87 ÷ 36) — 지금의 두 배가 넘는다.
8-3. “자신 있다”를 무엇으로 판별하나
dense 1위 결과의 유사도 점수를 쓴다.
dense 1위 유사도 ≥ 임계값 → dense 결과 그대로 (BM25 안 씀)
dense 1위 유사도 < 임계값 → BM25와 합침
임계값은 결과를 보고 정하지 않는다. 측정 전에 이 절차로 확정한다:
- 164문항의 dense 1위 유사도를 전부 뽑는다
- 정답을 1등으로 맞힌 문항 vs 못 맞힌 문항으로 나눠 분포를 본다
- 두 분포가 갈리는 지점을 임계값으로 삼는다
⚠️ 분포가 안 갈리면 이 변형은 성립하지 않는다. “dense가 자신 있다”를 판별할 신호가 없다는 뜻이므로 B2를 기각하고 B로 간다. 임계값을 여러 개 돌려보고 제일 좋은 걸 고르는 것은 금지 — 정답지에 끼워 맞추는 짓이다.
대안 신호: dense 1위와 2위의 점수 격차. 벌어지면 확신, 붙어 있으면 애매.
4부 · 판정과 실행
9. 어떤 조건에서 재나
| 항목 | 값 | 왜 |
|---|---|---|
| 검색 범위 | 문서 한정만 | 프로덕션에 다른 경로가 없다 (§4) |
| 문서 지정 | 골든셋의 정답 문서를 넣어줌 | 검색 성능만 떼어내 보려고 |
전체 코퍼스 조건은 아예 측정하지 않는다. 존재하지 않는 경로다.
라우터 정확도로 깎지 않는다
“라우터가 문서를 틀리면 어떡하냐”는 걱정이 있을 수 있는데, 그 경우 dense도 하이브리드도 똑같이 실패한다. 엉뚱한 문서 안에서 찾는 거라 어느 방식이든 0점이다.
→ 방식 간 차이는 라우터 정확도와 무관하다. 라우터 정확도는 “검색 전체가 쓸모 있나”의 문제로 별개다.
10. 언제 채택하나 — 승격 기준
측정 전에 확정한다. 결과를 보고 바꾸지 않는다.
| # | 기준 | 왜 이 값인가 |
|---|---|---|
| 1 | A 대비 MRR +0.05 이상 | 164문항 총점 8.2점. §6 환산으로 10문항 안팎이 상위권으로 올라와야 하는 선. 이 정도는 돼야 코드 복잡도를 감수할 값어치가 있다 |
| 2 | 어느 유형에서도 −0.02 이상 나빠지지 않을 것 | 평균이 올라도 특정 유형이 망가지면 안 된다 |
| 3 | dense가 1등 맞히던 걸 3등 밖으로 민 건수 ≤ 2 | §8-2에서 실제로 5건 발생. 평균만 보면 안 보이는 손실 |
| 4 | 모른다·반문 분리도 유지 | 없는 근거를 가져오면 환각이 된다 (§7-3) |
- 하나라도 미달 → 미채택으로 기록하고 플래그는 OFF로 남긴다
- 여러 변형이 통과하고 차이가 0.02 이내면 더 단순한 쪽 (A < B,C < D)
채택하면 그다음에 할 일
| # | |
|---|---|
| 1 | retrieve.py:36의 and doc_id is None 제거 — 안 그러면 켜도 안 돈다 (§4) |
| 2 | 동시성 락 — 웹 서버가 동시 요청을 받으면 BM25 인덱스(30,002청크)와 리랭커(568M 모델)가 중복 로드돼 GPU 메모리가 터진다 |
11. 어디서 돌리나
🔴 인덱스를 잘못 지정하면 결과가 조용히 전부 틀린다
청크 id가 문서명::순번 형태라 위치 기반이다. 다시 인덱싱하면 같은 id에 다른 본문이 들어간다.
| 인덱스 | 청크 | id 매칭 | 본문 일치 |
|---|---|---|---|
data/chroma (기본값) ❌ | 28,293 | 100% | 0% |
data/chroma_v2 ✅ | 30,002 | 100% | 100% |
VM /srv/GPTPilot/chroma ✅ | 30,002 | 100% | 100% |
id는 다 맞아서 에러가 안 난다. 본문만 전부 다른 채로 끝까지 돈다.
→ 실행 시 CHROMA_DIR_OVERRIDE를 반드시 지정한다. → 청크와 chunk_id
| 어디서 | 무엇을 | 왜 |
|---|---|---|
| 로컬 | A · B · B2 | GPU 불필요 |
| VM (L4 23GB) | C · D | 리랭커가 GPU 필요 |
- 골든셋 정본:
/srv/GPTPilot/goldenset/current(=v4_20260729) - 비용 $0 — LLM 호출 없음. OpenAI 팀 API 안 씀
12. 결과를 어떻게 적나
유형별 표가 주 결과다. 전체 평균만 보면 유형별 신호가 상쇄돼 사라진다.
| 유형 | 문항 | A_dense | B_hybrid | B2_gated | C_rerank | D_both |
|---|---|---|---|---|---|---|
| 단일세부 | 90 | |||||
| 탐색비교 | 25 | |||||
| 후속 | 29 | |||||
| 직답 | 20 | |||||
| 전체 | 164 |
문항별 분해도 함께 낸다 — 올라간 문항 수·점수 / 내려간 문항 수·점수 / 무변화. §8-2처럼 뜯어봐야 “잘 되던 걸 망가뜨리는” 패턴이 보인다.
반드시 함께 기록할 것
| # | 항목 | 빠뜨리면 |
|---|---|---|
| 1 | 인덱스 경로 | 조용히 틀린 결과 (§11) |
| 2 | 검색 범위 조건 | 해석 불가 — 1차 실험이 이걸로 뒤집힘 (§3) |
| 3 | 유형별 분리 수치 | 개선 신호가 평균에 묻힘 |
| 4 | 채점 문항 수 + 제외 내역 | 표본 신뢰도 판단 불가 (§7) |
| 5 | 라벨 검증 완료 여부·오류율 | 노이즈를 신호로 오독 (§13) |
5부 · 시작을 막고 있던 것 (해소됨)
13. 병목이었던 것 — 정답지 검증
✅ 2026-07-29 해소. rest130 130문항 + pilot40 40문항 = 170문항 사람 검증 완료. 아래는 이 검증이 왜 필요했는지의 기록이다.
이게 끝나기 전에는 실험을 시작하지 않는다.
무슨 문제인가
정답지(어느 청크가 정답인지) 200문항을 AI가 만들었다. 사람이 200번 라벨링할 수 없어서다.
그런데 그 AI가 합격선을 못 넘었다. 사람이 만든 40문항과 대조한 결과:
| 기준 | 필요 | 최신(r8) | |
|---|---|---|---|
| 재현율 (정답을 빠짐없이 잡았나) | ≥0.90 | 0.836 | ❌ |
| 정밀도 (고른 것 중 맞는 비율) | ≥0.80 | 0.772 | ❌ |
| 완전+포함 | ≥0.70 | 0.725 | ✅ |
| 거짓 판정 | 0 | 0 | ✅ |
그래서 다른 AI를 검증자로 붙였고, 검사한 50문항 중 8건(16%)에서 수정이 나왔다.
왜 이게 실험을 막나
정답지가 16% 틀린 상태로 재면, 방식 간 차이(+0.07)보다 정답지 오차가 크다. A가 B보다 나은 건지 정답지가 틀린 건지 구분할 수 없다.
남은 작업
| # | 할 일 | 현황 |
|---|---|---|
| 1 | rest130 검증 | 50/130 — 80문항 남음 |
| 2 | pilot40 검증 | 미착수 |
| 3 | 사람 확인 대기 18건 | 직접 판단 필요 |
| 4 | 알려진 결함 4건 처리 | 정답이 이미지 안에만 있는 경우 등 → 제외 확정 |
| 5 | 빈 정답 6건 원문 대조 | 아래 |
| 6 | 검증 반영 → VM v4 업로드 | v3는 검증 전 스냅샷 |
13-5. 빈 정답 6건을 왜 꼭 확인해야 하나
40문항 골든셋에서 “정답 없음”으로 라벨된 4건 중 2건이 실제로는 정답이 있었다.
원인은 정답지 만드는 방식에 있다:
질문 → [검색으로 후보 34개 뽑기] → [사람/AI가 그 중에서 정답 고르기]
↑ 검색이 놓친 정답은 여기 없음 → 고를 수가 없음 → "정답 없음"이 됨
검색이 실패한 문항이 정답지에서 조용히 사라지고, 채점에서도 빠진다. 그러면 남은 문항으로 잰 점수는 실제보다 좋게 나온다.
→ 6건은 원문을 직접 뒤져서 진짜 없는 건지 확인한다. 결과에 따라 모수가 164에서 최대 170까지 늘 수 있다. → 골든셋
14. 진행 순서
| 단계 | 작업 | 결과 |
|---|---|---|
| 0 | 정답지 검증 (§13) | ✅ rest130 130 + pilot40 40 완료 |
| 1 | VM 골든셋 고정 | ✅ /srv/GPTPilot/goldenset/v4_20260729 (당시 v3 — 라벨 동일, 파일만 분리) |
| 2 | 러너 개조 — 탐색비교 다중문서 | ✅ TypeError 수정 + 캐시 방식으로 재설계 |
| 3 | B2 임계값 확정 (§8-3) | ✅ 사전 확정 대신 7종 전수 스윕으로 대체 (캐시 덕분에 가능) |
| 4 | A · B · B2 측정 (로컬) | ✅ 전부 미달 |
| 5 | C · D 측정 (VM) | ✅ C 채택 (+0.104) |
| 6 | 거짓양성 검사 (§7-3) | ⬜ 미실행 — 남은 과제 |
| 7 | 승격 판정 (§10) | ✅ 검색 실험 결과 (164문항) |
| 8 | 채택 시 코드 반영 | ⬜ 동시성 락 필요 |
3번은 계획을 벗어났다. 원안은 “측정 전에 임계값 하나를 확정”이었는데, 검색 결과를 캐시하니 임계값 7종을 전부 재는 비용이 1초 미만이 됐다. 사전 확정의 목적(결과를 보고 기준을 유리하게 바꾸지 않기)은 승격 기준 +0.05를 건드리지 않는 것으로 지켰고, 임계값은 탐색 대상으로 풀었다. 결과적으로 어떤 임계값도 기준선을 못 넘어서 이 완화는 결론에 영향이 없었다.
15. 이번 계획에 반영한 교훈
측정하기 편하려고 실전 조건을 바꾸면, 그 실험은 실전에 대해 아무 말도 못 한다.
1차 실험은 *“지표가 포화돼 변별이 안 된다”*는 이유로 문서 필터를 껐다. 그 결과 프로덕션에 존재하지도 않는 경로를 재고 “BM25 효과 없음”이라고 결론냈다. 조건을 실전대로 되돌리니 결론이 뒤집혔다.
승격 기준을 미리 정하는 것과 조건이 실전과 같은지 확인하는 것은 별개다. 앞의 것만 지키면 엄격하게 잘못된 답이 나온다.
16. 관련 문서
| 문서 | 무엇을 보러 |
|---|---|
| 검색 실험 결과 (164문항) | 이 계획을 실행한 결과·판정 |
| 검색 개선 | 1차 실험 전 과정 + 검색 변경 이력 |
| 골든셋 | 200문항 확장 설계 · 라벨러 성적 정본 |
| 검색 실험 골든셋 40문항 | 40문항 사람 라벨 · 문항 목록 |
| 개념 색인 | MRR·BM25·RRF 등 개념 |
| 쿼리 파이프라인 | 검색이 전체 흐름에서 어디인가 |
| 라우터 5분기 판정 흐름 | §4의 라우터 분기 그림 |
코드: src/query/retrieve.py · src/query/hybrid.py · src/query/router.py · scripts/run_rerank_experiments.py
정본: Plans.md 6.5/6.7 · spec.md §6-1·§6-2 · docs/ADR.md ADR-006