搜索引擎打分机制揭秘:TF-IDF 和 BM25 真的在做“向量化”吗?

48 阅读5分钟

不是向量相似度,而是基于倒排索引的动态统计打分

❓ 一个更隐蔽的误解:搜索引擎会把“索引词”变成向量吗?

很多人知道 Elasticsearch 默认不是靠文档向量打分,但又产生了一个新疑问:

“那是不是搜索引擎先把每个索引词(比如‘人工智能’)变成一个向量,然后把我的搜索词也变成向量,再看它们是否匹配?”

答案仍然是否定的。

在传统的关键词搜索引擎(如 Lucene、Elasticsearch 默认模式)中:

  • 词项(term)只是一个字符串标识符,比如 "人工智能""工具"
  • 没有 embedding没有数值向量不参与任何向量运算
  • 匹配过程是精确的字符串相等比较:只有当你的查询词分词后得到的 term 和索引中的 term 完全一致(字节级相同),才算匹配。

换句话说:

搜索引擎不是在“找语义相近的词”,而是在“找完全相同的词”。

这就像图书馆的目录卡——只有你输入的书名一字不差,才能找到对应的书。它不会因为你说“AI”就自动找到“人工智能”的书(除非你显式配置了同义词扩展)。

那么,TF-IDF 或 BM25 中的权重(比如 IDF 值)是不是“词向量”? 也不是。 IDF 只是一个标量分数(一个数字),用来衡量这个词有多“重要”或“稀有”,但它不携带方向、不构成空间、不能做相似度计算。它只是打分公式里的一个系数。

总结一句话

在 BM25 这类系统中,词是符号,不是向量;匹配靠相等,不是相似。

那什么时候词才会被向量化?

场景是否向量化说明
传统关键词搜索(BM25)❌ 否词是字符串,匹配靠 exact match
同义词扩展(Synonym Graph)❌ 否仍是规则映射,非向量
Word2Vec / GloVe 词向量✅ 是通常用于 NLP 预处理,不直接用于 Lucene 检索
Dense Retrieval(稠密检索)✅ 是用 BERT 等模型将整个查询/文档编码为向量,通过 ANN 检索
ColBERT、SPLADE 等 late interaction 模型✅ 是对词做向量表示,但属于前沿研究,非默认行为

所以,如果你没显式启用向量搜索功能(比如 Elasticsearch 的 dense_vector + knn 查询),那你用的就还是纯符号匹配 + 统计打分的 BM25。

🔍 那 BM25 到底怎么打分?我们一步步拆解

假设你搜了两个词:“人工智能 工具”

搜索引擎会分别评估每个词对每篇文档的“贡献”,然后加起来。

第1步:看一个词在文档里出现的次数(TF)

比如,“人工智能”在某篇文章里出现了 3 次

直觉上:出现越多,越相关? 但不能无限加分——如果一篇文章重复刷“人工智能”100遍,它真的更好吗?不一定。

所以 BM25 引入了“饱和机制”:次数多了,加分就变慢。

第2步:看这个词有多“稀有”(IDF)

  • 如果“的”“是”这种常见词,哪怕出现 100 次,也不该加分太多;
  • 但“量子计算”“大模型蒸馏”这种专业词,只要出现一次,就很有价值。

这就是 IDF(逆文档频率)

一个词越少见,它的 IDF 越高,权重越大。

第3步:考虑文章长短(长度归一化)

一篇 1000 字的文章提到“人工智能”3 次, 和一篇 100 字的文章提到 3 次, 哪个更专注?

显然是短文!所以 BM25 会惩罚过长的文档,避免它们靠堆字数占便宜。

📐 把上面的逻辑变成公式

对于一个词 tt 和一篇文档 dd,BM25 的得分是:

score(t,d)=IDF(t)f(k1+1)f+k1(1b+bdavgdl)\text{score}(t, d) = \text{IDF}(t) \cdot \frac{f \cdot (k_1 + 1)}{f + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)}

别被吓到!我们对照上面三点来理解:

符号含义获取方式
ff词项 tt 在文档 dd 中的出现次数(词频)(即 TF(t,d)TF(t,d)索引时写入倒排列表
dd文档长度,即:包含的词项(term)总数索引时写入倒排列表
avgdl\text{avgdl}所有文档的平均长度索引级别元数据
IDF(t)\text{IDF}(t)逆文档频率,衡量词的稀有程度预计算:log(1+Nnt+0.5nt+0.5)\displaystyle \log\left(1 + \frac{N - n_t + 0.5}{n_t + 0.5}\right)
NN索引中文档总数全局统计量
ntn_t包含词项 tt 的文档数量由倒排索引的文档频率得出
k1k_1控制词频饱和度(默认 1.2)可配置参数
bb控制长度归一化强度(默认 0.75)可配置参数

💡 这些数据(ffd|d|avgdl\text{avgdl} 等)都是建索引时提前算好存下来的,查询时直接用,所以速度极快。

📊关于“文档的平均长度“的说明

1、“文档长度”指什么?

  • 不是字节数,也不是字符数;
  • 而是经过分词后,文档中包含的词项(term)总数

✅ 举例:

  • 文档 A:"人工智能是未来的技术。" 分词后:["人工智能", "是", "未来", "的", "技术"] → 长度 dA=5|d_A| = 5
  • 文档 B:一篇 1000 字的技术白皮书,分词后有 850 个词 → dB=850|d_B| = 850

💡 这个长度在建索引时就会计算好,并存储在字段的 norms 中(可关闭以节省空间)。

2、“所有文档的平均长度”怎么算?

假设你的索引里有 3 篇文档,长度分别是:

  • d1=100|d_1| = 100
  • d2=200|d_2| = 200
  • d3=300|d_3| = 300

那么: avgdl=100+200+3003=200\text{avgdl} = \frac{100 + 200 + 300}{3} = 200

在真实系统中(如 Elasticsearch),这个值是索引级别(index-level)的全局统计量,每次新增或删除文档时会动态更新(或近似更新)。

3、为什么需要 avgdl?——解决“长文档作弊”问题

想象两个文档都包含查询词 “大模型” 3 次:

  • 文档 X:短文,共 50 个词 → “大模型” 出现 3 次,占比 6%
  • 文档 Y:长报告,共 3000 个词 → “大模型” 出现 3 次,占比 0.1%

直觉上,文档 X 更聚焦于“大模型”,应该排得更靠前。

但如果不考虑长度,仅看词频 f=3f=3,两者 TF 部分得分一样,不公平。

👉 BM25 通过 davgdl\frac{|d|}{\text{avgdl}} 引入长度惩罚

  • 如果 d>avgdl|d| > \text{avgdl}(文档偏长)→ 分母变大 → 整体分数降低;
  • 如果 d<avgdl|d| < \text{avgdl}(文档偏短)→ 分母变小 → 分数更高(更专注)。

公式中的这一项: 1b+bdavgdl1 - b + b \cdot \frac{|d|}{\text{avgdl}} 就是动态调整 TF 饱和点的因子,其中 bb(默认 0.75)控制惩罚强度:

  • b=0b = 0:完全不考虑长度(退化为早期 TF-IDF);
  • b=1b = 1:完全按比例归一化。

🧪 举个真实例子

假设:

  • 全库有 100 万篇文章(N=1,000,000N = 1{,}000{,}000
  • “人工智能”出现在 1 万篇文章中(nt=10,000n_t = 10{,}000
  • 当前文章长度:200 词(d=200|d| = 200
  • 全库平均长度:150 词(avgdl=150\text{avgdl} = 150
  • “人工智能”在这篇文章中出现 3 次(f=3f = 3
  • 使用默认参数:k1=1.2k_1 = 1.2, b=0.75b = 0.75

1️⃣ 先算 IDF(衡量稀有度):

IDF(t)=log(1+Nnt+0.5nt+0.5)=log(1+1,000,00010,000+0.510,000+0.5)log(100)4.605\text{IDF}(t) = \log\left(1 + \frac{N - n_t + 0.5}{n_t + 0.5}\right) = \log\left(1 + \frac{1{,}000{,}000 - 10{,}000 + 0.5}{10{,}000 + 0.5}\right) \approx \log(100) \approx 4.605

2️⃣ 再算 TF 的“有效得分”(考虑饱和和长度):

先算分母: denom=f+k1(1b+bdavgdl)=3+1.2(10.75+0.75200150)=3+1.2(0.25+1.0)=4.5\text{denom} = f + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right) = 3 + 1.2 \cdot \left(1 - 0.75 + 0.75 \cdot \frac{200}{150}\right) = 3 + 1.2 \cdot (0.25 + 1.0) = 4.5

再算分子:f(k1+1)=32.2=6.6f \cdot (k_1 + 1) = 3 \cdot 2.2 = 6.6

所以 TF 部分得分:6.64.51.467\dfrac{6.6}{4.5} \approx 1.467

3️⃣ 最终这个词的贡献分:

score(t,d)=4.605×1.4676.76\text{score}(t, d) = 4.605 \times 1.467 \approx 6.76

如果另一个词“工具”贡献了 3.24 分,那这篇文档总分 ≈ 10.0


🔁 闭环流程:从你的搜索词到最终排序结果

现在我们把整个过程串起来,看看当你输入 “人工智能 工具” 时,搜索引擎到底做了什么。

步骤 1️⃣:查询解析与分词

  • 用户输入:"人工智能 工具"
  • 经过分词器处理 → 得到两个词项:
    • t1="人工智能"t_1 = \text{"人工智能"}
    • t2="工具"t_2 = \text{"工具"}

注意:这里没有向量化,只是字符串切分。


步骤 2️⃣:查倒排索引,获取候选文档集合

对每个词项,分别查倒排索引:

  • "人工智能" → 倒排列表:[d5,d12,d42,][d_5, d_{12}, d_{42}, \dots]
  • "工具" → 倒排列表:[d3,d12,d88,][d_3, d_{12}, d_{88}, \dots]

根据查询类型决定如何合并:

查询类型合并方式说明
match(默认)OR:取并集只要包含任一词就算候选
match + "operator": "and"AND:取交集必须同时包含两个词
match_phrase短语匹配要求词序和邻近性

假设使用默认 match(OR 模式),则候选文档 = 所有出现在任一列表中的文档。

💡 实际系统会限制候选集大小(如 top 10,000),避免计算爆炸。


步骤 3️⃣:对每个候选文档,计算总分

对每个候选文档 dd,执行:

  1. 检查它包含哪些查询词项;
  2. 每个匹配的词项 tt,用 BM25 公式计算 score(t,d)\text{score}(t, d)
  3. 将所有匹配词项的得分线性相加,得到文档总分:

score(d,q)=tquery_termsdscore(t,d)\text{score}(d, q) = \sum_{t \in \text{query\_terms} \cap d} \text{score}(t, d)

✅ 这就是你在 Elasticsearch 中看到的 _score

举个例子:

  • 文档 d12d_{12} 包含 “人工智能”(得分 6.76)和 “工具”(得分 3.24)
  • 总分 = 6.76+3.24=10.06.76 + 3.24 = 10.0
  • 文档 d5d_5 只包含 “人工智能” → 总分 = 6.76
  • 文档 d3d_3 只包含 “工具” → 总分 = 3.24

最终排序:d12d5d3d_{12} \rightarrow d_5 \rightarrow d_3


步骤 4️⃣:返回排序结果

系统按 _score 从高到低返回文档(可配合分页、过滤、高亮等)。

⚠️ 注意:BM25 不保证语义相关性! 如果你搜 “AI”,而文档写的是 “人工智能”,但没配合同义词,就完全匹配不上——因为 "AI" ≠ "人工智能"


🧩 补充:布尔逻辑与打分的关系

  • AND 查询:只有同时包含所有词的文档才进入打分阶段;
  • OR 查询:包含任一词的文档都打分,但只累加它实际命中的词;
  • NOT 查询:排除某些文档,不影响打分公式本身。

所有这些逻辑都在倒排索引的集合运算层完成,打分只发生在匹配之后


✅ 关键结论(再说一遍)

  • 没有文档向量,也没有词向量
  • 词项只是字符串符号,匹配靠 exact match
  • 所有计算都基于倒排索引中预存的统计信息
  • 打分是查询时动态计算的,但因为只涉及几个数字运算,所以极快;
  • 最终得分 = 所有匹配词的 BM25 分数之和;
  • 整个流程是:分词 → 倒排查找 → 布尔筛选 → term 级打分 → 求和 → 排序

🧠 什么时候才用“向量化”?

当你启用语义搜索时,比如:

  • 用 BERT 把整篇文档编码成一个 768 维向量;
  • 把你的问题也编码成向量;
  • 用 ANN(近似最近邻)找最相似的文档。

这才是真正的“向量匹配”。但这是新一代技术,和 BM25 属于不同范式。

目前工业界主流做法是:

BM25(关键词匹配) + 向量检索(语义匹配) → 混合搜索(Hybrid Search)

既保证精准召回,又覆盖语义泛化。


📌 总结一句话: BM25 不是向量化,而是一套基于统计的“现场打分规则”,高效、可解释、工程友好