- Published on
RAG 환각 줄이기 - 하이브리드 검색과 Rerank
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서치 기반 RAG는 "모델이 아는 척" 하는 환각을 줄이는 대표적인 접근이지만, 실제 운영에서는 여전히 엉뚱한 답이 나옵니다. 이유는 간단합니다. 모델이 틀린 게 아니라, 검색 단계에서 잘못된 근거를 가져오거나(Recall 문제), 가져온 근거 중 중요한 것을 못 고르거나(Precision 문제), 프롬프트에서 근거 사용을 강제하지 못하기 때문입니다.
이 글은 그중에서도 가장 ROI가 큰 조합인 하이브리드 검색(lexical + vector) + Rerank를 중심으로, RAG 환각을 "체감되게" 줄이는 실전 파이프라인을 다룹니다.
RAG 환각이 생기는 지점: 검색이 70%다
RAG 파이프라인을 단순화하면 아래 흐름입니다.
- 쿼리 전처리(정규화, 확장, 라우팅)
- 후보 검색(보통
top_k20~200) - Rerank로 재정렬(보통 5~30개로 축소)
- 컨텍스트 구성(청크 병합, 중복 제거, 길이 제한)
- 생성(근거 인용, 답변 형식 강제)
환각의 전형적인 패턴은 다음 둘 중 하나입니다.
- 관련 문서를 못 찾음: 벡터 검색만 쓰면 키워드가 중요한 질의에서 미스가 납니다. 예: 에러 코드, 설정 키, 함수명.
- 비슷해 보이는 문서를 가져옴: 임베딩 유사도는 "의미가 비슷한" 문서를 잘 찾지만, "정답 근거"를 보장하진 않습니다. 특히 제품 버전, 날짜, 정책 변경이 있는 도메인에서 치명적입니다.
그래서 운영 RAG에서는 보통 이렇게 갑니다.
- 후보는 넓게: 하이브리드로 Recall 확보
- 최종은 좁게: Rerank로 Precision 확보
하이브리드 검색: lexical과 vector를 같이 쓰는 이유
하이브리드는 보통 아래 두 스코어를 결합합니다.
- Lexical(BM25 계열): 토큰이 정확히 일치할수록 강함. 에러 코드, 옵션명, 고유명사에 강함.
- Vector(embedding ANN): 표현이 달라도 의미가 비슷하면 강함. 자연어 질문, 패러프레이즈에 강함.
둘 중 하나만 쓰면 구멍이 생깁니다.
- BM25만 쓰면: "의미는 같은데 표현이 다른" 질문에 약함
- Vector만 쓰면: "정확히 그 문자열"이 중요한 질의에 약함
결합 방식 3가지
- Union 후 Rerank
- BM25
top_k1+ Vectortop_k2를 합쳐서 Rerank - 가장 구현이 쉽고 효과가 안정적
- Weighted score fusion
- 정규화된 점수에 가중치를 줘서 합산
- 튜닝이 필요하지만 Rerank 비용을 줄일 수 있음
- 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가지를 권장합니다.
- 근거 기반 답변 강제
- "제공된 컨텍스트에 없는 내용은 모른다고 말하라"를 명시
- 인용 포맷 강제
- 문장 끝에
[source:chunk_id]같은 형태로 출처를 붙이게 하면, 모델이 근거를 찾으려는 압력이 생깁니다.
- 충돌 감지 지시
- 컨텍스트가 상충하면 "상충"이라고 말하고 조건을 나눠 답하게 합니다.
운영에서 자주 터지는 함정 6가지
1) 청크가 너무 큼 또는 너무 작음
- 너무 크면: Rerank가 "정답 문장"을 찾기 어렵고, 컨텍스트 낭비가 큽니다.
- 너무 작으면: 문맥이 끊겨서 오해를 부릅니다.
권장 출발점:
- 기술 문서: 300~800 토큰
- FAQ/가이드: 500~1200 토큰
- 코드 중심 문서: 함수 단위로 분리 + 주변 설명 포함
2) 메타데이터 필터링이 없음
버전, 제품군, 언어 같은 필터는 환각을 줄이는 지름길입니다.
예:
product = "k8s"version >= 1.27lang = "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) 인용 기반 자동 채점까지 확장하면 운영 품질이 한 단계 더 올라갑니다.