Published on

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

Authors

RAG를 운영하다 보면 어느 날부터 답변이 갑자기 엉뚱해지거나, 근거 문서가 빗나가고, 같은 질문인데도 일관성이 무너지는 순간이 옵니다. 보통 모델이 “퇴화”했다기보다, 검색 단계에서 후보군이 잘못 모이거나(Recall 붕괴), 그중 정답 문서가 상위로 못 올라오는(Ranking 붕괴) 경우가 대부분입니다.

이 글은 “RAG 품질 급락” 상황에서 가장 빠르게 효과를 보는 조합인 하이브리드 검색(lexical+vector) + Rerank를 중심으로, 운영 환경에서 실제로 튜닝하는 순서와 체크포인트를 정리합니다.

참고로 하이브리드 검색 자체를 Qdrant/LlamaIndex 관점에서 더 깊게 다룬 글은 아래도 함께 보면 좋습니다.

1) “품질 급락”을 검색 문제로 분해하기

우선 증상을 두 가지로 분리해야 합니다.

1-1. Recall 붕괴: 정답 문서가 후보군에 없다

  • Top k를 늘리면 갑자기 답이 맞기 시작한다
  • 특정 키워드(제품명, 에러코드, 약어)에서만 유독 약하다
  • 최신 문서/특정 섹션 문서만 못 찾는다

이 경우는 인덱싱/청킹/쿼리 전처리/하이브리드 결합 방식이 핵심입니다.

1-2. Ranking 붕괴: 후보군엔 있는데 위로 못 올라온다

  • 후보군을 보면 정답 문서가 k=50 안에는 있는데 k=5에는 없다
  • 유사하지만 다른 문서(비슷한 기능, 다른 버전)가 계속 1등을 먹는다

이 경우는 Rerank, 점수 스케일링, 하이브리드 가중치 조정이 핵심입니다.

2) 하이브리드 검색이 필요한 대표 케이스

벡터 검색만으로는 다음 유형에서 흔히 미끄러집니다.

  • 희귀 토큰: 에러코드, 버전 문자열, 옵션명
  • 정확 일치가 중요한 질의: “401 반복”, “AccessDenied 403”, “CrashLoopBackOff” 같은 케이스
  • 짧은 질의: 의미 벡터가 빈약해 근접 문서가 넓게 퍼짐

이럴 때 BM25 같은 lexical 검색을 섞으면 “정확 일치”를 잡아주고, 벡터는 “의미 유사”를 보완합니다.

3) 하이브리드 결합: 점수 스케일링이 반이다

하이브리드에서 흔한 실패는 “BM25 점수와 벡터 점수를 그냥 더했다”입니다. 두 점수는 스케일이 다르고 분포도 다르기 때문에, 스케일링 없이 합치면 한쪽이 항상 이깁니다.

3-1. 가장 단순하고 안전한 방식: 각 랭킹을 따로 뽑고 합치기

  • BM25 Top k1
  • Vector Top k2
  • 문서 집합을 합집합으로 만든 뒤, Rerank로 최종 정렬

이 방식은 점수 스케일링 이슈를 크게 줄이고, Rerank가 “최종 심판” 역할을 하게 만듭니다.

3-2. 점수 기반 결합을 꼭 해야 한다면

  • 벡터 유사도는 보통 [-1, 1] 또는 [0, 1]
  • BM25는 쿼리 길이/코퍼스에 따라 수십~수백까지 튈 수 있음

따라서 최소한 아래 중 하나는 하세요.

  • min-max 정규화
  • z-score 정규화
  • rank-based fusion(예: RRF)

RRF(Reciprocal Rank Fusion) 예시

RRF는 점수 대신 “순위”로 합칩니다. 스케일 문제에 강하고 구현도 쉽습니다.

from collections import defaultdict

def rrf_fusion(rank_lists, k=60):
    # rank_lists: [ [doc_id1, doc_id2, ...], [doc_idX, ...] ]
    scores = defaultdict(float)
    for docs in rank_lists:
        for rank, doc_id in enumerate(docs, start=1):
            scores[doc_id] += 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

bm25_rank = ["d7", "d2", "d9", "d1"]
vec_rank  = ["d2", "d3", "d7", "d8"]

fused = rrf_fusion([bm25_rank, vec_rank])
print(fused[:5])

운영 팁:

  • RRF의 k는 보통 50~100에서 시작
  • BM25/Vector 각각 Top 50~200 정도를 넣고, 최종은 Rerank로 10~30개만 정리

4) Rerank 도입: “정답을 위로 올리는” 가장 확실한 레버

하이브리드로 후보군을 넓히면 Recall은 좋아지지만, Top 5가 좋아진다는 보장은 없습니다. 여기서 Rerank가 필요합니다.

4-1. Rerank의 역할을 명확히 정의하기

  • 입력: 쿼리 + 후보 문서(제목/요약/청크)
  • 출력: 후보 문서의 재정렬(상위 n)

Rerank는 “정확히 이 질문에 답이 되는 문서인가”를 문장 단위로 판단하므로, 벡터 유사도보다 더 직접적입니다.

4-2. Rerank 후보군 크기(k)가 품질을 좌우한다

  • 후보군이 너무 작으면 Rerank가 아무리 좋아도 정답이 없음
  • 후보군이 너무 크면 비용/지연이 폭증

실전에서 자주 쓰는 старт 포인트:

  • BM25 Top 100
  • Vector Top 100
  • 합집합 후 중복 제거해서 보통 120~180
  • Rerank로 Top 10~20

4-3. 청크 텍스트를 그대로 넣지 말고 “rerank-friendly”로 가공

Rerank 입력이 길고 잡음이 많으면 성능이 떨어집니다.

권장:

  • 제목 + 섹션 헤더 + 본문 일부(예: 앞 500~1200자)
  • 코드 블록이 많으면 코드만 따로 요약하거나, 코드 주변 설명 문장만 우선 제공
  • 문서 메타데이터(제품, 버전, 날짜)를 1줄로 prepend
def build_rerank_text(doc):
    meta = f"product={doc['product']} version={doc['version']} date={doc['date']}"
    title = doc.get('title', '')
    header = doc.get('section', '')
    body = doc.get('text', '')[:1200]
    return f"{meta}\n{title}\n{header}\n{body}".strip()

5) 하이브리드+Rerank 튜닝 순서(운영 체크리스트)

여기부터가 “급락 대응”에서 시간을 아껴주는 순서입니다.

5-1. 먼저 Top k를 키워서 원인이 Recall인지 확인

  • 기존: retrieve Top 5
  • 테스트: retrieve Top 50 또는 100

Top 50에서 정답이 보이면 Ranking 문제일 확률이 큽니다.

5-2. 하이브리드로 후보군 Recall을 복구

  • BM25를 추가하고 합집합 구성
  • 또는 RRF로 1차 결합

이 단계 목표는 “정답 문서가 후보군에 들어오게 만들기”입니다.

5-3. Rerank로 Top 10 품질을 고정

  • 후보군 120~200 정도를 Rerank
  • 최종 컨텍스트는 Top 3~8개만 사용

5-4. 점수 임계치(Threshold)로 “모르면 모른다”를 허용

품질 급락의 또 다른 형태는 “근거가 없는데도 그럴듯하게 말함”입니다. Rerank 점수나 하이브리드 점수로 임계치를 두면 환각을 줄일 수 있습니다.

  • Rerank 상위 1개의 점수가 일정 기준 미만이면: 검색 실패로 처리
  • 검색 실패 시: 사용자에게 추가 질문(버전, 환경, 에러 로그)을 요청

주의: 임계치는 데이터셋마다 달라서 오프라인 평가로 보정해야 합니다.

6) 자주 놓치는 디테일 6가지

6-1. 쿼리 리라이트가 하이브리드 성능을 좌우한다

사용자 쿼리는 종종 불완전합니다.

  • “이거 왜 안 돼요” 같은 지시어
  • 제품/버전 누락
  • 로그 일부만 붙여넣기

간단한 리라이트로 lexical과 vector 둘 다 이득을 봅니다.

def rewrite_query(q, product=None, version=None):
    parts = []
    if product:
        parts.append(product)
    if version:
        parts.append(version)
    parts.append(q.strip())
    return " ".join(parts)

q2 = rewrite_query("CrashLoopBackOff 계속 떠요", product="EKS", version="1.29")

6-2. 필터(테넌트, 권한, 버전)와 검색을 섞는 순서

  • 먼저 필터링 후 검색하면 Recall이 급감할 수 있음
  • 먼저 검색 후 필터링하면 보안/권한 문제가 생길 수 있음

권장 패턴:

  • 인덱스 레벨에서 권한 분리(가능하면)
  • 최소한의 필수 필터만 검색 단계에 적용
  • 나머지는 Rerank 이후 후처리로 제한

6-3. 최신 문서가 안 잡히면 “재인덱싱”보다 먼저 볼 것

  • 청킹이 너무 커서 최신 섹션이 묻힘
  • 문서 날짜/버전 메타가 검색에 반영되지 않음
  • BM25 analyzer가 토큰을 잘못 쪼갬

임베딩 자체가 드리프트한 경우는 재인덱싱이 맞지만, 그 전에 위를 점검하는 게 비용이 적습니다.

6-4. 멀티링구얼/혼합 언어 코퍼스

한국어+영어가 섞이면

  • BM25는 형태소/토크나이저 영향이 큼
  • 벡터는 모델이 어느 언어에 강한지에 따라 편향

이 경우 하이브리드가 특히 유효하지만, Rerank는 언어 커버리지가 좋은 모델을 선택해야 합니다.

6-5. 중복 청크 제거는 필수

같은 문서에서 비슷한 청크가 여러 개 들어오면 컨텍스트가 낭비됩니다.

  • 문서 ID 기준으로 1차 중복 제거
  • 유사도 기준으로 2차 near-duplicate 제거

6-6. 컨텍스트에 넣는 문서 수를 줄여야 LLM이 덜 흔들린다

Rerank로 Top 20을 뽑아도, LLM 입력에는 Top 3~8만 넣는 편이 안정적입니다. 컨텍스트가 길어질수록 모델은 “중요한 문장”을 놓치고 요약 모드로 흐르기 쉽습니다.

7) 오프라인 평가 루프: 튜닝을 감으로 하지 않는 법

“급락”을 막으려면 최소한의 평가 루프가 있어야 합니다.

7-1. 로그에서 평가셋 만들기

  • 최근 1~2주 사용자 질문 중 대표 200~1000개 샘플
  • 정답 문서(또는 정답 문서 후보) 라벨링
  • 최소 라벨: 정답 문서 ID 1개만 있어도 됨

7-2. 지표는 검색/랭킹을 분리

  • Recall@k: 정답이 후보군에 들어왔는가
  • MRR@k: 정답이 얼마나 위에 있는가
  • nDCG@k: 다중 정답/부분 정답까지 반영

7-3. 실험 매트릭스(추천)

  • Vector only vs BM25 only vs Hybrid
  • Hybrid 결합: union vs RRF vs score-sum
  • Rerank: on/off
  • 후보군 크기: 50/100/200

이렇게만 돌려도 “어디가 병목인지”가 숫자로 드러납니다.

8) 최소 구현 예시: 하이브리드 후보군 + Rerank 파이프라인

아래는 개념을 보여주는 간단한 형태의 파이프라인입니다.

def retrieve_bm25(query, k):
    # return list of (doc_id, score)
    ...

def retrieve_vector(query, k):
    # return list of (doc_id, score)
    ...

def rerank(query, docs):
    # docs: list of {doc_id, text}
    # return list of (doc_id, rerank_score)
    ...

def hybrid_rag_candidates(query, k_bm25=100, k_vec=100, k_final=10):
    bm25 = retrieve_bm25(query, k_bm25)
    vec = retrieve_vector(query, k_vec)

    # 1) union by doc_id
    doc_ids = {d for d, _ in bm25} | {d for d, _ in vec}

    # 2) fetch texts
    docs = []
    for doc_id in doc_ids:
        doc = load_doc(doc_id)
        docs.append({"doc_id": doc_id, "text": build_rerank_text(doc)})

    # 3) rerank
    ranked = rerank(query, docs)

    # 4) take top k_final
    return ranked[:k_final]

핵심은 “하이브리드로 후보군을 넓히고, Rerank로 상위를 고정”하는 구조입니다. 점수 결합을 정교하게 만들기 전에, 이 구조만으로도 급락을 상당히 복구할 수 있습니다.

9) 마무리: 급락 대응의 정답은 “후보군 확장”과 “상위 고정”

RAG 품질이 떨어졌을 때 가장 흔한 함정은 LLM 프롬프트를 먼저 만지는 것입니다. 하지만 많은 케이스에서 문제는 생성이 아니라 검색입니다.

  • 하이브리드 검색으로 Recall을 복구하고
  • Rerank로 Top-N을 안정화한 뒤
  • 임계치와 평가 루프로 재발을 방지하세요.

이 흐름을 갖추면 “갑자기 이상해졌다”는 운영 이슈를, 재현 가능하고 측정 가능한 튜닝 작업으로 바꿀 수 있습니다.