Published on

LlamaIndex+Qdrant RAG 재랭킹으로 환각 30%↓

Authors

RAG(Retrieval-Augmented Generation)를 붙였는데도 답이 그럴듯하게 틀리는 경우가 생각보다 자주 나옵니다. 특히 벡터 검색 결과가 “주제는 비슷하지만 질문의 핵심을 직접 답하지 못하는 문서”로 채워지면, LLM은 그 빈틈을 자연스럽게 메우며 환각을 만듭니다.

이 글에서는 LlamaIndex + Qdrant 조합에서 재랭킹(reranking) 을 추가해, 실제로 “정답 근거가 되는 청크”가 컨텍스트 상단에 오도록 만들고, 그 결과 환각을 체감상 30% 수준으로 낮추는 접근을 설명합니다. 핵심은 단순히 재랭커를 붙이는 게 아니라, 검색 단계의 후보군(recall)과 재랭킹 단계의 정밀도(precision)를 분리해서 튜닝하는 것입니다.

관련해서 RAG 환각을 줄이는 더 넓은 전략(하이브리드 검색, 재랭커 튜닝)은 아래 글도 같이 보면 이해가 빨라집니다.

왜 Qdrant 벡터 검색만으로는 부족한가

Qdrant의 ANN(Approximate Nearest Neighbor) 벡터 검색은 빠르고 실용적이지만, 다음 한계가 있습니다.

  1. 임베딩 유사도는 “정답성”을 보장하지 않음
    • 질문과 문서가 비슷한 주제를 말해도, 질문이 요구하는 조건(기간, 수치, 예외, 버전)을 만족하지 않을 수 있습니다.
  2. 청크 분할/노이즈에 취약
    • 비슷한 문장 하나 때문에 청크 전체가 상위로 올라오기도 합니다.
  3. 상위 k 결과의 순서가 LLM 답변 품질을 좌우
    • LLM은 보통 컨텍스트 앞부분에 더 강하게 끌립니다. 상단이 “그럴듯한 오답 근거”면 환각이 늘어납니다.

재랭킹은 여기서 “LLM이 읽을 상위 컨텍스트”를 더 정확히 정렬해, 틀린 근거를 앞쪽에서 치우는 역할을 합니다.

재랭킹이 환각을 줄이는 메커니즘

구조를 단순화하면 아래 파이프라인입니다.

  1. 후보 검색(Recall): Qdrant에서 top_k = 20~50 정도로 넓게 가져오기
  2. 재랭킹(Precision): 질문-문서 쌍을 더 강한 모델로 점수화해 재정렬
  3. 컨텍스트 구성: 상위 n = 3~8개만 LLM에 넣기
  4. 생성: “근거 기반” 프롬프트로 답변

여기서 환각이 줄어드는 이유는:

  • 정답 근거가 상단에 오면 LLM이 그 근거를 중심으로 답을 구성
  • 주제만 비슷한 문서가 밀려나면 LLM이 빈틈을 메울 기회가 줄어듦
  • 재랭커 점수로 임계값(threshold) 을 적용하면 “근거 부족” 상황을 감지해 모름/추가 질문으로 전환 가능

LlamaIndex + Qdrant 구성(기본)

아래 예시는 Python 기준이며, Qdrant를 벡터 스토어로 쓰는 전형적인 형태입니다. 환경에 따라 패키지명/버전이 다를 수 있으니, 코드의 구조를 중심으로 보시면 됩니다.

# pip install llama-index qdrant-client

from qdrant_client import QdrantClient
from llama_index.core import VectorStoreIndex, StorageContext
from llama_index.vector_stores.qdrant import QdrantVectorStore

client = QdrantClient(url="http://localhost:6333")
vector_store = QdrantVectorStore(
    client=client,
    collection_name="docs",
)

storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex.from_vector_store(
    vector_store=vector_store,
    storage_context=storage_context,
)

query_engine = index.as_query_engine(similarity_top_k=20)
resp = query_engine.query("장애 발생 시 재시도 정책은 어떻게 설계해야 해?")
print(resp)

이 상태에서도 RAG는 동작하지만, similarity_top_k 결과의 순서가 “유사도 중심”이라 질문의 핵심과 어긋나는 컨텍스트가 상단을 차지할 수 있습니다.

재랭킹 추가: 후보는 넓게, 컨텍스트는 좁게

재랭킹을 붙일 때 가장 중요한 운영 원칙은 아래 2가지입니다.

  • Qdrant 검색 top_k넓게 가져와서 놓치지 않기
  • LLM에 넣는 컨텍스트는 재랭킹 후 상위만 엄선하기

재랭커 선택지

재랭커는 크게 두 부류로 나뉩니다.

  1. Cross-encoder 계열
    • 질문과 문서를 한 번에 넣고 관련도를 직접 점수화
    • 정확도가 높지만 비용/지연이 증가
  2. LLM 기반 rerank
    • LLM에게 “질문에 가장 답이 되는 문서 순서”를 매기게 함
    • 품질은 좋을 수 있으나 비용/변동성이 큼

실무에서는 보통 cross-encoder를 1차로 쓰고, 정말 중요한 질의에만 LLM rerank를 추가하는 방식이 안정적입니다.

아래는 “후보를 30개 뽑고, 재랭킹 후 상위 5개만 컨텍스트로 쓰는” 형태의 예시입니다.

# 예시용 의사 코드(사용하는 reranker 구현체에 맞게 조정)

from llama_index.core import VectorStoreIndex

# 1) 후보는 넓게
candidate_k = 30
# 2) 최종 컨텍스트는 좁게
final_n = 5

retriever = index.as_retriever(similarity_top_k=candidate_k)

nodes = retriever.retrieve("프로덕션에서 서킷 브레이커 임계값은 어떻게 잡아?")

# rerank(query, nodes) -> nodes를 관련도 순으로 재정렬해 반환한다고 가정
reranked_nodes = rerank(
    query="프로덕션에서 서킷 브레이커 임계값은 어떻게 잡아?",
    nodes=nodes,
)

context_nodes = reranked_nodes[:final_n]

answer = synthesize_answer(
    query="프로덕션에서 서킷 브레이커 임계값은 어떻게 잡아?",
    context_nodes=context_nodes,
)

print(answer)

핵심은 candidate_kfinal_n을 분리하는 것입니다. 많은 팀이 top_k=5로 바로 LLM에 넣는데, 이러면 “정답 후보가 6~20위에 숨어있던 케이스”를 영영 못 살립니다.

임계값(threshold)로 “근거 부족”을 감지하기

환각은 “근거가 약한데도 답을 만들 때” 폭발합니다. 재랭킹 점수(혹은 유사도 점수)를 이용해 아래 정책을 넣으면 효과가 큽니다.

  • 상위 1위 점수가 min_score 미만이면 답변 대신:
    • 근거가 부족합니다. 관련 문서를 더 제공해 주세요 또는
    • 질문을 더 구체화해 주세요로 전환
min_score = 0.35  # 예시: 재랭커 점수 스케일에 맞춰 조정

best = reranked_nodes[0]
if best.score < min_score:
    print("근거가 부족해 답변을 보류합니다. 질문을 더 구체화해 주세요.")
else:
    context_nodes = reranked_nodes[:5]
    print(synthesize_answer(query, context_nodes))

이 한 줄 정책이 “그럴듯한 헛소리”를 “정직한 모름”으로 바꾸는 데 매우 강력합니다.

프롬프트: 재랭킹만으로 부족하면 ‘근거 강제’를 넣어라

재랭킹이 컨텍스트 품질을 올려도, 모델이 여전히 상상으로 메우는 경우가 있습니다. 이때는 생성 프롬프트에 아래 제약을 추가합니다.

  • 컨텍스트에 없는 내용은 모름으로 답할 것
  • 답변에 인용(출처 청크 ID 또는 요약 근거)을 포함할 것
규칙:
1) 아래 제공된 컨텍스트에 근거한 내용만 답변한다.
2) 컨텍스트에 없는 정보는 "모르겠습니다"라고 답한다.
3) 답변 마지막에 근거로 사용한 청크 ID를 나열한다.

질문: ...
컨텍스트:
- [chunk:123] ...
- [chunk:456] ...

재랭킹과 근거 강제 프롬프트는 궁합이 좋습니다. 재랭킹이 “좋은 근거를 위로” 올리고, 프롬프트가 “근거 밖으로 나가지 않게” 막습니다.

튜닝 포인트 5가지(환각 30%↓에 기여하는 부분)

아래 항목은 실제로 환각이 줄어드는 데 기여도가 큰 순서로 정리했습니다.

1) 후보 top_k를 늘리고, 최종 컨텍스트는 줄이기

  • 추천 시작값: candidate_k=30, final_n=5
  • 증상별 조정:
    • “아예 엉뚱한 문서만 나옴”이면 candidate_k를 올리기
    • “컨텍스트가 길어 답이 흐림”이면 final_n을 내리기

2) 청크 크기와 오버랩

  • 너무 작으면 문맥이 끊겨 재랭커도 판단이 어려움
  • 너무 크면 잡음이 섞여 관련도 판단이 흔들림
  • 시작 가이드: 300800 토큰 범위에서 실험, 오버랩 1020%

3) 메타데이터 필터로 후보군 정제

Qdrant는 페이로드 기반 필터가 강점입니다. 예를 들어:

  • 버전별 문서(version) 필터
  • 제품/서비스별(service) 필터
  • 최신 문서 우선(updated_at) 가중

이걸 재랭킹 전에 적용하면 “비슷하지만 다른 제품 문서”가 섞이는 일이 확 줄어듭니다.

4) 재랭킹 점수 임계값과 폴백

  • min_score 미만이면 답변 보류
  • 폴백 전략:
    • 하이브리드 검색으로 재시도
    • 키워드 기반 BM25 후보를 섞기

5) 평가셋을 만들고 숫자로 본다

“환각 30%↓” 같은 개선은 측정 없이는 재현이 어렵습니다. 최소한 아래를 준비합니다.

  • 질문 50~200개
  • 정답 근거가 되는 문서/청크 라벨(가능하면)
  • 지표:
    • hit@k: 정답 근거가 상위 k에 포함되는 비율
    • faithfulness: 답변 문장이 컨텍스트에 의해 뒷받침되는 비율(샘플링 리뷰라도)

운영에서 자주 터지는 함정

재랭커가 “길이”에 끌리는 문제

긴 청크가 정보량이 많아 보여 점수가 올라가는 경우가 있습니다.

  • 해결: 청크 길이 제한, 요약 청크를 별도로 만들기, 혹은 길이 정규화

지연(latency) 증가

재랭킹은 후보 문서 수만큼 점수화를 하므로 비용이 늘어납니다.

  • 해결:
    • candidate_k를 무작정 키우지 말고, 메타데이터 필터로 후보를 줄이기
    • 캐시 적용(질문 정규화 후 rerank 결과 캐시)
    • 트래픽 구간별로 재랭킹 on/off 또는 단계적 적용

“정답이 문서에 없다” 케이스

재랭킹을 아무리 해도 근거가 없으면 답변은 위험합니다.

  • 해결: 임계값 기반 보류 + 추가 질문 유도

마무리: 재랭킹은 RAG의 ‘정렬 레이어’다

LlamaIndex + Qdrant 조합에서 재랭킹은 단순 옵션이 아니라, RAG 품질을 한 단계 끌어올리는 정렬 레이어입니다. 후보 검색은 넓게(Recall), 재랭킹으로 좁게(Precision), 그리고 임계값으로 정직하게(모름 처리) 가져가면 “그럴듯한 환각”이 눈에 띄게 줄어듭니다.

다음 단계로는 하이브리드 검색(BM25+벡터)과 재랭커 튜닝을 함께 적용해, 질문 유형별로 최적 파이프라인을 만드는 것을 추천합니다. 위 내부 링크 글의 체크리스트를 같이 적용하면 시행착오를 더 줄일 수 있습니다.