Qdrant 하이브리드 검색: Sparse + Dense 벡터 통합¶
Dense 임베딩만으로 검색하면 키워드 매칭에 약하다. "Python 3.12의 타입 힌트 문법"을 검색할 때 "파이썬 타입 어노테이션"은 잡지만 "3.12"라는 버전 숫자는 놓칠 수 있다. 반대로 BM25 같은 키워드 검색은 의미론적 유사성을 못 잡는다. xgen-retrieval에서 두 가지를 결합하는 하이브리드 검색을 구현한 과정을 정리한다.
Qdrant의 하이브리드 검색 API¶
Qdrant는 Prefetch + Fusion 방식으로 하이브리드 검색을 공식 지원한다.
from qdrant_client.models import (
SparseVectorParams, SparseIndexParams, SparseVector,
NamedSparseVector,
TextIndexParams, TextIndexType, TokenizerType,
Prefetch, FusionQuery, Fusion
)
Prefetch는 여러 검색 결과를 사전 수집하고, Fusion은 Reciprocal Rank Fusion(RRF) 알고리즘으로 결합한다.
# 커밋: feat: Implement sparse vector and full-text index support in retrieval system
# 날짜: 2025-12-29 14:96
# 커밋: feat: Update vector search method to use query_points with enhanced options
# 날짜: 2026-01-02 00:52
컬렉션 생성: Sparse + Dense 동시 지원¶
def create_collection(
self,
collection_name: str,
vector_size: int,
distance: str = "Cosine",
enable_sparse_vector: bool = False,
sparse_vector_name: str = "sparse",
enable_full_text: bool = False,
full_text_field: str = "chunk_text",
):
# Sparse Vector 설정
sparse_vectors_config = None
if enable_sparse_vector:
sparse_vectors_config = {
sparse_vector_name: SparseVectorParams(
index=SparseIndexParams(on_disk=False) # RAM에 인덱스 유지
)
}
# 컬렉션 생성
self.client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(
size=vector_size,
distance=Distance.COSINE
),
sparse_vectors_config=sparse_vectors_config,
)
# Full-Text Index 추가 (생성 후)
if enable_full_text:
self._create_full_text_index(collection_name, full_text_field)
Dense Vector(vectors_config)와 Sparse Vector(sparse_vectors_config)를 함께 설정한다. on_disk=False로 sparse 인덱스를 RAM에 올려서 검색 속도를 높였다.
Full-Text Index 생성¶
def _create_full_text_index(self, collection_name: str, field: str = "chunk_text"):
"""Full-Text Index 생성 (BM25 기반 텍스트 검색용)"""
self.client.create_payload_index(
collection_name=collection_name,
field_name=field,
field_schema=TextIndexParams(
type=TextIndexType.TEXT,
tokenizer=TokenizerType.WORD, # 단어 단위 토큰화
min_token_len=2, # 최소 2글자
max_token_len=15, # 최대 15글자
lowercase=True, # 소문자 정규화
)
)
Qdrant의 Full-Text Index는 payload 필드에 만들 수 있다. 한국어는 WORD 토크나이저로 공백 기준 분리를 기본으로 한다.
BM25 Sparse 클라이언트¶
외부 SPLADE 모델 없이 BM25로 sparse vector를 직접 계산한다.
class BM25SparseClient:
def __init__(self, config: Dict[str, Any] = None):
self.k1 = config.get("k1", 1.5) # 단어 빈도 포화 파라미터
self.b = config.get("b", 0.75) # 문서 길이 정규화 파라미터
self.avg_doc_length = config.get("avg_doc_length", 256)
self.vocab: Dict[str, int] = {} # 동적 어휘 사전
self.idf: Dict[str, float] = {}
def _compute_bm25_weights(self, tokens: List[str]) -> Dict[int, float]:
doc_length = len(tokens)
term_freqs = Counter(tokens)
weights = {}
for term, tf in term_freqs.items():
token_id = self._get_or_create_token_id(term)
# BM25 TF 계산
tf_component = (tf * (self.k1 + 1)) / (
tf + self.k1 * (1 - self.b + self.b * doc_length / self.avg_doc_length)
)
idf = self.idf.get(term, 1.0) # IDF (fit 전: 1.0 기본값)
weights[token_id] = tf_component * idf
return weights
한글, 영문, 숫자를 각각 추출하는 토크나이저를 사용한다.
def _tokenize(self, text: str) -> List[str]:
text = text.lower()
tokens = re.findall(r'[가-힣]+|[a-zA-Z]+|[0-9]+', text)
return tokens
RAGService에서 BM25 초기화¶
class RAGService:
def __init__(self, config_composer, embedding_client=None, ...):
# BM25 Sparse Embedding Client 초기화
from service.embedding.bm25_sparse_client import BM25SparseClient
self.sparse_embedding_client = BM25SparseClient(config={
"k1": 1.5,
"b": 0.75,
"avg_doc_length": 256
})
avg_doc_length=256은 청크 평균 길이(토큰 기준)를 의미한다. 실제 코퍼스에 맞게 조정하면 더 정확하지만, 초기값으로 256을 사용했다.
search_hybrid: Prefetch + Fusion¶
def search_hybrid(
self,
collection_name: str,
query_vector: List[float],
sparse_vector: Dict[str, List] = None,
limit: int = 10,
sparse_vector_name: str = "sparse",
fusion_method: str = "rrf",
):
"""Hybrid Search: Dense + Sparse, Prefetch + RRF Fusion"""
prefetch_queries = []
# 1. Dense Vector Prefetch
prefetch_queries.append(
Prefetch(
query=query_vector,
limit=limit * 2, # 최종 limit의 2배 수집
)
)
# 2. Sparse Vector Prefetch (BM25)
if sparse_vector:
prefetch_queries.append(
Prefetch(
query=NamedSparseVector(
name=sparse_vector_name,
vector=SparseVector(
indices=sparse_vector["indices"],
values=sparse_vector["values"],
)
),
limit=limit * 2,
)
)
# 3. Fusion (RRF)
results = self.client.query_points(
collection_name=collection_name,
prefetch=prefetch_queries,
query=FusionQuery(fusion=Fusion.RRF),
limit=limit,
with_payload=SEARCH_PAYLOAD_FIELDS,
).points
RRF(Reciprocal Rank Fusion)는 두 랭킹을 결합하는 알고리즘이다. 각 결과의 순위에 1/(k + rank) 가중치를 주어 합산한다. Dense와 Sparse 검색에서 모두 상위에 있는 결과가 최종적으로 높은 점수를 받는다.
유효하지 않은 포인트 필터링¶
# 커밋: feat: Add filtering for invalid points in vector search payload
# 날짜: 2026-01-02 00:44
# 커밋: feat: Refine vector search payload handling and metadata filtering
# 날짜: 2026-01-02 00:46
컬렉션 메타데이터 포인트가 검색 결과에 섞여 들어오는 문제가 있었다.
for hit in search_results:
payload = hit.payload or {}
# 메타데이터 포인트 제거 (컬렉션 설명 등)
if payload.get("type") == "collection_metadata":
continue
# 빈 페이로드 제거
if not payload.get("file_name") or not payload.get("chunk_text"):
continue
results.append({...})
type == "collection_metadata"인 포인트는 컬렉션 설명을 저장하는 특수 포인트라 검색 결과에서 제외한다.
검색 결과 구조¶
SEARCH_PAYLOAD_FIELDS = [
"document_id",
"chunk_index",
"chunk_text",
"file_name",
"file_path",
"file_type",
"page_number",
"type",
"directory_full_path",
]
with_payload를 전체 페이로드가 아닌 필요한 필드만 지정해서 네트워크 전송량을 줄였다.
실전 성능¶
정확한 A/B 테스트 수치는 없지만, 주관적인 체감으로:
- Dense only: 의미론적 검색에 강함. "머신러닝 기반 이상 탐지"를 검색하면 "AI 기반 anomaly detection"도 찾아줌
- Sparse only (BM25): 키워드 검색에 강함. 코드 변수명, 버전 번호, 고유명사 등
- Hybrid (RRF): 두 장점을 결합. 특히 기술 문서 검색에서 개선이 눈에 띔
BM25의 avg_doc_length를 실제 코퍼스 평균에 맞추면 키워드 매칭 점수가 더 정확해진다. 256은 일반 문서 기준이고, 코드 청크가 많으면 더 낮게, 긴 문서 청크가 많으면 더 높게 조정하는 게 좋다.
관련 글
- Sparse Vector와 Full-Text Index 하이브리드 검색 구현
BM25PythonQdrant - RAG 서비스의 토큰 관리와 컨텍스트 윈도우 최적화
LLMRAGxgen - Iterative RAG: 반복 검색으로 복잡한 질문 답변하기
Iterative RAGLLMRAG - 벡터 기반 시맨틱 검색 구현기
AI검색FastAPINLP - 문서 임베딩 파이프라인: 청킹 옵션과 전처리 전략
OCRRAGxgen