- Published on
LlamaIndex+Qdrant RAG 하이브리드 검색 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG에서 검색 품질은 곧 답변 품질입니다. 특히 문서가 많아질수록 벡터 유사도만으로는 다음 문제가 자주 발생합니다.
- 용어가 정확히 일치하는데도(에러 코드, 함수명, 설정 키) 벡터가 놓침
- 의미는 비슷하지만 정답이 아닌 문단이 상위에 올라옴
- 최신 문서, 특정 제품/버전/테넌트처럼 메타데이터 조건이 중요한데 검색 단계에서 충분히 반영되지 않음
이럴 때 가장 효과적인 접근이 하이브리드 검색입니다. Qdrant의 강점(필터, 페이로드, HNSW)과 LlamaIndex의 인덱싱/쿼리 파이프라인을 조합해, 키워드 기반 신호 + 임베딩 기반 신호를 같이 쓰고, 마지막에 리랭킹으로 정밀도를 끌어올립니다.
아래에서는 LlamaIndex+Qdrant 조합을 기준으로, 하이브리드 검색을 “작동하게 만드는 수준”이 아니라 “튜닝해서 품질을 올리는 수준”으로 정리합니다.
전체 아키텍처: 하이브리드 RAG의 3단계
실무에서 가장 안정적인 패턴은 다음 3단계입니다.
- 1차 후보군 생성(Recall): 벡터 검색 + 키워드(BM25 등) 검색을 병렬로 돌려 후보를 넓게 확보
- 스코어 결합/정규화(Blend): 두 결과를 합치고, 가중치/정규화로 상위 후보를 압축
- 리랭킹(Precision): Cross-encoder 또는 LLM 기반 리랭커로 최종 Top
k를 결정
이 단계가 중요한 이유는, 벡터와 키워드는 “잘하는 영역”이 다르기 때문입니다.
- 벡터: 의미 유사도, 표현이 달라도 찾음
- 키워드: 정확 일치(에러코드, 옵션명), 희귀 토큰, 숫자/버전
인덱싱 튜닝 1: 청크 전략이 하이브리드 성능을 좌우
하이브리드 검색을 도입해도 청크가 엉망이면 성능이 안 나옵니다. 특히 키워드 검색은 청크 단위로 점수를 매기므로, 청크가 너무 크면 잡음이 늘고 너무 작으면 문맥이 깨집니다.
권장 가이드(문서 성격에 따라 조정):
- 기술 문서/가이드:
400800토큰, overlap50120 - FAQ/짧은 문서:
200400토큰, overlap3080 - 로그/에러코드 중심:
150300토큰, overlap2060
LlamaIndex에서 기본 Splitter 대신 토큰 기반으로 통일하는 편이 튜닝이 쉽습니다.
from llama_index.core.node_parser import TokenTextSplitter
splitter = TokenTextSplitter(
chunk_size=600,
chunk_overlap=80,
)
nodes = splitter.get_nodes_from_documents(docs)
청크에 “제목/섹션 경로”를 페이로드로 넣기
하이브리드는 후보군이 넓어지는 만큼, 최종 답변에서 출처를 제어하기 위해 메타데이터가 중요합니다.
doc_id,source_url,section_title,product,version,updated_at- 운영에서 자주 쓰는 필터 키:
tenant_id,env,lang
이 메타데이터는 Qdrant의 payload로 들어가고, 검색 시 필터로 강력하게 작동합니다.
Qdrant 컬렉션 설계: 벡터 + 페이로드 + 인덱스
Qdrant에서 하이브리드를 하려면 최소한 다음을 챙깁니다.
- 벡터 설정: cosine 권장(일반적인 임베딩)
- payload 인덱싱: 필터 성능에 직결
- HNSW 파라미터: recall/latency 균형
예시(컬렉션 생성):
from qdrant_client import QdrantClient
from qdrant_client.http.models import Distance, VectorParams
client = QdrantClient(url="http://localhost:6333")
client.recreate_collection(
collection_name="kb",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)
payload 필드를 필터로 자주 쓴다면 인덱스를 추가합니다.
from qdrant_client.http.models import PayloadSchemaType
client.create_payload_index(
collection_name="kb",
field_name="tenant_id",
field_schema=PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
collection_name="kb",
field_name="updated_at",
field_schema=PayloadSchemaType.INTEGER,
)
LlamaIndex와 Qdrant 연결: 기본 벡터 검색 파이프라인
LlamaIndex에서 Qdrant를 벡터 스토어로 붙이는 기본 형태입니다.
from llama_index.vector_stores.qdrant import QdrantVectorStore
from llama_index.core import VectorStoreIndex, StorageContext
from qdrant_client import QdrantClient
qdrant = QdrantClient(url="http://localhost:6333")
vector_store = QdrantVectorStore(client=qdrant, collection_name="kb")
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex(nodes, storage_context=storage_context)
query_engine = index.as_query_engine(similarity_top_k=8)
resp = query_engine.query("Qdrant 필터로 tenant_id를 제한하는 방법")
print(resp)
여기까지는 “벡터만”입니다. 이제 하이브리드로 확장할 때 핵심은 후보군 생성 방식을 분리하고, 그 결과를 정규화해서 섞는 것입니다.
하이브리드 검색 전략 1: 병렬 후보군 + 스코어 블렌딩
가장 무난한 전략은 다음입니다.
- 벡터 검색으로 Top
k_v후보 - 키워드 검색(BM25)로 Top
k_b후보 - 합집합을 만든 뒤, 정규화된 점수로 가중합
문제는 Qdrant 단독으로 BM25를 “항상” 제공하는 형태가 아니라는 점입니다. 실무에서는 보통 다음 중 하나를 선택합니다.
- (A) 키워드 검색은 별도 엔진(예: Elasticsearch/OpenSearch)로 수행
- (B) Qdrant에 텍스트 인덱싱/하이브리드 기능을 쓰되, 버전/구성에 맞춰 적용
- (C) 간단히는 payload 텍스트에 대한 외부 BM25를 애플리케이션 레벨에서 수행
여기서는 이해를 위해 (A)처럼 “BM25 결과가 이미 있다”고 가정하고, LlamaIndex 쿼리 파이프라인에서 블렌딩하는 예시를 보여드립니다.
점수 정규화가 없으면 블렌딩은 망합니다
벡터 유사도는 보통 0.2~0.9 같은 범위로 몰리고, BM25는 문서 길이/용어 빈도에 따라 수십 점까지 튈 수 있습니다. 그대로 더하면 BM25가 항상 이깁니다.
권장 정규화:
- min-max 정규화(후보군 내)
- softmax(온도
tau로 분포 조절) - rank-based 점수(순위 기반 감쇠)
가장 튜닝이 쉬운 건 rank-based입니다.
import math
def rank_score(rank: int, k: int = 60) -> float:
# rank=1이 가장 큼, k가 클수록 완만
return 1.0 / (1.0 + (rank - 1) / k)
def blend_scores(vec_rank, bm25_rank, w_vec=0.6, w_bm25=0.4):
s_vec = rank_score(vec_rank) if vec_rank is not None else 0.0
s_bm = rank_score(bm25_rank) if bm25_rank is not None else 0.0
return w_vec * s_vec + w_bm25 * s_bm
튜닝 포인트는 w_vec와 w_bm25입니다.
- 에러코드/옵션명 질의가 많으면
w_bm25를 올림 - 자연어 질문이 많으면
w_vec를 올림
실무에서는 쿼리 분류를 간단히 넣어 가중치를 동적으로 바꾸기도 합니다.
- 숫자/대문자/언더스코어가 많으면 키워드 가중치 증가
- “왜/어떻게/비교” 같은 설명형이면 벡터 가중치 증가
하이브리드 검색 전략 2: 메타데이터 필터를 먼저 강하게
RAG 품질 이슈의 상당수는 “검색이 틀렸다”가 아니라 “검색 범위가 너무 넓었다”에서 시작합니다.
- 테넌트별 문서가 섞임
- 버전이 다른 문서가 섞임
- 한국어/영어 문서가 섞임
Qdrant는 필터가 강력하므로, 필터를 먼저 강하게 걸고 그 안에서 하이브리드를 돌리는 것이 안정적입니다.
from qdrant_client.http.models import Filter, FieldCondition, MatchValue
flt = Filter(
must=[
FieldCondition(key="tenant_id", match=MatchValue(value="t1")),
FieldCondition(key="lang", match=MatchValue(value="ko")),
]
)
# LlamaIndex 쪽에서 필터를 전달하는 방식은 사용 버전에 따라 다를 수 있습니다.
# 핵심은 Qdrant 검색 요청에 filter를 포함시키는 것입니다.
필터를 잘못 설계하면 “왜 검색이 갑자기 안 되지” 같은 장애로 이어집니다. 운영 환경에서 검색/서빙이 불안정할 때는 인프라 관점의 점검도 같이 필요합니다. 예를 들어 컨테이너가 OOM으로 죽으면 인덱스 워커가 재시작을 반복하면서 검색 품질이 아니라 “검색 자체가 불안정”해질 수 있습니다. 이때는 리눅스 OOM Kill 원인 추적 - dmesg·cgroup·journalctl 같은 방식으로 먼저 원인을 고정하는 게 좋습니다.
리랭킹: 하이브리드의 마지막 20점을 채우는 방법
하이브리드로 후보군을 잘 모아도, 최종 Top k를 잘못 뽑으면 답변이 흔들립니다. 그래서 실무에서는 다음 패턴이 매우 흔합니다.
- 1차: 벡터
k_v=30 - 1차: BM25
k_b=30 - 합집합: 최대 60개
- 2차: 리랭커로 Top
k=5~10
리랭커는 비용이 들지만, 후보가 60개 내외면 충분히 감당 가능합니다.
LlamaIndex에서는 리랭커 컴포넌트를 붙일 수 있습니다(버전에 따라 클래스명이 다를 수 있음). 여기서는 개념적으로 “노드 리스트를 받아 재정렬”하는 형태의 의사 코드로 설명합니다.
def rerank(query: str, nodes: list, top_n: int = 8):
# 실제 구현은 cross-encoder(예: bge-reranker)나 LLM scoring을 사용
# 여기서는 예시로 길이가 짧고 쿼리 토큰이 많이 포함된 노드를 우선
q_tokens = set(query.lower().split())
def score(n):
text = n.get_content().lower()
hit = sum(1 for t in q_tokens if t in text)
return hit - 0.001 * len(text)
nodes_sorted = sorted(nodes, key=score, reverse=True)
return nodes_sorted[:top_n]
리랭킹을 넣을 때 체감 품질이 가장 크게 오르는 케이스:
- “비슷한 문서가 너무 많아서” 벡터 상위가 흔들리는 경우
- 짧은 질의(예: 설정 키 하나)로 벡터가 애매하게 퍼지는 경우
튜닝 체크리스트: 어디서부터 만져야 하나
하이브리드 튜닝은 한 번에 다 하면 원인 분리가 안 됩니다. 아래 순서가 안전합니다.
1) 데이터/청크/메타데이터부터 고정
- 청크 크기/오버랩을 정하고, 문서 유형별로 예외를 줄 것
source_url,section_title,updated_at,version,tenant_id는 필수- 필터에 쓰는 payload는 Qdrant 인덱스를 생성
2) 후보군 크기부터 조정
k_v와k_b를 늘리면 recall은 오르지만 latency와 잡음도 증가- 시작점 추천:
k_v=2040,k_b=2040, 최종k=6~10
3) 스코어 결합은 “정규화+가중치”로 단순하게
- 먼저 rank-based로 시작
- 쿼리 타입별로 가중치만 다르게 해도 효과가 큼
4) 리랭커는 마지막에 붙이기
- 리랭커를 먼저 붙이면 비용만 늘고, 근본 recall 문제를 가립니다
성능(지연시간) 튜닝: 검색은 빨라도 RAG는 느릴 수 있다
하이브리드에서 흔한 함정은 검색 자체는 빠른데, 전체 API는 느린 케이스입니다.
- 후보군이 커져서 컨텍스트 구성/토큰이 늘어남
- 리랭커가 병목
- 동시성에서 타임아웃
서빙이 Cloud Run 같은 환경이면 타임아웃/동시성 튜닝이 필요할 수 있습니다. RAG API가 503/504로 흔들린다면 GCP Cloud Run 503/504 원인별 해결 - 타임아웃·동시성도 같이 참고해보세요.
또한 프론트가 Next.js라면, 검색 결과가 캐시와 엮여 “최신 문서가 반영되지 않는” 문제가 품질 이슈로 보이기도 합니다. 이 경우 Next.js 14 RSC 캐시 무효화로 데이터 꼬임 해결 같은 패턴으로 캐시 계층을 점검하는 것이 좋습니다.
실전 예시: 하이브리드 파이프라인을 하나로 묶기
아래는 “벡터 결과”와 “BM25 결과”를 받아서 합치고, 필터를 적용하고, 리랭킹까지 하는 전형적인 흐름입니다. 실제 BM25는 OpenSearch 등에서 가져오되, 여기서는 결과 포맷만 맞춘다고 가정합니다.
from dataclasses import dataclass
@dataclass
class Hit:
node_id: str
node: object
vec_rank: int | None = None
bm25_rank: int | None = None
score: float = 0.0
def hybrid_retrieve(query: str, vec_hits: list, bm25_hits: list,
w_vec=0.6, w_bm25=0.4, top_n=10):
pool = {}
for i, h in enumerate(vec_hits, start=1):
pool[h.node_id] = Hit(node_id=h.node_id, node=h.node, vec_rank=i)
for i, h in enumerate(bm25_hits, start=1):
if h.node_id in pool:
pool[h.node_id].bm25_rank = i
else:
pool[h.node_id] = Hit(node_id=h.node_id, node=h.node, bm25_rank=i)
for hit in pool.values():
hit.score = blend_scores(hit.vec_rank, hit.bm25_rank, w_vec=w_vec, w_bm25=w_bm25)
merged = sorted(pool.values(), key=lambda x: x.score, reverse=True)
merged_nodes = [h.node for h in merged[: max(top_n, 30)]]
# 마지막 리랭킹
final_nodes = rerank(query, merged_nodes, top_n=top_n)
return final_nodes
이 형태의 장점:
- 검색 소스(Qdrant 벡터, OpenSearch BM25)를 분리할 수 있음
- 스코어 결합 로직을 실험하기 쉬움
- 리랭커 교체가 쉬움
마무리: 하이브리드 튜닝의 목표는 “정답 문단을 상위에”
LlamaIndex+Qdrant 조합에서 하이브리드 검색은 단순히 기능을 켜는 게 아니라, 다음을 목표로 튜닝해야 합니다.
- 정확 일치가 중요한 질의에서 키워드 신호로 놓치지 않기
- 설명형 질의에서 벡터 신호로 의미 기반 문단을 확보하기
- 필터/메타데이터로 검색 범위를 올바르게 제한하기
- 리랭킹으로 최종 Top
k의 정밀도를 올리기
추천하는 첫 실험 세트는 k_v=30, k_b=30, rank-based 블렌딩 w_vec=0.6, w_bm25=0.4, 리랭킹 Top k=8입니다. 그 다음에는 쿼리 로그를 모아 “어떤 질의가 벡터에서 실패하고, 어떤 질의가 키워드에서 실패하는지”를 분류해 가중치와 청크 전략을 조정하면, 비용 대비 가장 빠르게 품질이 올라갑니다.