Published on

RAG 검색 품질 급락? 하이브리드+RRF 튜닝 가이드

Authors

RAG를 운영하다 보면 어느 날부터 답변이 뜬금없어지거나, 분명 문서에 있는 내용을 못 찾는 문제가 발생합니다. 대부분의 원인은 LLM이 아니라 검색 단계(retrieval) 에 있습니다. 특히 벡터 검색만으로 구성된 파이프라인은 데이터 분포가 조금만 바뀌어도(문서 길이, 용어, 업데이트 주기, 임베딩 모델 교체 등) 품질이 급격히 흔들릴 수 있습니다.

이 글에서는 “검색 품질 급락” 상황을 하이브리드 검색(lexical+vector) 으로 완화하고, 두 결과를 RRF(Reciprocal Rank Fusion) 로 안정적으로 합치는 방법을 실전 관점에서 정리합니다. 마지막에는 튜닝 포인트(가중치, k, 필터, 리랭커, 평가 지표)까지 체크리스트 형태로 제공합니다.

문서가 쌓이며 벡터DB 운영 비용이나 메모리 압박이 커지는 경우도 많습니다. 그럴 땐 TTL/요약 전략과 함께 검색 품질을 같이 설계하는 편이 안전합니다. 관련해서는 AutoGPT 메모리 누수? 벡터DB TTL·요약 전략도 함께 참고하면 좋습니다.

1) “검색 품질 급락”의 전형적인 증상과 원인

증상 패턴

  • 정확한 키워드가 포함된 문서가 있는데도 못 찾음 (특히 에러 코드, API 필드명, 약어)
  • 비슷한 주제의 문서만 반복적으로 가져오고, 정답 문서가 상위에 안 뜸
  • 최신 문서가 추가된 뒤, 과거 문서가 더 자주 뜨거나 반대로 신규 문서만 과도하게 편향
  • 질의가 짧을수록(예: “timeout”, “502”, “OOM”) 성능이 급락

원인 분류

  1. 임베딩 기반 검색의 한계
    • 고유명사/코드/에러 메시지/정확 매칭이 중요한 질의는 dense embedding이 약할 수 있습니다.
  2. 데이터 분포 변화
    • 문서 길이가 길어지거나, 동일 주제 문서가 급증하면 벡터 공간에서 군집이 뭉개져 상위 랭크가 흔들립니다.
  3. 청킹 전략 문제
    • 너무 큰 chunk는 잡음이 늘고, 너무 작은 chunk는 문맥이 끊겨 recall이 떨어집니다.
  4. 필터/메타데이터 결함
    • tenant_id, product, lang, version 같은 필터가 누락되면 “맞는 문서”가 아니라 “그럴듯한 문서”가 뜹니다.
  5. 스코어 스케일 불일치
    • vector score와 BM25 score를 단순 합산하면 스케일 차이로 한쪽이 지배합니다.

이 중 1번과 5번을 가장 빠르게 안정화하는 접근이 하이브리드 + RRF 입니다.

2) 하이브리드 검색이 왜 효과적인가

하이브리드 검색은 보통 아래 두 채널을 동시에 수행합니다.

  • Lexical(BM25 등): 키워드 정확 매칭에 강함
  • Dense(Vector): 의미 유사도, 표현 다양성에 강함

운영 관점에서 중요한 포인트는 “평균 성능”이 아니라 최악 케이스(worst-case) 를 줄이는 것입니다. 벡터가 흔들릴 때 BM25가 안전망이 되고, 키워드가 부족한 질의에서는 벡터가 안전망이 됩니다.

문제는 두 결과를 어떻게 합치느냐인데, 여기서 RRF가 빛납니다.

3) RRF(Reciprocal Rank Fusion) 핵심 개념

RRF는 스코어를 직접 합치지 않고, 순위(rank) 를 기반으로 결합합니다. 가장 흔한 형태는 다음과 같습니다.

  • 문서 d의 최종 점수
    • score(d) = sum( 1 / (k + rank_i(d)) )
    • 여기서 rank_i(d)는 i번째 랭커(예: BM25, Vector)에서의 순위입니다.
    • k는 상위 랭크의 영향력을 조절하는 상수입니다.

RRF의 장점

  • 스코어 스케일 정규화가 필요 없음 (BM25 vs cosine 등)
  • 한 랭커에서만 상위라도 최종 결과에 반영됨
  • 튜닝 파라미터가 단순 (k, 그리고 채널별 가중치 정도)

RRF의 단점

  • “정확한 스코어” 정보는 버리기 때문에, 미세한 점수 차이를 활용하기 어렵습니다.
  • 그래서 실전에서는 RRF로 후보를 뽑고, 마지막에 리랭킹(cross-encoder) 을 붙이는 구성이 가장 안정적입니다.

4) 실전 아키텍처: Hybrid 후보 생성 + RRF 결합 + 리랭킹

권장 파이프라인은 아래 순서입니다.

  1. 질의 전처리(언어/테넌트/버전 필터 확정)
  2. BM25로 top_n_lex 후보
  3. Vector로 top_n_vec 후보
  4. RRF로 합쳐 top_m 후보
  5. 리랭커로 최종 top_k 문서 선택
  6. 컨텍스트 구성(중복 제거, chunk 병합, citation)

리랭커는 비용이 들기 때문에 후보 수를 줄이는 것이 중요합니다. LLM 도구 호출이나 리랭킹 비용이 폭증하는 상황이라면, 호출 횟수 제어 전략도 같이 봐야 합니다. 관련해서는 LangChain 도구호출 무한루프 차단·비용 절감을 참고하세요.

5) 코드 예제: RRF 결합 구현(파이썬)

아래 예제는 BM25 결과와 벡터 결과를 각각 리스트로 받은 뒤 RRF로 합치는 최소 구현입니다. 문서 ID가 같으면 점수를 누적합니다.

from dataclasses import dataclass
from typing import Dict, List, Tuple

@dataclass(frozen=True)
class Hit:
    doc_id: str
    # rank는 1부터 시작한다고 가정
    rank: int


def rrf_fuse(
    ranked_lists: List[List[Hit]],
    k: int = 60,
    weights: List[float] | None = None,
) -> List[Tuple[str, float]]:
    """RRF fusion.

    ranked_lists: 예) [bm25_hits, vector_hits]
    k: RRF 상수. 클수록 상위 랭크 편향이 줄어듦.
    weights: 채널별 가중치. None이면 모두 1.0
    """
    if weights is None:
        weights = [1.0] * len(ranked_lists)
    if len(weights) != len(ranked_lists):
        raise ValueError("weights length must match ranked_lists")

    scores: Dict[str, float] = {}
    for w, hits in zip(weights, ranked_lists):
        for h in hits:
            scores[h.doc_id] = scores.get(h.doc_id, 0.0) + w * (1.0 / (k + h.rank))

    fused = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return fused


# 예시 입력
bm25 = [Hit("docA", 1), Hit("docB", 2), Hit("docC", 3)]
vec  = [Hit("docB", 1), Hit("docD", 2), Hit("docA", 5)]

fused = rrf_fuse([bm25, vec], k=60, weights=[1.0, 1.2])
print(fused[:5])

k는 얼마가 적당한가

  • 경험적으로 k=60이 자주 쓰이지만 정답은 아닙니다.
  • k가 작을수록 상위 1~3위의 영향력이 커지고, k가 클수록 상위/중위권이 더 고르게 섞입니다.
  • “벡터 결과가 가끔 1위를 엉뚱하게 찍는” 상황이면 k를 키워 상위 한두 개의 오판을 완화하는 쪽이 도움이 됩니다.

6) 튜닝 포인트 1: 채널별 후보 수(top_n_lex, top_n_vec)

RRF는 순위 기반이라 각 채널에서 가져오는 후보 수가 매우 중요합니다.

  • top_n_lex를 너무 작게
    • 키워드 매칭이 필요한 질의에서 recall이 낮아짐
  • top_n_vec를 너무 작게
    • 의미 기반으로 찾아야 하는 질의에서 recall이 낮아짐
  • 둘 다 너무 크게
    • 리랭커 비용 증가, 컨텍스트 중복 증가

권장 시작점(업무 문서/FAQ 기준)

  • top_n_lex = 50
  • top_n_vec = 50
  • RRF 후 top_m = 30
  • 리랭킹 후 top_k = 5 또는 8

이 값은 문서 수, 청크 수, 평균 길이에 따라 달라집니다. 중요한 건 “후보 단계에서 recall을 확보하고, 리랭킹에서 precision을 확보”하는 구조로 나누는 것입니다.

7) 튜닝 포인트 2: 가중치(weights)는 어떻게 잡나

RRF에 가중치를 넣는 가장 현실적인 이유는 하나입니다.

  • 우리 도메인은 키워드 정확 매칭이 더 중요하다(BM25 가중치 up)
  • 또는 동의어/표현 다양성이 더 중요하다(Vector 가중치 up)

예를 들어 장애/로그/에러코드/설정 키가 중요한 기술 문서 검색은 BM25를 더 신뢰하는 경우가 많습니다.

시작점 예시

  • 기술 문서/에러 코드 중심: weights=[1.2, 1.0]
  • 고객 문의/자연어 중심: weights=[1.0, 1.2]

가중치 튜닝은 온라인 A/B가 가장 좋지만, 최소한 오프라인에서도 “정답 문서가 top_k에 들어오는 비율”을 기준으로 그리드 서치가 가능합니다.

8) 튜닝 포인트 3: 필터링과 쿼리 라우팅(가장 흔한 실수)

검색 품질 급락의 원인이 사실은 retrieval 알고리즘이 아니라 필터 누락인 경우가 정말 많습니다.

  • tenant_id 누락으로 다른 고객 문서가 섞임
  • lang 누락으로 한국어 질의에 영어 문서가 상위 노출
  • version 누락으로 구버전 가이드가 상위 노출

또 하나는 쿼리 라우팅입니다.

  • 질의에 숫자/에러 코드/특수 토큰이 많으면 BM25 후보를 더 많이
  • 질의가 짧고 추상적이면 vector 후보를 더 많이

간단한 휴리스틱 예시입니다.

import re

def route_query(q: str) -> dict:
    has_error_code = bool(re.search(r"\b[A-Z]{2,10}-?\d{2,6}\b", q))
    has_snake_or_dot = ("_" in q) or ("." in q)
    short = len(q.split()) <= 3

    if has_error_code or has_snake_or_dot:
        return {"top_n_lex": 80, "top_n_vec": 40, "weights": [1.3, 1.0]}
    if short:
        return {"top_n_lex": 40, "top_n_vec": 80, "weights": [1.0, 1.3]}
    return {"top_n_lex": 50, "top_n_vec": 50, "weights": [1.1, 1.1]}

이 정도만 해도 “갑자기 못 찾는” 케이스가 꽤 줄어듭니다.

9) 튜닝 포인트 4: 청킹과 중복 제거(컨텍스트 품질을 좌우)

검색이 맞아도 답이 이상한 경우는 컨텍스트 구성 단계가 문제일 수 있습니다.

  • 같은 문서의 인접 chunk가 여러 개 뽑혀 컨텍스트가 중복됨
  • 표/코드 블록이 청킹 중간에 잘려 의미가 손상됨
  • 너무 작은 chunk로 인해 근거 문장이 빠짐

권장하는 후처리

  • 같은 doc_id에서 연속된 chunk는 병합
  • 유사도가 높은 chunk는 dedup
  • 컨텍스트에 넣기 전, chunk별로 title, section, updated_at 같은 메타를 붙여 LLM이 구조를 이해하게 함

10) 평가: 어떤 지표로 “급락”을 조기에 잡을까

운영에서 가장 중요한 건 “모델이 나빠졌다”가 아니라 “검색이 나빠졌다”를 분리해서 감지하는 것입니다.

오프라인 평가 기본 세트

  • Recall@k: 정답 문서가 상위 k에 들어오는 비율
  • MRR: 정답이 얼마나 위에 뜨는지(첫 정답 순위의 역수)
  • nDCG: 여러 정답이 있을 때 랭킹 품질

온라인 모니터링(가능하면)

  • 검색 결과 클릭/확장률(내부 툴이라면 문서 오픈 이벤트)
  • “근거 없음” 응답률
  • 후속 질문률(사용자가 같은 질문을 반복하는지)

그리고 품질 급락은 종종 인프라/성능 이슈에서 시작됩니다. 타임아웃으로 상위 후보가 잘리거나, 리랭커 호출이 실패하면 품질이 급격히 떨어집니다. 로컬 LLM 혹은 리랭커를 운영하며 지연이 문제라면 Transformers 로컬 LLM 속도 2배 - vLLM+PagedAttention처럼 서빙 최적화도 함께 고려하세요.

11) 현장 체크리스트: 하이브리드+RRF로 급락 복구하기

아래 순서대로 하면 “원인 미상 급락”을 재현 가능하게 줄일 수 있습니다.

  1. 정답 세트부터 만든다
    • 최근 실패 질의 50~200개를 모으고, 정답 문서(또는 정답 chunk)를 라벨링
  2. 필터 누락/메타데이터 오류를 먼저 잡는다
    • tenant_id, lang, version, product 필터
  3. BM25와 Vector를 동시에 돌리고 RRF로 결합한다
    • 시작점: k=60, top_n_lex=50, top_n_vec=50
  4. 가중치와 후보 수를 그리드로 튜닝한다
    • weights[1.0, 1.0], [1.2, 1.0], [1.0, 1.2]부터
  5. RRF 후 top_m을 줄이고 리랭커를 붙인다
    • 비용이 부담되면 top_m을 줄이고, 질의 라우팅으로 선택적으로 리랭킹
  6. 청킹/중복 제거로 컨텍스트 품질을 고정한다
    • 문서 단위 병합, 인접 chunk 병합, dedup
  7. 지표를 상시로 본다
    • 최소 Recall@k, MRR은 배치로라도 매일 계산

마무리

RAG 품질 급락은 대개 “LLM이 멍청해졌다”가 아니라, 검색이 분포 변화와 운영 이슈에 취약해서 생깁니다. 하이브리드 검색으로 채널을 분산하고, RRF로 스코어 스케일 문제 없이 안정적으로 결합하면 급락을 빠르게 완화할 수 있습니다. 그 위에 리랭킹과 컨텍스트 후처리를 얹으면, 평균 성능뿐 아니라 최악 케이스까지 함께 개선됩니다.

다음 단계로는 (1) 질의 라우팅을 더 정교하게 만들기, (2) 도메인별 리랭커/임베딩 모델 교체를 A/B로 검증하기, (3) TTL/요약/아카이빙으로 벡터DB의 장기 운영 비용을 관리하기를 추천합니다.