- Published on
RAG 검색 정확도 폭락? 하이브리드+Rerank 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG를 운영하다 보면 “어제까지 잘 맞던 답이 오늘은 엉뚱하다” 같은 검색 정확도 폭락을 겪습니다. 이때 흔히 임베딩 모델 교체나 청크 크기만 만지다가 시간을 날리는데, 실제로는 검색 파이프라인 전체(후보 생성, 필터, 스코어링, 랭킹, 컨텍스트 구성) 중 하나가 무너져도 체감 품질이 급락합니다.
이 글은 하이브리드 검색(lexical+vector) 과 rerank(재정렬) 를 축으로, 정확도 폭락을 빠르게 진단하고 튜닝하는 실전 절차를 정리합니다.
또한 벡터 인덱스 자체의 리콜 문제가 의심된다면, 아래 글에서 IVFFlat, HNSW 파라미터까지 함께 점검하는 게 좋습니다.
1) “정확도 폭락”을 먼저 수치로 분해하기
정확도 폭락은 보통 아래 중 하나로 분해됩니다.
- Recall 하락: 정답 문서가 후보에 아예 안 들어옴 (top
k에 없음) - Precision 하락: 후보는 들어오는데 상위 랭킹이 엉망 (top
k내 순서가 나쁨) - Context 구성 실패: 랭킹은 맞는데 청크 조합이 나빠 답변에 필요한 근거가 빠짐
운영에서 가장 빠른 진단은 “정답 문서가 후보에 들어오는가”를 보는 겁니다.
- 후보 생성 단계에서
k를 늘려도 정답이 안 들어오면 후보 생성(검색) 문제 k를 늘리면 들어오는데 상위로 못 올라오면 rerank 문제- 상위는 맞는데 답이 틀리면 컨텍스트 구성(청크, 윈도우, 중복 제거, 길이 제한) 문제
최소 진단 로그(권장)
요청 1건마다 아래를 남기면 원인 분해가 빨라집니다.
- query 원문
- 필터 조건(테넌트, 권한, 기간 등)
- 후보 생성별 top
k결과(lexical, vector) - 각 후보의 점수(예:
bm25_score,vector_distance,rerank_score) - 최종 컨텍스트에 실제로 들어간 청크 목록
2) 폭락의 흔한 원인: “벡터 검색만” 쓰고 있었다
임베딩 기반 vector 검색은 의미 유사도에 강하지만, 운영에서 다음 패턴에 취약합니다.
- 고유명사/코드/에러코드/약어: 의미보다는 문자열 매칭이 중요한 질의
- 최신 문서: 임베딩 분포가 바뀌거나 업데이트가 늦으면 누락
- 짧은 질의: 정보량이 적어 벡터 공간에서 방향이 불안정
- 도메인 용어 변화: 제품명/정책명 변경, 릴리즈 노트 누적
이때 하이브리드 검색은 “의미”와 “문자열 증거”를 같이 씁니다.
- lexical(BM25 등): 정확한 토큰 매칭에 강함
- vector: 동의어, 표현 변화에 강함
정확도 폭락을 막는 가장 단단한 기본기는 하이브리드 후보 생성 + rerank 입니다.
3) 하이브리드 검색 설계: 후보 생성은 넓게, rerank로 좁게
하이브리드의 핵심은 단순합니다.
- lexical에서 top
k1후보 - vector에서 top
k2후보 - 두 후보를 합쳐 dedupe
- rerank로 최종 top
k선정
여기서 중요한 튜닝 포인트는 다음입니다.
k1,k2는 리콜을 확보할 정도로 충분히 크게- 최종
k는 LLM 컨텍스트 비용을 고려해 작게 - 후보 합치기 단계에서 문서 단위 dedupe인지, 청크 단위 dedupe인지 정책을 명확히
권장 초기값(출발점)
- lexical top
k1: 50 - vector top
k2: 50 - merge 후 rerank 입력: 최대 100
- 최종 컨텍스트 청크: 6~12 (도메인/모델 컨텍스트에 따라 조정)
4) Rerank가 필요한 이유: “스코어 스케일이 다르다”
BM25 점수와 벡터 거리(또는 유사도)는 스케일이 다르고, 필터나 토큰화에 따라 분포가 흔들립니다. 단순히 가중합으로 합치면 운영 중 다음이 생깁니다.
- 특정 날부터 BM25가 과도하게 지배
- 임베딩 모델 변경 후 벡터 점수 분포가 달라져 랭킹이 깨짐
- 길이가 긴 청크가 유리해지는 등 편향 발생
rerank는 후보를 “같은 기준”으로 다시 비교합니다.
- Cross-encoder 계열(문장 쌍을 함께 넣고 관련도를 직접 예측)
- LLM 기반 rerank(비용이 크고 변동성이 있어 보통은 2순위)
운영에서는 보통 다음 전략이 비용 대비 효율이 좋습니다.
- 후보 생성: 빠른 검색(BM25+vector)
- rerank: cross-encoder(상대적으로 저렴, 안정적)
5) 실전 튜닝 순서(정확도 폭락 대응 플레이북)
5-1) 1단계: 필터를 의심하라
정확도 폭락의 의외로 큰 비중이 “검색이 아니라 필터”입니다.
- 권한 필터가 강화되어 정답 문서가 제외
- 테넌트 키 누락으로 다른 고객 문서가 섞임
- 기간 필터 기본값 변경
- 문서 상태 필터(게시, 삭제, 아카이브) 로직 변경
이건 rerank로는 못 고칩니다. 후보 자체가 없기 때문입니다.
5-2) 2단계: 하이브리드로 리콜을 복구
vector만 쓰고 있었다면 lexical을 추가하면서 리콜이 급복구되는 경우가 많습니다.
- 고유명사/버전/에러코드 질의에서 특히 효과적
5-3) 3단계: rerank로 상위 정밀도를 복구
리콜이 복구되면 그다음은 “정답을 위로 올리는” 작업입니다.
- rerank 입력 후보 수를 늘리면 품질은 오르지만 비용과 지연이 늘어남
- 보통 rerank 입력을 50에서 100으로 늘리는 것이 체감 개선이 큼
5-4) 4단계: 컨텍스트 구성 정책을 고정하고 실험
rerank가 좋아도 컨텍스트 구성에서 망하면 답이 틀립니다.
- 같은 문서에서 인접 청크를 함께 가져오는 windowing
- 중복 제거(동일 문서/동일 섹션 과다 포함 방지)
- 섹션 헤더/목차 같은 “정보 밀도 낮은 청크” 제외
6) 예시 구현: 하이브리드 후보 생성 + rerank 파이프라인
아래 코드는 개념을 보여주기 위한 예시입니다. 실제 환경에서는 검색엔진(예: OpenSearch, Elasticsearch) 또는 DB(예: Postgres pgvector)에 맞춰 구현하세요.
from dataclasses import dataclass
from typing import List, Dict, Tuple
@dataclass
class Candidate:
chunk_id: str
doc_id: str
text: str
bm25: float | None = None
vec: float | None = None # similarity or negative distance
rerank: float | None = None
def merge_dedupe(cands1: List[Candidate], cands2: List[Candidate]) -> List[Candidate]:
merged: Dict[str, Candidate] = {}
for c in cands1 + cands2:
if c.chunk_id in merged:
# keep best signals if present
merged[c.chunk_id].bm25 = merged[c.chunk_id].bm25 or c.bm25
merged[c.chunk_id].vec = merged[c.chunk_id].vec or c.vec
else:
merged[c.chunk_id] = c
return list(merged.values())
def hybrid_search(query: str, k1: int = 50, k2: int = 50) -> List[Candidate]:
lexical = bm25_search(query, top_k=k1) # returns Candidate with bm25
vector = vector_search(query, top_k=k2) # returns Candidate with vec
merged = merge_dedupe(lexical, vector)
return merged
def rerank(query: str, cands: List[Candidate], top_k: int = 10) -> List[Candidate]:
pairs = [(query, c.text) for c in cands]
scores = cross_encoder_score(pairs) # list[float]
for c, s in zip(cands, scores):
c.rerank = float(s)
cands.sort(key=lambda x: x.rerank or -1e9, reverse=True)
return cands[:top_k]
def build_context(top: List[Candidate], max_chunks: int = 10) -> str:
# simple: concatenate; in practice add windowing, dedupe by doc_id, etc.
top = top[:max_chunks]
return "\n\n".join([c.text for c in top])
def rag_retrieve(query: str) -> Tuple[List[Candidate], str]:
cands = hybrid_search(query, k1=50, k2=50)
top = rerank(query, cands, top_k=10)
ctx = build_context(top, max_chunks=10)
return top, ctx
핵심은 “후보 생성은 넓게, rerank로 좁게”입니다.
7) 하이브리드 점수 결합을 꼭 해야 한다면(가중합의 함정과 보완)
rerank가 어렵거나 비용 제한이 있다면 BM25와 벡터 점수를 결합할 수 있습니다. 다만 분포가 달라 폭락이 생기기 쉬우므로 최소한 정규화를 넣으세요.
- BM25는 쿼리마다 점수 범위가 크게 달라짐
- 벡터 유사도도 모델/인덱스/정규화 여부에 따라 분포가 바뀜
아래는 쿼리 단위 min-max 정규화 예시입니다.
def minmax(xs: list[float]) -> list[float]:
lo, hi = min(xs), max(xs)
if hi - lo < 1e-9:
return [0.0 for _ in xs]
return [(x - lo) / (hi - lo) for x in xs]
def combine_scores(cands: list[Candidate], alpha: float = 0.5) -> list[Candidate]:
bm25s = [c.bm25 or 0.0 for c in cands]
vecs = [c.vec or 0.0 for c in cands]
nb = minmax(bm25s)
nv = minmax(vecs)
for c, b, v in zip(cands, nb, nv):
c.rerank = alpha * b + (1.0 - alpha) * v
cands.sort(key=lambda x: x.rerank or -1e9, reverse=True)
return cands
하지만 운영 안정성 관점에서는 “가중합 튜닝”이 다시 운영 포인트가 됩니다. 가능하면 cross-encoder rerank로 옮기는 편이 장기적으로 안전합니다.
8) Rerank 튜닝 체크리스트
8-1) rerank 입력 후보를 늘렸는데도 개선이 없다
- 후보 생성 단계에서 정답이 아예 없을 가능성이 큼
- 필터/토큰화/인덱스 리콜 문제를 먼저 확인
8-2) rerank가 특정 문서 타입만 과대평가한다
- 템플릿 문구가 많은 문서(예: 공통 푸터, 면책 조항)가 상위로 올라오는 현상
- 해결: 청크 전처리에서 boilerplate 제거, 또는 rerank 입력에서 해당 섹션 제외
8-3) rerank가 너무 느리다
- 입력 후보 수를 줄이기 전에, 먼저 후보를 “문서 단위로 그룹핑 후 상위 문서만 rerank”하는 2단계를 고려
- 캐시: 동일 쿼리 또는 유사 쿼리 캐싱
9) 폭락을 예방하는 운영 장치: 오프라인 평가와 회귀 테스트
정확도 폭락을 사후 대응만 하면 끝이 없습니다. 최소한 아래는 자동화하는 편이 좋습니다.
- 대표 질의 셋(업무 핵심 질문 100~500개)
- 정답 문서 또는 정답 청크 라벨(완벽하지 않아도 됨)
- 지표
- Recall@
k(후보 생성 단계) - MRR@
k또는 nDCG@k(랭킹 품질) - 컨텍스트 길이 대비 정답 포함률
- Recall@
인덱스 설정 변경이나 임베딩 모델 교체는 회귀 테스트 없이 반영하면 쉽게 폭락합니다. 특히 pgvector의 IVFFlat/HNSW 파라미터는 리콜에 직결되므로, 인덱스 튜닝과 검색 파이프라인 튜닝을 분리해서 보지 마세요.
10) 마무리: “하이브리드로 리콜, rerank로 정밀도”가 기본값
RAG 검색 정확도가 폭락했을 때의 우선순위는 대체로 다음 순서가 가장 빠릅니다.
- 필터/권한/테넌트/기간 조건으로 정답이 제외되지 않았는지 확인
- 하이브리드 검색으로 후보 리콜을 복구(
k1,k2를 충분히) - rerank로 상위 정밀도를 복구(입력 후보 50에서 시작해 100까지 실험)
- 컨텍스트 구성 정책을 고정하고 windowing, dedupe, boilerplate 제거를 적용
- 오프라인 평가로 회귀를 막아 “폭락”을 이벤트가 아니라 테스트 실패로 바꾸기
이 흐름대로 가면, “임베딩이 문제인가요” 같은 막연한 질문을 “후보 리콜이 떨어졌고, lexical을 섞고 rerank 입력을 100으로 늘리면 복구된다”처럼 측정 가능한 튜닝 작업으로 바꿀 수 있습니다.