· 40분 읽기
하이브리드 검색 완전 가이드: BM25, 벡터 검색, 그리고 RRF
검색 품질을 두고 키워드 검색과 벡터 검색 중 하나를 고르라는 질문은 이제 낡은 질문입니다. 키워드 검색(BM25)은 정확한 용어 매칭에 강하지만 동의어를 놓치고, 벡터 검색은 의미를 이해하지만 정확한 키워드 매칭에 약합니다. 그래서 현대 검색 엔진은 두 방식을 병렬로 실행한 뒤 결과를 하나로 합치는 하이브리드 검색을 표준으로 채택했습니다. 이 글에서는 검색 파이프라인의 흐름 그대로 1부 키워드 랭킹(BM25), 2부 벡터 검색(Faiss와 OpenSearch k-NN), 3부 랭크 퓨전(RRF) 순서로 하이브리드 검색의 전체 그림을 정리합니다.
하이브리드 검색 파이프라인 한눈에 보기
flowchart LR
Query[사용자 쿼리] --> BM25[BM25 검색<br/>Top 100]
Query --> Vector[벡터 검색<br/>Top 100]
BM25 --> Fusion[RRF<br/>Reciprocal Rank Fusion]
Vector --> Fusion
Fusion --> Final[최종 결과<br/>Top 10]
| 검색 방식 | 강점 | 약점 |
|---|---|---|
| BM25 | 정확한 용어 매칭 | 동의어, 문맥 이해 불가 |
| 벡터 검색 | 의미적 유사성 감지 | 정확한 키워드 매칭 약함 |
| RRF 하이브리드 | 두 장점 결합 | 파이프라인 복잡도 증가 |
BM25는 "자동차"로 검색하면 "차량"만 언급한 문서를 놓치고, 벡터 검색은 "iPhone 15 Pro"를 검색했는데 의미가 비슷한 "iPhone 14" 문서를 상위에 올리기도 합니다. 하이브리드 검색은 이 두 실패 모드를 서로 보완하게 만드는 구조입니다.
1부. 키워드 랭킹: BM25
BM25(Best Matching 25)는 Elasticsearch, OpenSearch, Lucene 등 거의 모든 현대 검색 엔진의 기본 랭킹 알고리즘입니다. 1994년 Stephen Robertson과 Karen Spärck Jones가 제안한 확률적 정보 검색 모델로, 고전적인 TF-IDF의 한계를 극복했습니다.
TF-IDF에서 BM25로
TF-IDF(Term Frequency - Inverse Document Frequency)는 정보 검색의 고전적인 알고리즘입니다.
$$
\text{TF-IDF}(t, d) = \text{TF}(t, d) \times \text{IDF}(t)
$$
하지만 TF-IDF에는 치명적인 문제가 있습니다.
- 용어 빈도의 무한 성장: 단어가 100번 등장하면 1번 등장한 것보다 100배 중요할까요?
- 문서 길이 무시: 10페이지 문서와 1페이지 문서를 동등하게 취급합니다.
BM25는 두 가지 개선으로 이를 해결합니다.
- 용어 빈도 포화(Saturation): 빈도가 높아질수록 영향력 증가 속도가 감소
- 문서 길이 정규화: 긴 문서에 불리하지 않도록 보정
graph LR
subgraph TF-IDF
A1["TF 10 → 점수 10"] --> A2["TF 100 → 점수 100"]
end
subgraph BM25
B1["TF 10 → 점수 2.5"] --> B2["TF 100 → 점수 2.9"]
end
수학적 공식
쿼리 $Q$와 문서 $D$에 대한 BM25 점수는 다음과 같습니다.
$$
\text{BM25}(D, Q) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}
$$
| 기호 | 의미 | 설명 |
|---|---|---|
| $q_i$ | 쿼리 용어 | 쿼리에 포함된 i번째 단어 |
| $f(q_i, D)$ | TF | 문서 D에서 $q_i$의 등장 횟수 |
| $|D|$ | 문서 길이 | 문서 D의 단어 수 |
| $\text{avgdl}$ | 평균 문서 길이 | 전체 문서의 평균 단어 수 |
| $k_1$ | 포화 파라미터 | 기본값: 1.2 (범위: 0~3) |
| $b$ | 길이 정규화 | 기본값: 0.75 (범위: 0~1) |
IDF는 다음과 같이 계산합니다.
$$
\text{IDF}(q_i) = \ln\left(\frac{N - n(q_i) + 0.5}{n(q_i) + 0.5} + 1\right)
$$
- $N$: 전체 문서 수
- $n(q_i)$: 용어 $q_i$를 포함한 문서 수
파라미터 튜닝: k1과 b
k1은 용어 빈도(TF)가 점수에 미치는 영향을 조절합니다.
xychart-beta
title "k1 값에 따른 TF 포화 곡선"
x-axis "Term Frequency" [0, 5, 10, 15, 20, 25, 30]
y-axis "Score Contribution" 0 --> 3.5
line "k1=0.5" [0, 1.5, 2.0, 2.2, 2.3, 2.4, 2.4]
line "k1=1.2" [0, 1.8, 2.4, 2.6, 2.7, 2.8, 2.9]
line "k1=2.0" [0, 2.0, 2.7, 3.0, 3.1, 3.2, 3.3]
| k1 값 | 특성 | 적합한 상황 |
|---|---|---|
| 0.5 이하 | 빠른 포화 | 불용어가 많은 콘텐츠 |
| 1.2 (기본) | 균형 | 일반적인 텍스트 검색 |
| 2.0 이상 | 느린 포화 | 전문 용어가 많은 기술 문서 |
b는 문서 길이가 점수에 미치는 영향을 조절합니다.
| b 값 | 의미 | 적합한 상황 |
|---|---|---|
| 0 | 길이 무시 | 모든 문서가 비슷한 길이 |
| 0.75 (기본) | 적절한 패널티 | 일반적인 웹 문서 |
| 1 | 강한 패널티 | 긴 문서가 불리해야 할 때 |
Python 구현
import math
from collections import Counter
from typing import List, Dict
class BM25:
def __init__(self, corpus: List[List[str]], k1: float = 1.2, b: float = 0.75):
self.k1 = k1
self.b = b
self.corpus = corpus
self.corpus_size = len(corpus)
# 문서 길이 계산
self.doc_lengths = [len(doc) for doc in corpus]
self.avgdl = sum(self.doc_lengths) / self.corpus_size
# IDF 계산
self.doc_freqs = self._calc_doc_freqs()
self.idf = self._calc_idf()
# TF 캐싱
self.doc_term_freqs = [Counter(doc) for doc in corpus]
def _calc_doc_freqs(self) -> Dict[str, int]:
"""각 용어가 등장하는 문서 수 계산"""
df = {}
for doc in self.corpus:
for term in set(doc):
df[term] = df.get(term, 0) + 1
return df
def _calc_idf(self) -> Dict[str, float]:
"""IDF 점수 계산"""
idf = {}
for term, freq in self.doc_freqs.items():
idf[term] = math.log(
(self.corpus_size - freq + 0.5) / (freq + 0.5) + 1
)
return idf
def score(self, query: List[str], doc_idx: int) -> float:
"""단일 문서에 대한 BM25 점수 계산"""
score = 0.0
doc_len = self.doc_lengths[doc_idx]
term_freqs = self.doc_term_freqs[doc_idx]
for term in query:
if term not in self.idf:
continue
tf = term_freqs.get(term, 0)
idf = self.idf[term]
# BM25 공식
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl)
score += idf * (numerator / denominator)
return score
def search(self, query: List[str], top_k: int = 10) -> List[tuple]:
"""전체 문서에서 상위 k개 검색"""
scores = [(i, self.score(query, i)) for i in range(self.corpus_size)]
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
사용 예제는 다음과 같습니다.
# 샘플 문서 (토큰화된 상태)
corpus = [
["machine", "learning", "is", "subset", "of", "artificial", "intelligence"],
["deep", "learning", "uses", "neural", "networks", "for", "learning"],
["natural", "language", "processing", "is", "part", "of", "ai"],
["machine", "learning", "algorithms", "learn", "from", "data"],
]
# BM25 인덱스 생성
bm25 = BM25(corpus)
# 검색
query = ["machine", "learning"]
results = bm25.search(query, top_k=3)
for doc_idx, score in results:
print(f"문서 {doc_idx}: {score:.4f} - {' '.join(corpus[doc_idx][:5])}...")
문서 3: 1.2847 - machine learning algorithms learn from...
문서 0: 1.0923 - machine learning is subset of...
문서 1: 0.4651 - deep learning uses neural networks...
Elasticsearch에서 BM25 설정
Elasticsearch는 BM25를 기본 유사도 알고리즘으로 사용하며, 인덱스 설정에서 k1과 b를 조정할 수 있습니다.
PUT /my-index
{
"settings": {
"index": {
"similarity": {
"custom_bm25": {
"type": "BM25",
"k1": 1.5,
"b": 0.8
}
}
}
},
"mappings": {
"properties": {
"content": {
"type": "text",
"similarity": "custom_bm25"
}
}
}
}
필드별로 다른 설정을 적용할 수도 있습니다. 제목처럼 짧은 필드는 길이 정규화를 약하게, 설명처럼 긴 필드는 강하게 거는 식입니다.
PUT /products
{
"settings": {
"index": {
"similarity": {
"title_similarity": {
"type": "BM25",
"k1": 1.2,
"b": 0.3
},
"description_similarity": {
"type": "BM25",
"k1": 1.5,
"b": 0.9
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"similarity": "title_similarity"
},
"description": {
"type": "text",
"similarity": "description_similarity"
}
}
}
}
점수가 어떻게 계산되었는지는 _explain API로 확인합니다.
GET /my-index/_explain/1
{
"query": {
"match": { "content": "machine learning" }
}
}
{
"explanation": {
"value": 2.8547,
"description": "weight(content:learning in 0) [PerFieldSimilarity]",
"details": [
{
"value": 1.8,
"description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5))"
},
{
"value": 1.58,
"description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl))"
}
]
}
}
실무 최적화
도메인별 k1, b 튜닝: 평가 데이터가 있다면 그리드 서치로 최적 조합을 찾습니다.
# A/B 테스트를 통한 최적 파라미터 탐색
param_grid = {
'k1': [0.5, 1.0, 1.2, 1.5, 2.0],
'b': [0.0, 0.25, 0.5, 0.75, 1.0]
}
best_params = None
best_ndcg = 0
for k1 in param_grid['k1']:
for b in param_grid['b']:
bm25 = BM25(corpus, k1=k1, b=b)
ndcg = evaluate_ndcg(bm25, test_queries, ground_truth)
if ndcg > best_ndcg:
best_ndcg = ndcg
best_params = (k1, b)
print(f"최적 파라미터: k1={best_params[0]}, b={best_params[1]}")
필드 부스팅: 필드별 중요도를 가중치로 반영합니다.
GET /products/_search
{
"query": {
"multi_match": {
"query": "wireless headphones",
"fields": ["title^3", "description^1", "brand^2"],
"type": "best_fields"
}
}
}
함수 스코어 결합: 판매량, 최신성 같은 비즈니스 신호를 BM25 점수와 곱해 최종 점수를 만듭니다.
GET /products/_search
{
"query": {
"function_score": {
"query": { "match": { "title": "laptop" } },
"functions": [
{
"field_value_factor": {
"field": "sales_count",
"factor": 1.2,
"modifier": "sqrt"
}
},
{
"gauss": {
"created_at": {
"origin": "now",
"scale": "30d"
}
}
}
],
"score_mode": "multiply"
}
}
}
BM25 변형과 도메인별 권장값
낮은 TF에 과도한 페널티를 주지 않는 BM25+, 긴 문서 패널티를 완화한 BM25L 같은 변형도 있습니다.
$$
\text{BM25+} = \text{BM25} + \delta \cdot \text{IDF}(q_i)
$$
여기서 $\delta$는 하한 보정 상수이며 일반적으로 1을 사용합니다. BM25L은 TF 항을 다음과 같이 정규화합니다.
$$
c(q_i, D) = \frac{f(q_i, D)}{1 - b + b \cdot \frac{|D|}{\text{avgdl}}}
$$
도메인별 출발점으로 삼을 만한 설정은 다음과 같습니다.
| 상황 | 권장 설정 |
|---|---|
| 일반 웹 검색 | k1=1.2, b=0.75 (기본값) |
| 짧은 제목 검색 | k1=1.2, b=0.3 |
| 긴 기술 문서 | k1=1.5~2.0, b=0.75 |
| 상품 설명 | k1=1.2, b=0.9 |
| FAQ/Q&A | k1=0.8, b=0.4 |
2부. 벡터 검색: Faiss와 OpenSearch k-NN
키워드 매칭이 놓치는 의미적 유사성은 임베딩 벡터 검색으로 잡습니다. 실무에서 가장 널리 쓰이는 선택지는 순수 벡터 검색 라이브러리인 Faiss와 벡터 검색을 내장한 분산 검색 엔진 OpenSearch입니다.
| 항목 | Faiss | OpenSearch |
|---|---|---|
| 개발사 | Meta (Facebook AI Research) | AWS / OpenSearch Project |
| 유형 | 라이브러리 | 분산 검색 엔진 |
| 언어 | C++ (Python/Go 바인딩) | Java (REST API) |
| 라이선스 | MIT | Apache 2.0 |
| 주요 용도 | 순수 벡터 검색 | 벡터 + 전문 검색 통합 |
Faiss: 순수 벡터 검색 라이브러리
Faiss는 수십억 개의 벡터를 밀리초 단위로 검색할 수 있도록 최적화된 고성능 유사도 검색 라이브러리입니다.
- GPU 가속: CUDA를 활용한 병렬 처리로 CPU 대비 최대 100배 이상 빠른 검색
- 다양한 인덱스 타입: 정확도와 속도 사이의 트레이드오프를 세밀하게 조절 가능
- 메모리 효율: Product Quantization(PQ)를 통한 벡터 압축으로 메모리 사용량 최소화
- 인메모리 처리: 디스크 I/O 없이 RAM에서 직접 검색
graph TD
subgraph Exact["정확한 검색 (Exact)"]
Flat["IndexFlatL2 / IndexFlatIP"]
end
subgraph Approximate["근사 검색 (Approximate)"]
IVF["IVF (Inverted File Index)"]
HNSW["HNSW (Hierarchical NSW)"]
PQ["PQ (Product Quantization)"]
end
Flat --> |"100% 정확, 느림"| Use1["소규모 데이터셋"]
IVF --> |"클러스터 기반"| Use2["대규모 데이터셋"]
HNSW --> |"그래프 기반"| Use3["고속 검색"]
PQ --> |"압축 벡터"| Use4["메모리 제약 환경"]
기본 사용법은 단순합니다.
import numpy as np
import faiss
# 데이터 준비: 10만 개의 128차원 벡터
d = 128 # 차원
nb = 100000 # 데이터베이스 크기
nq = 10 # 쿼리 수
np.random.seed(1234)
xb = np.random.random((nb, d)).astype('float32')
xq = np.random.random((nq, d)).astype('float32')
# 1. Flat Index (정확한 검색)
index_flat = faiss.IndexFlatL2(d)
index_flat.add(xb)
k = 5 # 상위 5개 결과
D, I = index_flat.search(xq, k)
print(f"가장 가까운 이웃: {I[0]}")
print(f"거리: {D[0]}")
GPU 가속은 인덱스 변환 한 줄로 적용됩니다.
import faiss
# GPU 리소스 초기화
res = faiss.StandardGpuResources()
# CPU 인덱스 → GPU 인덱스 변환
index_flat = faiss.IndexFlatL2(d)
gpu_index = faiss.index_cpu_to_gpu(res, 0, index_flat) # GPU 0번 사용
# GPU에서 검색 (동일한 API)
gpu_index.add(xb)
D, I = gpu_index.search(xq, k)
복잡한 인덱스는 Index Factory 문자열로 조합합니다.
# 문자열로 인덱스 구조 정의
# "IVF100,PQ8" = 100개 클러스터 + 8바이트 PQ 압축
index = faiss.index_factory(d, "IVF100,PQ8")
# 훈련 필요
index.train(xb)
index.add(xb)
# 검색
D, I = index.search(xq, k)
OpenSearch: 통합 검색 플랫폼
OpenSearch는 Elasticsearch의 오픈소스 포크로, 전문 검색과 벡터 검색을 모두 지원합니다.
- 하이브리드 검색: 키워드 검색과 벡터 검색을 단일 쿼리로 결합
- 분산 아키텍처: 클러스터 기반 수평 확장
- 다중 엔진 지원: Faiss, Lucene, NMSLIB 중 선택 가능
- 필터링 통합: 메타데이터 기반 pre/post 필터링
- 관리 용이성: REST API로 모든 작업 가능
| 엔진 | 알고리즘 | 훈련 필요 | 특징 |
|---|---|---|---|
| Lucene | HNSW | 아니오 | 기본 엔진, 안정적 |
| Faiss | HNSW, IVF | IVF만 필요 | 고성능, 다양한 옵션 |
| NMSLIB | HNSW | 아니오 | 레거시, 호환성 |
Faiss 엔진과 HNSW 알고리즘으로 k-NN 인덱스를 만드는 예시입니다.
PUT /embedding-index
{
"settings": {
"index": {
"knn": true,
"knn.algo_param.ef_search": 100
}
},
"mappings": {
"properties": {
"title": { "type": "text" },
"embedding": {
"type": "knn_vector",
"dimension": 768,
"method": {
"name": "hnsw",
"space_type": "l2",
"engine": "faiss",
"parameters": {
"ef_construction": 128,
"m": 24
}
}
}
}
}
}
문서 색인과 k-NN 검색은 일반 검색과 같은 REST API를 사용합니다.
POST /embedding-index/_doc
{
"title": "Introduction to Vector Search",
"embedding": [0.12, -0.34, 0.56, ...] // 768차원 벡터
}
GET /embedding-index/_search
{
"size": 10,
"query": {
"knn": {
"embedding": {
"vector": [0.11, -0.32, 0.54, ...],
"k": 10
}
}
}
}
무엇을 선택할까
성능과 운영 특성이 뚜렷하게 갈립니다.
| 시나리오 | Faiss | OpenSearch |
|---|---|---|
| 순수 벡터 검색 (10억 벡터) | 매우 우수 | 보통 |
| GPU 가속 | 네이티브 지원 | 미지원 |
| 분산 검색 | 직접 구현 필요 | 내장 |
| 실시간 업데이트 | 인덱스 재구축 필요 | 실시간 반영 |
| 항목 | Faiss | OpenSearch |
|---|---|---|
| 설치 | pip install | 클러스터 구성 |
| 모니터링 | 직접 구현 | OpenSearch Dashboards |
| 백업/복구 | 직접 구현 | 스냅샷 API |
| 고가용성 | 직접 구현 | 레플리카 자동화 |
graph LR
subgraph Faiss방식["Faiss (직접 구현)"]
App1[App Server 1] --> Shard1[(Shard 1)]
App2[App Server 2] --> Shard2[(Shard 2)]
App3[App Server 3] --> Shard3[(Shard 3)]
Router[Router] --> App1
Router --> App2
Router --> App3
end
subgraph OpenSearch방식["OpenSearch (내장)"]
Client[Client] --> Coordinator[Coordinator Node]
Coordinator --> Data1[Data Node 1]
Coordinator --> Data2[Data Node 2]
Coordinator --> Data3[Data Node 3]
end
Faiss가 유리한 경우는 다음과 같습니다.
- 초저지연이 필요한 경우: 밀리초 단위 응답이 중요한 실시간 추천
- GPU 자원이 풍부한 경우: ML 인프라에 GPU가 이미 존재
- 순수 벡터 검색만 필요한 경우: 키워드 검색이 불필요
- 커스텀 최적화가 필요한 경우: 인덱스 파라미터 세밀 조정
# Faiss 최적 사용 예: 실시간 추천 시스템
def get_recommendations(user_embedding, k=10):
D, I = gpu_index.search(user_embedding.reshape(1, -1), k)
return product_ids[I[0]] # 밀리초 내 반환
OpenSearch가 유리한 경우는 다음과 같습니다.
- 하이브리드 검색이 필요한 경우: 키워드 + 벡터 검색 통합
- 운영 편의성이 중요한 경우: 모니터링, 백업 등 운영 기능 필요
- 기존 Elasticsearch 인프라가 있는 경우: 마이그레이션 용이
- 실시간 데이터 업데이트가 필요한 경우: 인덱스 재구축 없이 갱신
RAG 파이프라인처럼 메타데이터 필터와 벡터 검색을 함께 쓰는 경우가 대표적입니다.
{
"query": {
"bool": {
"filter": { "term": { "category": "technical" } },
"must": {
"knn": {
"embedding": {
"vector": [/* query embedding */],
"k": 20
}
}
}
}
}
}
flowchart LR
User[User Query] --> Embed[Embedding Model]
Embed --> Search[OpenSearch k-NN]
Search --> Context[Retrieved Context]
Context --> LLM[LLM<br/>GPT/Claude]
LLM --> Response[Final Response]
두 도구는 경쟁 관계라기보다 상호 보완적입니다. OpenSearch는 내부적으로 Faiss를 엔진으로 사용할 수 있으므로, 복합적인 검색 요구사항에는 OpenSearch를, 순수 성능이 중요한 ML 파이프라인에는 Faiss를 직접 사용하는 조합이 일반적입니다.
3부. 랭크 퓨전: RRF
**Reciprocal Rank Fusion(RRF)**은 여러 검색 시스템의 결과를 단일 랭킹으로 통합하는 알고리즘입니다. 하이브리드 검색의 마지막 조각으로, OpenSearch 2.19+, Elasticsearch 8.8+ 등 주요 검색 엔진이 공식 지원합니다.
점수가 아니라 순위로 합치는 이유
RRF는 각 검색 시스템의 점수(score)가 아닌 순위(rank)를 기반으로 결과를 결합합니다.
- 점수 정규화 불필요: BM25 점수(0
∞)와 코사인 유사도(01)를 직접 비교할 필요가 없습니다. - 이상치에 강건: 극단적인 점수 값의 영향을 최소화합니다.
- 단순함: 파라미터 하나($k$)만으로 동작합니다.
수식과 상수 k
문서 $d$에 대한 RRF 점수는 다음과 같습니다.
$$
\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + r(d)}
$$
- $R$: 검색 결과 리스트의 집합 (BM25 결과, 벡터 검색 결과 등)
- $r(d)$: 리스트 $r$에서 문서 $d$의 순위 (1부터 시작)
- $k$: 랭킹 상수 (기본값: 60)
k는 상위 순위와 하위 순위 간의 점수 차이를 조절합니다.
| k 값 | 특성 | 1위 vs 10위 점수 비율 |
|---|---|---|
| 1 | 상위 순위 극도로 우대 | 5.5배 |
| 60 (기본) | 균형 잡힌 가중치 | 1.16배 |
| 1000 | 순위 차이 거의 무시 | 1.01배 |
xychart-beta
title "k 값에 따른 순위별 점수 기여도"
x-axis "Rank" [1, 5, 10, 20, 30, 50]
y-axis "Score Contribution" 0 --> 0.5
line "k=1" [0.50, 0.17, 0.09, 0.05, 0.03, 0.02]
line "k=60" [0.016, 0.015, 0.014, 0.013, 0.011, 0.009]
손으로 계산해 보기
두 검색 시스템(BM25, 벡터)의 결과를 RRF(k=60)로 결합하면 다음과 같습니다.
| 문서 | BM25 순위 | 벡터 순위 | RRF 점수 (k=60) |
|---|---|---|---|
| A | 1 | 5 | $\frac{1}{61} + \frac{1}{65} = 0.0318$ |
| B | 3 | 1 | $\frac{1}{63} + \frac{1}{61} = 0.0322$ |
| C | 2 | 10 | $\frac{1}{62} + \frac{1}{70} = 0.0304$ |
| D | - | 2 | $\frac{0}{-} + \frac{1}{62} = 0.0161$ |
최종 순위는 B > A > C > D입니다. 문서 B는 BM25에서 3위였지만 벡터 검색에서 1위였기 때문에 최종 1위가 됩니다. 한쪽 시스템에만 등장한 문서(D)는 자연스럽게 점수가 낮아집니다.
Python 구현
from typing import List, Any
from collections import defaultdict
def reciprocal_rank_fusion(
rankings: List[List[Any]],
k: int = 60
) -> List[tuple]:
"""
여러 검색 결과 리스트를 RRF로 결합
Args:
rankings: 각 검색 시스템의 결과 리스트 (문서 ID 순서대로)
k: 랭킹 상수 (기본값: 60)
Returns:
(문서 ID, RRF 점수) 튜플의 정렬된 리스트
"""
rrf_scores = defaultdict(float)
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
rrf_scores[doc_id] += 1 / (k + rank)
# 점수 기준 내림차순 정렬
sorted_results = sorted(
rrf_scores.items(),
key=lambda x: x[1],
reverse=True
)
return sorted_results
# 사용 예시
bm25_results = ["doc_A", "doc_B", "doc_C", "doc_D", "doc_E"]
vector_results = ["doc_C", "doc_A", "doc_F", "doc_B", "doc_G"]
fused_results = reciprocal_rank_fusion([bm25_results, vector_results])
for doc_id, score in fused_results[:5]:
print(f"{doc_id}: {score:.4f}")
doc_A: 0.0325
doc_C: 0.0323
doc_B: 0.0318
doc_F: 0.0159
doc_D: 0.0156
검색 시스템별로 신뢰도가 다르다면 가중치를 적용합니다.
def weighted_rrf(
rankings: List[List[Any]],
weights: List[float] = None,
k: int = 60
) -> List[tuple]:
"""
가중치가 적용된 RRF
Args:
rankings: 검색 결과 리스트들
weights: 각 검색 시스템의 가중치 (합이 1이 아니어도 됨)
k: 랭킹 상수
"""
if weights is None:
weights = [1.0] * len(rankings)
rrf_scores = defaultdict(float)
for ranking, weight in zip(rankings, weights):
for rank, doc_id in enumerate(ranking, start=1):
rrf_scores[doc_id] += weight / (k + rank)
return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
# BM25에 더 높은 가중치 부여
results = weighted_rrf(
[bm25_results, vector_results],
weights=[0.7, 0.3], # BM25: 70%, Vector: 30%
k=60
)
셋 이상의 검색 시스템도 같은 방식으로 결합합니다.
# BM25 + Dense Vector + Sparse Vector 결합
bm25_results = ["A", "B", "C", "D", "E"]
dense_results = ["C", "A", "F", "B", "G"]
sparse_results = ["B", "D", "A", "H", "C"]
# 다른 가중치 (BM25: 40%, Dense: 40%, Sparse: 20%)
fused_weighted = weighted_rrf(
[bm25_results, dense_results, sparse_results],
weights=[0.4, 0.4, 0.2]
)
OpenSearch 하이브리드 파이프라인
OpenSearch 2.19+에서는 score-ranker-processor로 RRF 기반 하이브리드 검색 파이프라인을 만듭니다.
PUT /_search/pipeline/hybrid-rrf-pipeline
{
"description": "Hybrid search with RRF",
"phase_results_processors": [
{
"score-ranker-processor": {
"combination": {
"technique": "rrf",
"rank_constant": 60
}
}
}
]
}
검색 시에는 hybrid 쿼리에 키워드 쿼리와 k-NN 쿼리를 나란히 넣고 파이프라인을 지정합니다.
POST /products/_search?search_pipeline=hybrid-rrf-pipeline
{
"query": {
"hybrid": {
"queries": [
{
"match": {
"title": "wireless headphones"
}
},
{
"knn": {
"embedding": {
"vector": [0.12, -0.34, 0.56, ...],
"k": 100
}
}
}
]
}
},
"size": 10
}
순위가 아니라 정규화된 점수로 결합하고 싶다면 normalization-processor를 사용합니다(OpenSearch 2.10+).
PUT /_search/pipeline/normalized-hybrid-pipeline
{
"description": "Normalized hybrid search",
"phase_results_processors": [
{
"normalization-processor": {
"normalization": {
"technique": "min_max"
},
"combination": {
"technique": "arithmetic_mean",
"parameters": {
"weights": [0.4, 0.6]
}
}
}
}
]
}
Elasticsearch RRF
Elasticsearch 8.8+에서는 retriever 문법으로 RRF를 사용합니다.
POST /products/_search
{
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"match": { "title": "laptop" }
}
}
},
{
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ...],
"k": 100,
"num_candidates": 200
}
}
],
"rank_constant": 60,
"rank_window_size": 100
}
},
"size": 10
}
retriever는 중첩이 가능해서, 제목/본문 필드별 RRF 결과를 다시 벡터 검색과 RRF로 합치는 다단계 구성도 만들 수 있습니다.
Two-Stage Retrieval
대량 트래픽 환경에서는 빠른 BM25로 후보를 좁힌 뒤 벡터 리랭킹과 RRF를 적용하는 2단계 구성이 효율적입니다.
flowchart TD
Query[쿼리] --> Stage1["Stage 1: 후보 검색<br/>(BM25 Top-1000)"]
Stage1 --> Stage2["Stage 2: 리랭킹<br/>(Vector + RRF)"]
Stage2 --> Final["최종 Top-10"]
def two_stage_search(query: str, final_k: int = 10) -> List[dict]:
# Stage 1: 빠른 BM25로 후보 검색
candidates = bm25_search(query, top_k=1000)
candidate_ids = [doc["id"] for doc in candidates]
# Stage 2: 후보 내에서 벡터 리랭킹
query_embedding = embed(query)
vector_scores = compute_similarities(query_embedding, candidate_ids)
# RRF 적용
bm25_ranking = candidate_ids # 이미 BM25 순위대로 정렬됨
vector_ranking = sorted(candidate_ids, key=lambda x: vector_scores[x], reverse=True)
fused = reciprocal_rank_fusion([bm25_ranking, vector_ranking])
return fused[:final_k]
BM25와 벡터 검색을 비동기로 병렬 실행하고, 동일 쿼리에 대한 퓨전 결과를 캐싱하면 지연 시간을 더 줄일 수 있습니다. k 값은 nDCG 같은 오프라인 지표로 20, 40, 60, 80을 비교해 고르는 방식이 일반적입니다.
RRF vs 다른 퓨전 방법
| 방법 | 장점 | 단점 |
|---|---|---|
| RRF | 정규화 불필요, 단순함 | 점수 정보 손실 |
| Linear Combination | 점수 보존 | 정규화 필요 |
| Borda Count | 이해 쉬움 | 동점 처리 복잡 |
| Learned Fusion | 최적화 가능 | 학습 데이터 필요 |
점수의 크기 자체가 의미 있는 도메인이라면 Min-Max 정규화 후 가중 합산하는 Linear Combination이 낫고, 학습 데이터가 충분하다면 학습 기반 퓨전으로 넘어갈 수 있습니다. 그 전까지 RRF는 가장 안전한 기본값입니다.
도메인별 권장 설정
| 도메인 | k 값 | BM25 가중치 | 벡터 가중치 |
|---|---|---|---|
| E-commerce | 60 | 0.4 | 0.6 |
| 기술 문서 | 40 | 0.6 | 0.4 |
| Q&A/FAQ | 20 | 0.3 | 0.7 |
| 법률/의료 | 80 | 0.7 | 0.3 |
| 뉴스/블로그 | 60 | 0.5 | 0.5 |
도입 후에는 A/B 테스트로 검증합니다.
- 다양한 k 값 (20, 40, 60, 80) 비교
- BM25/벡터 가중치 조합 테스트
- 쿼리 유형별 성능 분석 (단일 키워드 vs 문장형)
- 클릭률(CTR), 체류 시간 등 비즈니스 메트릭 측정
정리
BM25는 제안된 지 30년이 지난 지금도 수학적 견고함과 실용적 성능 덕분에 검색의 표준으로 남아 있고, 벡터 검색은 그 위에 의미 이해를 더했으며, RRF는 둘을 정규화 없이 묶어 줍니다. 언제 무엇을 조합할지는 다음 표를 출발점으로 삼으면 됩니다.
| 상황 | 권장 조합 |
|---|---|
| 정확한 키워드 중심 검색 (상품 코드, 로그, 오류 메시지) | BM25 단독 또는 BM25 가중치 상향 |
| 의미 중심 검색 (FAQ, Q&A) | 벡터 가중치 상향 + RRF (k=20~40) |
| 일반 검색 서비스 첫 도입 | BM25 + 벡터 + RRF (k=60, 동일 가중치) |
| 초저지연 추천, GPU 인프라 보유 | Faiss 직접 사용 |
| 운영 편의와 하이브리드 통합 우선 | OpenSearch/Elasticsearch 하이브리드 파이프라인 |
| 정밀 튜닝이 필요한 서비스 | 가중 RRF + A/B 테스트 |
| 대량 트래픽 | Two-Stage Retrieval + 캐싱 |
시작은 단순하게, 기본값의 RRF 하이브리드로 출발해서 지표가 쌓이면 k1/b, k, 가중치를 도메인에 맞게 조여 가는 순서를 권합니다.
참고 자료
- Robertson, S., & Zaragoza, H. (2009). The Probabilistic Relevance Framework: BM25 and Beyond
- Cormack, G. V., Clarke, C. L. A., & Buettcher, S. (2009). Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods
- Elasticsearch Similarity Module
- Elasticsearch RRF
- OpenSearch k-NN Documentation
- OpenSearch Score Ranker Processor
- Faiss GitHub Repository