文档助手将 E4012 的正确页面排到第 14 位,暴露了纯向量检索对精确标识符的不足。文章建议并行运行 BM25 与向量检索,用 RRF 合并排名,并检查分词器是否拆散错误码和版本号。
一位用户在我的文档助手里输入了 E4012。排名第一的结果是 E4021 的页面,第二是 E4013。真正解释 E4012 的页面排在第 14 位,远远超出了我只取前 5 条结果的范围。于是,LLM 根本没看到正确页面,却信心十足地解释了另一个错误。
一张截图,就把 BM25 与 embeddings 的问题说清楚了。Dense vector search 很擅长回答“这是什么意思”,却不擅长“找到这个确切的字符串”。错误码、SKU、函数名、工单 ID 和版本号,恰好都落在这个缺口里。
我花了一个晚上,一直认定是索引坏了。其实没有。Embedding 模型只是在老老实实地做它原本被设计来做的事。
Embedding 模型会把文本压缩成语义。在它们看来,E4012 和 E4021 的意思几乎一样,都是“这个产品里的某个错误码”,所以它们的向量会落在彼此附近。
BM25 根据 token 的精确匹配来打分,再按 token 的稀有程度加权。如果一个错误码只出现在一篇文档里,它就会获得很高的 IDF 权重,BM25 因而会把那篇文档排在第一位。
解决办法是混合检索:并行运行 BM25 和向量搜索,再用 Reciprocal Rank Fusion(RRF,倒数排名融合)合并结果。
记得检查 BM25 的 tokenizer。如果默认 analyzer 把 ERR_CONN_RESET 或 v2.3.1 拆成碎片,你为了获得精确匹配能力而引入 BM25 的优势,也就被它拆没了。
在我自己的 120 条查询测试集上,混合检索补回了纯 dense 检索漏掉的所有标识符查询,同时没有影响自然语言查询的效果。
向量搜索在标识符上失手,是因为 embedding 编码的是一段文本在讲什么,而不是它包含哪些确切字符。两篇介绍相邻错误码的页面,讲的事情差不多,因此得到的向量也几乎一样。
来看看 E4012 会经历什么:
结果就是:E4012 页面和 E4021 页面在向量空间里只差一丁点。查询 E4012 时,究竟哪个页面排在前面,基本取决于噪声。
查询越短,问题越严重。单独一个 E4012 几乎没有上下文,所以它的 embedding 只是一个模糊的“像是错误码”的向量。它和所有错误页面都比较接近,却没有特别接近其中某一篇。
BM25 会统计词项的精确匹配,再根据每个词项在整个语料库中的稀有程度加权,从而给文档排序。如果一个词项只出现在一篇文档里,它的权重就会很高,那篇文档也就会胜出。
评分公式包含三个组成部分:
log((N - n + 0.5) / (n + 0.5) + 1),其中 N 是文档总数,n 是包含该词项的文档数。在我大约有 2,300 个 chunk 的语料库里,像“connection”这样的词出现在数百个 chunk 中,而 e4012 只出现在 3 个 chunk 中。代入这些数字,e4012 的 IDF 大约是 6.5,而“connection”只有 1.5 左右。BM25 并不知道什么是错误码。它只知道这个 token 很少见,而查询里恰好包含它。对于标识符查找,这种判断方式正合适。
下面是使用 rank_bm25 的最小实现:
import re
from rank_bm25 import BM25Okapi
def tokenize(text: str) -> list[str]:
# keep codes like E4012, ERR_CONN_RESET, v2.3.1 as single tokens
return re.findall(r"[A-Za-z0-9_][A-Za-z0-9_.\-]*[A-Za-z0-9_]|[A-Za-z0-9]", text.lower())
corpus_tokens = [tokenize(c.text) for c in chunks]
bm25 = BM25Okapi(corpus_tokens, k1=1.2, b=0.75)
scores = bm25.get_scores(tokenize("E4012"))
我第一次尝试 BM25 失败,是因为 tokenizer 在建立索引之前,就把标识符拆成了碎片。一旦 ERR_CONN_RESET 变成 err、conn、reset 这三个常见词,稀有词项的优势就消失了。
我一开始用的是 text.split()。它会把带尾随逗号的 E4012, 保留下来,视为与 E4012 不同的 token,导致我有一半的匹配悄无声息地丢了。后来我换成了一个会去掉标点的“正规”分词器,结果它把 v2.3.1 拆成 v2、3、1,又把 ERR_CONN_RESET 拆成了三个普通词。
无论你使用哪种 BM25 实现,都要检查下面这些问题,包括 Elasticsearch、OpenSearch、Postgres full-text、Tantivy 和 rank_bm25:
_、. 或 - 处分词?拿一个真实的标识符跑一下,看看结果。上面的正则表达式很粗糙,而这恰恰是它的用意。它会把任何看起来像标识符的内容保留为一个整体。在信任它之前,先拿你那些最难处理的 ID 测一遍。
同时运行两种搜索,再用 Reciprocal Rank Fusion 合并两份排序列表,就能把它们结合起来。RRF 不看原始分数,只看排名位置。这一点很重要,因为 BM25 分数和 cosine similarity 完全不在同一个量级上。
公式是:对每篇文档,在所有包含它的列表中,分别计算 1 / (k + rank),然后求和。原始 RRF 论文中常用的默认值是 k = 60。
def rrf(result_lists: list[list[str]], k: int = 60, top_n: int = 5) -> list[str]:
scores: dict[str, float] = {}
for results in result_lists:
for rank, doc_id in enumerate(results, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)[:top_n]
dense_ids = vector_search(query, limit=50)
sparse_ids = bm25_search(query, limit=50)
context_ids = rrf([dense_ids, sparse_ids])
为什么这对 E4012 有效?BM25 把正确页面排在第 1 位,得分为 1/61。Dense 检索把它排在第 14 位,得分为 1/74。总分大约是 0.030。E4021 页面虽然在 dense 检索里排第 1,却根本没有出现在 BM25 的结果列表里,所以它只有 1/61,也就是大约 0.016 分。正确页面的分数几乎是它的两倍。
而面对“为什么我的上传一直卡着不动”这样的模糊问题,BM25 返回的匹配往往比较弱,也比较分散,dense 检索却能准确找到答案。RRF 让每次查询中更强的信号发挥作用,你无需再写一个 router。
每一侧都取 50 个候选结果,别只取 5 个。如果正确文档在某份列表里排第 14,而你只取了前 10 个,融合阶段就永远看不到它。
在我自己的测试集上,混合检索找回了纯 dense 检索漏掉的所有标识符查询,自然语言查询的表现则基本保持不变。这些数字来自我的语料库和 embedding 模型,并不是通用 benchmark。
测试设置是:从支持工单中抽取 120 条真实查询,为每条查询标注能够回答它的 chunk。其中 38 条包含标识符,例如错误码、配置键、CLI flag 或版本字符串。我衡量的是正确 chunk 是否进入前 5 名。
120 条查询确实不多。把它理解为“方向是对的”,别把它当成“效果就一定是这个数字”。用真实查询建立你自己的评测集;只有它才能告诉你,你的语料库实际表现如何。
Cross-encoder reranker 能改善排序,但它只能重排检索阶段交给它的结果。如果 dense 检索把正确 chunk 埋在第 14 位,而你只取了 10 个候选结果,再好的 reranker 也看不到它。
Reranker 读到的同样是那些 subword 片段,所以虽然它们处理标识符的能力比 bi-encoder 强,也并非不会出错。把 reranker 放在混合检索之后,而不是用它替代混合检索。
[A-Z]+\d{3,}、\w+_\w+ 或 v\d+\.\d+ 的内容,由此估算标识符查询在流量中的占比。我的占比大约是三分之一。两个都用。Embeddings 找的是与查询含义相近的段落;BM25 找的是包含相同稀有 token 的段落。向量搜索会漏掉 E4012 这样的错误码,是因为 embedding 模型把错误码中的数字压缩进了一个由周围语义主导的向量,让相邻错误码的向量几乎重叠。BM25 则会给这个稀有的精确 token 很高的 IDF 权重,把匹配文档排在第一。运行两种 retriever,每一侧取大约 50 个候选结果,用 k = 60 的 Reciprocal Rank Fusion 合并,并确保 BM25 tokenizer 能完整保留标识符。这套组合既能处理“这个错误是什么意思”,也能处理“查找错误 E4012”。
本文作者是面试准备平台 Preterview 背后的开发者。
如果需要采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。