Published on

RAG 리랭커로 환각 줄이는 실전 튜닝 가이드

Authors
Binance registration banner

서치 기반 RAG를 운영하다 보면 “검색은 했는데 답이 이상하다”는 피드백을 자주 받습니다. 로그를 까보면 대개 원인이 명확합니다. 리트리버가 뽑아온 문서 중 상위 몇 개가 질문과 미묘하게 어긋나거나, 상위 문서가 맞더라도 컨텍스트에 섞여 들어간 노이즈가 LLM의 추론을 오염시키는 경우가 많습니다.

이때 가장 비용 대비 효과가 큰 개선책이 리랭커(reranker) 입니다. 핵심은 간단합니다.

  • 1단계: 빠른 리트리버로 후보 top_k를 넉넉히 뽑기
  • 2단계: 느리지만 정확한 리랭커로 후보를 재정렬하고 top_n만 컨텍스트로 넣기

이 글은 “리랭커를 붙였더니 점수만 늘고 환각은 그대로” 같은 시행착오를 줄이기 위해, 실제 운영에서 먹히는 튜닝 포인트를 중심으로 정리합니다.

모델 서빙/롤백 관점에서 리랭커를 독립 서비스로 운영한다면, 핫스왑 배포 전략도 같이 고민해야 합니다. 관련해서는 Triton Inference Server 모델 핫스왑 배포·롤백 실전도 함께 참고하면 좋습니다.

왜 리랭커가 환각을 줄이나

RAG 환각은 보통 아래 중 하나로 발생합니다.

  1. 컨텍스트 미스매치: 질문과 관련 없는 문서가 상위에 섞임
  2. 근거 부족: 관련 문서가 있긴 하지만 답을 만들 만큼 결정적 근거가 없음
  3. 컨텍스트 오염: 일부 문서가 맞지만, 다른 문서가 상충/노이즈를 만들어 LLM이 그럴듯한 결론으로 도약

리랭커는 특히 1, 3을 강하게 줄입니다.

  • 리트리버(dual-encoder)는 “대충 비슷한 의미”를 넓게 잡는 데 강함
  • 리랭커(cross-encoder)는 “질문-문서 쌍을 같이 읽고” 정밀하게 판단하는 데 강함

즉, 후보는 넓게, 채택은 엄격하게라는 정책을 구현하는 도구가 리랭커입니다.

리랭커 구조: 후보 생성과 재정렬의 역할 분리

권장 파이프라인은 다음 형태입니다.

  1. Query rewrite(선택): 사용자 질문을 검색 친화적으로 정규화
  2. Retriever: 벡터 검색으로 후보 top_k 확보(예: 50~200)
  3. Reranker: 후보를 재정렬하고 top_n만 선택(예: 3~10)
  4. Context builder: 중복 제거, 섹션 스니펫화, 토큰 예산에 맞게 압축
  5. 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_ktop_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가지만 제대로 잡아도 “그럴듯한데 틀린 답”이 눈에 띄게 줄어드는 걸 체감할 수 있습니다.