真实用_explain拿到BM25评分的完整计算明细,用Python手工重算公式验证与ES内部完全一致,并用4篇相同长度、词频依次翻倍的文档量化k1参数带来的真实饱和效应。
代码3篇文档: doc1{body:"cat cat cat dog bird"}(5词,cat出现3次) doc2{body:"cat dog dog bird fish mouse"}(6词,cat出现1次) doc3{body:"dog bird fish"}(3词,不含cat) POST /bm25_test/_explain/1 {"query":{"term":{"body":"cat"}}}
doc1命中"cat"的完整explain: → score = boost * idf * tf = 0.72744286 → boost = 2.2 → idf = log(1 + (N-n+0.5)/(n+0.5)) = 0.47000363 其中 n(含cat的文档数)=2, N(总文档数)=3 → tf = freq/(freq + k1*(1-b+b*dl/avgdl)) = 0.70351756 其中 freq=3.0, k1=1.2, b=0.75, dl(doc1长度)=5.0, avgdl(平均长度)=4.6666665
这份真实的explain明细直接把BM25公式的每一个分量都摊开展示了出来:idf(逆文档频率)衡量"cat"这个词在整个语料库里有多稀有,tf(词频饱和项)衡量"cat"在当前这篇文档里出现得有多频繁,并且真实考虑了文档长度的归一化。两者相乘再乘以boost,就是这次匹配真实贡献的最终得分。这不是理论公式,而是ES在这次真实查询里真实用到的每一个具体数值。
代码import math N, n, freq, k1, b, dl, avgdl, boost = 3, 2, 3.0, 1.2, 0.75, 5.0, 4.6666665, 2.2 idf = math.log(1 + (N-n+0.5)/(n+0.5)) tf = freq/(freq + k1*(1-b+b*dl/avgdl)) score = boost * idf * tf
手工计算结果: → idf = 0.47000362924573563 (ES真实返回0.47000363,一致) → tf = 0.7035175822580235 (ES真实返回0.70351756,一致) → score = 0.7274427971788041 (ES真实返回0.72744286,一致)
代码真实构造4篇长度都是10个词的文档,cat出现次数依次翻倍: 1次/2次/4次/8次(其余用x填充凑够长度) POST /bm25_sat/_explain/1 ... /_explain/4 {"query":{"term":{"body":"cat"}}}
freq=1: score=0.10536051 freq=2: score=0.14487070 (比freq=1真实涨了约37.5%,不是100%) freq=4: score=0.17830240 (比freq=2真实只涨了约23.1%) freq=8: score=0.20155925 (比freq=4真实只涨了约13.0%)
这组真实的递增曲线直接展示了k1参数存在的意义:BM25真实故意让词频的贡献呈现边际收益递减,而不是简单线性累加。如果没有这个饱和机制,一篇文档靠疯狂堆砌同一个关键词(关键词堆砌,SEO作弊的经典手法)就能无限推高相关性得分;而真实的BM25公式里,词频从1到2带来的得分提升(约37.5%)明显大于从4到8带来的提升(约13.0%),词频越高,继续增加带来的边际收益真实越小。k1这个参数(本次实测固定在ES默认值1.2)真实控制的就是这条饱和曲线弯曲的速度,k1越大,饱和发生得越慢;本文这组真实数据直接把"BM25不是简单的词频计数"这个结论用具体数字量化了出来。
每一步都是本文上面在本机真实的Elasticsearch 8.15.0上跑出来的原始结果。
_explain API 与 Python 手工公式验证