
3.1 Elasticsearch-TF-IDF vs BM25 评分公式拆解
在 Elasticsearch 5.0 之前,默认的文本相似度计算模型是 TF-IDF(Lucene 的 Practical Scoring Function)。5.0 之后,官方把默认模型换成了 BM25。很多老项目升级后发现“同样的查询语句,打分变了,排序也变了”,根源就在这两条公式的差异。下面把两条公式拆开到字段级别,逐行对比它们对“词频饱和”和“文档长度归一化”这两个核心问题的不同态度,并给出可直接在 Kibana Dev-Tools 里验证的实验脚本。
1. 公式骨架对照
| 维度 | TF-IDF(Lucene 实际实现) | BM25(Lucene 实现) |
|---|---|---|
| 词频因子 | tf(t in d) = √freq | `freq / (freq + k₁·(1-b+b· |
| 逆文档频率 | idf(t) = 1 + log((N-n+0.5)/(n+0.5)) | 同左,Lucene 直接复用 |
| 长度归一化 | `norm(d) = 1/√ | d |
| 查询 boost | 外部乘 q.boost | 外部乘 q.boost |
| 协调因子 | coord(q,d)(命中查询子句比例) | 无(BM25 本身已含饱和度) |
注意:Lucene 的 TF-IDF 并不是教科书里的“纯 TF·IDF”,而是做了平方根平滑和长度归一化之后的 Practical Scoring Function,下文简称 TF-IDF。
2. 逐因子拆解
2.1 词频饱和度(Saturation)
-
TF-IDF
tf = √freq永无止境增长,freq=100 时 tf=10,freq=400 时 tf=20,线性放大。结果是:一篇堆砌关键词 400 次的文档,得分可以碾压真正相关的短文档。 -
BM25
分母里freq + k₁·(...)让 tf 随 freq 增加而趋近上限 1/k₁。k₁ 默认 1.2,意味着 freq 从 1→10→100,tf 分量从 0.45→0.89→0.99,几乎“封顶”。垃圾堆砌不再有效。
实验:
PUT idx_satu
{ "settings": { "number_of_shards": 1, "similarity": { "classic": { "type": "classic" }, "bm25": { "type": "BM25", "k1": 1.2 } } }, "mappings": { "properties": { "title": { "type": "text", "similarity": "classic", "fields": { "bm25": { "type": "text", "similarity": "bm25" } } } } } }
POST idx_satu/_doc/1
{ "title": "java" }
POST idx_satu/_doc/2
{ "title": "java java java java java java java java java java" } // 10次
GET idx_satu/_search
{ "explain": true, "query": { "match": { "title": "java" } } }
对比 _explanation 中 tf(freq=1) 与 tf(freq=10) 的得分倍数:
- classic: 1 → 3.16(√10)
- bm25: 1 → 1.9(接近 2 倍即饱和)
2.2 文档长度归一化
-
TF-IDF
索引时把1/√|d|乘进 norm,查询阶段不可调。短文档天生自带 buff;长文档被惩罚过度,常常“有理说不出”。 -
BM25
长度惩罚因子(1-b+b·|d|/avgdl)把“相对长度”放在分母,且 b 可动态调:- b=0:完全取消长度惩罚,适合标题、标签等短字段;
- b=1:全额惩罚,适合正文。
由此带来一个实战技巧:同一条查询可以跨多个字段,各自使用不同的 b 值。
实验:
PUT idx_len
{ "settings": { "number_of_shards": 1, "similarity": {
"short": { "type": "BM25", "b": 0 },
"long": { "type": "BM25", "b": 1 }
} }, "mappings": { "properties": {
"title": { "type": "text", "similarity": "short" },
"body": { "type": "text", "similarity": "long" }
} } }
POST idx_len/_doc/1
{ "title": "java", "body": "java is a language" }
POST idx_len/_doc/2
{ "title": "java java", "body": "java".repeat(200) } // 200次
GET idx_len/_search
{ "query": { "multi_match": { "query": "java", "fields": [ "title^2", "body" ] } } }
结果:
- title 字段 b=0,长度几乎不影响,doc1 的短标题仍拿高分;
- body 字段 b=1,doc2 因长度被大幅降权,尽管词频高也追不上 doc1。
3. 参数速查表
| 参数 | 作用 | TF-IDF | BM25 |
|---|---|---|---|
| k₁ | 词频饱和度拐点 | 无(固定√freq) | 默认 1.2,区间 [1.2,2.0] |
| b | 长度惩罚力度 | 无(固定 1/√d) | 默认 0.75,可 0~1 |
调参口诀:
- 堆砌党多 → 把 k₁ 降到 0.5~1.0,更快饱和;
- 短字段(标题、标签)→ b=0;
- 长字段(文章、日志)→ b=1;
- 想复现老 TF-IDF 的排序 → 直接把 similarity 改回 “classic”,但会丢失 BM25 的饱和度好处。
4. 升级兼容性建议
-
索引级回退:
在 7.x/8.x 集群里仍可显式指定"similarity": "classic",但官方已标记为 deprecated,未来版本会移除。 -
字段级混合:
同一索引让 title 用 BM25(b=0),body 用 BM25(b=1),兼顾短字段高权、长字段防刷。 -
重新打分测试:
升级前先用_reindex把数据拷到测试索引,改 similarity 后跑一遍_search?explain=true,把 TOP100 id 与线上对比,可提前发现“排序漂移”。
5. 小结一句话
TF-IDF 像“少年时期的搜索引擎”,词频无限放大、长度一刀砍;BM25 则是“成年版”,用饱和曲线和可调长度因子,让堆砌失效、让短长字段各得其所。在 Elasticsearch 里,这两条公式只差一个 similarity 名字,却可能让同一批查询结果面目全非。理解公式的每一寸弯曲,才能在升级、调参、排障时做到“手中有公式,心里不慌张”。
更多技术文章见公众号: 大城市小农民
1233

被折叠的 条评论
为什么被折叠?



