sayu.day

하이브리드 검색 완전 가이드: 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에는 치명적인 문제가 있습니다.

  1. 용어 빈도의 무한 성장: 단어가 100번 등장하면 1번 등장한 것보다 100배 중요할까요?
  2. 문서 길이 무시: 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가 유리한 경우는 다음과 같습니다.

  1. 초저지연이 필요한 경우: 밀리초 단위 응답이 중요한 실시간 추천
  2. GPU 자원이 풍부한 경우: ML 인프라에 GPU가 이미 존재
  3. 순수 벡터 검색만 필요한 경우: 키워드 검색이 불필요
  4. 커스텀 최적화가 필요한 경우: 인덱스 파라미터 세밀 조정
# 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가 유리한 경우는 다음과 같습니다.

  1. 하이브리드 검색이 필요한 경우: 키워드 + 벡터 검색 통합
  2. 운영 편의성이 중요한 경우: 모니터링, 백업 등 운영 기능 필요
  3. 기존 Elasticsearch 인프라가 있는 경우: 마이그레이션 용이
  4. 실시간 데이터 업데이트가 필요한 경우: 인덱스 재구축 없이 갱신

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)를 기반으로 결과를 결합합니다.

  1. 점수 정규화 불필요: BM25 점수(0∞)와 코사인 유사도(01)를 직접 비교할 필요가 없습니다.
  2. 이상치에 강건: 극단적인 점수 값의 영향을 최소화합니다.
  3. 단순함: 파라미터 하나($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, 가중치를 도메인에 맞게 조여 가는 순서를 권합니다.

참고 자료