主题
混合检索: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,而不是反过来。
混合检索不是免费午餐。它换来的召回率,代价是延迟和一份额外要维护的索引。