Published on

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

Authors

서치 기반 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가지만 제대로 잡아도 “그럴듯한데 틀린 답”이 눈에 띄게 줄어드는 걸 체감할 수 있습니다.