---
url: /rag/hybrid-search.md
description: >-
  纯向量和纯 BM25 各有盲区，混合检索把两路结果合起来，但两边的分数量纲不同不能直接相加。这篇讲 RRF 的公式与 k 的取值、Elasticsearch
  与 Weaviate 的默认参数、跨编码器重排的位置，以及我踩过的几个坑。
---

# 混合检索：BM25 和向量召回的结果怎么合并

纯向量检索有个尴尬。用户搜 `ERR_SSL_PROTOCOL_ERROR`，embedding 把这串符号当成普通文本，召回的常常是同主题、但不含这个错误码的段落。

反过来，纯 BM25（关键词打分）也不灵。用户问「怎么让模型别胡说」，一个词都对不上，全军覆没。

一个管语义，一个管字面。混合检索（hybrid search）就是把两路结果合起来。难的不是召回，是合并。

## 分数为什么不能直接相加

BM25 的分数没有上界。语料越大、查询词越罕见，得分能飙到几十甚至上百。

向量的余弦相似度被压在 -1 到 1 之间，实际命中通常挤在 0.7 到 0.9 这个窄区间里。

把这两组数直接加权求和，等于让 BM25 单方面决定排序。所以主流做法不是合并分数，而是合并名次。

## RRF：只认名次，不认分数

RRF（Reciprocal Rank Fusion，倒数排名融合）出自 Cormack 等人 2009 年的 SIGIR 论文。公式只有一行：

```
score(d) = Σ 1 / (k + rank_i(d))
```

一个文档在第 i 路里排第几名，就贡献 1/(k+名次) 分，多路再相加。论文建议 k=60，并说这个取值并不敏感，k 在 10 到 100 之间 MAP 几乎不动。

```python
# 按论文公式实现的 RRF，七行
def rrf(rankings, k=60):
    scores = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
    return sorted(scores.items(), key=lambda x: -x[1])
```

有意思的是那个 k。k=0 时，各路的头名几乎决定一切；k 很大时，公式退化成「按名次倒数求和」，高名次的优势被抹平。所以 k 调的是「一路的强项能压过另一路多少」。

## 各家默认值

Elasticsearch 和 OpenSearch 的 RRF 默认 `rank_constant` 都是 60，跟着论文走。

ES 的路径是：RRF 在 8.8（2023 年 5 月）以技术预览进来，8.14（2024 年 6 月）引入 retriever 框架，8.16（2024 年 11 月）转 GA。

但 GA 之后它仍绑在 Enterprise 授权里。我用 Basic 版试过 `retriever` 语法，查询能通过，线上却开不起，会直接抛 license 错误。

绕开的办法是在应用层自己融合：BM25 和 kNN 各发一次，拿到两个 ID 列表，再喂给上面那段函数。

LangChain 的 EnsembleRetriever 走加权版 RRF。参数名不叫 k，叫 c，默认同样是 60，weights 不填则各路等权。

Weaviate 思路不同，它有两个融合策略。`rankedFusion` 只留名次，`relativeScoreFusion` 先把每路分数做 min-max 归一化再加权。

从 v1.24 起默认换成了后者，因为它保留的原始信息更多。

它的 `alpha` 默认 0.75，偏向量一侧。alpha=0 退化成纯关键词，alpha=1 是纯向量。

## 融合完还要不要重排

要。RRF 融合的是名次，不是相关性。它没法知道「第 3 名其实比第 1 名更贴题」。

我一般这样接：两路各召回 50 条，RRF 合成 20 条短名单，再用 cross-encoder 重排模型（比如 bge-reranker）打分，取前 5 条喂给大模型。

重排模型比 embedding 慢得多。它要把「问题 + 文档」拼在一起过一遍，所以只适合放在短名单上，不适合对全库跑。

## 几个我踩过的坑

单路召回加 RRF 没用。语料全是短文本、查询也少出现专有名词时，纯向量就够了，加一路 BM25 只是徒增延迟。

`window_size` 要比最终的 size 大。ES 的 rrf 里 window\_size 默认 100、size 默认 10。你要是把 window\_size 也设成 10，等于只让每路贡献 10 条候选，融合空间被自己掐死。

`id_key` 选错会去重失败。LangChain 的 EnsembleRetriever 默认用 `page_content` 判断文档是否相同。两路返回同一段文字，只要空格不同就会被当成两条，短名单里冒出重复。

我会先上纯向量跑一遍评测，召回不达标再加 BM25，而不是反过来。

混合检索不是免费午餐。它换来的召回率，代价是延迟和一份额外要维护的索引。
