키워드 기반 BM25와 벡터 기반 시맨틱 검색을 결합한 하이브리드 검색의 원리, 구현 방법, 그리고 Reciprocal Rank Fusion 전략을 다룹니다.
5장에서 구축한 벡터 기반 시맨틱 검색은 의미적 유사성을 기반으로 관련 문서를 찾아냅니다. 그러나 벡터 검색만으로는 모든 검색 시나리오를 커버하지 못합니다. 반대로, 전통적인 키워드 검색(BM25) 역시 고유한 한계가 있습니다.
벡터 검색은 의미적 유사성에 집중하므로, 정확한 키워드 매칭이 중요한 경우에 약점을 보입니다. 제품 코드, 오류 번호, 법률 조항 번호 같은 고유 식별자를 검색할 때, 의미적으로 유사한 다른 문서가 더 높은 순위를 차지할 수 있습니다.
질문: "에러 코드 ERR-4521의 원인은?"
벡터 검색 결과 (의미 기반):
1. "에러 코드 ERR-4520 해결 방법..." (유사한 의미, 잘못된 코드)
2. "시스템 오류 진단 가이드..." (관련 주제, 구체적이지 않음)
3. "ERR-4521 발생 시 대응 절차..." (정확한 문서, 3위)
BM25 검색 결과 (키워드 기반):
1. "ERR-4521 발생 시 대응 절차..." (정확한 키워드 매칭, 1위)BM25는 단어의 표면적 형태만 비교하므로, 동의어나 다른 표현으로 같은 의미를 전달하는 문서를 찾지 못합니다.
질문: "직원 복지 혜택은 무엇인가요?"
BM25 검색 결과:
"복지"라는 단어가 포함된 문서만 검색
"사내 복리후생 안내" 문서는 놓칠 수 있음
벡터 검색 결과:
"복지", "복리후생", "혜택", "지원" 등 의미적으로 관련된 모든 문서 검색하이브리드 검색(Hybrid Search)은 BM25와 벡터 검색을 병렬로 실행한 뒤, 두 결과를 융합 알고리즘으로 합치는 방식입니다. 이를 통해 키워드 정밀도와 의미적 이해를 동시에 확보합니다.
사용자 질문
|
+---> BM25 검색 ----> 결과 A (키워드 기반 순위)
| |
+---> 벡터 검색 ----> 결과 B (의미 기반 순위)
|
융합 알고리즘 (RRF 등)
|
최종 순위 결과실무 벤치마크에서 하이브리드 검색은 단일 검색 방식 대비 일관되게 높은 성능을 보입니다. 키워드 매칭의 정밀도와 시맨틱 검색의 재현율을 동시에 확보할 수 있기 때문입니다.
BM25(Best Match 25)는 TF-IDF의 확장판으로, 문서 내 용어 빈도(Term Frequency)와 역문서 빈도(Inverse Document Frequency)를 기반으로 관련성 점수를 계산합니다.
from rank_bm25 import BM25Okapi
import re
class BM25Retriever:
"""BM25 기반 키워드 검색기"""
def __init__(self, documents):
self.documents = documents
# 토큰화 (한국어의 경우 형태소 분석기 권장)
self.tokenized_docs = [
self._tokenize(doc.page_content) for doc in documents
]
self.bm25 = BM25Okapi(self.tokenized_docs)
def _tokenize(self, text):
"""간단한 공백 기반 토큰화"""
# 프로덕션에서는 mecab, kiwi 등 한국어 형태소 분석기 사용 권장
text = text.lower()
tokens = re.findall(r"\w+", text)
return tokens
def search(self, query, top_k=10):
"""BM25 검색"""
tokenized_query = self._tokenize(query)
scores = self.bm25.get_scores(tokenized_query)
# 상위 K개 인덱스
top_indices = sorted(
range(len(scores)),
key=lambda i: scores[i],
reverse=True
)[:top_k]
results = []
for idx in top_indices:
results.append({
"document": self.documents[idx],
"score": float(scores[idx]),
"rank": len(results) + 1
})
return results
# 사용 예시
bm25_retriever = BM25Retriever(all_chunks)
bm25_results = bm25_retriever.search("ERR-4521 에러 원인", top_k=10)한국어 텍스트에서 BM25의 성능은 토큰화 품질에 크게 좌우됩니다. 공백 기반 토큰화는 한국어의 교착어 특성 때문에 한계가 있습니다. KoNLPy의 Mecab이나 Kiwi 같은 한국어 형태소 분석기를 사용하면 검색 품질이 크게 향상됩니다.
RRF(역순위 융합)는 여러 검색 결과의 순위를 결합하는 가장 널리 사용되는 알고리즘입니다. 점수의 절대값이 아닌 순위를 기반으로 하므로, 서로 다른 스케일의 점수를 정규화 없이 결합할 수 있습니다.
각 문서의 RRF 점수는 다음과 같이 계산됩니다.
RRF_score(d) = sum( 1 / (k + rank_i(d)) ) for each retriever i
여기서:
- k: 상수 (보통 60)
- rank_i(d): i번째 검색기에서 문서 d의 순위 (1부터 시작)k 값은 높은 순위 결과에 대한 가중치를 조절합니다. k가 클수록 순위 차이에 따른 점수 차이가 줄어들어 결과가 더 균등해집니다.
def reciprocal_rank_fusion(result_lists, k=60):
"""
여러 검색 결과 리스트를 RRF로 융합
Args:
result_lists: 검색 결과 리스트의 리스트.
각 결과는 (doc_id, document) 튜플
k: RRF 상수 (기본값 60)
Returns:
융합된 순위의 (doc_id, score, document) 리스트
"""
rrf_scores = {}
doc_map = {}
for results in result_lists:
for rank, (doc_id, document) in enumerate(results, start=1):
if doc_id not in rrf_scores:
rrf_scores[doc_id] = 0.0
doc_map[doc_id] = document
rrf_scores[doc_id] += 1.0 / (k + rank)
# 점수 기준 내림차순 정렬
sorted_results = sorted(
rrf_scores.items(),
key=lambda x: x[1],
reverse=True
)
return [
(doc_id, score, doc_map[doc_id])
for doc_id, score in sorted_results
]
# 사용 예시
bm25_results = [(doc.metadata["id"], doc) for doc in bm25_docs]
vector_results = [(doc.metadata["id"], doc) for doc in vector_docs]
fused = reciprocal_rank_fusion([bm25_results, vector_results])
top_5 = fused[:5]BM25 결과: 벡터 결과: RRF 결과 (k=60):
1. 문서A 1. 문서C 문서A: 1/61 + 1/63 = 0.0322
2. 문서B 2. 문서A 문서C: 1/63 + 1/61 = 0.0322
3. 문서C 3. 문서D 문서B: 1/62 + 1/64 = 0.0317
4. 문서D 4. 문서B 문서D: 1/64 + 1/63 = 0.0315
5. 문서E 5. 문서F 문서E: 1/65 = 0.0154
--> 문서A와 문서C가 최상위 (두 검색 모두에서 높은 순위)RRF 외에도, 각 검색 방식의 점수를 정규화한 뒤 가중 합산하는 방법이 있습니다. 이 방식은 도메인 특성에 따라 키워드 검색과 시맨틱 검색의 비중을 조절할 수 있습니다.
import numpy as np
def weighted_hybrid_search(
query,
bm25_retriever,
vector_retriever,
alpha=0.7,
top_k=5
):
"""
가중 하이브리드 검색
Args:
alpha: 벡터 검색 가중치 (0=순수 BM25, 1=순수 벡터)
"""
# BM25 검색
bm25_results = bm25_retriever.search(query, top_k=20)
# 벡터 검색
vector_results = vector_retriever.similarity_search_with_score(
query, k=20
)
# 점수 정규화 (Min-Max)
bm25_scores = [r["score"] for r in bm25_results]
if max(bm25_scores) > min(bm25_scores):
bm25_norm = [
(s - min(bm25_scores)) / (max(bm25_scores) - min(bm25_scores))
for s in bm25_scores
]
else:
bm25_norm = [1.0] * len(bm25_scores)
vector_scores = [score for _, score in vector_results]
if max(vector_scores) > min(vector_scores):
vector_norm = [
(s - min(vector_scores)) / (max(vector_scores) - min(vector_scores))
for s in vector_scores
]
else:
vector_norm = [1.0] * len(vector_scores)
# 가중 합산
combined = {}
for result, norm_score in zip(bm25_results, bm25_norm):
doc_id = result["document"].metadata.get("id")
combined[doc_id] = {
"document": result["document"],
"score": (1 - alpha) * norm_score
}
for (doc, _), norm_score in zip(vector_results, vector_norm):
doc_id = doc.metadata.get("id")
if doc_id in combined:
combined[doc_id]["score"] += alpha * norm_score
else:
combined[doc_id] = {
"document": doc,
"score": alpha * norm_score
}
# 정렬 및 상위 K개 반환
sorted_results = sorted(
combined.values(),
key=lambda x: x["score"],
reverse=True
)
return sorted_results[:top_k]alpha 값의 최적값은 데이터와 사용 사례에 따라 다릅니다. 일반적으로 0.5~0.7 사이에서 시작하여, 평가 메트릭을 기반으로 조정하세요. 기술 문서처럼 정확한 용어가 중요한 경우 BM25 비중을 높이고(alpha=0.5), 자연어 질의가 주를 이루는 경우 벡터 비중을 높이세요(alpha=0.7).
SPLADE(Sparse Lexical and Expansion)는 BM25의 한계를 극복하기 위해 등장한 학습 기반 희소 검색 모델입니다. BERT 같은 언어 모델을 활용하여 쿼리를 확장하고, 각 용어의 중요도를 학습합니다.
BM25: "재택근무 신청" --> {"재택근무": 1, "신청": 1}
SPLADE: "재택근무 신청" --> {"재택근무": 2.1, "신청": 1.8, "원격": 0.9,
"근무": 0.7, "재택": 1.5, "출근": 0.3, ...}SPLADE는 쿼리에 없는 관련 용어도 자동으로 확장하므로, BM25보다 의미적 매칭 능력이 뛰어납니다. 동시에 희소 벡터 형태이므로, 기존 역색인(Inverted Index) 인프라를 활용할 수 있어 효율적입니다.
# BAAI/bge-m3 모델은 밀집+희소 임베딩을 동시에 생성
from FlagEmbedding import BGEM3FlagModel
model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)
# 밀집 벡터와 희소 벡터를 동시에 생성
output = model.encode(
["재택근무 신청 방법을 알려주세요"],
return_dense=True,
return_sparse=True
)
dense_embedding = output["dense_vecs"][0] # 1024차원 밀집 벡터
sparse_embedding = output["lexical_weights"][0] # 희소 벡터 (용어:가중치)Weaviate는 하이브리드 검색을 기본 내장하고 있어 가장 간편하게 사용할 수 있습니다.
import weaviate
from weaviate.classes.query import MetadataQuery, HybridFusion
client = weaviate.connect_to_local()
collection = client.collections.get("Document")
# 하이브리드 검색
results = collection.query.hybrid(
query="에러 코드 ERR-4521 해결 방법",
limit=5,
alpha=0.6, # 0=순수 BM25, 1=순수 벡터
fusion_type=HybridFusion.RELATIVE_SCORE, # 또는 RANKED
return_metadata=MetadataQuery(score=True, explain_score=True)
)
for obj in results.objects:
print(f"점수: {obj.metadata.score:.4f}")
print(f"내용: {obj.properties['content'][:100]}")
print("---")Elasticsearch 8.x는 밀집 벡터 검색(kNN)과 BM25를 모두 지원하므로, 하이브리드 검색을 구현할 수 있습니다.
from elasticsearch import Elasticsearch
es = Elasticsearch("http://localhost:9200")
def hybrid_search_es(query, query_vector, index="documents", k=5):
"""Elasticsearch 하이브리드 검색"""
response = es.search(
index=index,
body={
"size": k,
"query": {
"bool": {
"should": [
# BM25 검색
{
"multi_match": {
"query": query,
"fields": ["content", "title"],
"boost": 0.3
}
},
# kNN 벡터 검색
{
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.query_vector, 'embedding') + 1.0",
"params": {
"query_vector": query_vector
}
},
"boost": 0.7
}
}
]
}
}
}
)
return response["hits"]["hits"]# 파라미터 그리드 서치
alphas = [0.3, 0.5, 0.6, 0.7, 0.8]
k_values = [30, 60, 100]
best_score = 0
best_params = None
for alpha in alphas:
for k in k_values:
# 테스트 셋에서 평가
results = evaluate_hybrid_search(
test_queries,
test_relevant_docs,
alpha=alpha,
rrf_k=k
)
if results["ndcg@10"] > best_score:
best_score = results["ndcg@10"]
best_params = {"alpha": alpha, "rrf_k": k}
print(f"최적 파라미터: {best_params}")
print(f"NDCG@10: {best_score:.4f}")하이브리드 검색의 alpha 파라미터를 테스트 셋에서 튜닝할 때, 반드시 별도의 검증 셋(Validation Set)을 사용하세요. 테스트 셋으로 직접 튜닝하면 과적합(Overfitting) 위험이 있습니다.
하이브리드 검색은 BM25의 키워드 정밀도와 벡터 검색의 의미적 이해를 결합하여 단일 검색 방식의 한계를 극복합니다. RRF는 서로 다른 스케일의 점수를 순위 기반으로 안정적으로 융합하는 표준 알고리즘이며, 가중 합산 방식은 도메인에 따라 세밀한 비중 조절이 가능합니다. SPLADE 같은 학습된 희소 검색은 BM25의 어휘 한계를 극복하는 진보된 접근법입니다.
다음 장에서는 검색된 결과의 순위를 재조정하는 리랭킹(Reranking)에 대해 다룹니다. 하이브리드 검색이 넓게 가져온 후보 문서 중에서, 리랭커가 정밀하게 최상위 결과를 선별하는 과정을 살펴보겠습니다.
이 글이 도움이 되셨나요?
Cross-Encoder 리랭킹의 원리, Cohere Rerank API, 오픈소스 리랭커 비교, 그리고 프로덕션 환경에서의 효과적인 리랭킹 전략을 다룹니다.
문서 로딩부터 임베딩 생성, 벡터 저장, 유사도 검색까지 RAG 파이프라인의 전체 흐름을 실제 코드로 구현합니다.
RAGAS, 충실도, 컨텍스트 정밀도 등 RAG 시스템의 품질을 객관적으로 측정하는 평가 프레임워크와 핵심 메트릭을 다룹니다.