하이브리드 검색
Hybrid Search
키워드 검색과 시맨틱 검색을 결합한 방식. RAG의 검색 품질 향상.
Hybrid Search
키워드 검색과 시맨틱 검색을 결합한 방식. RAG의 검색 품질 향상.
하이브리드 검색(Hybrid Search)은 키워드 기반의 전통적인 검색(BM25, TF-IDF)과 벡터 기반의 시맨틱 검색을 결합한 검색 방식입니다. 키워드 검색은 정확한 용어 매칭에 강하고, 시맨틱 검색은 의미적으로 유사한 문서를 찾는 데 뛰어납니다. 두 방식의 장점을 결합하면 단일 방식보다 더 정확하고 포괄적인 검색 결과를 얻을 수 있어, RAG(검색 증강 생성) 시스템의 검색 품질을 크게 향상시킵니다.
순수 벡터 검색의 한계는 분명합니다. 예를 들어 "iPhone 15 Pro Max 가격"이라는 쿼리에서 벡터 검색은 "스마트폰 가격 비교"처럼 의미적으로 유사한 문서는 찾지만, 정확히 "iPhone 15 Pro Max"라는 단어가 포함된 문서를 놓칠 수 있습니다. 반대로 키워드 검색은 동의어("아이폰")나 관련 개념("애플 플래그십")을 이해하지 못합니다. 하이브리드 검색은 이 두 가지 문제를 동시에 해결합니다.
하이브리드 검색의 핵심은 두 검색 결과를 어떻게 결합하느냐입니다. 가장 일반적인 방법은 RRF(Reciprocal Rank Fusion)로, 각 문서의 순위를 기반으로 점수를 매겨 합산합니다. 가중 합계 방식은 벡터 점수와 키워드 점수에 각각 가중치(예: 0.7, 0.3)를 곱해 합칩니다. 쿼리 특성에 따라 가중치를 동적으로 조절하는 방식도 있습니다. 2025년 현재 Pinecone, Weaviate, Qdrant 등 주요 벡터 DB가 네이티브 하이브리드 검색을 지원합니다.
실무에서 하이브리드 검색 도입 시 고려사항이 있습니다. 첫째, 인덱스 구조 - 벡터 인덱스(HNSW)와 역인덱스(BM25)를 모두 유지해야 합니다. 둘째, 지연시간 - 두 검색을 병렬로 실행해도 단일 검색보다 느릴 수 있습니다. 셋째, 가중치 튜닝 - 도메인과 쿼리 유형에 따라 최적의 비율이 다르므로 A/B 테스트가 필요합니다. RAG 시스템에서 하이브리드 검색은 Recall을 10-30% 향상시키는 것으로 알려져 있습니다.
LangChain과 Pinecone을 사용한 하이브리드 검색 RAG 구현 예제입니다.
from pinecone import Pinecone, ServerlessSpec
from langchain_openai import OpenAIEmbeddings
from langchain_community.retrievers import PineconeHybridSearchRetriever
from langchain_text_splitters import RecursiveCharacterTextSplitter
from pinecone_text.sparse import BM25Encoder
import tiktoken
# 1. Pinecone 하이브리드 검색 설정
def setup_hybrid_search():
"""Pinecone 하이브리드 검색 인덱스 생성"""
pc = Pinecone()
# 하이브리드 검색용 인덱스 (sparse + dense 지원)
index_name = "hybrid-search-rag"
if index_name not in pc.list_indexes().names():
pc.create_index(
name=index_name,
dimension=1536, # OpenAI ada-002
metric="dotproduct", # 하이브리드 검색에 필수
spec=ServerlessSpec(
cloud="aws",
region="us-east-1"
)
)
return pc.Index(index_name)
# 2. 문서 인덱싱 (Dense + Sparse 벡터)
def index_documents(documents: list[str], index):
"""문서를 벡터화하고 인덱싱"""
# Dense 임베딩 (시맨틱)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# Sparse 임베딩 (키워드 - BM25)
bm25_encoder = BM25Encoder()
bm25_encoder.fit(documents) # 코퍼스에 맞게 학습
bm25_encoder.dump("bm25_params.json") # 저장
# 텍스트 분할
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50
)
vectors = []
for i, doc in enumerate(documents):
chunks = text_splitter.split_text(doc)
for j, chunk in enumerate(chunks):
# Dense 벡터
dense_vec = embeddings.embed_query(chunk)
# Sparse 벡터 (BM25)
sparse_vec = bm25_encoder.encode_documents([chunk])[0]
vectors.append({
"id": f"doc_{i}_chunk_{j}",
"values": dense_vec,
"sparse_values": sparse_vec,
"metadata": {"text": chunk, "doc_id": i}
})
# 배치 업서트
index.upsert(vectors=vectors, namespace="documents")
return bm25_encoder
# 3. 하이브리드 검색 수행
def hybrid_search(
query: str,
index,
bm25_encoder: BM25Encoder,
top_k: int = 5,
alpha: float = 0.7 # dense 가중치 (1-alpha = sparse)
):
"""하이브리드 검색 실행"""
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# Dense 쿼리 벡터
dense_query = embeddings.embed_query(query)
# Sparse 쿼리 벡터
sparse_query = bm25_encoder.encode_queries([query])[0]
# 하이브리드 검색
results = index.query(
vector=dense_query,
sparse_vector=sparse_query,
top_k=top_k,
include_metadata=True,
namespace="documents"
)
return [(match.metadata["text"], match.score) for match in results.matches]
# 4. RRF (Reciprocal Rank Fusion) 직접 구현
def reciprocal_rank_fusion(
vector_results: list,
keyword_results: list,
k: int = 60 # RRF 상수
) -> list:
"""두 검색 결과를 RRF로 결합"""
fused_scores = {}
# 벡터 검색 결과 스코어링
for rank, (doc_id, _) in enumerate(vector_results):
fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1 / (k + rank + 1)
# 키워드 검색 결과 스코어링
for rank, (doc_id, _) in enumerate(keyword_results):
fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1 / (k + rank + 1)
# 점수 기준 정렬
sorted_results = sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
return sorted_results
# 5. LangChain 하이브리드 Retriever 사용
def create_langchain_hybrid_retriever(index, bm25_encoder):
"""LangChain 하이브리드 검색 리트리버"""
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
retriever = PineconeHybridSearchRetriever(
embeddings=embeddings,
sparse_encoder=bm25_encoder,
index=index,
top_k=5,
alpha=0.7 # dense 비중
)
return retriever
# 사용 예시
if __name__ == "__main__":
# 인덱스 설정
index = setup_hybrid_search()
# 샘플 문서 인덱싱
documents = [
"iPhone 15 Pro Max는 티타늄 프레임과 A17 Pro 칩을 탑재한 애플의 플래그십 스마트폰입니다.",
"갤럭시 S24 울트라는 삼성의 최신 프리미엄 스마트폰으로 AI 기능이 강화되었습니다.",
"스마트폰 가격은 브랜드, 스펙, 출시 시기에 따라 크게 달라집니다."
]
bm25 = index_documents(documents, index)
# 하이브리드 검색 테스트
query = "아이폰 15 프로 맥스 가격"
results = hybrid_search(query, index, bm25)
print("하이브리드 검색 결과:")
for text, score in results:
print(f" [{score:.3f}] {text[:50]}...")
| 방식 | 정확한 키워드 | 의미적 유사성 | 지연시간 | 사용 사례 |
|---|---|---|---|---|
| BM25 (키워드) | 우수 | 불가 | ~5ms | 정확한 용어 검색 |
| 벡터 (시맨틱) | 보통 | 우수 | ~20ms | 의미 기반 검색 |
| 하이브리드 (RRF) | 우수 | 우수 | ~30ms | RAG, 범용 검색 |
| 하이브리드 (가중합) | 좋음~우수 | 좋음~우수 | ~25ms | 튜닝된 도메인 검색 |
BM25의 IDF 계산은 전체 코퍼스 기반입니다. 인덱싱할 문서 전체로 fit()하지 않으면 희귀 단어의 가중치가 잘못 계산됩니다.
alpha=0.5가 모든 도메인에 최적은 아닙니다. 기술 문서, 법률, 의료 등 도메인별로 키워드/시맨틱 비중이 다르므로 반드시 평가 후 조정하세요.
짧은 키워드 쿼리와 긴 자연어 질문에 다른 가중치를 적용하세요. 쿼리 분류기를 추가하면 검색 품질을 더 높일 수 있습니다.