후보 캐시
검색을 한 번만 돌려 후보를 저장해두고, 실험 조합은 그 파일 위에서 계산으로 끝내는 방식.
같은 이름으로 불리는 층이 둘 있어서 헷갈리기 쉽다. 나눠서 본다.
① 후보 캐시 — 검색을 재사용한다
문제
리랭커 실험은 조합이 12개다.
임베딩 3종 × 리랭커 2종 × BM25 on/off = 12
조합마다 처음부터 검색하면 검색을 12번 돌린다. BM25 융합만 문항당 3초라, 변형 7개에 이미 한 시간이 넘었다.
착안
검색 결과는 조합이 뭐든 똑같다.
Dense 검색이 뽑아오는 상위 100개는 BM25를 켜든 끄든, 리랭커를 R1으로 하든 R2로 하든 변하지 않는다. 달라지는 건 그 100개를 어떻게 재정렬하느냐뿐이다.
검색 1회 → 문항별 후보 100개를 파일로 저장 ← 후보 캐시
↓
조합 12개가 이 파일을 각자 읽어 재정렬만
효과 (1차 실측)
| 스크립트 | 역할 | 소요 |
|---|---|---|
build_retrieval_cache.py | 후보를 1회 패스로 저장 | 1,887초 |
eval_variants_offline.py | A/B/A_doc/B_doc + 게이팅 7종 스윕 | 1초 미만 |
rerank_on_cache.py | C/D 리랭커 (VM L4) | 93초 |
원래 불가능했던 게 가능해진 것도 있다. 하이브리드 검색 게이팅 임계값을 사전에 하나 찍고 가려 했는데, 캐시 덕에 7종을 전수 스윕해서 “전부 끄는 게 최적”이라는 결론을 얻었다.
무엇이 들어 있나 — 문항당 후보 목록 셋
캐시는 질문 하나에 대해 서로 다른 방식으로 뽑은 후보를 세 벌 담는다. 이후 문서에 계속 나오는 이름이라 여기서 정리해둔다.
| 필드 | 뽑는 법 | 위상 |
|---|---|---|
dense_doc | 대상 문서를 먼저 특정하고, 그 문서 안에서만 유사도 검색 | ★ 프로덕션 경로. 모든 판정의 기준 |
dense_full | 100개 문서 30,002청크 전체에서 유사도 검색 | 문서를 못 특정했을 때의 대체 경로 |
bm25_doc | 키워드가 겹치는 것(BM25)을 대상 문서 안에서만 | 하이브리드 실험용 |
bm25 (구) | 같은 것을 전체 코퍼스에서 | ⚠️ dense와 범위가 달라 하이브리드 측정을 망쳤다 → 검색 실험 결과 §10-2 |
문서를 먼저 좁히는 쪽이 낫다. RFP는 서식이 비슷해서 전체를 뒤지면 다른 99개 문서의 닮은 문장에 휘둘린다. 좁혀두면 그 방해가 사라진다.
실측으로도 그렇다 — 리랭커까지 켠 종단 MRR@10이 C_doc 0.7443 > C_full 0.7313.
두 검색기를 합칠 때는 범위가 같아야 한다. dense는 문서 스코프인데 BM25가 전역이면 합친 결과는 하이브리드가 아니라 잡음 주입이다 — 실제로 그 사고가 났다.
문서범위와 전체범위를 둘 다 저장하는 이유는, 1차에서 프로덕션에 없는 경로(전체범위)만 재다가 결론이 뒤집힌 적이 있어서다 → 검색 실험 설계 §1. 나란히 두면 그 사고를 되풀이하지 않는다.
거리도 같이 저장한다
chunk_id만 저장하면 순위밖에 못 쓴다. 거리를 함께 담아둔 덕분에 게이팅 판정(거리 임계값)을 재검색 없이 계산할 수 있었다.
NDA 안전성
캐시에는 chunk_id와 거리만 담는다. 질문·청크 원문은 저장하지 않는다. 파일이 유출돼도 RFP 내용이 새지 않는다.
② 캐시 생성 최적화 — 캐시를 만드는 시간을 줄인다
①이 검색 횟수를 줄인 거라면, 이건 그 한 번을 빠르게 만드는 얘기다.
문제
캐시 파일 하나 만드는 데 1,887초(31분). 문항당 11.5초다.
처음에 원인을 잘못 짚었다
“문항마다
doc_id필터를 걸어 3만 청크를 전수 스캔하는 것”이 병목이라고 봤다. 프로파일링해보니 그 구간은 문항당 0.16초, 전체의 3%였다. 그럴듯한 설명이었지만 재보기 전까지는 추측이었다.
실제 병목 (문항당 실측)
| 구간 | 소요 | |
|---|---|---|
bm25_rank | 3.65초 | ← 진짜 병목 |
dense_full | 2.05초 | |
dense_doc (문서 필터) | 0.16초 | 의심했던 곳. 3%뿐 |
bm25_rank가 호출마다 28k 청크를 다시 조회하고, 토큰 캐시를 다시 파싱하고,
코퍼스 지문을 다시 해싱하고, 28k 토큰열을 JSON으로 다시 직렬화하고 있었다.
문항 164개니까 그 짓을 164번 했다.
고친 방법
준비 작업을 1회로 접었다. 그뿐이다.
| 소요 | |
|---|---|
| 이전 | 1,887초 (31분) |
| 이후 | 136초 (2분 16초) |
13.9배. dense 경로는 건드리지 않았다 — 그래서 산출물이 비트 단위로 동일하다.
numpy 정확계산 경로는 --engine exact로 따로 뒀다 (아래 참조).
왜 중요한가
임베딩 3종(e5 / bge-m3 / KURE-v1)마다 캐시를 만들어야 해서 3번 돈다. 93분이 7분이 된다.
검증 방법
성능만 보고 채택하면 안 된다. 1차 산출물이 정답지 역할을 한다.
164문항 전량에서 dense_full·dense_doc·bm25 세 필드가
chunk_id 순서까지 일치하고 거리 최대차 0.0임을 pytest로 막아뒀다.
덤으로 알게 된 것 — Chroma 필터 검색은 근사다
정확 계산 경로를 만들어 대조하니 **164문항 중 9문항(5.5%)**에서 상위 20이 달랐다.
Chroma가 doc_id 필터를 걸어도 전수 비교가 아니라 근사 탐색이라, 더 가까운 청크를 놓친다.
실측 예 — 정확 계산은 거리 0.087415짜리를 찾았는데 Chroma는 못 찾았다.
이것 때문에 엔진을 섞으면 안 된다. M0 캐시는 근사 검색으로 만들어졌으므로, M1·M2만 정확 계산으로 만들면 새 모델이 검색 방식 덕에 유리해진다. 세 모델이 같은 엔진을 써야 한다.
이 방식이 안 통하는 경우
캐시 재사용의 전제는 후보 집합이 조합과 무관하다는 것이다. 전제가 깨지는 상황이 있다.
| 상황 | 왜 깨지나 |
|---|---|
| 임베딩을 바꾼다 | dense 후보 자체가 달라진다 → 모델마다 캐시를 새로 만들어야 한다 |
| 청킹을 바꾼다 | 청크와 chunk_id가 밀려 캐시의 id가 다른 걸 가리킨다 |
| top-k를 늘린다 | 저장해둔 100개 밖은 알 수 없다 |
임베딩 비교 실험이 정확히 첫 번째 경우라, 캐시를 3벌 만드는 것이다. 세 번째 때문에 캐시는 넉넉히(k=100) 뜬다 — 실제 평가는 상위 20까지만 쓴다.
관련
Dense 검색 · BM25 · 크로스인코더 리랭커 · 청크와 chunk_id · 검색 실험 설계 §10