Published on

RAG 환각 줄이기 - Hybrid Search와 Rerank 튜닝

Authors

서론부터 결론까지 한 문장으로 요약하면, RAG 환각은 모델의 문제가 아니라 검색 단계에서 잘못 뽑힌 근거가 LLM에 주입되면서 증폭되는 경우가 많습니다. 특히 벡터 검색만 쓰면 의미 유사도는 잡지만 키워드 제약이 약해지고, 키워드(BM25)만 쓰면 동의어·문맥 추론이 약해집니다. 그래서 실무에서는 Hybrid Search로 후보를 넓히고, Rerank로 최종 근거를 정밀하게 고르는 파이프라인이 가장 비용 대비 효과가 좋습니다.

이 글은 Hybrid Search 가중치, 후보군 크기, Rerank 모델/입력 길이/스코어 결합 방식, 그리고 실제로 환각을 줄이는 평가 지표와 운영 팁까지 한 번에 정리합니다.

RAG 환각이 생기는 지점: 검색-재정렬-생성의 연결 고리

RAG 파이프라인을 단순화하면 아래 흐름입니다.

  1. Query 정규화 및 확장(옵션)
  2. 1차 검색(Retrieval): 벡터, BM25, 혹은 Hybrid
  3. 2차 재정렬(Rerank): cross-encoder 또는 LLM 기반
  4. 컨텍스트 구성(Top-k 문서, chunk 병합/요약)
  5. 생성(Answer) + 인용(citation)

환각이 늘어나는 대표 패턴은 다음과 같습니다.

  • 리콜 부족: 정답 근거가 후보군에 아예 없음. LLM은 빈칸을 메우려다 그럴듯한 답을 생성합니다.
  • 정밀도 부족: 후보군에 정답도 있지만, 더 그럴듯해 보이는 오답 chunk가 상위에 올라감.
  • chunk 설계 문제: 문서가 잘려 맥락이 끊기거나, 표/코드/정의가 분리되어 근거가 왜곡됨.
  • 컨텍스트 과다: 너무 많은 chunk를 넣어 모델이 핵심 근거를 놓침(주의 분산).

Hybrid Search와 Rerank 튜닝은 위 네 가지 중 특히 리콜정밀도, 그리고 컨텍스트 과다를 동시에 다루는 가장 직접적인 방법입니다.

Hybrid Search 기본 설계: 후보는 넓게, 최종은 좁게

Hybrid Search는 보통 다음 두 점수를 결합합니다.

  • score_dense: 임베딩 기반 유사도(코사인/내적)
  • score_sparse: BM25 같은 키워드 기반 점수

결합은 보통 선형 결합으로 시작합니다.

  • score = w_dense * norm(score_dense) + w_sparse * norm(score_sparse)

여기서 핵심은 norm입니다. 벡터 점수와 BM25 점수는 스케일이 완전히 다르기 때문에 정규화 없이 가중치를 주면 튜닝이 불가능해집니다.

정규화 전략 3가지

  1. min-max 정규화: 후보 집합 내에서 0~1로 스케일
  2. z-score 정규화: 평균 0, 표준편차 1
  3. rank 기반 정규화: 점수 대신 순위를 점수로 변환(예: reciprocal rank)

실무에서는 rank 기반이 튜닝 내성이 좋아서 먼저 추천합니다. 점수 분포가 바뀌어도 순위는 상대적으로 안정적이기 때문입니다.

결합 방식: RRF(Reciprocal Rank Fusion)부터 시작

가중치 튜닝이 부담스럽다면 RRF가 안전합니다.

  • RRF(d) = 1/(k + rank_dense(d)) + 1/(k + rank_sparse(d))

여기서 k는 보통 60 전후로 시작합니다.

아래는 파이썬으로 RRF를 구현해 후보를 합치는 예시입니다.

from collections import defaultdict

def rrf_fusion(dense_ranked_ids, sparse_ranked_ids, k=60):
    score = defaultdict(float)
    for r, doc_id in enumerate(dense_ranked_ids, start=1):
        score[doc_id] += 1.0 / (k + r)
    for r, doc_id in enumerate(sparse_ranked_ids, start=1):
        score[doc_id] += 1.0 / (k + r)
    return sorted(score.items(), key=lambda x: x[1], reverse=True)

# dense_ranked_ids = ["d7", "d2", ...]
# sparse_ranked_ids = ["d2", "d9", ...]
# fused = rrf_fusion(dense_ranked_ids, sparse_ranked_ids)

RRF는 단순하지만, 벡터가 강한 쿼리키워드가 강한 쿼리가 섞인 환경에서 평균 성능이 잘 나옵니다. 이후에 선형 결합으로 넘어가도 됩니다.

후보군 크기 설정: Hybrid는 넓게, Rerank는 딱 필요한 만큼

환각을 줄이려면 후보군이 너무 좁아도 안 되고, 너무 넓어도 안 됩니다.

  • retrieval_top_k(1차 후보): 보통 50~300
  • rerank_top_k(재정렬 입력): 보통 20~80
  • final_context_k(LLM에 넣는 chunk): 보통 4~12

추천 시작점은 아래와 같습니다.

  • Hybrid retrieval: top_k = 150
  • Rerank input: top_k = 50
  • LLM context: top_k = 8

이렇게 계층적으로 줄이면 리콜을 확보하면서도 LLM에 불필요한 텍스트를 덜 넣어 근거 기반 답변을 유도할 수 있습니다.

Rerank가 환각을 줄이는 이유: cross-encoder의 문장 단위 판단

벡터 검색은 query와 document를 각각 임베딩해 근사 유사도를 계산합니다. 반면 Rerank(cross-encoder)는 query와 document를 함께 넣고 이 문서가 질문에 답이 되는가를 직접 분류/회귀합니다.

즉, Hybrid Search로 정답이 있을 법한 후보를 모으고, Rerank로 정답 근거를 위로 올리면 환각이 급격히 줄어듭니다.

Rerank 입력 텍스트 구성 팁

Rerank 품질은 모델도 중요하지만 입력 포맷이 더 중요할 때가 많습니다.

  • chunk 앞에 문서 제목, 섹션 헤더, 날짜/버전, 제품명 같은 메타를 짧게 붙이기
  • 표/코드가 핵심이면, 전처리로 텍스트화하되 의미가 보존되게 만들기
  • chunk 길이는 너무 길면 비용과 노이즈 증가. 보통 300~1,200 tokens 범위에서 실험

예시 포맷:

[title] Kubernetes IRSA Troubleshooting
[section] OIDC provider and STS
[source] internal-wiki

<chunk text...>

주의: 본문에 부등호를 그대로 쓰면 MDX에서 JSX로 오인될 수 있으니 위처럼 표시가 필요하면 실제 구현에서는 &lt;chunk text...&gt; 또는 백틱으로 감싸는 방식을 쓰세요.

튜닝 포인트 1: Hybrid 가중치와 쿼리 유형 분기

모든 쿼리에 동일한 w_dense, w_sparse를 쓰면 특정 유형에서 성능이 무너집니다. 실무에서는 쿼리 유형을 간단히 분기해 가중치를 다르게 주는 것만으로도 환각이 줄어듭니다.

쿼리 유형 예시

  • 정확 키워드형: 에러 코드, 함수명, 설정 키, 버전 문자열
  • 자연어 설명형: 증상 기반 질문, "왜"/"어떻게" 질문
  • 혼합형: 제품명+증상+키워드

간단한 휴리스틱:

  • 숫자/대문자/특수 토큰 비율이 높으면 sparse 비중 증가
  • 길이가 길고 서술형이면 dense 비중 증가
import re

def classify_query(q: str) -> str:
    has_code = bool(re.search(r"[A-Z_]{3,}|\d{3,}|0x[0-9a-fA-F]+", q))
    if has_code:
        return "keyword"
    if len(q.split()) >= 8:
        return "natural"
    return "mixed"

def hybrid_weights(qtype: str):
    if qtype == "keyword":
        return 0.3, 0.7  # dense, sparse
    if qtype == "natural":
        return 0.7, 0.3
    return 0.5, 0.5

이 정도만 해도 에러 코드가 포함된 질문에서 벡터가 엉뚱한 유사 문서를 끌고 와 환각이 나는 케이스를 크게 줄일 수 있습니다.

튜닝 포인트 2: chunk 전략이 Rerank를 망치는 방식

Rerank는 문서가 질문에 답이 되는지 잘 판단하지만, chunk 자체가 답을 담고 있지 않으면 한계가 있습니다.

  • 정의가 다음 페이지에 있는데 chunk가 잘려 있으면 Rerank가 낮게 줌
  • 표가 텍스트로 깨져 있으면 의미가 손상되어 오답이 상위로 올라옴

실무 팁:

  • 섹션 기반 chunking(헤더 단위) + 최대 길이 제한
  • 코드/설정은 블록 단위로 보존
  • chunk overlap은 50~150 tokens 정도로 시작

벡터 DB에서 리콜이 급락하는 문제가 있다면 HNSW 파라미터도 함께 점검해야 합니다. 관련해서는 Milvus RAG 리콜 급락? HNSW 파라미터 튜닝을 같이 보면 검색 단계의 바닥 성능을 안정화하는 데 도움이 됩니다.

튜닝 포인트 3: Rerank 스코어와 Hybrid 스코어 결합

Rerank를 쓴다고 해서 1차 점수를 완전히 버리기보다, 아래처럼 섞으면 안정성이 올라갑니다.

  • final_score = a * rerank_score + b * hybrid_score + c * freshness_boost

특히 사내 문서처럼 최신 문서가 더 신뢰할 수 있는 도메인에서는 freshness_boost가 환각을 줄입니다. 오래된 문서의 deprecated 내용을 근거로 답하는 경우가 꽤 흔하기 때문입니다.

간단한 freshness 예시:

from datetime import datetime

def freshness_boost(updated_at: datetime, now: datetime, half_life_days=180):
    age_days = (now - updated_at).days
    # 2^(-age/half_life)
    return 2 ** (-age_days / half_life_days)

평가 방법: "정답률"이 아니라 "근거 정확도"를 측정하라

환각을 줄이는 튜닝에서 가장 흔한 실패는, 생성 결과의 정답률만 보고 retrieval을 개선했다고 착각하는 것입니다. RAG는 근거가 맞으면 답도 맞아질 확률이 커지는 구조라서, 아래를 분리해 측정해야 합니다.

추천 오프라인 지표

  • Recall@k (retrieval): 정답 문서가 top-k 후보에 포함되는가
  • MRR / nDCG (rerank): 정답 문서가 얼마나 위로 올라오는가
  • Context Precision: LLM에 넣은 chunk 중 실제로 답에 필요한 비율
  • Attribution / Citation accuracy: 답변 문장과 인용 chunk가 실제로 일치하는가

실무에서는 최소한 Recall@50, nDCG@10, Citation accuracy 3개는 같이 봐야 합니다.

간단한 평가 데이터셋 구성

  • 실제 사용자 질문 로그에서 200~1,000개 샘플
  • 각 질문에 대해 정답 문서(또는 정답 chunk) 라벨링
  • 질문 유형(키워드형/자연어형) 태깅

이렇게 해두면 Hybrid 가중치 분기나 Rerank 교체의 효과가 수치로 보입니다.

운영에서 환각을 더 줄이는 안전장치

검색과 재정렬을 튜닝해도, 운영에서는 예외가 발생합니다. 아래 가드레일을 걸면 "모르는 건 모른다"를 강제할 수 있습니다.

1) 근거 부족 시 답변 거부(Abstain)

  • rerank_top1_score가 임계값 미만이면 답변 대신 추가 질문 또는 "근거 부족" 응답
  • top1 - top2 점수 차이가 너무 작으면 불확실로 판단
def should_abstain(top1, top2, min_score=0.25, min_gap=0.03):
    if top1 < min_score:
        return True
    if (top1 - top2) < min_gap:
        return True
    return False

2) 프롬프트에서 인용 강제 + 인용 없는 문장 금지

환각 억제에는 프롬프트 방어도 같이 가야 합니다. 특히 "근거 없는 추론"을 금지하고, 문장마다 인용을 강제하면 체감 품질이 좋아집니다. 관련 패턴은 Chain-of-Thought 유출 막는 프롬프트 방어 7패턴도 함께 참고할 만합니다.

3) 스트리밍/툴콜 오류로 컨텍스트가 비는 문제 방지

운영에서 의외로 흔한 게, 툴콜/스트리밍 오류로 검색 결과가 비었는데도 LLM이 답을 생성해버리는 케이스입니다. 이때 환각이 폭발합니다. 파이프라인 단계별로 검색 결과 0건이면 생성 금지 같은 하드 체크를 넣고, API 오류를 확실히 감지해야 합니다. 유사 사례로 OpenAI API+LangChain 스트리밍 툴콜 400 해결법도 같이 보면 장애 대응에 도움이 됩니다.

추천 튜닝 레시피: 가장 빨리 효과 보는 순서

아래 순서대로 하면 투자 대비 환각 감소 폭이 큽니다.

  1. chunk 품질 점검: 섹션 단위 chunking, overlap, 메타데이터 포함
  2. Hybrid 도입: RRF로 시작해서 안정화
  3. 후보군 확대: retrieval top_k를 늘리고 rerank로 줄이기
  4. Rerank 도입/교체: cross-encoder 기반, 입력 포맷 최적화
  5. 쿼리 유형 분기: 키워드형은 sparse 비중, 서술형은 dense 비중
  6. abstain 정책: 점수 임계값과 gap 기반 거부
  7. 지표 체계화: Recall@k, nDCG, citation accuracy를 대시보드화

결론: Hybrid는 리콜을, Rerank는 정밀도를, 가드레일은 신뢰를 만든다

RAG 환각을 줄이는 가장 현실적인 방법은 LLM을 더 큰 모델로 바꾸는 게 아니라, 정답 근거가 컨텍스트에 들어오게 만드는 확률을 올리는 것입니다.

  • Hybrid Search로 "정답이 후보에 들어올" 확률을 높이고
  • Rerank로 "정답이 상위에 올라올" 확률을 높이며
  • abstain과 인용 강제로 "근거 없이는 말하지 않는" 행동을 강제하면

같은 모델, 비슷한 비용에서도 환각은 눈에 띄게 줄어듭니다. 다음 단계로는 도메인별 하드 네거티브를 모아 Rerank를 미세튜닝하거나, 질문 유형 분기를 더 정교하게 만들어 장기적으로 안정성을 끌어올리는 것을 권장합니다.