Published on

RAG 환각 줄이기 - 하이브리드 검색+재랭킹 튜닝

Authors

서치 기반 RAG에서 환각(hallucination)은 흔히 “LLM이 거짓말했다”로 끝나지만, 실제로는 검색(retrieval)과 컨텍스트 구성(prompting) 단계의 작은 선택들이 누적되어 발생하는 경우가 많습니다. 특히 벡터 검색만 단독으로 쓰거나, BM25만 쓰거나, 혹은 재랭킹 없이 상위 k개를 그대로 넣는 방식은 “그럴듯하지만 틀린 답”을 만들기 쉬운 조건입니다.

이 글에서는 환각을 줄이기 위한 실전 조합인 하이브리드 검색(lexical + dense) + 재랭킹(rerank) + 튜닝 루프를 중심으로, 어떤 파라미터를 어떻게 만져야 체감 품질이 오르는지 정리합니다.

운영에서 RAG는 결국 분산 시스템입니다. 캐시/동시성/데이터 신선도 이슈가 겹치면 “검색 결과가 왜 이래?”가 됩니다. Next.js 캐시가 꼬이는 문제를 겪어봤다면, RAG 캐시도 비슷한 유형의 사고가 납니다. 참고: Next.js 14 App Router RSC 캐시 꼬임 해결

RAG 환각의 대표 원인 5가지

1) 관련 문서가 아예 검색되지 않음(Recall 부족)

  • 벡터 임베딩이 도메인 용어를 잘 못 담음
  • 청크가 너무 길거나 짧아 의미가 흐려짐
  • 필터(권한/테넌트/시간)가 과하게 걸려 후보가 사라짐

2) 검색은 됐는데 상위에 못 올라옴(Ranking 실패)

  • dense는 의미 유사도에 강하지만, 숫자/코드/정확한 키워드 매칭에 약할 때가 있음
  • BM25는 키워드엔 강하지만 동의어/문맥에 약함

3) 상위 k를 그대로 넣어 컨텍스트가 오염됨(Context pollution)

  • 상위 10개 중 2개만 진짜 정답인데, 나머지 8개가 모델을 흔듦
  • 서로 다른 버전의 문서(구버전/신버전)가 섞여 충돌

4) 질문이 모호한데 모델이 “추정”해 답함

  • “정책”인지 “구현”인지, “현재 버전”인지 “과거”인지 불명확
  • 이때는 검색 이전에 질문 재작성(query rewrite)이나 clarifying question이 필요

5) 인용/근거 강제가 없어 모델이 자유롭게 서술함

  • “모르면 모른다고 말해라”만으로는 부족
  • 답변 형식에 근거 스니펫/출처를 포함시키는 것이 중요

하이브리드 검색이 환각을 줄이는 이유

하이브리드 검색은 보통 아래 두 신호를 합칩니다.

  • Lexical(BM25 등): 키워드 정확도, 숫자/식별자/에러코드에 강함
  • Dense(Vector): 의미 유사도, 표현 변형/동의어에 강함

RAG에서 환각을 줄이려면, “후보 문서가 올바르게 들어오는 것(Recall)”과 “그중 정답이 위로 올라오는 것(Precision)” 둘 다 잡아야 합니다. 하이브리드는 후보 풀을 넓히면서도 재랭킹으로 정밀도를 회복하기 좋은 구조입니다.

실전 아키텍처(권장)

  1. Query normalize + optional rewrite
  2. BM25로 k1개 후보
  3. Vector로 k2개 후보
  4. union 후 중복 제거
  5. Cross-encoder 재랭킹으로 top k_final
  6. 컨텍스트 압축(optional) 후 LLM 생성

점수 결합 전략: 단순 가중합부터 시작

BM25 점수와 벡터 유사도는 스케일이 달라 그대로 더하면 망합니다. 실무에서는 아래 중 하나로 시작합니다.

  • 각 점수를 0~1로 정규화 후 가중합
  • Reciprocal Rank Fusion(RRF): 점수 스케일 문제를 회피

RRF 예시(추천: 튜닝 난이도 낮음)

RRF는 각 랭커의 순위만 사용합니다.

score(d) = sum(1 / (k + rank_i(d)))

여기서 k는 보통 60 전후를 많이 씁니다.

from collections import defaultdict

def rrf_fusion(bm25_ranked_ids, dense_ranked_ids, k=60):
    score = defaultdict(float)
    for r, doc_id in enumerate(bm25_ranked_ids, start=1):
        score[doc_id] += 1.0 / (k + r)
    for r, doc_id in enumerate(dense_ranked_ids, start=1):
        score[doc_id] += 1.0 / (k + r)
    return sorted(score.keys(), key=lambda x: score[x], reverse=True)
  • BM25가 잡아낸 “정확 키워드 문서”와 dense가 잡아낸 “의미 유사 문서”가 상위로 모입니다.
  • 이후 재랭킹이 “진짜 답”을 top으로 끌어올릴 확률이 높아집니다.

재랭킹이 환각을 줄이는 핵심 포인트

하이브리드로 후보를 잘 모아도, 최종 top k_final이 틀리면 LLM은 그럴듯하게 답을 지어냅니다. 이때 cross-encoder 재랭커가 강력합니다.

  • 입력: (query, passage)
  • 출력: “이 passage가 query에 답이 되는가” 점수

Dense retrieval은 보통 bi-encoder라서 query와 passage를 독립적으로 임베딩하지만, cross-encoder는 둘을 함께 보고 정밀 판단을 합니다. 즉, Precision을 올려 환각을 줄이는 단계입니다.

재랭킹 튜닝에서 가장 중요한 3가지

1) 재랭킹 대상 후보 수 k_candidates

  • 너무 작으면 재랭커가 고를 게 없음(Recall 손실)
  • 너무 크면 비용/지연 증가

권장 시작점:

  • BM25 50 + dense 50 union 후 중복 제거
  • 재랭커 입력 80~120개
  • 최종 컨텍스트 top 4~8개

2) passage 길이(토큰) 제한

재랭커는 긴 문서에서 핵심을 못 잡거나 비용이 커질 수 있습니다.

  • passage를 200~400 토큰 정도로 맞추거나
  • 청크 단위를 “의미 단락” 중심으로 재설계

3) 도메인에 맞는 negative 샘플

재랭커 성능은 “헷갈리기 쉬운 오답”을 잘 구분하는지에 달립니다.

  • 같은 제품/비슷한 기능 문서를 hard negative로 넣어야 함
  • 단순 랜덤 negative는 효과가 제한적

컨텍스트 구성: top-k를 줄이고, 충돌을 제거하라

재랭킹까지 했는데도 환각이 난다면, LLM 입력 컨텍스트가 오염됐을 가능성이 큽니다.

체크리스트

  • 중복 제거: 동일 문서의 인접 청크가 3개씩 들어가면 다양성이 죽음
  • 버전 충돌 제거: “v1 문서”와 “v2 문서”가 같이 들어가면 답이 섞임
  • 정책/가이드/레퍼런스 구분: 질문 타입에 맞는 문서만 남김

간단한 규칙 기반 필터 예시

def filter_context(chunks, max_chunks=6):
    # chunks: list of dict: {"doc_id", "version", "type", "text", "score"}
    seen_doc = set()
    out = []

    for c in sorted(chunks, key=lambda x: x["score"], reverse=True):
        if c["doc_id"] in seen_doc:
            continue
        if c.get("version") == "deprecated":
            continue
        out.append(c)
        seen_doc.add(c["doc_id"])
        if len(out) >= max_chunks:
            break
    return out

이런 단순 규칙만으로도 “서로 다른 문서가 섞여 생기는 환각”이 눈에 띄게 줄어듭니다.

프롬프트: 근거 기반 답변을 강제하는 최소 장치

검색/재랭킹이 좋아도, 모델이 근거 없이 확장 서술하면 환각이 납니다. 프롬프트는 길게 쓰기보다 출력 제약을 명확히 하는 게 효과적입니다.

시스템 지침:
- 제공된 컨텍스트에 없는 내용은 추측하지 말고 "모르겠습니다"라고 답하세요.
- 답변에는 반드시 근거 스니펫을 1~3개 인용하고, 각 인용의 출처 문서 ID를 함께 적으세요.

유저 질문:
{question}

컨텍스트:
{context}

추가로, JSON 출력 등을 강제한다면 SDK 레벨에서 파싱 오류가 운영 장애로 이어질 수 있습니다. 응답 형식 강제와 에러 처리 패턴은 한 번 정리해두는 게 좋습니다. 참고: Python OpenAI SDK 400 invalid_json 원인과 해결

튜닝 루프: 오프라인 평가 없이 감으로 하면 끝이 안 난다

환각을 줄이는 튜닝은 “좋아진 것 같다”가 아니라, 최소한의 오프라인 지표가 필요합니다.

1) 데이터셋 만들기(작게 시작)

  • 질문 50~200개
  • 정답 근거가 되는 문서/청크를 라벨링(가능하면)
  • 자주 터지는 환각 케이스를 의도적으로 포함

2) 지표

  • Retrieval recall@k: 정답 문서가 top k 후보에 들어왔는가
  • Rerank MRR: 정답이 몇 등으로 올라왔는가
  • Answer grounded rate: 답변 문장 중 컨텍스트로 뒷받침되는 비율(샘플링 평가)

3) 실험 우선순위(효율 좋은 순)

  1. 청크 전략(길이/오버랩/헤더 포함)
  2. 하이브리드 후보 수 k1, k2
  3. RRF의 k 또는 가중합 가중치
  4. 재랭커 입력 수 k_candidates와 최종 k_final
  5. 컨텍스트 필터(버전/권한/문서 타입)
  6. 프롬프트 출력 제약

운영 팁: 지연시간과 비용을 관리하는 방법

하이브리드 + 재랭킹은 품질이 좋아지는 대신 비용과 지연이 늘기 쉽습니다.

  • 캐시: 동일 쿼리/유사 쿼리의 검색 결과 캐시
  • 2단계 재랭킹: 1차는 가벼운 모델, 2차는 cross-encoder로 top 30만
  • 동적 k: 짧고 명확한 질문은 k_candidates를 줄이고, 모호한 질문은 늘림
  • 타임아웃 전략: 재랭킹 타임아웃 시 하이브리드 top-k로 degrade

분산 환경에서 타임아웃/재시도/권한 문제는 항상 같이 옵니다. 예를 들어 EKS에서 인증/권한이 꼬이면 외부 검색/LLM 호출이 간헐 실패하고, 그 결과 “컨텍스트가 비어 환각이 증가”하는 형태로 나타나기도 합니다. 참고: EKS Pod에서 AWS ECR 403 AccessDenied 해결

추천 시작 설정(바로 적용 가능한 프리셋)

  • 청크: 300500 토큰, 오버랩 5080 토큰, 제목/섹션 헤더 포함
  • 후보:
    • BM25 top 50
    • dense top 50
    • union 후 최대 80~120개 재랭킹
  • 재랭킹:
    • top 6개를 컨텍스트로 사용
  • 컨텍스트 필터:
    • 문서 중복 제거
    • deprecated 버전 제외
    • 문서 타입 우선순위(예: 공식 문서 gt 블로그)
  • 프롬프트:
    • 근거 인용 강제
    • 컨텍스트 밖 추측 금지

마무리

RAG 환각을 줄이는 가장 확실한 방법은 “모델을 더 큰 것으로 바꾸기”가 아니라, **검색 후보를 넓히되(하이브리드), 최종 컨텍스트를 더 정확하게 만들고(재랭킹), 오염을 제거하는 것(필터링/청크/버전 관리)**입니다.

하이브리드 검색은 Recall을, 재랭킹은 Precision을, 컨텍스트 튜닝은 Grounding을 올립니다. 이 3가지를 작은 오프라인 평가 루프로 돌리면, 환각은 체감적으로 줄고 답변 일관성은 올라갑니다.