Published on

RAG 검색 정확도 폭락? 하이브리드+Rerank 튜닝

Authors

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로 좁게

하이브리드의 핵심은 단순합니다.

  1. lexical에서 top k1 후보
  2. vector에서 top k2 후보
  3. 두 후보를 합쳐 dedupe
  4. 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 (랭킹 품질)
    • 컨텍스트 길이 대비 정답 포함률

인덱스 설정 변경이나 임베딩 모델 교체는 회귀 테스트 없이 반영하면 쉽게 폭락합니다. 특히 pgvector의 IVFFlat/HNSW 파라미터는 리콜에 직결되므로, 인덱스 튜닝과 검색 파이프라인 튜닝을 분리해서 보지 마세요.

10) 마무리: “하이브리드로 리콜, rerank로 정밀도”가 기본값

RAG 검색 정확도가 폭락했을 때의 우선순위는 대체로 다음 순서가 가장 빠릅니다.

  1. 필터/권한/테넌트/기간 조건으로 정답이 제외되지 않았는지 확인
  2. 하이브리드 검색으로 후보 리콜을 복구(k1, k2 를 충분히)
  3. rerank로 상위 정밀도를 복구(입력 후보 50에서 시작해 100까지 실험)
  4. 컨텍스트 구성 정책을 고정하고 windowing, dedupe, boilerplate 제거를 적용
  5. 오프라인 평가로 회귀를 막아 “폭락”을 이벤트가 아니라 테스트 실패로 바꾸기

이 흐름대로 가면, “임베딩이 문제인가요” 같은 막연한 질문을 “후보 리콜이 떨어졌고, lexical을 섞고 rerank 입력을 100으로 늘리면 복구된다”처럼 측정 가능한 튜닝 작업으로 바꿀 수 있습니다.