후보 캐시

검색을 한 번만 돌려 후보를 저장해두고, 실험 조합은 그 파일 위에서 계산으로 끝내는 방식.

같은 이름으로 불리는 층이 있어서 헷갈리기 쉽다. 나눠서 본다.


① 후보 캐시 — 검색을 재사용한다

문제

리랭커 실험은 조합이 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.pyA/B/A_doc/B_doc + 게이팅 7종 스윕1초 미만
rerank_on_cache.pyC/D 리랭커 (VM L4)93초

원래 불가능했던 게 가능해진 것도 있다. 하이브리드 검색 게이팅 임계값을 사전에 하나 찍고 가려 했는데, 캐시 덕에 7종을 전수 스윕해서 “전부 끄는 게 최적”이라는 결론을 얻었다.

무엇이 들어 있나 — 문항당 후보 목록

캐시는 질문 하나에 대해 서로 다른 방식으로 뽑은 후보를 세 벌 담는다. 이후 문서에 계속 나오는 이름이라 여기서 정리해둔다.

필드뽑는 법위상
dense_doc대상 문서를 먼저 특정하고, 그 문서 안에서만 유사도 검색프로덕션 경로. 모든 판정의 기준
dense_full100개 문서 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_rank3.65초← 진짜 병목
dense_full2.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