Published on

RAG 환각 줄이기 - ColBERTv2+Rerank 최적화

Authors

RAG에서 환각을 줄이려면 “모델을 더 똑똑하게” 만드는 것보다, 검색 단계에서 틀린 근거가 들어오지 않게 막는 편이 훨씬 재현성 있고 비용 효율적입니다. 특히 초기 검색이 약하면 LLM은 그럴듯한 문장으로 빈칸을 채우는 경향이 있어, 결과적으로 “근거가 없는 정답 같은 답변”이 자주 발생합니다.

이 글에서는 ColBERTv2(후보 생성) + Cross-Encoder Rerank(정밀 재정렬) 조합을 중심으로, 환각을 체계적으로 줄이는 튜닝 포인트를 정리합니다. 핵심은 다음 3가지입니다.

  • 후보군을 “많이” 뽑되, 정답이 섞일 확률을 높이는 방식으로 뽑기
  • Rerank 점수로 답변 가능/불가능을 게이트하기
  • LLM에 넣는 컨텍스트를 “많이”가 아니라 정확하고 좁게 구성하기

관련해서 LLM 호출 비용과 실패 재시도 전략도 중요합니다. 운영 환경에서는 레이트리밋과 백오프가 품질만큼이나 장애율에 영향을 주므로, 필요하면 OpenAI API 429 재시도·백오프 패턴 실전 가이드도 함께 참고하세요.

환각의 원인: 검색 품질 문제를 먼저 의심하라

RAG 환각은 크게 세 부류로 나뉩니다.

  1. Retrieval miss: 정답 문서가 검색 후보에 없음
  2. Retrieval noise: 후보는 많지만 상위에 잡음 문서가 올라옴
  3. Context packing error: 좋은 문서가 있어도 chunk 선택/정렬/중복 제거가 나빠서 LLM이 엉뚱한 근거를 학습함

여기서 ColBERTv2와 Rerank는 1, 2를 직접 타격하고, 컨텍스트 구성 규칙은 3을 줄입니다.

왜 ColBERTv2인가: “후보를 넓게, 근거는 촘촘하게”

ColBERTv2는 문서와 질의를 토큰 단위로 임베딩하고, late interaction으로 매칭합니다. 실무적으로 중요한 장점은 다음입니다.

  • Recall에 강함: 단일 벡터(dense)보다 “부분 일치”를 잘 잡아 후보군에 정답이 들어올 확률이 높습니다.
  • 후보 생성에 적합: 최종 정답을 고르는 용도라기보다, rerank로 넘길 고품질 후보 pool을 만드는 역할에 강합니다.

다만 ColBERTv2만으로 top-k를 바로 컨텍스트로 쓰면, 상위에 잡음이 섞일 수 있습니다. 그래서 실전에서는 “ColBERTv2로 넓게 뽑고, Cross-Encoder로 정밀하게 줄인다”가 정석입니다.

기본 파이프라인: Retrieve - Rerank - Gate - Answer

권장 흐름은 아래와 같습니다.

  1. ColBERTv2로 top-K 후보 추출 (예: K=50 또는 K=100)
  2. Cross-Encoder reranker로 top-k 재정렬 (예: k=5 또는 k=10)
  3. rerank 점수 기반으로 답변 게이트 적용
  4. 컨텍스트를 구성해 LLM에 질의

여기서 환각을 줄이는 핵심은 3번입니다. “근거가 약하면 대답하지 않기”가 시스템적으로 가능해집니다.

구현 예시: ColBERTv2 후보 + Cross-Encoder Rerank

아래 코드는 개념을 보여주는 예시입니다. 실제 라이브러리/모델은 조직 환경에 맞게 바꾸면 됩니다.

from dataclasses import dataclass
from typing import List, Tuple

@dataclass
class Doc:
    doc_id: str
    text: str

class ColBERTv2Retriever:
    def __init__(self, index_path: str):
        self.index_path = index_path

    def search(self, query: str, top_k: int) -> List[Tuple[Doc, float]]:
        # TODO: 실제 ColBERTv2 검색 결과로 교체
        raise NotImplementedError

class CrossEncoderReranker:
    def __init__(self, model_name: str):
        self.model_name = model_name

    def rerank(self, query: str, docs: List[Doc]) -> List[Tuple[Doc, float]]:
        # TODO: (query, doc) pair 점수화로 교체
        raise NotImplementedError


def rag_retrieve_rerank(
    query: str,
    retriever: ColBERTv2Retriever,
    reranker: CrossEncoderReranker,
    K: int = 80,
    k: int = 8,
) -> List[Tuple[Doc, float]]:
    candidates = retriever.search(query, top_k=K)
    candidate_docs = [d for d, _ in candidates]

    reranked = reranker.rerank(query, candidate_docs)
    reranked = sorted(reranked, key=lambda x: x[1], reverse=True)
    return reranked[:k]

이 다음 단계에서, rerank 점수 분포를 보고 “답변해도 되는지”를 결정합니다.

Rerank 점수로 게이트 세우기: 환각을 줄이는 가장 강력한 레버

많은 팀이 rerank를 붙여놓고도 환각이 줄지 않는 이유는, rerank 결과를 그냥 top-k 컨텍스트로만 사용하기 때문입니다. 중요한 건 “점수 기반 거절”입니다.

게이트 전략 1: 절대 임계값(threshold)

  • max_score가 임계값 미만이면 “근거 부족”으로 답변 거절
def should_answer(reranked: List[Tuple[Doc, float]], threshold: float) -> bool:
    if not reranked:
        return False
    best_score = reranked[0][1]
    return best_score >= threshold

임계값은 데이터셋에 따라 달라서, 반드시 오프라인 평가로 잡아야 합니다.

게이트 전략 2: 상대 마진(margin)

  • 1등과 2등 점수 차이가 작으면 애매한 매칭일 수 있음
def should_answer_with_margin(
    reranked: List[Tuple[Doc, float]],
    threshold: float,
    margin: float,
) -> bool:
    if len(reranked) == 0:
        return False
    if reranked[0][1] < threshold:
        return False
    if len(reranked) == 1:
        return True
    return (reranked[0][1] - reranked[1][1]) >= margin

실무에서는 thresholdmargin을 함께 쓰면 “그럴듯한 오답”이 크게 줄어듭니다.

게이트 전략 3: 엔테일먼트(자연어 추론) 기반 검증

rerank 이후 top-1 또는 top-3 문서만으로 “이 문서가 질문에 답을 포함하는가”를 NLI 스타일로 한 번 더 확인하는 방식도 있습니다. 비용은 늘지만, 고위험 도메인에서는 효과가 큽니다.

컨텍스트 구성 최적화: top-k를 그대로 넣지 마라

rerank가 좋아도, 컨텍스트를 잘못 넣으면 환각이 다시 올라옵니다.

1) chunk는 “길이”보다 “자족성”

  • 문장 몇 개만 떼면 정의/조건/예외가 빠져 오해가 생깁니다.
  • 권장: 의미 단위로 끊고, 최소한의 앞뒤 문맥을 포함

2) 중복 제거는 필수

유사 chunk를 여러 개 넣으면 LLM이 특정 표현을 과신하거나, 서로 다른 버전의 문서를 섞어 답을 왜곡할 수 있습니다.

def dedup_by_jaccard(chunks: List[str], threshold: float = 0.85) -> List[str]:
    def jaccard(a: set, b: set) -> float:
        if not a and not b:
            return 1.0
        return len(a & b) / max(1, len(a | b))

    kept = []
    kept_sets = []
    for c in chunks:
        s = set(c.lower().split())
        if all(jaccard(s, ks) < threshold for ks in kept_sets):
            kept.append(c)
            kept_sets.append(s)
    return kept

3) “근거 우선” 정렬

LLM은 앞부분 컨텍스트에 더 강하게 끌립니다. rerank 상위 문서를 먼저 배치하고, 각 chunk 앞에 출처 메타를 붙여 추적 가능하게 만드세요.

평가: 환각을 수치로 다루는 방법

환각을 줄이는 작업은 감으로 하면 끝이 없습니다. 최소한 아래를 로그로 남기고, 오프라인 평가 루프를 만드세요.

  • Recall@K (ColBERTv2 후보에 정답 문서가 들어왔는가)
  • MRR@k 또는 nDCG@k (rerank가 정답을 위로 끌어올렸는가)
  • Abstain rate (게이트가 얼마나 자주 거절하는가)
  • Answer accuracy when answered (대답했을 때만의 정답률)

특히 운영에서는 “정답률” 하나로 보면 위험합니다. 거절을 늘리면 정답률이 올라가 보이기 때문입니다. 그래서 coverage와 같이 봐야 합니다.

실전 튜닝 체크리스트

아래 순서대로 튜닝하면 시행착오가 줄어듭니다.

1) ColBERTv2의 K를 먼저 키워라

  • K=20에서 시작하면 recall miss가 잦습니다.
  • 비용이 허용되면 K=50 또는 K=100까지 올리고 rerank로 줄이세요.

2) rerank는 k를 너무 크게 잡지 마라

  • top-k를 20개씩 넣으면 컨텍스트가 넓어져 오히려 환각이 늘 수 있습니다.
  • 보통 k=5에서 시작해, 부족하면 k=8 정도로 확장하는 편이 안전합니다.

3) 게이트 임계값은 도메인별로 분리

  • FAQ, 정책 문서, 기술 문서의 점수 분포가 다릅니다.
  • 최소한 “카테고리별 threshold”를 따로 두는 것을 권장합니다.

4) 질문 리라이트는 신중하게

질문을 LLM으로 리라이트하면 retrieval이 좋아질 때도 있지만, 반대로 원문의 제약 조건이 사라져 환각이 늘기도 합니다. 리라이트를 한다면 “원 질문도 함께 검색”하는 듀얼 쿼리를 고려하세요.

추론 프롬프트 설계 측면에서는, 불필요한 장문 추론을 강제하기보다 결과를 구조화해 검증 가능성을 높이는 패턴이 유용합니다. 관련해서 Chain-of-Thought 없이 추론 유도하는 5패턴을 참고하면, “근거 인용 + 짧은 결론” 형태로 안정화하는 데 도움이 됩니다.

운영 팁: 비용, 지연시간, 장애를 함께 다루기

ColBERTv2+rerank는 품질이 좋은 대신, 구성에 따라 지연과 비용이 늘 수 있습니다.

  • rerank는 요청당 pair 수가 곧 비용입니다. K를 키우면 pair가 폭증하므로, 1차 후보는 넓게 뽑되 rerank 입력은 상한을 둔다 같은 정책이 필요합니다.
  • LLM 호출은 레이트리밋과 타임아웃이 빈번합니다. 재시도는 무작정 하면 중복 과금과 지연을 부릅니다. 백오프와 지터를 포함한 패턴을 적용하세요. 자세한 구현은 OpenAI API 429 재시도·백오프 패턴 실전 가이드에 정리해두었습니다.

또한 로컬 LLM을 붙이는 경우, 컨텍스트 길이 증가가 OOM으로 직결됩니다. 검색 품질을 올려 컨텍스트를 줄이는 것이 메모리 튜닝만큼 중요합니다. 필요하면 Transformers 로컬 LLM OOM - 4bit+KV 캐시 튜닝도 함께 보세요.

권장 레퍼런스 설정(출발점)

팀/도메인마다 다르지만, 출발점으로 아래 조합을 많이 씁니다.

  • ColBERTv2 K=80
  • Cross-Encoder rerank k=8
  • 게이트: threshold + margin
  • 컨텍스트: 상위 3~5개 chunk, 중복 제거, 출처 메타 포함
  • 출력: “근거 인용”을 강제하고, 근거 없으면 거절

마무리

RAG 환각을 줄이는 가장 확실한 방법은 “LLM이 상상할 여지”를 줄이는 것입니다. ColBERTv2로 recall을 확보하고, Cross-Encoder rerank로 precision을 끌어올린 뒤, rerank 점수 기반 게이트와 컨텍스트 구성 규칙을 결합하면 환각을 구조적으로 통제할 수 있습니다.

다음 단계로는 (1) 도메인별 임계값 자동 튜닝, (2) 실패 케이스 클러스터링으로 인덱싱/청킹 규칙 개선, (3) 엔테일먼트 검증 레이어 추가를 통해 “대답 품질”을 한 단계 더 안정화하는 것을 추천합니다.