用 Elasticsearch 做全文搜索时,结果排序背后那个 _score 到底是怎么算出来的?为什么有时候一篇明明只提了一次关键词的短文档,排得比反复出现的长文档还靠前?答案藏在相关性打分算法里。ES 从 5.0 起把默认打分模型从经典 TF-IDF 换成了 BM25,这次更换不是营销噱头,而是修掉了 TF-IDF 两个实打实的工程缺陷。本文把这条演进线讲透。
场景:搜索为什么需要打分
关系型数据库的 WHERE title LIKE '%xxx%' 只能回答「匹配 / 不匹配」,没有「多相关」的概念。但全文搜索的本质是排序问题:用户输入「分布式数据库一致性」,命中成千上万篇文档,引擎必须给每篇算一个分,把最相关的顶到前面。打分模型干的就是这件事。
衡量相关性的两个最朴素直觉是:
- 一个词在某文档里出现得越多,这篇越相关 → 词频 TF(Term Frequency)
- 一个词在整个语料里越罕见,它越有区分度 → 逆文档频率 IDF(Inverse Document Frequency)
「的、是、在」这种词到处都是(IDF 极低,没区分价值),而「Raft」「向量化执行」这种词只在少数文档出现(IDF 高,一旦命中信号很强)。TF-IDF 就是把这两个直觉相乘。
机制:经典 TF-IDF 的两个缺陷
Lucene 早期的实用打分公式形态大致是:
1 | score = TF · IDF² · fieldNorm |
它能用,但有两个硬伤:
缺陷一:TF 线性增长,没有饱和。 直觉上,一个词从出现 1 次到 10 次,相关性提升很明显;但从 100 次到 110 次,几乎没区别——它已经「足够相关」了。可经典 TF(哪怕开了平方根)仍随词频持续单调上涨。结果就是堆砌关键词的文档(早年的 SEO 作弊就靠这个)会被异常拔高。
缺陷二:文档长度归一化太粗暴。 1/sqrt(length) 一刀切地惩罚长文档。可现实里长文档有两类:一类是「内容确实丰富」的长,一类是「灌水注水」的长。前者不该被惩罚,后者该。经典公式无法区分,只会按长度死板打压。
机制:BM25 如何修复
BM25(Best Matching 25,源自 1990 年代的 Okapi 项目,是概率检索框架下的成熟模型)保留 IDF·TF 的骨架,但在两处做了精巧改造,引入两个可调参数 k1 和 b:
1 | score(D, q) = Σ IDF(qᵢ) · ────────────────────────────────────────── |
改造一:TF 饱和。 看分式 f·(k1+1) / (f + k1·...)。当词频 f 趋向无穷大时,整个值趋向上界 (k1+1)/b项...,是一条收敛曲线而非直线。词频从 1 涨到 5 收益巨大,从 50 涨到 100 几乎无感——这正符合人的直觉。k1 控制饱和快慢:k1 越大越晚饱和(越接近线性),越小越早封顶。
改造二:相对长度归一化。 分母里的 b · |D|/avgdl 是用当前文档长度与平均长度的比值做归一化,而不是绝对长度。一篇「比平均长很多」的文档才会被惩罚;如果整个语料都是长文档,avgdl 也大,彼此就不吃亏。b=0 完全不考虑长度,b=1 完全按相对长度惩罚,0.75 是经验折中。
类比:TF-IDF 像一个只会「数次数」的死板评委,喊得越多分越高;BM25 像个老练评委——前几次重复加分明显,喊到第 50 次就听烦了不再加分,而且会问「你这篇是真有料才长,还是单纯啰嗦」。
示例:在 ES 里调参与解释
ES 默认相似度就是 BM25,可以在 mapping 级别调 k1/b:
1 | PUT /articles |
排查「为什么这条排这么靠前/靠后」时,_explain 是利器,它会把 IDF、TF、归一化每一项的数值摊开给你看:
1 | GET /articles/_explain/42 |
工程权衡与踩坑
分数不能跨查询比较。 _score 是相对当前这次查询、当前这批命中算出来的,没有绝对意义。query A 的 8.3 分和 query B 的 8.3 分毫不相干。想把「相关度」转成业务可用的百分比阈值(比如「相似度低于 60% 就不展示」),用原始 _score 卡死阈值几乎必踩坑。
IDF 是分片局部的。 这是分布式搜索的经典陷阱。IDF 依赖 docFreq 和 docCount,而 ES 默认每个分片独立用自己分片内的统计量算 IDF。如果文档在分片间分布不均,同一个词在不同分片算出的 IDF 不同,会导致排序轻微抖动。数据量大时大数定律会抹平,小索引或测试环境差异明显。需要全局精确时可以用 ?search_type=dfs_query_then_fetch 先做一轮全局词频汇总,代价是多一次网络往返。
别神化 BM25。 BM25 是纯词频统计模型,它不理解语义。「番茄」搜不出「西红柿」,「mysql 慢」匹配不到「数据库性能问题」。它解决的是「词形匹配下的排序」,语义检索得靠向量召回(kNN / 稠密向量)。现代搜索的主流是「BM25 召回 + 向量召回」混合,再用 RRF(Reciprocal Rank Fusion)之类做融合排序,而非二选一。
中文分词决定一切。 打分再准,分词错了全白搭。中文没有空格,「南京市长江大桥」分成「南京市/长江大桥」还是「南京/市长/江大桥」直接决定命中与否。BM25 的输入是 term,term 由 analyzer 产生——所以选对分词器(如 ik_smart / ik_max_word)往往比调 k1/b 影响更大。
小结
- 相关性打分的两块基石是 TF(词频,越多越相关)和 IDF(逆文档频率,越罕见越有区分度)。
- 经典 TF-IDF 有两个缺陷:词频线性不饱和(利于关键词堆砌)、长度归一化绝对化(粗暴打压长文)。
- BM25 用
k1引入 TF 饱和曲线、用b引入相对平均长度的归一化,两处都更贴合直觉,故成为 ES 默认模型。 - 工程上记住三件事:
_score不可跨查询比较、IDF 默认是分片局部的、BM25 不懂语义——它是召回排序的基线,不是终点。