Published on

RAG 환각 줄이기 - 하이브리드 검색과 Rerank

Authors

서치 기반 RAG는 "모델이 아는 척" 하는 환각을 줄이는 대표적인 접근이지만, 실제 운영에서는 여전히 엉뚱한 답이 나옵니다. 이유는 간단합니다. 모델이 틀린 게 아니라, 검색 단계에서 잘못된 근거를 가져오거나(Recall 문제), 가져온 근거 중 중요한 것을 못 고르거나(Precision 문제), 프롬프트에서 근거 사용을 강제하지 못하기 때문입니다.

이 글은 그중에서도 가장 ROI가 큰 조합인 하이브리드 검색(lexical + vector) + Rerank를 중심으로, RAG 환각을 "체감되게" 줄이는 실전 파이프라인을 다룹니다.

RAG 환각이 생기는 지점: 검색이 70%다

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

  1. 쿼리 전처리(정규화, 확장, 라우팅)
  2. 후보 검색(보통 top_k 20~200)
  3. Rerank로 재정렬(보통 5~30개로 축소)
  4. 컨텍스트 구성(청크 병합, 중복 제거, 길이 제한)
  5. 생성(근거 인용, 답변 형식 강제)

환각의 전형적인 패턴은 다음 둘 중 하나입니다.

  • 관련 문서를 못 찾음: 벡터 검색만 쓰면 키워드가 중요한 질의에서 미스가 납니다. 예: 에러 코드, 설정 키, 함수명.
  • 비슷해 보이는 문서를 가져옴: 임베딩 유사도는 "의미가 비슷한" 문서를 잘 찾지만, "정답 근거"를 보장하진 않습니다. 특히 제품 버전, 날짜, 정책 변경이 있는 도메인에서 치명적입니다.

그래서 운영 RAG에서는 보통 이렇게 갑니다.

  • 후보는 넓게: 하이브리드로 Recall 확보
  • 최종은 좁게: Rerank로 Precision 확보

하이브리드 검색: lexical과 vector를 같이 쓰는 이유

하이브리드는 보통 아래 두 스코어를 결합합니다.

  • Lexical(BM25 계열): 토큰이 정확히 일치할수록 강함. 에러 코드, 옵션명, 고유명사에 강함.
  • Vector(embedding ANN): 표현이 달라도 의미가 비슷하면 강함. 자연어 질문, 패러프레이즈에 강함.

둘 중 하나만 쓰면 구멍이 생깁니다.

  • BM25만 쓰면: "의미는 같은데 표현이 다른" 질문에 약함
  • Vector만 쓰면: "정확히 그 문자열"이 중요한 질의에 약함

결합 방식 3가지

  1. Union 후 Rerank
  • BM25 top_k1 + Vector top_k2를 합쳐서 Rerank
  • 가장 구현이 쉽고 효과가 안정적
  1. Weighted score fusion
  • 정규화된 점수에 가중치를 줘서 합산
  • 튜닝이 필요하지만 Rerank 비용을 줄일 수 있음
  1. RRF(Reciprocal Rank Fusion)
  • 점수 대신 랭크 기반으로 결합
  • 검색 엔진이 다르거나 점수 스케일이 달라도 안정적

운영에서는 1번이 가장 흔합니다. 후보를 넓게 모아두고, 비싼 모델은 Rerank에만 쓰면 비용 대비 효과가 좋습니다.

Rerank: "비슷한" 문서가 아니라 "정답 근거"를 고른다

Rerank는 쿼리와 문서(청크)를 함께 넣고 "이 문서가 질문에 답하는 데 얼마나 유용한가"를 점수화합니다. 보통 cross-encoder 계열이거나, LLM 기반 스코어링을 씁니다.

Rerank를 넣으면 환각이 줄어드는 이유는 다음과 같습니다.

  • 벡터 유사도는 문서 전체 의미가 비슷하면 상위로 올립니다.
  • Rerank는 질문에 답하는 문장/근거가 실제로 포함되어 있는지를 더 직접적으로 봅니다.

특히 "정책/버전/조건" 같은 제약이 있는 질문에서 차이가 큽니다.

예:

  • 질문: "Spring Boot 3에서 가상스레드 적용 후 TPS가 떨어지는 원인"
  • 벡터 검색은 "성능" 관련 글을 많이 가져오지만
  • Rerank는 "가상스레드"와 "TPS 급락"을 동시에 다루는 청크를 올립니다.

관련해서 성능 이슈를 진단하는 글을 자주 운영 문서에 붙이는데, 디버깅 관점은 아래 글도 참고할 만합니다.

실전 파이프라인 설계: 후보는 넓게, 컨텍스트는 좁게

권장 기본값(출발점)은 아래입니다.

  • BM25 후보: top_k=50
  • Vector 후보: top_k=50
  • Union 후 중복 제거: 60~90개 수준
  • Rerank 입력: 최대 60개
  • 최종 컨텍스트: 6~12개 청크

여기서 중요한 건 최종 컨텍스트를 너무 많이 넣지 않는 것입니다. 컨텍스트가 길어질수록 모델은

  • 중요한 근거를 놓치거나
  • 서로 충돌하는 문장을 섞어
  • 그럴듯한 중간 답을 만들어 환각처럼 보이게 됩니다.

코드 예제: 하이브리드 + RRF + Rerank (Python)

아래 예시는 개념을 보여주기 위한 스켈레톤입니다. BM25 검색과 벡터 검색 결과를 RRF로 합친 뒤, cross-encoder로 Rerank하고 최종 컨텍스트를 구성합니다.

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

@dataclass
class Chunk:
    id: str
    text: str
    meta: Dict

@dataclass
class Scored:
    chunk: Chunk
    score: float

def rrf_fusion(
    bm25_ranked: List[Scored],
    vec_ranked: List[Scored],
    k: int = 60,
    rrf_k: int = 60,
) -> List[Chunk]:
    """Reciprocal Rank Fusion.
    score = sum(1 / (rrf_k + rank))
    """
    scores: Dict[str, float] = {}
    chunks: Dict[str, Chunk] = {}

    def add(rank_list: List[Scored]):
        for idx, s in enumerate(rank_list, start=1):
            cid = s.chunk.id
            chunks[cid] = s.chunk
            scores[cid] = scores.get(cid, 0.0) + 1.0 / (rrf_k + idx)

    add(bm25_ranked)
    add(vec_ranked)

    fused = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:k]
    return [chunks[cid] for cid, _ in fused]

# --- Rerank (cross-encoder) ---

def rerank_cross_encoder(query: str, candidates: List[Chunk], model) -> List[Scored]:
    pairs = [(query, c.text) for c in candidates]
    # model.predict returns relevance scores
    rel = model.predict(pairs)
    scored = [Scored(chunk=c, score=float(s)) for c, s in zip(candidates, rel)]
    return sorted(scored, key=lambda x: x.score, reverse=True)

def build_context(top: List[Scored], max_chunks: int = 8, max_chars: int = 8000) -> str:
    selected = []
    total = 0
    for s in top[:max_chunks]:
        t = s.chunk.text.strip()
        if not t:
            continue
        if total + len(t) > max_chars:
            break
        selected.append(t)
        total += len(t)
    return "\n\n---\n\n".join(selected)

# --- Pipeline ---

def retrieve_hybrid(query: str, bm25, vector_index, cross_encoder) -> Tuple[str, List[Scored]]:
    bm25_hits = bm25.search(query, top_k=50)      # returns List[Scored]
    vec_hits = vector_index.search(query, top_k=50)

    candidates = rrf_fusion(bm25_hits, vec_hits, k=80)
    reranked = rerank_cross_encoder(query, candidates, cross_encoder)

    context = build_context(reranked, max_chunks=8, max_chars=8000)
    return context, reranked[:8]

포인트는 다음입니다.

  • 검색 점수 스케일이 달라도 RRF는 안정적입니다.
  • Rerank는 후보 수가 많아질수록 비용이 증가하므로, Union 후 60~100개 선에서 자르는 게 현실적입니다.
  • 컨텍스트는 6~12개 청크 정도로 제한하는 편이 답변 안정성이 좋습니다.

프롬프트에서 환각을 더 줄이는 최소 장치

검색을 잘해도, 모델이 컨텍스트를 무시하면 환각이 나옵니다. 생성 단계에서는 아래 3가지를 권장합니다.

  1. 근거 기반 답변 강제
  • "제공된 컨텍스트에 없는 내용은 모른다고 말하라"를 명시
  1. 인용 포맷 강제
  • 문장 끝에 [source:chunk_id] 같은 형태로 출처를 붙이게 하면, 모델이 근거를 찾으려는 압력이 생깁니다.
  1. 충돌 감지 지시
  • 컨텍스트가 상충하면 "상충"이라고 말하고 조건을 나눠 답하게 합니다.

운영에서 자주 터지는 함정 6가지

1) 청크가 너무 큼 또는 너무 작음

  • 너무 크면: Rerank가 "정답 문장"을 찾기 어렵고, 컨텍스트 낭비가 큽니다.
  • 너무 작으면: 문맥이 끊겨서 오해를 부릅니다.

권장 출발점:

  • 기술 문서: 300~800 토큰
  • FAQ/가이드: 500~1200 토큰
  • 코드 중심 문서: 함수 단위로 분리 + 주변 설명 포함

2) 메타데이터 필터링이 없음

버전, 제품군, 언어 같은 필터는 환각을 줄이는 지름길입니다.

예:

  • product = "k8s"
  • version >= 1.27
  • lang = "ko"

검색 전에 필터링하면 Rerank 부담도 줄어듭니다.

3) 최신 문서와 구 문서가 섞임

정책이 바뀐 도메인에서 가장 흔한 환각 원인입니다.

해결:

  • 문서에 published_at을 넣고 최신 가중치 부여
  • "최신 우선" 컬렉션과 "아카이브" 컬렉션 분리

4) Rerank가 "정답"이 아니라 "키워드"를 고르는 문제

cross-encoder도 학습 데이터에 따라 키워드 매칭처럼 동작할 수 있습니다.

해결:

  • Rerank 입력에 문서 제목, 섹션 헤더를 함께 포함
  • 쿼리 리라이트로 질문을 더 명확히

5) 모델 호출 실패나 레이트리밋으로 Rerank가 스킵됨

Rerank가 빠지면 품질이 급락하는 경우가 많습니다. 운영에서는 재시도와 백오프가 필수입니다.

6) 관측 지표가 없음: 환각을 "느낌"으로만 판단

최소한 아래는 로그로 남겨야 합니다.

  • BM25 상위 10개 문서 ID
  • Vector 상위 10개 문서 ID
  • Fusion 후 후보 수
  • Rerank 상위 N개 점수 분포
  • 최종 컨텍스트에 포함된 chunk ID
  • 답변에 포함된 인용 ID

이게 없으면, 품질이 나빠졌을 때 원인이 "임베딩"인지 "색인"인지 "Rerank"인지 분해가 안 됩니다.

품질 평가: 오프라인과 온라인을 분리하라

오프라인 평가(재현성)

  • 질의-정답-근거 문서 세트를 100~500개만 만들어도 효과가 큽니다.
  • 지표는 단순하게 시작하세요.
    • Recall@k (정답 근거가 후보에 들어왔는가)
    • MRR 또는 nDCG (정답 근거가 상위에 왔는가)

하이브리드의 목표는 Recall@k를 올리는 것이고, Rerank의 목표는 MRR을 올리는 것입니다.

온라인 평가(현실성)

  • 사용자 피드백(좋아요/싫어요)
  • "근거가 도움이 되었는가" 체크
  • 실패 케이스 자동 수집

운영에서는 장애 대응처럼 원인을 빨리 좁혀야 합니다. 이런 접근은 인프라/배포 트러블슈팅과도 결이 비슷합니다.

권장 아키텍처 요약

  • 저장: 문서 원문 + 청크 + 메타데이터(버전, 날짜, 제품, 권한)
  • 인덱스: BM25 인덱스 + 벡터 인덱스 동시 운영
  • 검색: Union 또는 RRF로 후보 풀 구성
  • 정렬: cross-encoder Rerank로 상위 컨텍스트 확정
  • 생성: 근거 인용 강제 + 상충 감지
  • 관측: 검색 결과, Rerank 점수, 인용 ID를 로그화

마무리: 환각을 줄이는 가장 싼 방법은 Rerank다

RAG 환각을 줄이려면 "더 큰 모델"보다 먼저 검색 품질과 컨텍스트 선택을 의심해야 합니다. 하이브리드 검색으로 Recall을 확보하고, Rerank로 Precision을 올리면 다음 변화가 바로 보입니다.

  • 엉뚱한 문서를 근거로 답하는 빈도 감소
  • "비슷하지만 다른" 문서로 인한 정책/버전 혼동 감소
  • 컨텍스트가 짧아져 답변이 더 일관됨

다음 단계로는 (1) 쿼리 라우팅, (2) 메타데이터 기반 필터링 고도화, (3) 인용 기반 자동 채점까지 확장하면 운영 품질이 한 단계 더 올라갑니다.