详解 BM25(词汇匹配精确词/代码/错误ID)和稠密向量(语义近义)各有的盲区,引入 Reciprocal Rank Fusion 将两种排名列表融合,无需统一量纲即可兼顾关键词精确性和语义泛化能力。
现代检索有两件利器,却各自只有半副眼睛。词法搜索(BM25)匹配真实词汇:在精确关键词、产品编码、错误 ID 和罕见技术术语上无可匹敌——但它很死板,无法判断 "sign in" 和 "log in" 是同一个意思,而且一段没有任何共同词汇的释义会得零分。稠密向量搜索匹配语义:它将查询和文档嵌入向量,用余弦相似度排序,所以同义词和释义轻松过关——但它会把罕见 token 模糊平均成一个语义向量,所以一篇泛泛的 "error" 文章可能排在一篇恰好提到你具体错误码的文档前面。
两者没有严格的好坏之分。它们遗漏的东西不同。混合搜索同时运行两种方法,再将两个排序列表融合。
BM25 对文档中的每个查询词项求和:idf(罕见词权重更高)、通过 k1 饱和的 tf(第 5 个 "password" 仅仅略微胜过第 4 个)、以及通过 b 实现的长度惩罚。像 X0-2231 这样的罕见编码会获得巨大的 idf 值,因此包含它的那个文档火箭般蹿到最前面。
import math
N = len(corpus); avgdl = sum(len(d) for d in docs_tok)/N
def idf(t): # BM25 idf, non-negative
n = sum(t in set(d) for d in docs_tok)
return math.log(1 + (N - n + 0.5)/(n + 0.5))
def bm25(q_tok, d, k1=1.5, b=0.75):
score, dl = 0.0, len(d)
for t in set(q_tok):
f = d.count(t)
if not f: continue
score += idf(t) * (f*(k1+1)) / (f + k1*(1 - b + b*dl/avgdl))
return score
稠密向量这一侧将文本嵌入到一个向量中,在这里决定近邻的是语义而非拼写,然后通过余弦相似度排序——所以 "sign in" 和 "log in" 会排在一起,但一个罕见的精确编码会模糊成一个泛化的 error 向量。
打印各自的前 3 名,失败之处就像镜像:BM25 找到了编码文档但漏掉了释义;稠密找到了释义但把一篇泛化的 error 文档排在了精确编码文档前面。每个列表都只对了一半。没有一种单一的排序能把两个正确答案都放在最前面。
Reciprocal Rank Fusion 扔掉了分数——因为它们存在于不兼容的尺度上(BM25 是 0 到几十,余弦相似度是 0 到 1)——把每个文档出现的所有列表的 1/(k + rank) 加总。一个在任何列表中排第 1 的文档获得一大票;在两个列表中都表现尚可也会累加;一个在某个列表中从未被检索到的文档则从该列表贡献 0。
def rrf(rankings, k=60):
score = {}
for ranked in rankings: # e.g. [lex, dense]
for rank, doc in enumerate(ranked, start=1):
score[doc] = score.get(doc, 0) + 1.0/(k + rank)
return sorted(score, key=score.get, reverse=True)
fused = rrf([lex, dense], k=60)
# the code doc (BM25 #1) and the login doc (dense #1) BOTH surface.
因为每个正确答案在各自列表中都是第 1 名,每个都获得了一个 top 1/(k+1) 的票,两者都浮了上来。小的 k 让排名第 1 的命中占据主导;大的 k 则压平票数差距,这样跨列表的一致性就更重要。
你可以直接用 α·BM25 + (1−α)·dense 融合分数——但如果不先做 min-max 归一化,BM25 的原始量级会碾压余弦相似度,无论你选什么 α。先把每个列表归一化到 0–1,α 才成为一个真正的旋钮。一旦仔细归一化并调优 α,加权求和可以胜过 RRF——但这种校准恰恰是 RRF 通过只使用排名来让你省掉的。
真正的格局是这样的:Weaviate、Elasticsearch/OpenSearch、Qdrant、Milvus 和 pgvector 都原生支持 BM25+向量混合搜索,RRF 内置其中。混合搜索修复召回率(两个答案都被检索到);其后的重排器修复精确率(最好的那个落到第 1 名),然后 LLM 才开始读一个字。
Try the live engine — flip queries, fusion methods, and the normalize toggle: https://dev48v.infy.uk/ai/days/day57-hybrid-search.html