搜一段报错时,你大概不希望系统只返回一篇“主题很像”的文章。错误码、函数名、版本号,少一个字都可能是另一回事。关键词检索(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
按内容或用户属性划分的子群体,用来检查平均值掩盖了什么