- Published on
RAG 리랭커로 환각 줄이는 실전 튜닝 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서치 기반 RAG를 운영하다 보면 “검색은 했는데 답이 이상하다”는 피드백을 자주 받습니다. 로그를 까보면 대개 원인이 명확합니다. 리트리버가 뽑아온 문서 중 상위 몇 개가 질문과 미묘하게 어긋나거나, 상위 문서가 맞더라도 컨텍스트에 섞여 들어간 노이즈가 LLM의 추론을 오염시키는 경우가 많습니다.
이때 가장 비용 대비 효과가 큰 개선책이 리랭커(reranker) 입니다. 핵심은 간단합니다.
- 1단계: 빠른 리트리버로 후보
top_k를 넉넉히 뽑기 - 2단계: 느리지만 정확한 리랭커로 후보를 재정렬하고
top_n만 컨텍스트로 넣기
이 글은 “리랭커를 붙였더니 점수만 늘고 환각은 그대로” 같은 시행착오를 줄이기 위해, 실제 운영에서 먹히는 튜닝 포인트를 중심으로 정리합니다.
모델 서빙/롤백 관점에서 리랭커를 독립 서비스로 운영한다면, 핫스왑 배포 전략도 같이 고민해야 합니다. 관련해서는 Triton Inference Server 모델 핫스왑 배포·롤백 실전도 함께 참고하면 좋습니다.
왜 리랭커가 환각을 줄이나
RAG 환각은 보통 아래 중 하나로 발생합니다.
- 컨텍스트 미스매치: 질문과 관련 없는 문서가 상위에 섞임
- 근거 부족: 관련 문서가 있긴 하지만 답을 만들 만큼 결정적 근거가 없음
- 컨텍스트 오염: 일부 문서가 맞지만, 다른 문서가 상충/노이즈를 만들어 LLM이 그럴듯한 결론으로 도약
리랭커는 특히 1, 3을 강하게 줄입니다.
- 리트리버(dual-encoder)는 “대충 비슷한 의미”를 넓게 잡는 데 강함
- 리랭커(cross-encoder)는 “질문-문서 쌍을 같이 읽고” 정밀하게 판단하는 데 강함
즉, 후보는 넓게, 채택은 엄격하게라는 정책을 구현하는 도구가 리랭커입니다.
리랭커 구조: 후보 생성과 재정렬의 역할 분리
권장 파이프라인은 다음 형태입니다.
- Query rewrite(선택): 사용자 질문을 검색 친화적으로 정규화
- Retriever: 벡터 검색으로 후보
top_k확보(예: 50~200) - Reranker: 후보를 재정렬하고
top_n만 선택(예: 3~10) - Context builder: 중복 제거, 섹션 스니펫화, 토큰 예산에 맞게 압축
- Generator: 답변 생성 + 인용/근거 포함
여기서 환각을 줄이는 핵심 레버는 3, 4입니다.
top_k를 늘리면 recall이 올라가지만 노이즈도 증가- 리랭커가 노이즈를 걸러
top_n을 정교하게 만들면 precision이 올라가고 환각이 감소
어떤 리랭커를 써야 하나: cross-encoder vs LLM-as-reranker
1) Cross-encoder 리랭커(권장)
질문과 문서를 함께 넣고 relevance score를 내는 모델입니다. 장점은 일관된 점수 체계, 단점은 후보 수만큼 추론 비용이 든다는 점입니다.
- 장점: 빠르고 예측 가능, 운영 최적화 쉬움
- 단점:
top_k가 커질수록 비용 선형 증가
2) LLM-as-reranker
LLM에게 후보 목록을 주고 “관련도 순으로 정렬”을 시키는 방식입니다.
- 장점: 도메인 지식/추론이 필요한 리랭킹에 강할 수 있음
- 단점: 결과가 흔들리고, 토큰 비용이 크며, 프롬프트 품질에 민감
운영 안정성 관점에서는 cross-encoder를 1차 리랭커로 두고, LLM 기반은 “어려운 케이스만” 보조로 쓰는 구성이 무난합니다.
실전 튜닝 1: top_k와 top_n의 황금비
많은 팀이 top_k=5, top_n=5로 시작합니다. 그런데 이러면 리랭커의 의미가 약합니다. 리랭커는 “후보를 넓게 뽑은 뒤” 빛납니다.
추천 시작점:
top_k: 50 (문서 길이가 길거나 도메인이 넓으면 100)top_n: 5 (답변이 짧고 명확하면 3)
튜닝 규칙(경험칙):
- 환각이 많다:
top_n을 줄이거나, 리랭커 임계값을 올려 컨텍스트를 더 보수적으로 - 정답이 자주 빠진다:
top_k를 늘리고, 리랭커 임계값을 낮추거나top_n을 늘려 recall을 보강
중요한 점은 top_n을 늘리는 게 항상 좋은 게 아니라는 것입니다. 컨텍스트가 길어질수록 LLM은 “그럴듯한 연결”을 만들 여지가 커져 환각이 늘 수 있습니다.
실전 튜닝 2: 리랭커 점수 임계값(threshold)으로 보수적 생성
리랭커는 보통 score를 제공합니다. 이 score로 “컨텍스트 채택 여부”를 결정할 수 있습니다.
- score가 낮으면: 아예 컨텍스트를 넣지 않고 “근거 부족” 응답으로 전환
- score가 높으면: 정상 RAG
이 전환이 환각을 크게 줄입니다. 즉, 답을 못 하는 상황을 제품적으로 허용하면 환각은 눈에 띄게 줄어듭니다.
아래는 파이썬 의사코드 예시입니다.
from dataclasses import dataclass
@dataclass
class Doc:
id: str
text: str
@dataclass
class ScoredDoc:
doc: Doc
score: float
def build_context(query: str, docs: list[Doc], rerank_fn, top_k: int = 50, top_n: int = 5,
min_score: float = 0.35) -> list[ScoredDoc]:
# docs는 이미 retriever에서 top_k로 뽑혔다고 가정
scored: list[ScoredDoc] = []
for d in docs[:top_k]:
scored.append(ScoredDoc(doc=d, score=rerank_fn(query, d.text)))
scored.sort(key=lambda x: x.score, reverse=True)
picked = [x for x in scored[:top_n] if x.score >= min_score]
return picked
def answer(query: str, retrieved_docs: list[Doc], rerank_fn, llm_fn):
picked = build_context(query, retrieved_docs, rerank_fn)
if not picked:
return {
"type": "abstain",
"text": "제공된 문서에서 질문에 대한 근거를 찾지 못했습니다. 더 구체적인 조건이나 키워드를 알려주세요."
}
context = "\n\n".join([f"[doc:{x.doc.id}]\n{x.doc.text}" for x in picked])
prompt = f"질문: {query}\n\n근거 문서:\n{context}\n\n근거만 사용해 답변하고, 각 문장에 인용을 붙이세요."
return {"type": "rag", "text": llm_fn(prompt)}
min_score는 데이터에 따라 달라서 정답/오답 샘플로 calibration이 필요합니다. 하지만 “없으면 말하지 않기”를 강제하는 것만으로도 환각이 크게 줄어듭니다.
실전 튜닝 3: 청크 전략은 리랭커 전제에서 다시 잡아야 한다
리랭커를 붙이면 청크를 무작정 작게 쪼개는 전략이 오히려 손해가 될 수 있습니다.
- 청크가 너무 작으면: cross-encoder가 판단할 근거가 부족해 점수가 불안정
- 청크가 너무 크면: 관련 구간은 일부인데 노이즈가 많아져 점수가 낮아질 수 있음
권장 접근:
- 문서 구조가 있는 경우: 제목/섹션 단위로 자르기
- 구조가 없는 경우: 300~800 토큰 범위에서 실험
- 리랭커 입력에는 “섹션 제목 + 본문 일부” 같이 힌트를 포함
또 하나의 팁은 “후보 문서 전체를 리랭커에 넣지 말고, 검색된 스니펫 주변만 잘라 넣는 것”입니다. 이렇게 하면 리랭커가 실제로 판단해야 하는 구간이 선명해집니다.
실전 튜닝 4: 중복 제거와 상충 문서 처리로 컨텍스트 오염 줄이기
리랭커를 붙여도 환각이 남는 대표 케이스는 유사 문서가 여러 개 들어가 컨텍스트가 편향되거나, 버전이 다른 문서가 섞여 상충하는 경우입니다.
권장되는 컨텍스트 빌더 규칙:
- 동일 문서에서 뽑힌 청크는 최대 1~2개로 제한
- 같은 내용의 중복 청크는 cosine 유사도 기준으로 제거
- 버전/날짜 메타데이터가 있으면 최신 우선
- 상충 가능성이 높은 도메인(정책, 가격, 스펙)은 “출처 우선순위”를 명시
예시(중복 제거 의사코드):
import numpy as np
def dedup_by_embedding(scored_docs, embed_fn, sim_threshold: float = 0.92):
picked = []
picked_vecs = []
for sd in scored_docs:
v = embed_fn(sd.doc.text)
if not picked_vecs:
picked.append(sd)
picked_vecs.append(v)
continue
sims = [cosine(v, pv) for pv in picked_vecs]
if max(sims) < sim_threshold:
picked.append(sd)
picked_vecs.append(v)
return picked
def cosine(a, b):
a = np.array(a)
b = np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-12))
리랭커는 “질문-문서 관련도”는 잘 보지만, “컨텍스트 전체의 구성 품질”까지 자동으로 보장하진 않습니다. 운영에서 환각을 줄이려면 컨텍스트 빌더가 마지막 한 방입니다.
실전 튜닝 5: 리랭커 학습 없이도 되는 로그 기반 평가 지표
리랭커 도입 후 튜닝을 하려면 “좋아졌는지”를 측정해야 합니다. 정답 라벨이 없을 때도 다음 지표로 방향성을 잡을 수 있습니다.
- Answer abstain rate: 임계값 때문에 답변을 거절한 비율
- Citation coverage: 답변 문장 중 인용이 붙은 비율
- Top-1 dominance: 컨텍스트에서 1등 문서가 차지하는 비중(너무 높으면 편향)
- Contradiction flag: 컨텍스트 내 상충 표현(예: 날짜/버전 불일치) 탐지 빈도
특히 abstain rate는 제품 경험과 직결됩니다.
- 너무 낮다: 환각을 감수하고 다 답하는 모드
- 너무 높다: 안전하지만 “쓸모없다”는 평가를 받기 쉬움
현실적인 목표는 “환각 민감 도메인에서는 abstain을 올리고, 일반 도메인에서는 낮추는” 식의 질문 유형별 정책 분리입니다.
실전 튜닝 6: 리랭커를 붙였는데도 환각이 줄지 않는 흔한 원인
원인 1: 후보 top_k가 너무 작다
리랭커는 후보가 있어야 고릅니다. top_k가 5~10이면 리랭커가 할 일이 거의 없습니다.
원인 2: 리랭커 입력에 쿼리/문서 전처리가 맞지 않는다
예를 들어 문서에 표/코드/로그가 많으면, 리랭커가 읽기 어려운 형태로 들어가 점수가 흔들립니다.
- 표는 “키:값” 형태로 평탄화
- 코드 블록은 필요한 부분만 남기고 축약
- 로그는 헤더/핵심 라인만 추출
원인 3: 컨텍스트가 너무 길다
리랭커가 잘 골라도, 최종 컨텍스트가 길고 산만하면 LLM이 다른 방향으로 새기 쉽습니다. top_n을 줄이고, 청크를 “답에 필요한 구간” 위주로 압축하세요.
원인 4: 생성 프롬프트가 근거 준수를 강제하지 않는다
리랭커는 검색 품질을 올릴 뿐, LLM이 근거를 반드시 따르게 만들지는 않습니다.
프롬프트에 아래를 명시하세요.
- 근거 문서에 없는 내용은 “모른다”라고 답할 것
- 각 문장에 인용을 붙일 것
- 인용할 수 없으면 해당 문장을 삭제할 것
운영 팁: 리랭커는 별도 서비스로 분리하는 게 편하다
리랭커는 트래픽 패턴이 다릅니다.
- 리트리버는 QPS가 높고 지연에 민감
- 리랭커는 후보 수에 따라 비용이 커지고, 배치/병렬화 최적화가 중요
따라서 리랭커를 별도 서비스로 분리하면 다음이 쉬워집니다.
- 모델 교체/롤백
- 배치 추론으로 비용 절감
- 캐시(질문-문서 쌍 점수 캐시) 적용
모델 배포를 자주 바꾸는 팀이라면, 서빙 레이어에서 안전한 롤백 체계를 먼저 갖추는 게 전체 안정성에 도움이 됩니다. 관련 운영 전략은 Triton Inference Server 모델 핫스왑 배포·롤백 실전에서 자세히 다뤘습니다.
예시 구현: Node.js에서 리트리버 + 리랭커 파이프라인 뼈대
아래는 “리트리버로 후보를 가져오고, 리랭커 점수로 상위만 컨텍스트로 구성”하는 최소 뼈대입니다.
< 와 > 문자가 본문에 노출되면 MDX 빌드 에러가 날 수 있어, 타입/제네릭 표기는 피하고 단순화했습니다.
// pseudo-code
async function retrieve(query, topK) {
// vector DB 검색 결과라고 가정
// return [{ id, text, metadata } ...]
}
async function rerank(query, docs) {
// return [{ id, text, score } ...]
// score는 클수록 관련도가 높다고 가정
}
function buildContext(scoredDocs, topN, minScore) {
const picked = scoredDocs
.filter(d => d.score >= minScore)
.slice(0, topN);
return picked.map(d => `[doc:${d.id}]\n${d.text}`).join("\n\n");
}
async function ragAnswer({ query, topK = 50, topN = 5, minScore = 0.35 }) {
const candidates = await retrieve(query, topK);
const scored = await rerank(query, candidates);
scored.sort((a, b) => b.score - a.score);
const context = buildContext(scored, topN, minScore);
if (!context) {
return {
type: "abstain",
text: "근거 문서에서 답을 찾지 못했습니다. 질문을 더 구체화해 주세요."
};
}
const prompt = [
`질문: ${query}`,
"",
"근거 문서:",
context,
"",
"지침: 근거 문서에 있는 내용만 사용해 답하고, 각 문장 끝에 [doc:id] 형태로 인용을 붙이세요."
].join("\n");
const answer = await callLLM(prompt);
return { type: "rag", text: answer };
}
이 뼈대에 다음을 추가하면 운영 품질이 확 올라갑니다.
- 질문 정규화/리라이트
- 문서 스니펫 추출(전체가 아니라 관련 구간만)
- 중복 제거
- 상충 탐지
- 질문 유형별
minScore정책
마무리: 리랭커는 “정답률”보다 “보수성”을 설계하는 도구다
리랭커를 도입하면 검색 결과가 좋아지는 건 맞지만, 환각을 진짜로 줄이려면 리랭커 점수로 컨텍스트를 보수적으로 구성하고, 필요하면 답변 거절(abstain) 로 빠지는 제품 정책까지 함께 설계해야 합니다.
실전에서 바로 적용할 체크리스트는 다음입니다.
- 후보
top_k는 넉넉히, 채택top_n은 작게 시작 min_score임계값으로 근거 부족 시 답변을 멈추기- 청크 크기/구조를 리랭커 친화적으로 재설계
- 중복/상충을 컨텍스트 빌더에서 정리
- 프롬프트로 근거 준수를 강제하고 인용을 요구
이 5가지만 제대로 잡아도 “그럴듯한데 틀린 답”이 눈에 띄게 줄어드는 걸 체감할 수 있습니다.