搜一段报错时,你大概不希望系统只返回一篇“主题很像”的文章。错误码、函数名、版本号,少一个字都可能是另一回事。关键词检索(retrieval)保留了这些字面线索;不过,命中两个词和重复命中同一个词,该怎么比较?
先不看公式,想两个小变化。一篇笔记把 cache 连写 20 次,不该因此比真正解释报错的笔记有用 20 倍;两篇都只提了一次 error,一句话的报错记录和整本操作手册提供的线索强度也未必相同。BM25 把“重复的收益会递减”和“文档有长有短”都放进了打分,但它并没有理解哪篇真的解决了问题。
只想先弄懂区别,可以读这段直觉和参数对照。想看分数怎么来,下面用 4 段短文本完整算一遍;要接到自己的系统,再看实现与排错。这是混合检索的前置解释,不要求先学 embedding。
1. 先分清:一个词出现几次,还是几篇里出现过?
查询是 cache error。为了让每一步都能手算,把文档简化成下面这些已经分好的词,不考虑语序、标点和大小写。
| 文档 | 分词结果 | 长度 | cache 次数 |
error 次数 |
|---|---|---|---|---|
| A | cache cache error |
3 | 2 | 1 |
| B | cache error |
2 | 1 | 1 |
| C | cache guide setup notes |
4 | 1 | 0 |
| D | release notes |
2 | 0 | 0 |
词频(term frequency, TF)看一篇内部:cache 在 A 中出现 2 次。文档频率(document frequency, DF)看整个库:有 3 篇包含 cache,所以它的 DF 是 3,不是总共出现的 4 次。
这里文档数 $N=4$,平均长度 $\overline L=(3+2+4+2)/4=2.75$。这些量都取自同一个语料快照。换一批文档、改一次切块方式,统计量也会变。
2. TF-IDF:常见词少加一点,少见词多加一点
TF-IDF 把词频和逆文档频率(inverse document frequency, IDF)相乘:前者看这篇提了多少次,后者看这个词在库里有多常见。先选一个简单版本,使用自然对数:
$$ \begin{aligned} \operatorname{idf}(t)&=\ln\frac{N}{\operatorname{df}(t)},\\ w(t,d)&=f(t,d)\operatorname{idf}(t). \end{aligned} $$
$f(t,d)$ 是词 $t$ 在文档 $d$ 的次数。查询中的不同词各算一次,把对应权重相加;不在文档中的词贡献 0,不在整个词表(vocabulary)中的查询词也忽略。TF-IDF 是一类权重约定,不是唯一一种打分函数。IR 教材的定义可以用来核对这里的乘积形式。
cache 的 IDF 为 $\ln(4/3)\approx0.2877$;error 为 $\ln(4/2)\approx0.6931$。因此 A 得分 $2\times0.2877+0.6931\approx1.2685$,B 是 $0.9808$。
| 文档 | cache 的贡献 |
error 的贡献 |
TF-IDF 求和 |
|---|---|---|---|
| A | 0.5754 | 0.6931 | 1.2685 |
| B | 0.2877 | 0.6931 | 0.9808 |
| C | 0.2877 | 0 | 0.2877 |
| D | 0 | 0 | 0 |
A 靠多写一次 cache 排在了 B 前面。这不一定错,但重复 20 次,真的应该得到 20 倍的贡献吗?
一种改法是把正词频换成 $1+\ln f$,未命中仍是 0;这是 sublinear TF。另一种做法把查询和文档都转成权重向量,再做 cosine normalization。后者不等于上面的权重求和:如果两边都带 IDF,点积(inner product)的单词贡献包含 IDF 的平方,还要除以向量范数。比较实现前,先对齐公式,别只核对“都叫 TF-IDF”。
“重复一遍,分数翻倍”要看用的是哪种 TF
把 cache error 整段复制一次,得到 cache error cache error。固定同一套 IDF:原始次数从 [1, 1] 变成 [2, 2],上面的求和分数会翻倍;但按文档长度计算的相对词频,两者都是 [1/2, 1/2]。如果用 L2 归一化后的 TF-IDF 向量,两份向量的方向也一样,和同一查询的 cosine 分数不会变。
所以,“TF-IDF 不管长度、重复就能无限加分”只适用于某些具体写法。比如 scikit-learn 1.7 的 TF-IDF默认做 L2 归一化,平滑 IDF 为 $1+\ln((N+1)/(\operatorname{df}+1))$,并不是本节的原始次数求和。这里固定语料统计,是为了只观察重复的影响;如果把新文档加入库里重新 fit,IDF 也可能变。
3. BM25:重复有用,但不会一直等比例加分
下面采用带正值 IDF 的 BM25:
$$ \begin{aligned} r_t&=\frac{N-\operatorname{df}(t)+0.5}{\operatorname{df}(t)+0.5},\\ \operatorname{idf}_{+}(t)&=\ln(1+r_t). \end{aligned} $$
$$ \begin{aligned} a_d&=1-b+bL_d/\overline L,\\ g(t,d)&=\frac{f(t,d)(k_1+1)}{f(t,d)+k_1a_d},\\ S(q,d)&=\sum_{t\in\operatorname{unique}(q)} \operatorname{idf}_{+}(t)g(t,d). \end{aligned} $$
取 $k_1=1.2,b=0.75$。IDF 约定与参数默认值可对照 Lucene 10.3.1;这里是教学公式,不承诺逐位复现 Lucene 的完整 scorer、字段统计或长度编码。
这里 $r_t$ 只是计算 IDF 的中间比值;$a_d$ 是文档长度修正项,$g(t,d)$ 是修正后的词频因子,最后按查询词加总得到分数 $S(q,d)$。拆开写,是为了能逐项检查,不是又多了几个要调的参数。
公式可以拆成两件事:
- 词频饱和(term-frequency saturation):先固定长度修正项,$f$ 越大,继续重复带来的增量越小。
- 长度归一化(length normalization):词频相同的情况下,较长的文档通常会被适当降权。一个词在 10 个词的短记录里出现,和在几千词的综述里出现,证据强度未必相同。
先只看词频部分,固定 $L_d/\overline L=1$:
| 词频 $f$ | 原始 TF | $f(k_1+1)/(f+k_1)$,$k_1=1.2$ |
|---|---|---|
| 1 | 1 | 1.0000 |
| 2 | 2 | 1.3750 |
| 4 | 4 | 1.6923 |
| 8 | 8 | 1.9130 |
同一长度下,这一因子的极限是 $k_1+1=2.2$。Lucene 从 8.0 起把分子里的 $(k_1+1)$ 去掉了(LUCENE-8563):它对每篇文档都是同一个常数,不改变排序。所以 Lucene 算出的分数是这里的 $1/2.2$,名次一样(10.3.1 的 BM25Similarity 源码)。这是把两个影响分开看的控制例子;真的往文档后面添词,还会同时改变文档长度,不能只改 $f$。
回到那 4 篇文档:cache 的新 IDF 为 $0.3567$,error 为 $0.6931$。A 的长度修正项是 $1-0.75+0.75\times3/2.75\approx1.0682$。先算它的词频因子 $g(\texttt{cache},A)$,再乘 IDF:
$$ \begin{aligned} \frac{2\times2.2}{2+1.2\times1.0682}&\approx1.3407,\\ 0.3567\times1.3407&\approx0.4782. \end{aligned} $$
| 文档 | cache 的贡献 |
error 的贡献 |
BM25 求和 |
|---|---|---|---|
| A | 0.4782 | 0.6683 | 1.1465 |
| B | 0.4015 | 0.7802 | 1.1817 |
| C | 0.3008 | 0 | 0.3008 |
| D | 0 | 0 | 0 |
这次 B 略高于 A:同样覆盖两个查询词,B 更短,A 多写一次 cache 的收益则被压小了。这个排序只是公式的结果,不是“B 一定更相关”的人工标签。 它们可能一个是无用的日志,一个才有修复方案;内容是否有用,仍要另外判断。
4. 两个参数怎么选,哪些结论别混在一起?
| 改什么 | 直接影响 | 要小心什么 |
|---|---|---|
| $k_1=0$ | 命中的词只加一次 IDF,不再按词频加权 | 未命中项应直接返回 0,别算出 $0/0$ |
| 增大 $k_1$ | 在固定长度比下,允许重复次数产生更大差异 | 不是越大越好,要看开发集 |
| $b=0$ | 关闭长度修正 | 词频饱和仍在,不能叫“退化成原始 TF” |
| $b=1$ | 长度修正项变为 $L_d/\overline L$ | 不代表长文一定更差,词频和命中的词也在变 |
还有一个容易踩的坑:有的 BM25 公式没有 IDF 外层的 1 +。在那个约定下,一个词出现在超过一半的文档里,IDF 可能为负;我们的 cache 就会遇到。这是公式约定不同,不一定是 bug。IR 教材的 BM25 一节同时讨论了这些形式。
不要把 BM25 分数当相关性概率,也不要直接和 embedding 的余弦分数相加。混合检索与重排一篇里的 RRF,就是一种不要求原始分数同尺度的融合办法。
排在前面,还是只是碰巧先输出?
假设另一个库有 6 篇文档,恰好 3 篇包含 cache。在不带外层 1 + 的 IDF 约定下:
$$ \operatorname{idf}(\texttt{cache})=\ln\frac{6-3+0.5}{3+0.5}=0. $$
只搜 cache 时,无论重复几次,它的贡献都是 0。检索引擎可以按文档 ID 或输入顺序打破平局,但先显示的那篇并没有得到更高分。如果改用本页的正值 IDF,它变成 $\ln 2$;此时词频与长度才会影响这个词的得分。
rank_bm25 0.2.2 的 BM25Okapi就是一个需要核对约定的实例:它计算不带外层 1 + 的 IDF,再替换负值;恰好为 0 的项不会因此变成正值。看演示输出时,先查逐词分数和是否打平,再解释排序。设了 $b=0$ 的实验,也不能用来证明长度归一化有效,因为它根本没启用这个因素。
5. 写出来:先验证计算,再考虑索引
单个词的贡献只需要几行。参数检查与语料统计由外层的 LexicalIndex 负责;这个函数假定输入已合法,且出现过的词对应正的平均文档长度。
def bm25_term(frequency, length, average_length, inverse_frequency, k1, length_weight):
if frequency == 0:
return 0.0
length_factor = 1 - length_weight + length_weight * length / average_length
return inverse_frequency * frequency * (k1 + 1) / (frequency + k1 * length_factor)
代码里用 length_weight 表示公式的 $b$,避免把这个参数和其他权重混淆。完整的标准库实现是 lexical_retrieval.py,从仓库根目录执行:
python3 04-search/code/lexical_retrieval.py
TF-IDF: [1.2685, 0.9808, 0.2877, 0.0]
BM25: [1.1465, 1.1817, 0.3008, 0.0]
代码接收已经分词的字符串序列,不负责分词、不自动转小写。示例里的 .split() 只是为了复算上面的英文词表,不适用于中文搜索。重复的查询词去重;空文档得 0 分,但计入文档数和平均长度;空库返回空列表,全空文档也不会除以 0。真实引擎对缺失字段、空字段的统计约定可能不同,接入时需要单独核对。
建统计表需要遍历 $N$ 篇文档及其中总计 $T$ 个词,期望时间为 $O(N+T)$。设查询去重前有 $M$ 个词、去重后有 $Q$ 个不同词,则一次查询的期望时间为 $O(M+N(1+Q))$,包括查询词去重,以及即使查询为空也要为每篇文档生成一个分数的开销;这里假设哈希表操作的期望代价为常数。若 $U$ 是所有“文档–不同词”组合的数量,统计表占用为 $O(N+U)$,其中 $N$ 项也包含空文档。这是方便检查的参考实现,不是大规模搜索引擎。
实际查询通常从倒排索引(inverted index)读候选(candidate):cache → A:2, B:1, C:1,error → A:1, B:1。这样不必为了命中词扫描 D。但高频词的 postings 仍可能很长,Top-K、过滤、剪枝和索引更新各有代价,不能简单说“有倒排就 O(1)”。
6. 接到真实数据,优先查这几件事
| 现象 | 先查什么 | 一个小验证 |
|---|---|---|
| 明明有同样的报错,却搜不到 | 文档和查询的 analyzer 是否一致 | 打印 ERR_42、C++、中文词的实际 tokens |
| 切块后分数和排名变了 | 文档数、DF、平均长度都可能变 | 固定查询,比较切块前后的逐词贡献 |
| 长教程总被压在短便签下面 | 长度修正是否过重、正文是否混入导航垃圾 | 分长度切片(slice)比较,并扫 $b$;不要只看全局均值 |
| 精确词命中了,但回答仍错 | 命中不等于内容可信、当前或能回答问题 | 看版本、权限、证据段,再看 reranker / RAG |
| 换个说法就完全没有结果 | 字面重叠不足 | 加 dense 路线,固定总候选数比较独有相关结果 |
稀有词也可能是拼写错误,不天然等于重要线索。停用词、同义词、字段权重都要看任务;像错误码这样的字段,随手拆开或统一清洗反而可能丢掉最有用的部分。
评估时保留一份固定的查询与相关性判断,单独看精确标识符、自然语言改写、中文和长文档等切片。开发集选 $k_1,b$,留出的测试集只做最终比较。检索库可以按任务要求包含待检索文档;不要用测试集的相关性标签反复调参数。
7. 再往哪里看?
- 词不同、意思接近怎么办:双塔检索。
- 两路怎样合在一起:混合检索与重排。
- 找到之后怎样形成可核对的回答:RAG 的证据链。
- 想确认自己理解了:把查询改成
cache cache error unknown。本实现应与cache error同分。接着设 $b=0$,看排序为什么变化;再设 $k_1=0$,A、B 应打平。
以上例子是教学语料,不是产品效果或模型对比结果。标准公式之外的统计与工程约定,最终以你实际使用的引擎版本为准。
本页术语 5
- 检索 retrieval
- 从外部语料里找出相关内容
- 词表 vocabulary
- 模型能输出的所有 token 的集合
- 点积 inner product
- 两个向量对应分量相乘再求和,双塔的打分方式
- 候选 candidate
- 召回阶段捞出来、等待精排挑选的内容
- 切片 slice
- 按内容或用户属性划分的子群体,用来检查平均值掩盖了什么