Published on

RAG 회수율 급락? 하이브리드+리랭커 튜닝

Authors

RAG를 운영하다 보면 어느 날부터 답변 품질이 급격히 나빠지는 순간이 옵니다. 로그를 보면 LLM은 그럭저럭 잘 말하는데, 정작 근거 문서가 엉뚱하거나(오답 근거), 필요한 문서를 못 가져오거나(미회수) 하는 패턴이 반복됩니다. 대부분은 생성 문제가 아니라 회수(retrieval) 단계의 회수율(recall) 급락이 원인입니다.

이 글은 “회수율이 떨어졌다”는 증상만 보고도 빠르게 원인을 좁히고, 하이브리드 검색(lexical+vector) + 리랭커(reranker) 조합을 통해 회수율과 정밀도를 함께 끌어올리는 튜닝 방법을 다룹니다. 특히 운영 환경에서 자주 발생하는 데이터/인덱스/쿼리 변화에 대한 방어 전략을 중심으로 정리합니다.

관련해서 LangChain/생태계 의존성 이슈로 디버깅 시간이 새는 경우가 많습니다. 환경이 갑자기 깨졌다면 먼저 LangChain Pydantic v2 호환 오류 5분 해결법도 함께 점검해 두면 좋습니다.

1) “회수율 급락”을 먼저 정의하고 계측하기

회수율 급락은 감으로 판단하면 해결이 늦습니다. 최소한 아래 3가지는 숫자로 잡아야 합니다.

  • Recall@k: 정답 문서(또는 정답 청크)가 상위 k개 결과에 포함되는 비율
  • MRR: 정답이 몇 번째에 등장하는지(상위로 끌어올릴수록 좋음)
  • nDCG@k: 다중 정답/가중치가 있을 때 유용

운영에서 흔한 실수는 “LLM 응답 평가”만 하고 retrieval 평가는 안 하는 것입니다. RAG는 두 단계(회수/생성)라서, 회수 성능이 무너지면 프롬프트를 아무리 만져도 회복이 잘 안 됩니다.

오프라인 평가용 최소 데이터셋 만들기

  • 질문 q
  • 정답 문서 ID 또는 정답 청크 ID(가능하면)
  • 정답 근거 스니펫(없으면 문서 단위라도)

이 데이터셋은 200~1,000개만 있어도 튜닝 방향이 잡힙니다.

2) 회수율이 떨어지는 대표 원인 7가지

회수율 급락은 보통 “검색이 못해서”가 아니라 검색 조건이 바뀌었는데 시스템이 따라가지 못해서 발생합니다.

2.1 문서 분할(chunking) 변화로 의미가 깨짐

  • 청크가 너무 작아 문맥이 끊김
  • 청크가 너무 커서 임베딩이 뭉개짐
  • 제목/섹션 정보가 청크에 포함되지 않아 키워드 매칭이 약해짐

대응: 청크에 title, h2, path, tags 같은 메타를 앞에 프리픽스로 넣어 임베딩/키워드 양쪽을 강화합니다.

2.2 임베딩 모델 교체/버전업으로 분포가 달라짐

임베딩 모델이 바뀌면 벡터 공간 자체가 달라져서 기존 인덱스가 무의미해질 수 있습니다.

대응: 임베딩 모델 버전은 인덱스 스키마에 명시하고, 바뀌면 재색인을 강제합니다.

2.3 인덱스/필터 조건이 바뀌어 후보군이 줄어듦

  • tenantId, lang, visibility 같은 필터가 의도치 않게 좁아짐
  • 날짜 필터 기본값이 바뀌어 최신 문서만 보게 됨

대응: retrieval 단계에서 “필터로 몇 개가 남았는지”를 반드시 로깅합니다.

2.4 쿼리 전처리(정규화, 형태소, 동의어)가 깨짐

특히 한국어는 띄어쓰기/조사/복합명사 때문에 lexical 검색이 흔들릴 수 있습니다.

대응: 최소한 아래는 고정 규칙으로 넣습니다.

  • 공백 정규화
  • 숫자/단위 표준화(예: 200ms, 200 ms)
  • 영문 대소문자 통일

2.5 벡터 검색 파라미터가 바뀜

예를 들어 HNSW에서 efSearch를 낮추면 지연은 줄지만 recall이 떨어질 수 있습니다.

대응: latency 예산 내에서 efSearch 또는 후보 수를 올리고, 이후 리랭커로 정밀도를 회복합니다.

2.6 도메인 변화(새로운 용어/제품명/정책)

새 용어가 등장하면 임베딩도 lexical도 둘 다 흔들립니다. 특히 문서가 신규인데 질문이 그 문서를 겨냥할수록 미회수가 늘어납니다.

대응: 신규 문서가 들어오면 “핫 키워드 사전”을 만들고, 하이브리드 가중치를 lexical 쪽으로 잠시 올려서 방어합니다.

2.7 중복/근접 문서가 늘어나 리랭킹 없이는 상위가 오염됨

유사한 공지/릴리즈 노트가 반복되면 top-k가 비슷한 문서로 채워져 정답 다양성이 줄어듭니다.

대응: MMR 또는 diversity 전략 + 리랭커 적용.

3) 하이브리드 검색이 회수율 방어에 강한 이유

하이브리드는 보통 다음 두 신호를 섞습니다.

  • Lexical(BM25 등): 키워드 정확 매칭에 강함(희귀 용어, 코드, 에러 메시지)
  • Vector(semantic): 표현이 달라도 의미가 비슷하면 찾음(패러프레이즈)

회수율 급락 상황은 대개 한쪽 신호가 깨진 상태입니다. 하이브리드는 한쪽이 흔들려도 다른 쪽이 버팀목이 되어 최소 회수율을 지켜줍니다.

하이브리드의 핵심 튜닝 포인트

  • 후보군 합치기 방식: union 후 점수 정규화
  • 가중치: score = alpha * bm25 + (1-alpha) * vector
  • 후보 수: lexical top k1, vector top k2를 넉넉히 뽑고 리랭커로 정리

운영 팁: “최종 top-5”만 보지 말고, “리랭커 이전 후보 top-50”에서 정답이 들어오는지부터 확인해야 합니다.

4) 리랭커가 해결하는 문제와 한계

리랭커는 보통 cross-encoder 계열로, 쿼리-문서 쌍을 함께 읽고 관련도를 더 정확히 매깁니다.

  • 장점: top-k 정밀도 급상승, 근거 오염 감소
  • 단점: 비용/지연 증가, 후보군에 정답이 없으면 못 살림

즉, 하이브리드로 recall을 확보하고, 리랭커로 precision을 확보하는 구조가 가장 안전합니다.

5) 실전 파이프라인: 하이브리드 + 리랭커

아래는 “BM25 + 벡터 검색” 결과를 합치고, 리랭커로 최종 순위를 만드는 예시입니다. 구현체는 Elasticsearch/OpenSearch, pgvector, Qdrant, Pinecone 등 무엇이든 적용 가능합니다.

5.1 점수 정규화와 합치기(중요)

서로 다른 스코어 스케일을 그대로 더하면 한쪽이 압도합니다. 최소-최대 정규화나 rank-based fusion을 권장합니다.

from dataclasses import dataclass
from typing import List, Dict

@dataclass
class Hit:
    doc_id: str
    score: float
    text: str

def minmax_norm(scores: Dict[str, float]) -> Dict[str, float]:
    if not scores:
        return {}
    vals = list(scores.values())
    lo, hi = min(vals), max(vals)
    if hi == lo:
        return {k: 1.0 for k in scores}
    return {k: (v - lo) / (hi - lo) for k, v in scores.items()}

def hybrid_fuse(bm25_hits: List[Hit], vec_hits: List[Hit], alpha: float = 0.5) -> List[Hit]:
    bm25 = {h.doc_id: h for h in bm25_hits}
    vec = {h.doc_id: h for h in vec_hits}

    bm25_scores = minmax_norm({k: v.score for k, v in bm25.items()})
    vec_scores = minmax_norm({k: v.score for k, v in vec.items()})

    doc_ids = set(bm25.keys()) | set(vec.keys())
    fused = []
    for doc_id in doc_ids:
        b = bm25_scores.get(doc_id, 0.0)
        s = vec_scores.get(doc_id, 0.0)
        score = alpha * b + (1 - alpha) * s
        text = (bm25.get(doc_id) or vec.get(doc_id)).text
        fused.append(Hit(doc_id=doc_id, score=score, text=text))

    fused.sort(key=lambda x: x.score, reverse=True)
    return fused

운영에서 alpha는 고정값보다 “쿼리 타입에 따라 동적으로” 주는 편이 좋습니다.

  • 에러 코드/로그/식별자 포함: lexical 가중치 증가
  • 자연어 설명형 질문: vector 가중치 증가

5.2 쿼리 타입에 따른 동적 가중치 예시

import re

def choose_alpha(query: str) -> float:
    # lexical을 더 믿어야 하는 패턴들
    if re.search(r"\b[A-Z]{2,}-\d+\b", query):
        return 0.75  # 예: JIRA 티켓
    if re.search(r"\b0x[0-9a-fA-F]+\b", query):
        return 0.8
    if re.search(r"\b\d+\.\d+\.\d+\b", query):
        return 0.7  # 버전
    if re.search(r"Exception|Error|Traceback|SIG[A-Z]+", query):
        return 0.85
    return 0.5

5.3 리랭커 적용(후보 top-N에만)

리랭커 비용을 줄이려면 하이브리드로 top-N까지만 추리고, 그 후보만 리랭킹합니다.

from typing import Tuple

def rerank(query: str, candidates: List[Hit], reranker, top_n: int = 50, top_k: int = 5) -> List[Hit]:
    pool = candidates[:top_n]
    pairs = [(query, h.text) for h in pool]

    # reranker는 cross-encoder처럼 (q, doc) 점수를 반환한다고 가정
    scores: List[float] = reranker.score(pairs)

    reranked = [Hit(doc_id=h.doc_id, score=s, text=h.text) for h, s in zip(pool, scores)]
    reranked.sort(key=lambda x: x.score, reverse=True)
    return reranked[:top_k]

리랭커를 붙였는데도 품질이 안 오르면, 대개 다음 중 하나입니다.

  • 후보군 top_n이 너무 작아 정답이 후보에 없음
  • 청크 텍스트에 필요한 메타(제목/섹션)가 없어 리랭커가 판단을 못 함
  • 질문이 너무 짧아 리랭커가 비교할 단서가 부족함(쿼리 확장 필요)

6) 튜닝 순서: “후보 확보”부터 하고 “정렬”을 만져라

회수율 급락을 복구할 때 가장 안전한 순서는 아래입니다.

  1. 필터/권한/언어 조건으로 후보가 잘리는지 확인(로깅)
  2. chunking/메타 프리픽스 점검
  3. vector 검색의 후보 수 k2 또는 ANN 파라미터(예: efSearch)를 올려 recall 확보
  4. lexical top k1도 올려 희귀 키워드 방어
  5. 하이브리드 fusion에서 alpha 튜닝(동적 가중치 포함)
  6. 리랭커 도입/교체/프롬프트형 리랭킹(가능하면 모델 기반)
  7. 최종 top-k에서 MMR/diversity로 중복 제거

핵심은 “정렬 품질(리랭커)”은 후보가 있어야 의미가 있다는 점입니다.

7) 운영 체크리스트: 회수율 급락을 조기에 잡는 관측 포인트

7.1 Retrieval 단계 로그에 반드시 남길 것

  • 쿼리 원문, 정규화된 쿼리
  • lexical top k1 결과 수, vector top k2 결과 수
  • 필터 적용 전/후 후보 수
  • 하이브리드 fusion 상위 문서들의 점수 분해(bm25_norm, vec_norm, final)
  • 리랭커 적용 전 후보 top_n에 정답 포함 여부(오프라인 평가에서)

7.2 “지연이 늘어도 회수율을 지키는” 전략

하이브리드와 리랭커는 지연을 늘릴 수 있습니다. 하지만 사용자 경험은 “빠른 오답”보다 “조금 느린 정답”이 낫습니다. 지연 예산을 다루는 관점은 음성/실시간 시스템에서도 동일합니다. 레이턴시를 다루는 감각은 OpenAI Realtime API로 음성 에이전트 지연 200ms 줄이기에서 소개한 방식처럼, 단계별 병목을 쪼개서 접근하는 게 효과적입니다.

실전 팁:

  • 벡터 검색은 빠르게, 후보는 넉넉히
  • 리랭커는 후보 top_n만, 그리고 캐시(동일 질문/유사 질문) 적용
  • 문서가 자주 바뀌는 영역은 “요약 인덱스”를 별도로 두고 1차 회수에 사용

8) 자주 쓰는 튜닝 레시피 4가지

8.1 희귀 키워드가 많은 도메인: lexical 강화

  • alpha를 높임(예: 0.7)
  • BM25에서 필드 부스팅: title 가중치 증가
  • 문서에 식별자/코드/에러 메시지 필드를 분리 저장

8.2 자연어/상담형 질의: vector 강화 + 리랭커 필수

  • alpha를 낮춤(예: 0.3)
  • vector 후보 k2 확대
  • 리랭커 top_n 확대

8.3 중복 문서가 많음: diversity 도입

  • fusion 후 MMR 적용
  • 동일 문서/동일 섹션에서 너무 많은 청크가 top-k를 점유하지 않게 제한

8.4 다국어/혼합 언어: 언어 감지 후 라우팅

  • 쿼리 언어 감지 후 해당 언어 인덱스로 우선 검색
  • 필요하면 cross-lingual embedding을 쓰되, lexical은 언어별 analyzer를 분리

9) 결론: 회수율은 “하이브리드로 지키고, 리랭커로 올린다”

RAG 품질이 떨어졌을 때 가장 흔한 함정은 생성 모델을 먼저 의심하는 것입니다. 하지만 운영에서의 급락은 대개 retrieval 파이프라인의 작은 변화(필터, 인덱스, 청크, 모델 버전, ANN 파라미터)에서 시작합니다.

  • 하이브리드 검색은 한쪽 신호가 흔들려도 회수율을 방어합니다.
  • 리랭커는 후보군 안에서 정답을 위로 끌어올려 정밀도를 회복합니다.
  • 튜닝 순서는 “후보 확보”가 먼저, “정렬 개선”이 다음입니다.

마지막으로, 프레임워크/의존성 업데이트로 retrieval 결과가 달라지는 케이스도 많습니다. 관련 오류로 삽질 시간을 줄이려면 LangChain Pydantic v2 호환 오류 5분 해결법을 미리 체크리스트에 넣어두는 것을 권합니다.