포스트

BM25+Vector+RRF로 “안정적으로” 이기는 법: 2026년형 Hybrid Search 랭킹 병합 실전 가이드

BM25+Vector+RRF로 “안정적으로” 이기는 법: 2026년형 Hybrid Search 랭킹 병합 실전 가이드

들어가며

RAG 시스템을 운영해보면 “semantic search만으로는” 자꾸 빈틈이 드러납니다. 대표적으로:

  • 사용자 쿼리가 정확한 토큰(제품명/에러코드/약어/버전/정책 코드) 중심일 때 → vector가 놓치거나 엉뚱한 유사문서를 끌어옴
  • 반대로 쿼리가 의도/의미 중심(자연어) 일 때 → BM25가 동의어/패러프레이즈를 못 따라감
  • 그리고 가장 골치 아픈 건, 배포 후에 랭킹이 미세하게 흔들리며 “일관성”이 깨지는 문제(LLM 응답 품질이 체감상 급락)

그래서 2026년 현재, 프로덕션 RAG에서 “기본값”에 가까워진 형태가 Hybrid Retrieval(BM25 + dense vector) + (필요 시) reranking입니다. 특히 서로 다른 스코어 스케일을 섞을 때 score normalization보다 rank-based fusion인 RRF(Reciprocal Rank Fusion)가 안정적으로 쓰이는 흐름이 강합니다. (infoq.com)

언제 쓰면 좋나?

  • 질의가 키워드형/의미형이 섞여 들어오고, “누락(Recall)”이 치명적인 RAG
  • 사내 문서/티켓/런북처럼 정확한 문자열 매칭이 의미를 갖는 코퍼스
  • 검색 품질을 실험으로 계속 올릴 계획이 있는 팀(평가 셋/로그 기반 튜닝)

언제 쓰면 안 되나?

  • 데이터가 너무 작고(수백~수천 문서), 질의도 단순해서 BM25 단독으로도 충분할 때
  • latency 예산이 빡빡하고(예: p95 50ms), 인프라 여유가 없는데 두 검색을 병렬로 돌려야 할 때
  • “검색”이 아니라 “정확한 필터링/룩업”이 본질인 서비스(이 경우 DB/SQL이 우선)

🔧 핵심 개념

1) Hybrid Search의 본질: “두 개의 리콜 엔진” + “한 개의 병합 정책”

  • BM25(lexical/sparse): inverted index 기반. 토큰 단위로 강력한 precision/설명가능성.
  • Dense vector(semantic): embedding 공간에서 ANN(k-NN, HNSW 등)으로 의미 기반 recall.
  • 문제: BM25 score와 vector score는 스케일이 다르다 → 단순 가중합이 생각보다 깨지기 쉽다. 그래서 병합이 핵심. (opensearch.org)

2) RRF(Reciprocal Rank Fusion): “점수”가 아니라 “순위”를 합친다

RRF는 각 검색 결과 리스트에서 문서의 rank를 기반으로 점수를 줍니다.

  • 각 리스트 i에서 문서 d의 rank가 rank_i(d)라면
    RRF(d) = Σ_i 1 / (k + rank_i(d))
  • k는 보통 50~60 근처로 잡아 상위 랭크에만 과도하게 쏠리지 않게 합니다.

왜 RRF가 하이브리드에 강하나?

  • BM25와 vector가 만들어내는 score 분포가 달라도 rank만 믿고 합치니 안정적
  • 한 쪽에서만 “압도적으로 큰 스코어”가 나와 전체를 집어삼키는 현상이 감소
  • OpenSearch도 “스코어 정규화(min-max, L2) 대신 RRF가 더 안정적”이라는 맥락으로 하이브리드에 RRF를 밀고 있습니다. (opensearch.org)

3) 2026년형 실전 파이프라인(권장)

많은 팀이 아래 구조로 수렴합니다:

1) BM25 topN (키워드 recall) 2) Vector topN (의미 recall) 3) RRF로 후보 합치기 (topK 후보군 생성) 4) (옵션) Cross-encoder rerank (질의-문서 pairwise로 “정밀도” 올리기) 5) (옵션) MMR/다양성 (중복 chunk 억제)

InfoQ도 “vector만으론 부족해서 hybrid+fusion이 필요”라는 방향으로 정리하고, OpenSearch는 hybrid query 및 RRF를 제품 기능으로 강화하는 흐름입니다. (infoq.com)


💻 실전 코드

아래 예시는 “현실적인 RAG 운영”을 가정합니다.

  • 코퍼스: 사내 런북/장애 티켓/설계 문서 chunk
  • 요구: (1) 정확한 에러코드 매칭 (2) 자연어 질의 의미 매칭 (3) 결과 병합의 안정성
  • 구현: PostgreSQL + pgvector + BM25(tsvector) 로 BM25와 vector를 각각 뽑고, 애플리케이션에서 RRF fusion
    (Postgres는 운영에 강하고, 이미 많은 팀이 “vector + BM25 + RRF” 조합을 실제로 사용한다고 공유됩니다. (reddit.com))

0) 의존성/전제

  • PostgreSQL 15+ 권장
  • pgvector 확장 설치
  • Python 3.11+
1
pip install psycopg[binary]==3.2.9 numpy==2.0.1

1) 스키마(문서 chunk + BM25 인덱스 + 벡터)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 확장
CREATE EXTENSION IF NOT EXISTS vector;

-- 문서 chunk 테이블
CREATE TABLE IF NOT EXISTS kb_chunks (
  id           bigserial PRIMARY KEY,
  doc_id       text NOT NULL,
  chunk_id     int  NOT NULL,
  title        text,
  body         text NOT NULL,
  -- 한국어면 별도 설정이 필요할 수 있으나, 여기서는 영어/혼합 코퍼스 가정
  body_tsv     tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(body,''))) STORED,
  embedding    vector(1024), -- 예: bge 계열/사내 임베딩 차원에 맞추기
  updated_at   timestamptz NOT NULL DEFAULT now()
);

-- BM25(FTS) 인덱스
CREATE INDEX IF NOT EXISTS kb_chunks_tsv_idx ON kb_chunks USING GIN (body_tsv);

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

2) 하이브리드 검색 + RRF 병합(프로덕션형)

포인트는 세 가지입니다.

  • BM25와 vector를 각각 topN으로 넉넉히 뽑는다(예: 100)
  • RRF로 합친 뒤 topK(예: 20)를 만들고
  • 그 topK를 RAG context로 넘기거나, 필요 시 rerank한다
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
import os
import numpy as np
import psycopg

DSN = os.environ["PG_DSN"]

def rrf_fuse(rankings: list[list[int]], k: int = 60) -> dict[int, float]:
    """
    rankings: 여러 검색 결과에서 doc_id(여기서는 kb_chunks.id)의 순서 리스트
    return: id -> rrf_score
    """
    scores: dict[int, float] = {}
    for r in rankings:
        for idx, doc_id in enumerate(r, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + idx)
    return scores

def search_bm25(cur, query: str, topn: int = 100) -> list[int]:
    cur.execute(
        """
        SELECT id
        FROM kb_chunks
        WHERE body_tsv @@ websearch_to_tsquery('english', %s)
        ORDER BY ts_rank_cd(body_tsv, websearch_to_tsquery('english', %s)) DESC
        LIMIT %s
        """,
        (query, query, topn),
    )
    return [row[0] for row in cur.fetchall()]

def search_vector(cur, embedding: np.ndarray, topn: int = 100) -> list[int]:
    # pgvector는 python list로 넘겨도 동작
    cur.execute(
        """
        SELECT id
        FROM kb_chunks
        WHERE embedding IS NOT NULL
        ORDER BY embedding <=> %s::vector
        LIMIT %s
        """,
        (embedding.tolist(), topn),
    )
    return [row[0] for row in cur.fetchall()]

def fetch_chunks(cur, ids: list[int]) -> list[dict]:
    cur.execute(
        """
        SELECT id, doc_id, chunk_id, title, body
        FROM kb_chunks
        WHERE id = ANY(%s)
        """,
        (ids,),
    )
    rows = cur.fetchall()
    by_id = {r[0]: r for r in rows}
    out = []
    for i in ids:
        r = by_id.get(i)
        if not r:
            continue
        out.append({"id": r[0], "doc_id": r[1], "chunk_id": r[2], "title": r[3], "body": r[4]})
    return out

def hybrid_search(query: str, query_embedding: np.ndarray, topn_each: int = 100, topk: int = 20):
    with psycopg.connect(DSN) as conn:
        with conn.cursor() as cur:
            bm25_ids = search_bm25(cur, query, topn=topn_each)
            vec_ids = search_vector(cur, query_embedding, topn=topn_each)

            fused = rrf_fuse([bm25_ids, vec_ids], k=60)
            # RRF 점수 내림차순으로 topk
            top_ids = [doc_id for doc_id, _ in sorted(fused.items(), key=lambda x: x[1], reverse=True)[:topk]]

            chunks = fetch_chunks(cur, top_ids)
            return chunks

if __name__ == "__main__":
    # 예시: "EKS node disk pressure evicted pods" 같은 운영 질의
    q = "EKS DiskPressure evicted pods kubelet log location"
    # 실제로는 embedding 모델 호출 결과를 넣어야 함(예: OpenAI/사내 모델)
    fake_emb = np.random.randn(1024).astype(np.float32)
    fake_emb = fake_emb / np.linalg.norm(fake_emb)

    results = hybrid_search(q, fake_emb)
    print(f"top{len(results)} chunks:")
    for r in results[:5]:
        print("-", r["doc_id"], r["chunk_id"], (r["title"] or "")[:60])

예상 출력(형태)

  • top20 chunks가 나오고, 상위에는
    • BM25가 강한 “DiskPressure”, “evicted”, “kubelet” 같은 토큰 정확 매칭 문서
    • vector가 강한 “노드 디스크 부족 → eviction” 의미권 문서 가 섞여 들어오는 게 정상입니다.

확장(2단계 빌드업)

  • topK 결과에 대해 cross-encoder reranker(예: bge-reranker 계열)를 붙이면, 하이브리드의 recall을 유지하면서 precision을 크게 올리는 패턴이 논문/실전 보고에서 반복됩니다. (arxiv.org)

⚡ 실전 팁 & 함정

Best Practice 1) “Fusion 전에 topN을 넉넉히” 잡아라

RRF는 순위 기반이라, 애초에 각 후보 리스트에 문서가 등장하지 않으면 절대 올라오지 않습니다.
보통 topN_each=50~200을 두고 latency/비용을 보며 조정합니다. (코퍼스가 크고 ANN이 빠르면 200도 가능)

Best Practice 2) RRF의 k는 “안정성 레버”

  • k가 너무 작으면 1~3위에 과도하게 쏠려, 한 쪽 엔진의 편향이 다시 커질 수 있습니다.
  • k를 50~60 근처로 두는 레시피가 널리 쓰이고(OpenSearch도 RRF를 하이브리드 안정화로 소개), 운영에서 튜닝 포인트가 됩니다. (opensearch.org)

Best Practice 3) “언제 rerank할지”를 정해 비용을 통제

cross-encoder rerank는 효과가 좋지만 비쌉니다. 2026년 실전 가이드는 “항상 rerank”보다:

  • (a) fused topK의 점수/엔트로피가 애매할 때만 rerank
  • (b) 특정 카테고리 질의(정책/결제/보안)만 rerank 처럼 조건부 rerank로 가는 사례가 많습니다. (aitechconnect.in)

흔한 함정/안티패턴

  • 스코어를 억지로 min-max로 맞춘 뒤 weighted sum: 데이터 분포가 바뀌면(신규 문서/배포/인덱스 튜닝) 랭킹이 미세하게 출렁일 수 있음. RRF가 선호되는 이유가 여기 있습니다. (opensearch.org)
  • chunk 품질 무시: 하이브리드/병합은 “retrieval”을 개선할 뿐, 잘못 쪼갠 chunk(너무 길거나, 헤더/코드/표가 섞여 의미가 흐림)는 그대로 망가진 context로 들어갑니다.
  • Hybrid와 sort/rescore 조합 제약 미확인: 엔진(OpenSearch 등)별로 hybrid query에서 rescore/sort 조합 제약이 있습니다. 운영 전 반드시 확인해야 합니다. (docs.opensearch.org)

비용/성능/안정성 트레이드오프(결정 기준)

  • BM25는 CPU/디스크 친화적, vector ANN은 메모리/CPU(또는 GPU) 부담이 큼
  • 반대로 vector는 의미 recall을 크게 올리지만, exact token precision이 약해 “환각 방지”에 불리해질 수 있음
    → 그래서 BM25를 버리기보다 BM25를 안전장치로 유지하는 구성이 2026년에도 유효합니다. (infoq.com)
  • OpenSearch는 dense뿐 아니라 neural sparse(역색인 기반 sparse embedding) 같은 대안도 밀고 있고, dense+neural sparse를 hybrid로 섞는 방향도 현실적인 선택지입니다(특히 비용/지연이 민감하면). (docs.opensearch.org)

🚀 마무리

정리하면, 2026년 6월 기준으로 “프로덕션 RAG의 하이브리드 검색”은 다음 결론에 수렴합니다.

  • BM25와 dense vector는 서로의 실패 모드를 상쇄한다.
  • 병합은 score-based보다 RRF 같은 rank-based fusion이 안정적인 경우가 많다. (opensearch.org)
  • 최종 품질은 (Hybrid + Fusion)만으로 끝나지 않고, 필요 시 cross-encoder rerank로 마무리하는 2-stage/3-stage가 강력하다. (arxiv.org)

도입 판단 기준(현실적인 체크리스트) 1) 우리 쿼리에 에러코드/약어/정확 문자열이 자주 등장하는가? → Yes면 BM25 필수 2) 동의어/표현 다양성이 큰가? → Yes면 vector 필수 3) “스코어 튜닝”에 자신이 없는가/랭킹 흔들림이 싫은가? → RRF 우선 4) p95 latency/비용 예산이 낮은가? → topN을 줄이고, rerank는 조건부로

다음 학습 추천

  • OpenSearch의 hybrid query와 RRF 동작/제약(운영 시 중요) (docs.opensearch.org)
  • neural sparse(희소 임베딩) + dense의 조합(비용 대비 효율 관점) (docs.opensearch.org)
  • 하이브리드+rerank가 실제로 single-stage를 이긴다는 최근 벤치마크 흐름 (arxiv.org)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.