- Published on
LlamaIndex+Qdrant RAG 리콜 2배 튜닝 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서치 품질이 애매할 때 RAG에서 가장 먼저 무너지는 지표는 대개 리콜(recall)입니다. 모델이 똑똑해도, 정답 문서가 검색 후보에 안 들어오면 생성 단계는 그럴듯한 환각을 만들거나(혹은 안전하게 “모르겠다”)로 끝납니다. 특히 LlamaIndex + Qdrant 조합은 구성 자유도가 높은 만큼 “기본값”만으로는 리콜이 낮게 나오는 경우가 많습니다.
이 글은 리콜을 체감 2배까지 올리는 데 효과가 컸던 튜닝 포인트를, LlamaIndex 파이프라인 관점(청킹/메타데이터/쿼리/리랭킹)과 Qdrant 관점(HNSW/검색 파라미터/하이브리드)을 함께 묶어 정리합니다. 목표는 단순히 top_k를 키우는 게 아니라, 같은 비용에서 더 많은 정답 문서를 후보로 올리는 것입니다.
성능(지연) 최적화는 별도 축이지만, Qdrant HNSW 지연을 줄이는 튜닝은 아래 글을 함께 보면 좋습니다.
1) “리콜 2배”를 정의하고, 측정부터 고정하기
리콜 튜닝은 감으로 하면 끝이 없습니다. 먼저 다음을 고정하세요.
- 쿼리 세트: 실제 사용자 질문 50~200개(가능하면 실패 케이스 포함)
- 정답 근거: 질문별로 “정답이 포함된 chunk id 혹은 문서 id” 라벨
- 지표
Recall@k: 정답 chunk가 상위 k 후보에 포함되는 비율MRR@k: 정답이 위에 있을수록 가중- (가능하면)
nDCG@k: 다중 정답/가중치가 있을 때
리콜이 낮은 RAG는 보통 아래 중 하나입니다.
- 청킹이 잘못되어 정답 근거가 쪼개져서 embedding이 흐려짐
- 메타데이터/필터 설계가 엉켜서 정답이 검색 대상에서 제외됨
- Qdrant 검색 파라미터(
ef,m,exact)가 리콜을 깎음 - dense만 쓰다가 키워드 기반(희소) 신호를 놓침
- 리랭커가 없거나, 후보군이 너무 좁아 리랭커가 구할 수 없음
이제부터는 “어디서 리콜이 새는지”를 단계별로 막습니다.
2) 인덱싱: 청킹이 리콜의 바닥을 결정한다
2-1. chunk 크기/오버랩을 “정답 근거 단위”로 맞추기
리콜을 올리는 가장 값싼 방법은 청킹을 바로잡는 것입니다.
- FAQ/정의형:
400~800토큰 + 오버랩80~150 - 긴 기술 문서/가이드:
800~1200토큰 + 오버랩150~250 - 코드/로그: 문단 기준 + 짧은 청크(문맥보다 정확도가 중요)
오버랩은 단순히 늘린다고 좋은 게 아니라, 정답 문장이 청크 경계에 걸릴 확률을 줄이는 수준이면 충분합니다.
아래는 LlamaIndex에서 토큰 기반 splitter를 쓰는 예시입니다.
from llama_index.core.node_parser import TokenTextSplitter
splitter = TokenTextSplitter(
chunk_size=900,
chunk_overlap=180,
)
nodes = splitter.get_nodes_from_documents(docs)
2-2. “헤더/섹션 경계”를 보존하면 리콜이 오른다
기술 문서의 정답은 보통 섹션 제목과 함께 이해됩니다. 제목을 날려버리면 embedding이 약해져서 리콜이 떨어집니다.
- 마크다운/HTML은 헤더를 청크 본문에 prefix로 포함
section_path같은 메타데이터를 저장해서 필터/리랭킹에 활용
for n in nodes:
# 예: "Kubernetes / Ingress / Timeout" 같은 경로를 만들었다고 가정
n.metadata["section_path"] = n.metadata.get("section_path", "")
if n.metadata["section_path"]:
n.text = f"[SECTION] {n.metadata['section_path']}\n\n" + n.text
2-3. 중복/근접 청크 제거(near-duplicate)로 후보 다양성 확보
리콜이 낮아 보이는 케이스 중 일부는 “사실 후보에 정답이 있는데” 상위 k가 비슷한 청크로 도배되어 정답이 밀리는 현상입니다.
- 같은 문서에서 유사 청크가 과도하면, 후보 다양성이 줄어듦
- 해결: 인덱싱 단계에서 near-duplicate 제거 또는 검색 후 MMR/다양성 적용
3) Qdrant: 검색 파라미터가 리콜을 깎는 지점
Qdrant는 HNSW 기반 근사 탐색이 기본이라, 파라미터에 따라 리콜/지연이 트레이드오프를 가집니다.
3-1. hnsw_ef(또는 query ef)를 올리면 리콜이 먼저 오른다
ef는 탐색 폭을 넓혀 근사 오차를 줄입니다.- 리콜이 낮으면 가장 먼저
ef를 올려서 “근사 때문에 정답이 빠지는지” 확인하세요.
Python 클라이언트에서 검색 파라미터를 지정하는 예시입니다.
from qdrant_client import QdrantClient
from qdrant_client.http.models import SearchParams
client = QdrantClient(url="http://localhost:6333")
hits = client.search(
collection_name="docs",
query_vector=query_vec,
limit=20,
search_params=SearchParams(hnsw_ef=256, exact=False),
)
권장 접근:
- 기준선:
hnsw_ef=64~128 - 리콜이 낮으면:
hnsw_ef=256~512까지 올려 A/B - 지연이 부담되면: 이후에 인덱스
m,ef_construct, 샤딩/캐시로 다시 최적화
3-2. “정확도 디버그 모드”: exact=True로 바닥 리콜 확인
리콜 문제인지, 근사 탐색 문제인지 분리하려면 exact=True(브루트포스)로 한 번 돌려보세요.
exact=True에서 리콜이 충분히 나오면: HNSW 파라미터 문제exact=True에서도 리콜이 낮으면: 청킹/임베딩/쿼리 설계 문제
hits = client.search(
collection_name="docs",
query_vector=query_vec,
limit=20,
search_params=SearchParams(exact=True),
)
3-3. 인덱스 구성(m, ef_construct)도 리콜에 영향
m이 너무 낮으면 그래프 연결성이 약해져 리콜이 떨어질 수 있습니다.ef_construct가 낮으면 인덱스 품질이 떨어져 검색 리콜이 낮아질 수 있습니다.
다만 운영 중 컬렉션을 재구축해야 하는 비용이 있으니, 순서는 다음을 추천합니다.
- 쿼리
hnsw_ef올려서 즉시 확인 - 효과가 있으면 인덱스 재구축으로 비용 최적화
4) LlamaIndex 쿼리 튜닝: “질문을 검색 친화적으로 바꾸기”
4-1. Query rewrite(다중 쿼리)로 리콜을 크게 올릴 수 있다
사용자 질문은 짧고 애매합니다. 반면 문서에는 동의어/약어/제품명이 다양하게 존재합니다. 이때 다중 쿼리를 생성해 병렬 검색 후 합치면 리콜이 확 뛰는 경우가 많습니다.
- 원 질문 1개로
top_k=10하는 것보다 - 리라이트 3~5개로 각각
top_k=10후 dedup하면 - 후보군의 “표현 다양성”이 확보되어 리콜이 올라갑니다.
개념 예시(간단화):
def rewrite_queries(q: str) -> list[str]:
# 실제로는 LLM을 써서 동의어/약어/영문 키워드 포함하도록 생성
return [
q,
f"{q} 원인",
f"{q} 해결 방법",
f"{q} troubleshooting",
]
all_hits = []
for rq in rewrite_queries(user_query):
all_hits.extend(client.search(
collection_name="docs",
query_vector=embed(rq),
limit=10,
search_params=SearchParams(hnsw_ef=256),
))
# 점수 정규화/중복 제거 후 상위 N개 선택
4-2. 메타데이터 필터는 “너무 일찍” 걸면 리콜이 꺾인다
태그/서비스/버전 같은 필터는 정확도를 올리지만, 잘못 걸면 정답을 검색 대상에서 제거합니다.
권장 패턴:
- 1차: 필터 없이 넓게 검색(리콜 확보)
- 2차: 후보에 대해 필터/리랭킹/정책 적용
특히 “사용자가 버전을 언급하지 않았는데 버전 필터가 기본으로 걸리는” 구조는 리콜을 크게 깎습니다.
5) 하이브리드 검색: dense만 쓰지 말고 sparse 신호를 섞기
리콜 2배를 가장 자주 만드는 조합은 dense + sparse 하이브리드입니다.
- dense: 의미 유사도에 강함(패러프레이즈)
- sparse(BM25류): 키워드/에러코드/약어/고유명사에 강함
예를 들어 로그의 ReadTimeout 같은 토큰은 dense가 놓치기 쉽고, sparse가 잘 잡습니다.
Qdrant는 sparse 벡터도 지원하므로(구성에 따라) 컬렉션에 dense + sparse를 함께 넣고, 쿼리에서 가중 합을 쓰는 전략을 고려할 수 있습니다.
의사 코드(개념):
# dense_vec: embedding
# sparse_vec: BM25/토큰 가중치 벡터 (구현/파이프라인에 따라 다름)
hits = client.search(
collection_name="docs",
query_vector=("dense", dense_vec),
limit=20,
)
# 또는 named vectors + fusion을 사용하는 구조로 확장
구현 디테일은 스택마다 달라서, 핵심만 정리하면 다음과 같습니다.
- 에러코드/설정 키/CLI 옵션이 많은 도메인일수록 sparse 혼합 효과가 큼
- 하이브리드 후에는 리랭킹이 거의 필수(스코어 스케일이 다름)
6) 리랭킹: 후보군을 넓히고, 최종 정렬로 정확도를 회수
리콜을 올리려면 후보군을 넓혀야 하고, 그러면 정밀도(precision)가 떨어집니다. 이 균형을 잡는 게 리랭커입니다.
권장 파이프라인:
- 1차 검색:
top_k=30~80(리콜 확보) - 리랭킹: cross-encoder 또는 LLM rerank
- 최종 컨텍스트:
top_n=5~10
LlamaIndex에서 리랭커를 붙이는 패턴(개념 예시):
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import SentenceTransformerRerank
rerank = SentenceTransformerRerank(
model="cross-encoder/ms-marco-MiniLM-L-6-v2",
top_n=8,
)
query_engine = RetrieverQueryEngine(
retriever=retriever, # Qdrant 기반 retriever
node_postprocessors=[rerank],
)
resp = query_engine.query("qdrant hnsw_ef 올리면 뭐가 달라져?")
리콜 2배를 목표로 할 때 자주 쓰는 값:
- 1차
similarity_top_k=50 - 리랭커
top_n=8 - Qdrant
hnsw_ef=256
이 조합은 비용이 늘지만, “정답이 후보에 들어오지 않는 문제”를 상당히 줄여줍니다.
7) “리콜이 안 오르는” 흔한 함정 5가지
7-1. 임베딩 모델/차원 불일치, 정규화 문제
- 컬렉션 벡터 크기와 임베딩 차원이 다르면 당연히 문제
- cosine 유사도를 쓰는데 벡터 정규화가 일관되지 않으면 스코어가 흔들림
7-2. 언어 혼합(한글/영문) 도메인에서 한쪽만 강한 모델 사용
- 문서에는 영문 에러/키가 많고 질문은 한글인 경우가 흔함
- 다국어 임베딩 또는 쿼리 리라이트(한글
+영문 키워드)를 병행
여기서 + 기호 자체는 문제 없지만, 본문에 부등호 문자가 들어가면 MDX에서 깨질 수 있으니(예: ->) 로그/코드 표기는 항상 백틱으로 감싸는 습관이 안전합니다.
7-3. payload(메타데이터) 인덱싱 미설정으로 필터가 느려지고 결국 필터를 빼버림
- 필터가 느리면 운영에서 필터를 꺼버리고, 그 결과 노이즈가 늘어 리랭킹이 과부하
- 필요한 필드는 Qdrant payload index를 잡아두는 편이 낫습니다.
7-4. chunk id 추적이 안 되어 평가가 불가능
- 리콜 튜닝은 “정답 chunk가 들어왔는지”를 봐야 합니다.
doc_id,chunk_id,source_uri를 payload에 반드시 저장하세요.
7-5. 운영 장애 때문에 파라미터를 보수적으로 고정
hnsw_ef를 올리면 지연이 늘 수 있고, 트래픽 순간 피크에서 502/504로 보일 수 있습니다. RAG는 검색과 생성이 직렬로 붙어 있어 타임아웃 전파가 빠릅니다. 인그레스/서버 타임아웃과 스트리밍 안정성은 아래 글이 실전적으로 도움이 됩니다.
8) 추천 튜닝 레시피: “리콜 먼저 2배, 비용은 그 다음”
아래 순서대로 하면, 원인 분리가 깔끔하고 성공 확률이 높습니다.
8-1. Step 1: exact로 바닥 리콜 확인
exact=True,top_k=50로 평가- 여기서도 리콜이 낮으면 청킹/임베딩/쿼리 문제
8-2. Step 2: 청킹 재조정
chunk_size=900,overlap=180정도로 시작- 섹션 헤더 prefix 추가
- near-duplicate 완화
8-3. Step 3: HNSW 탐색 폭 확장
exact=False로 되돌리고hnsw_ef=256적용- 리콜 상승폭과 지연 증가폭을 같이 기록
8-4. Step 4: 다중 쿼리 + 후보 확장
- 리라이트 3~5개
- 각
top_k=10~20 - 합쳐서
top_k=50후보군 구성
8-5. Step 5: 리랭킹으로 정밀도 회수
- cross-encoder 리랭커로
top_n=8 - 최종 컨텍스트는 5~10개로 제한
이 조합이 “리콜이 2배 가까이 뛰는” 가장 흔한 경로입니다.
9) 최소 동작 예시: LlamaIndex + Qdrant에서 리콜 지향 설정
아래 코드는 구조를 보여주는 예시입니다. 실제 프로젝트에서는 임베딩/리라이트/스파스 벡터 파이프라인을 서비스에 맞게 구성하세요.
from llama_index.core import VectorStoreIndex
from llama_index.vector_stores.qdrant import QdrantVectorStore
from llama_index.core.storage.storage_context import StorageContext
from llama_index.core.node_parser import TokenTextSplitter
from llama_index.core.postprocessor import SentenceTransformerRerank
from llama_index.core.query_engine import RetrieverQueryEngine
from qdrant_client import QdrantClient
from qdrant_client.http.models import SearchParams
# 1) Split
splitter = TokenTextSplitter(chunk_size=900, chunk_overlap=180)
nodes = splitter.get_nodes_from_documents(docs)
# 2) Qdrant 연결
qdrant = QdrantClient(url="http://localhost:6333")
vector_store = QdrantVectorStore(
client=qdrant,
collection_name="docs",
)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex(nodes, storage_context=storage_context)
# 3) Retriever: 후보를 넓게
retriever = index.as_retriever(similarity_top_k=50)
# 4) Rerank: 최종 컨텍스트는 줄이기
rerank = SentenceTransformerRerank(
model="cross-encoder/ms-marco-MiniLM-L-6-v2",
top_n=8,
)
# 5) Query engine
query_engine = RetrieverQueryEngine(
retriever=retriever,
node_postprocessors=[rerank],
)
# 6) (선택) Qdrant 검색 파라미터는 구현에 따라 retriever 내부로 전달
# 여기서는 개념적으로 hnsw_ef=256을 사용한다고 가정
search_params = SearchParams(hnsw_ef=256, exact=False)
resp = query_engine.query("qdrant에서 hnsw_ef를 올리면 리콜이 왜 좋아져?")
print(resp)
포인트는 3가지입니다.
- 1차 후보군을 넓게(
similarity_top_k=50) - Qdrant 탐색 폭을 넓게(
hnsw_ef=256) - 리랭킹으로 최종 컨텍스트를 다시 줄이기(
top_n=8)
10) 마무리: 리콜은 “검색 시스템” 문제로 다뤄야 한다
LlamaIndex + Qdrant RAG에서 리콜을 2배 수준으로 끌어올리는 과정은, 결국 검색 시스템의 기본기로 돌아갑니다.
- 청킹은 정답 근거 단위에 맞추고
- 근사 탐색 파라미터로 빠지는 정답을 먼저 복구하고
- 다중 쿼리/하이브리드로 표현 다양성을 확보한 뒤
- 리랭킹으로 정밀도를 회수하세요.
이 순서로 튜닝하면 “top_k만 키워서 비용만 늘리는” 실패를 피하면서, 실제 사용자 체감(정답을 찾는 비율)을 빠르게 올릴 수 있습니다.