포스트

BM25+Vector 하이브리드 검색, “점수 합산”은 버리고 RRF로 가라: 2026년형 RAG 랭킹 병합 실전 가이드

BM25+Vector 하이브리드 검색, “점수 합산”은 버리고 RRF로 가라: 2026년형 RAG 랭킹 병합 실전 가이드

들어가며

RAG에서 “정답이 코퍼스에 있는데도” 모델이 엉뚱한 답을 내는 순간은 대개 retrieval이 틀렸을 때입니다. 특히 실무 데이터는 다음 두 부류의 쿼리가 섞입니다.

  • 정확 토큰 쿼리: SKU, 에러 코드, RFC 번호, 버전 스트링(“v2.3”), 조항 번호(“Section 4(b)(iii)”)처럼 문자열이 정답 키인 쿼리
  • 의도/의미 쿼리: “결제 실패 재시도 정책”, “권한 상승 방지”, “유사한 장애 사례”처럼 표현이 바뀌어도 의미가 같은 쿼리

여기서 pure vector search는 정확 토큰에 약하고, BM25는 의미/동의어/표현 변화에 약합니다. 그래서 2025~2026년 실무 스택에서 기본값처럼 굳어진 패턴이 hybrid search(BM25 + dense vector) + rank fusion(RRF) 입니다. OpenSearch는 hybrid search와 RRF(Score ranker)를 공식 문서/기능으로 밀고 있고, Elasticsearch도 retrievers API에서 RRF retriever를 제공합니다. (docs.opensearch.org)

언제 쓰면 좋나

  • RAG/검색이 정확 토큰 + 의미 검색을 동시에 만족해야 할 때(대부분의 엔터프라이즈 문서/이커머스/지원 티켓)
  • 쿼리 분포가 넓고, “사용자가 뭘 입력할지” 통제가 안 될 때
  • 임베딩 모델/벡터DB만으로 “버전/코드” 쿼리에서 자주 미끄러질 때(실제 현업 경험담도 매우 많습니다). (reddit.com)

언제 쓰면 안 되나(또는 우선순위 낮나)

  • 코퍼스가 작고(수천~수만), 쿼리도 한 종류로 단순해서 한 가지 리트리버만으로 충분한 경우
  • 데이터가 “키워드가 깨진 표/레이아웃” 중심이라 BM25가 추가 정보를 못 주는 경우(하이브리드가 기대만큼 안 오르는 전형). (reddit.com)
  • 평가 체계 없이 “하이브리드면 무조건 좋아진다”는 전제로 도입하는 경우(오히려 디버깅 지옥)

🔧 핵심 개념

1) Hybrid search의 구조: “두 개의 1st-stage retriever”

하이브리드는 보통 병렬 후보군 생성입니다.

  1. Lexical retriever(BM25): inverted index 기반으로 용어 매칭 + BM25 스코어
  2. Dense retriever(kNN / ANN): embedding 공간에서 cosine/L2 등으로 근접 이웃 탐색(HNSW 등)
  3. Fusion: 두 결과 리스트를 합쳐 최종 랭킹 생성(이 글의 핵심)

중요: 여기서 BM25 점수와 vector 점수는 스케일이 다르고 분포도 다릅니다. 그래서 “점수 합산”이 생각보다 자주 깨집니다(인덱스/모델/정규화 방식에 따라 점수가 흔들림).

2) 랭킹 병합 전략: Convex Combination vs RRF

실무에서 자주 비교되는 병합은 두 가지입니다(Elastic도 둘 다 이야기합니다). (elastic.co)

  • Convex Combination(가중합)
    final = α * bm25_score + (1-α) * vector_score
    장점: 직관적, 튜닝으로 성능을 뽑을 수 있음
    단점: 점수 정규화/스케일링이 필수. 데이터/모델/버전에 따라 α가 쉽게 무너짐

  • Reciprocal Rank Fusion(RRF)
    핵심 아이디어: 점수 대신 “순위(rank)”만 믿자
    각 리스트에서 문서의 기여도를 1 / (k + rank)로 두고 합산

    • k는 rank 완만함을 조절(관행적으로 60이 많이 언급/사용) (digitalapplied.com)
    • rank 기반이라 스코어 정규화 지옥을 피함
    • OpenSearch는 2.19에서 RRF를 hybrid search의 핵심 병합으로 소개합니다. (opensearch.org)

왜 RRF가 실무 친화적인가?

  • BM25/벡터가 내놓는 “점수”는 서로 비교 불가하지만, “상위 몇 개”라는 순위는 비교 가능합니다.
  • 병합 실패의 80%가 “정규화/가중치 튜닝 실패”에서 오는데, RRF는 이를 구조적으로 회피합니다.
  • 실제 벤치마크/가이드에서 RRF가 BM25 단독/벡터 단독보다 이기는 패턴이 자주 보고됩니다(물론 데이터셋 의존). (digitalapplied.com)

3) 2026년형 RAG에서 “fusion 다음”은 reranking

하이브리드는 보통 recall을 올리는 1st-stage입니다. 그 다음 정확도를 올리는 방법은:

  • shortlist(예: 50~200개) 뽑고
  • cross-encoder reranker로 재정렬

SemEval-2026 같은 최근 파이프라인에서도 “query rewriting → hybrid(RRF) → cross-encoder reranking” 3단 구성이 자연스럽게 등장합니다. (arxiv.org)


💻 실전 코드

현실적인 시나리오: 사내 기술 문서/릴리즈 노트/에러 코드 KB를 Postgres 하나로 운영 중이고, 이미 pgvector를 쓰고 있는데 “v2.3”, “ERR-1042”, “Section 7” 같은 쿼리에서 retrieval이 흔들린다.
→ BM25(또는 FTS) + pgvector를 병렬로 돌리고, 클라이언트에서 RRF로 병합한 뒤, 상위 N개를 LLM 컨텍스트로 넣는다.

참고: Postgres에서 “진짜 BM25”는 확장(예: pg_textsearch 같은 서드파티)을 쓰기도 하고, 기본 tsvector는 BM25는 아니지만 lexical 신호로는 충분히 쓸 때가 많습니다. (BM25 확장과 hybrid/RRF 구현 흐름이 2026년에도 활발히 공유됩니다.) (thebuild.com)

0) 의존성/환경

1
2
3
4
5
# Python 3.11+
pip install psycopg[binary] pgvector numpy

# (옵션) 임베딩 생성용
pip install openai

1) 스키마/인덱스(예시)

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
-- extensions
CREATE EXTENSION IF NOT EXISTS vector;

-- 문서 테이블
CREATE TABLE IF NOT EXISTS kb_docs (
  id           BIGSERIAL PRIMARY KEY,
  source       TEXT NOT NULL,         -- 예: "release-notes", "runbook"
  title        TEXT NOT NULL,
  body         TEXT NOT NULL,
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),

  -- lexical 검색용(간단 버전): tsvector
  fts          tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title,'')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,'')),  'B')
  ) STORED,

  -- dense vector
  embedding    vector(1536)            -- 예: text-embedding-3-large 차원(예시)
);

-- FTS 인덱스
CREATE INDEX IF NOT EXISTS kb_docs_fts_gin ON kb_docs USING GIN (fts);

-- vector 인덱스(HNSW)
CREATE INDEX IF NOT EXISTS kb_docs_embedding_hnsw
ON kb_docs USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 128);

2) Hybrid retrieval + RRF fusion (실행 가능)

  • BM25 쪽은 여기서 ts_rank_cd 기반(=Postgres 기본 FTS)으로 구현
  • vector는 cosine distance 기반
  • 두 결과를 각각 topK 뽑고, RRF로 합쳐 topN을 반환
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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import os
import numpy as np
from psycopg import connect
from psycopg.rows import dict_row

# -----------------------------
# RRF 구현 (클라이언트 사이드)
# -----------------------------
def rrf_fuse(rank_lists: list[list[int]], k: int = 60) -> dict[int, float]:
    """
    rank_lists: 각 retriever가 반환한 doc_id 리스트(랭크 순)
    return: doc_id -> rrf_score
    """
    scores: dict[int, float] = {}
    for lst in rank_lists:
        for rank, doc_id in enumerate(lst, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return scores

def topn_by_score(score_map: dict[int, float], n: int) -> list[int]:
    return [doc for doc, _ in sorted(score_map.items(), key=lambda x: x[1], reverse=True)[:n]]

# -----------------------------
# DB 쿼리 (FTS / Vector)
# -----------------------------
FTS_SQL = """
SELECT id, ts_rank_cd(fts, websearch_to_tsquery('english', %(q)s)) AS score
FROM kb_docs
WHERE fts @@ websearch_to_tsquery('english', %(q)s)
ORDER BY score DESC
LIMIT %(k)s;
"""

VEC_SQL = """
SELECT id, (1 - (embedding <=> %(emb)s::vector)) AS score
FROM kb_docs
ORDER BY (embedding <=> %(emb)s::vector) ASC
LIMIT %(k)s;
"""

FETCH_DOCS_SQL = """
SELECT id, source, title, body, updated_at
FROM kb_docs
WHERE id = ANY(%(ids)s)
"""

def embed_query_openai(q: str) -> list[float]:
    # 실제 운영에서는 배치/캐시/timeout/retry 필수
    from openai import OpenAI
    client = OpenAI()
    resp = client.embeddings.create(
        model="text-embedding-3-large",
        input=q
    )
    return resp.data[0].embedding

def hybrid_search(conn, query: str, *,
                  fts_k: int = 80,
                  vec_k: int = 80,
                  final_n: int = 20,
                  rrf_k: int = 60):
    emb = embed_query_openai(query)

    with conn.cursor(row_factory=dict_row) as cur:
        cur.execute(FTS_SQL, {"q": query, "k": fts_k})
        fts_rows = cur.fetchall()
        fts_ids = [r["id"] for r in fts_rows]

        cur.execute(VEC_SQL, {"emb": emb, "k": vec_k})
        vec_rows = cur.fetchall()
        vec_ids = [r["id"] for r in vec_rows]

        fused = rrf_fuse([fts_ids, vec_ids], k=rrf_k)
        top_ids = topn_by_score(fused, final_n)

        cur.execute(FETCH_DOCS_SQL, {"ids": top_ids})
        docs = cur.fetchall()

    # 출력(디버깅용): 각 doc의 fused score도 같이
    doc_by_id = {d["id"]: d for d in docs}
    result = []
    for doc_id in top_ids:
        d = doc_by_id.get(doc_id)
        if not d:
            continue
        result.append({
            "id": doc_id,
            "rrf_score": fused[doc_id],
            "title": d["title"],
            "source": d["source"],
            "updated_at": str(d["updated_at"]),
            "preview": (d["body"][:220] + "...") if len(d["body"]) > 220 else d["body"]
        })
    return result

if __name__ == "__main__":
    dsn = os.environ.get("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/postgres")
    q = "what changed in v2.3 auth flow ERR-1042"
    with connect(dsn) as conn:
        hits = hybrid_search(conn, q, fts_k=100, vec_k=100, final_n=15, rrf_k=60)
        for h in hits[:5]:
            print(f"[{h['rrf_score']:.4f}] {h['title']} ({h['source']}, {h['updated_at']})")
            print(f"  {h['preview']}\n")

예상 출력(형태)

  • pure vector만 쓰면 “auth flow” 관련 문서가 상위에 오지만 v2.3이 아닌 버전이 섞임
  • hybrid+RRF에서는 “v2.3”, “ERR-1042”를 정확히 포함한 문서가 후보군에 살아남고, 의미 유사 문서도 같이 섞여 상위로 올라옴
    (실제로 현업에서도 “버전/코드 쿼리”가 하이브리드 도입 트리거가 되는 사례가 반복됩니다). (reddit.com)

⚡ 실전 팁 & 함정

Best Practice 1) RRF는 “가중치 튜닝 회피”가 목적이다

Convex Combination은 멋있어 보이지만, 운영에서 자주 발생하는 변경(임베딩 모델 교체, chunk 정책 변경, 인덱스 리빌드)에 취약합니다. RRF는 OpenSearch/Elastic 진영에서도 “정규화보다 rank 기반”을 강조하는 이유가 있습니다. (opensearch.org)

  • 시작점 추천: RRF(k=60, 각 retriever topK=50~200)

Best Practice 2) “rank_window_size / topK”를 충분히 줘라

RRF는 겹치는 후보군이 있어야 시너지가 납니다. 각 retriever가 top10만 주면 서로 교집합이 거의 없어져 “그냥 두 리스트 섞기”가 됩니다.
Elastic의 RRF retriever도 자식 retriever가 rank_window_size 만큼 문서를 모아 fusion한다고 명시합니다. (elastic.co)

  • 경험칙: 최종 topN이 20이면, 각 브랜치는 최소 5~10배(top100~200) 뽑고 fusion

Best Practice 3) 하이브리드가 끝이 아니라 “2-stage”로 설계하라

최근 벤치마크/대회성 파이프라인은 hybrid로 recall 확보 → reranker로 precision 확보가 정석입니다. (arxiv.org)

  • 비용 절감 팁: reranker는 전체 코퍼스가 아니라 fusion shortlist에만 적용

흔한 함정/안티패턴 1) “하이브리드 했는데 개선이 없어요”

가능성이 큰 원인:

  • 데이터가 구조적/표 위주인데 lexical이 제대로 인덱싱되지 않음(깨진 추출 텍스트) → BM25가 추가 신호를 못 줌 (reddit.com)
  • chunking이 너무 커서 exact token이 묻히거나, 너무 작아 문맥이 분해됨
  • 쿼리 자체가 짧고 정확 토큰만 있는데 vector를 과도하게 섞음(오히려 노이즈)

대응:

  • 쿼리를 토큰형/의도형으로 분류해서 branch별 topK를 다르게 주거나(간단한 규칙으로도 효과)
  • 인덱싱 파이프라인(텍스트 정제/표 처리)부터 재점검

흔한 함정/안티패턴 2) RRF(k)와 topK를 “감”으로 고정

  • k가 너무 작으면 상위 몇 개에 과도하게 쏠리고, 너무 크면 리스트가 평평해집니다.
  • 하지만 k는 α 튜닝보다 훨씬 안정적이라, 보통은 60 근처에서 시작해 평가로 미세조정이 현실적입니다. (digitalapplied.com)

비용/성능/안정성 트레이드오프

  • 성능(quality): BM25+vector는 대체로 recall을 올리지만, 최종 정확도는 reranker 유무가 좌우
  • 비용(latency): retriever 2개를 병렬로 돌리므로 p95가 상승할 수 있음 → ANN 파라미터(ef_search 등), topK, 캐시로 제어
  • 운영 복잡도: “두 시스템(ES + vectorDB)”로 가기 전에, Postgres/Elastic/OpenSearch처럼 한 엔진에서 끝내는 선택이 운영상 이점(최근에도 이 논의가 계속됩니다). (thebuild.com)

🚀 마무리

핵심 요약:

  • 2026년 실무 RAG에서 hybrid search(BM25 + dense vector)는 “옵션”이 아니라 대부분의 코퍼스에서 기본 안전장치에 가깝습니다. (learn.microsoft.com)
  • 랭킹 병합은 점수 합산(Convex Combination)보다 RRF가 도입/운영 난이도가 낮고 안정적입니다. (opensearch.org)
  • 제대로 쓰려면: 각 branch topK 확보 → RRF fusion → (가능하면) cross-encoder reranking의 2-stage로 설계하세요. (arxiv.org)

도입 판단 기준(실무 체크리스트):

  • 내 쿼리에 “버전/코드/정확 문자열”이 자주 섞이는가? → Yes면 hybrid 우선
  • vector-only에서 “비슷한 문서인데 다른 버전”이 자주 뜨는가? → Yes면 BM25 신호가 필요
  • 평가(Recall@K / MRR / nDCG) 없이 감으로 튜닝 중인가? → 먼저 로그 기반 평가셋부터

다음 학습 추천:

  • OpenSearch의 hybrid search + RRF(Score ranker) 문서/블로그로 파라미터와 실행 흐름을 벤더 관점에서 한 번 정리 (docs.opensearch.org)
  • Elasticsearch의 retrievers API에서 RRF retriever의 rank_window_size 등 실행 모델 이해 (elastic.co)
  • “hybrid + reranking”이 왜 통하는지 최근 파이프라인/벤치마크 사례로 감 잡기 (arxiv.org)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.