BM25+Vector 하이브리드 검색, 2026년 7월 기준 “랭킹 병합”이 승부처인 이유 (RRF/정규화/리랭킹 실전 설계)
들어가며
RAG에서 “검색이 잘 된다”는 말은 보통 (1) 필요한 근거를 놓치지 않고(recall), (2) 상위에 쓸모없는 조각을 올리지 않으며(precision), (3) 점수/모델/도메인이 바뀌어도 튜닝이 무너지지 않는(stability) 상태를 뜻합니다. 2026년 시점에도 여전히 BM25(lexical)만으로는 의미 기반 질의에 약하고, vector search(semantic)만으로는 정확 키워드/식별자/약어/코드/버전 문자열에 약합니다. 그래서 실무에서는 “둘 다 돌리고 합치자”가 표준이 됐고, 문제는 그 다음: 어떻게 합칠 것인가(랭킹 병합 전략) 입니다.
하이브리드 검색이 특히 잘 맞는 경우
- 도메인 용어/식별자/버전 문자열이 중요한 문서(예: API reference, 장애 리포트, 정책 문서): BM25가 강점
- 질의가 자연어+키워드 혼합(예: “timeout 504 nginx upstream keepalive”): 둘 다 필요
- RAG에서 “근거를 못 찾는” 실패가 자주 나올 때(= recall 문제)
오히려 하이브리드가 “우선순위가 아닐” 수 있는 경우
- 이미 dense만으로도 recall이 충분하고, 문제는 상위 정렬 품질(precision) 인 경우: 이때는 hybrid보다 cross-encoder reranker가 먼저 투자 대비 효과가 큼(하이브리드는 recall, reranker는 precision을 주로 개선) (reddit.com)
- 운영 복잡도를 늘리기 싫고, 문서/질의가 단순해서 BM25만으로 충분한 경우
- 점수 블렌딩을 잘못하면(정규화 실패/가중치 오버튜닝) 오히려 품질이 흔들릴 때 → 아래에서 병합 전략으로 해결
🔧 핵심 개념
1) BM25 vs Vector: “스코어의 세계관”이 다르다
- BM25 score: inverted index 기반이며 스케일이 질의/코퍼스/term 빈도에 따라 크게 요동(상한이 사실상 고정되어 있지 않음) (elastic.co)
- Vector similarity score: cosine/dot/L2 등이며 모델/정규화/인덱스(ANN) 설정에 영향을 받음
이 둘을 “점수 그대로 더하기(weighted sum)” 하면, 스코어 분포가 달라서 한쪽이 지배하거나, 특정 outlier가 전체를 흔들기 쉽습니다.
2) 랭킹 병합 전략 3종: Score fusion / Rank fusion / Reranking
실무에서 병합은 보통 3계층으로 나뉩니다.
(A) Score-based fusion (정규화 + 가중합)
- 예: min-max, L2 normalization 후
w1*bm25 + w2*vector - 장점: 직관적, 구현 쉬움
- 단점: 분포가 달라서 튜닝이 깨지기 쉬움, outlier/도메인 편향에 취약
OpenSearch에서도 “정규화/조합(Score ranker)” 계열을 지원하지만, 데이터 분포가 거칠면 안정성이 떨어질 수 있습니다. (opensearch.org)
(B) Rank-based fusion: RRF(Reciprocal Rank Fusion)
- 핵심 아이디어: 점수는 버리고 “각 리스트에서의 순위(rank)”만 사용
- 공식(대표 형태):
score(d) = Σ 1 / (k + rank_i(d))k는 상위에 너무 쏠리지 않게 하는 완충값(일반적으로 50~60 근방을 많이 씀; 제품/문서 도메인마다 튜닝)
- 장점:
- BM25와 vector처럼 서로 다른 스코어 스케일을 섞을 때 매우 안정적
- outlier에 강함(점수가 아니라 순위 기반)
- OpenSearch가 Neural Search plugin에서 하이브리드 병합으로 RRF를 강조하는 이유도 여기에 있습니다. (opensearch.org)
- Elasticsearch도 hybrid 쿼리에서
rrfretriever 조합을 공식적으로 안내합니다. (elastic.co)
(C) Reranking (cross-encoder 등)
- 하이브리드/단일 검색으로 뽑은 후보(top-k) 를 “정밀 모델”로 재정렬
- 장점: precision에 강함
- 단점: 비용/지연 증가, 배치/캐싱/서빙 설계 필요
SemEval-2026 등의 파이프라인에서도 “query rewriting → hybrid(BM25+dense)+RRF → cross-encoder rerank” 3단 구성이 흔합니다. (arxiv.org)
3) 내부 흐름(추천 아키텍처): “Recall을 넓히고 → 합치고 → 좁힌다”
실전 RAG 기준으로 가장 재현성 좋은 흐름은 다음입니다.
1) BM25로 topK_s 후보 추출 (정확 키워드/코드/약어 방어)
2) Vector로 topK_d 후보 추출 (의미 기반 매칭 확보)
3) RRF로 병합 → topK_fused (스케일 이슈 제거, 안정적인 앙상블) (opensearch.org)
4) (선택) cross-encoder rerank on topK_fused (정밀도 상승) (arxiv.org)
5) (선택) MMR로 다양성 확보(중복 chunk 제거)
- 단, OpenSearch 쪽은 hybrid query 레벨에서 MMR 적용이 구조적으로 까다롭다는 논의가 있습니다(하이브리드 병합 전/후 적용 위치 문제). (forum.opensearch.org)
💻 실전 코드
아래 예제는 “toy”가 아니라, 운영에서 자주 쓰는 Postgres 기반 RAG 인덱스를 가정합니다.
- 저장소: PostgreSQL
- Sparse: full-text(BM25 계열 랭킹,
ts_rank_cd) - Dense: pgvector
- 병합: RRF (애플리케이션 레이어에서 구현)
- 시나리오: 고객사 내부 기술 문서(가이드/런북/ADR/티켓)에서 “정확 키워드 + 의미”를 동시에 잡는 검색
참고: pgvector는 Postgres full-text search와 함께 hybrid 구성을 권장/언급합니다. (github.com)
1) 셋업 (확장/테이블/인덱스)
1
2
# PostgreSQL 16+ 가정
psql -d ragdb -c "CREATE EXTENSION IF NOT EXISTS vector;"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 문서 chunk 테이블
CREATE TABLE IF NOT EXISTS doc_chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
title text,
body text NOT NULL,
-- full-text
tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title,'')), 'A') ||
setweight(to_tsvector('english', body), 'B')
) STORED,
-- embedding (예: 1536차원)
embedding vector(1536),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- BM25 계열 검색 가속 (GIN)
CREATE INDEX IF NOT EXISTS idx_doc_chunks_tsv ON doc_chunks USING GIN (tsv);
-- 벡터 인덱스(HNSW)
-- pgvector 버전에 따라 옵션이 조금 다릅니다.
CREATE INDEX IF NOT EXISTS idx_doc_chunks_hnsw
ON doc_chunks USING hnsw (embedding vector_cosine_ops);
예상되는 운영 포인트
- embedding이 L2 normalize된 모델이면 cosine/dot 선택이 성능/정확도에 영향(공식도 inner product 권장 케이스 언급) (github.com)
- HNSW는 빌드 비용/메모리 사용량이 크므로 ingestion 배치/리인덱싱 전략을 같이 잡아야 합니다.
2) 애플리케이션: BM25 + Vector 각각 topK를 뽑고 RRF로 병합
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import os
from dataclasses import dataclass
from typing import List, Dict, Tuple
import psycopg
import numpy as np
@dataclass
class Hit:
chunk_id: int
doc_id: str
title: str
body: str
score: float # raw score (bm25 or vector)
def rrf_fuse(
ranked_lists: List[List[Hit]],
k: int = 60,
topn: int = 20
) -> List[Tuple[Hit, float]]:
"""
Reciprocal Rank Fusion.
- ranked_lists: 이미 각 리스트가 score desc로 정렬되어 있다고 가정
- k: 완충 상수 (너무 작은 값은 1~3위에 과도 집중)
"""
agg: Dict[int, float] = {}
rep: Dict[int, Hit] = {}
for hits in ranked_lists:
for rank, h in enumerate(hits, start=1):
agg[h.chunk_id] = agg.get(h.chunk_id, 0.0) + 1.0 / (k + rank)
rep.setdefault(h.chunk_id, h)
fused = sorted(agg.items(), key=lambda x: x[1], reverse=True)[:topn]
return [(rep[chunk_id], score) for chunk_id, score in fused]
def bm25_search(conn, query: str, k: int) -> List[Hit]:
# BM25 "유사" 랭킹: ts_rank_cd + tsquery. 운영에서는 query parser(phrase/AND/OR) 더 정교화 권장
sql = """
SELECT id, doc_id, coalesce(title,''), body,
ts_rank_cd(tsv, websearch_to_tsquery('english', %(q)s)) AS score
FROM doc_chunks
WHERE tsv @@ websearch_to_tsquery('english', %(q)s)
ORDER BY score DESC
LIMIT %(k)s;
"""
rows = conn.execute(sql, {"q": query, "k": k}).fetchall()
return [Hit(r[0], r[1], r[2], r[3], float(r[4])) for r in rows]
def vector_search(conn, embedding: List[float], k: int) -> List[Hit]:
# cosine distance는 작을수록 유사. score로 쓰려면 변환(예: 1 - distance)
sql = """
SELECT id, doc_id, coalesce(title,''), body,
1.0 - (embedding <=> %(e)s::vector) AS score
FROM doc_chunks
WHERE embedding IS NOT NULL
ORDER BY embedding <=> %(e)s::vector
LIMIT %(k)s;
"""
rows = conn.execute(sql, {"e": embedding, "k": k}).fetchall()
return [Hit(r[0], r[1], r[2], r[3], float(r[4])) for r in rows]
def hybrid_search(conn, query: str, query_embedding: List[float]) -> List[Tuple[Hit, float]]:
bm25_hits = bm25_search(conn, query, k=50)
vec_hits = vector_search(conn, query_embedding, k=50)
# 핵심: score 블렌딩 대신 "랭킹" 병합(RRF)
fused = rrf_fuse([bm25_hits, vec_hits], k=60, topn=20)
return fused
if __name__ == "__main__":
# 실제로는 여기서 embedding 모델(OpenAI/Bedrock/self-host) 호출해 query_embedding을 만듭니다.
# 데모를 위해 난수(실행 가능성 확보용)로 대체하지 말고, 운영 코드에서는 반드시 실제 embedding을 넣으세요.
raise SystemExit("운영에서는 query_embedding을 실제 embedding API로 생성해 넣으세요.")
예상 출력(형태)
- 최종 결과는
[(Hit, rrf_score), ...]형태이며, - rrf_score는 0~1 사이 확률이 아니라 순위 기반 앙상블 점수입니다(모니터링 시 해석 주의).
3) 확장: “RRF 다음에 reranker”를 붙일 때의 기준
- fused top20~100을 만든 뒤 cross-encoder로 재정렬하면 precision이 크게 오르는 패턴이 많습니다(특히 기술 문서/FAQ 혼재). (arxiv.org)
- 이 조합은 커뮤니티/프로덕션에서도 사실상 디폴트 파이프라인으로 굳어가는 흐름입니다. (reddit.com)
⚡ 실전 팁 & 함정
Best Practice 1) “가중합”보다 RRF를 기본값으로 두고, 가중치는 나중에
BM25와 vector 점수는 분포가 달라서, 가중합은 환경이 바뀔 때(문서 추가/모델 변경/언어 혼합) 쉽게 깨집니다. 반면 RRF는 스케일 독립적이라 운영 안정성이 높습니다. OpenSearch/Elasticsearch 모두 hybrid에서 RRF를 전면에 두는 이유가 명확합니다. (opensearch.org)
Best Practice 2) topK를 “충분히 크게” 뽑고, 최종은 “작게” 잘라라
- sparse topK_s, dense topK_d를 너무 작게 잡으면(예: 10/10) 하이브리드의 목적(=recall 개선)이 안 나옵니다.
- 대신 s=50~200, d=50~200 정도로 넉넉히 뽑고, fused는 20~50으로 자른 뒤 rerank로 정밀도를 끌어올리는 설계가 비용 대비 좋습니다(데이터 크기/지연 SLO에 맞게 조정).
Best Practice 3) 평가 지표로 “무엇을 고칠지” 먼저 결정
- recall이 낮으면: hybrid / query rewriting / multi-query
- recall은 괜찮은데 precision/faithfulness가 낮으면: reranker / chunking / dedup(MMR 등)
이 분리를 해두면, 하이브리드를 붙여도 개선이 미미한 상황(실제로 종종 발생)에서 삽질을 줄일 수 있습니다. (reddit.com)
흔한 함정 1) RRF k값을 “작게” 두고 상위 몇 개만 먹는 구조가 되는 것
- k가 너무 작으면 1~3위가 과도하게 유리해져서, 사실상 “한 엔진의 top1이 모든 걸 결정”하는 상황이 됩니다.
- 반대로 너무 크면 상위 간 변별이 약해집니다.
실무에선 k를 고정(예: 60)하고, topK_s/topK_d를 조절하는 편이 튜닝 난이도가 낮습니다.
흔한 함정 2) 하이브리드를 “MMR/다양성” 문제 해결로 오해
하이브리드는 주로 놓치는 문서를 줄이는(recall) 도구입니다. 중복 chunk 제거/다양성 확보는 다른 층위 문제라서, 별도 단계(MMR, clustering, section-aware chunking)가 필요합니다. OpenSearch 쪽에서도 hybrid query 구조상 MMR을 바로 끼우기 어렵다는 논의가 있습니다. (forum.opensearch.org)
비용/성능/안정성 트레이드오프 (현실 버전)
- ANN(HNSW): 지연은 줄지만 “근사”라서 recall이 약간 떨어질 수 있음 → 그래서 하이브리드+rerank가 더 중요해짐
- reranker: 품질은 오르지만 비용/지연이 크게 증가 → topK를 작게 유지하고 캐싱/배치가 관건
- Postgres(FTS+pgvector): 단일 스택 장점이 크지만, 샤딩/대규모(수억~)에서 전용 검색엔진 대비 운영 난도가 올라갈 수 있음(볼륨/팀 역량에 따라 선택)
🚀 마무리
2026년 7월의 하이브리드 검색은 “BM25+vector를 같이 돌린다”가 끝이 아니라, 병합을 어떻게 하느냐가 품질의 대부분을 결정합니다. 점수 스케일이 다른 두 세계를 섞을 때는 RRF 같은 rank fusion이 운영 안정성과 재현성이 가장 좋고 (opensearch.org), 그 위에 cross-encoder reranker를 얹어 precision을 마무리하는 구성이 대회/논문/실무 모두에서 반복적으로 등장합니다. (arxiv.org)
도입 판단 기준(체크리스트)
- 내 RAG가 “근거를 못 찾는” 실패가 많다 → hybrid + RRF 우선
- 근거는 찾는데 “엉뚱한 chunk가 상위”다 → reranker 우선
- 점수 튜닝이 매번 깨진다 → 가중합 대신 RRF로 전환
다음 학습 추천
- OpenSearch/Elasticsearch의 hybrid + RRF 구현을 직접 따라 하며(각 제품의 retriever/processor 체인 이해) (docs.opensearch.org)
- 내 도메인 데이터로 recall/precision을 분리 평가하고(예: RAGAS류), “hybrid vs rerank” 투자 우선순위를 정하는 것부터 시작하는 것을 권합니다.