上一篇解决了搜索的执行流程——Query-Then-Fetch 两阶段在集群中怎样运转。这一篇进入每条结果的分数是怎么算出来的。

ES 的搜索结果按 _score 降序排列。_score 是 BM25 算法算出的相关性分数。理解 BM25,才能理解为什么有些文档排在前面、有些排在后面,也才能有针对性地调整搜索相关性。

本文只抓一个问题:BM25 的三个因子分别衡量什么,以及它们怎样组合成最终分数。

BM25 的直觉

BM25 的名字来自"Best Matching 25"(第 25 号最佳匹配公式)。它取代了 ES 5.x 之前使用的 TF-IDF 模型,成为 Lucene 和 ES 的默认相似度算法。

BM25 的核心思路可以用一句话概括:一个词项越稀有(IDF 高)、在文档中出现越多次(TF 高)、文档越短(字段长度归一化),包含这个词项的文档就越相关。

分数由三个因子相乘构成:

1
score(q, d) = IDF(q) × TF_saturation(q, d) × length_norm(d)
flowchart TB
    IDF["IDF<br/>词项在多少文档中出现?<br/>越稀有 → 值越高"]
    TF["TF 饱和<br/>词项在本文档出现几次?<br/>边际递减, 有上界"]
    LN["字段长度归一化<br/>文档有多长?<br/>越短 → 信号越强"]
    SCORE(("最终 score"))

    IDF -->|×| SCORE
    TF -->|×| SCORE
    LN -->|×| SCORE

    K1["k1 (默认 1.2)<br/>控制 TF 饱和速度"] -.-> TF
    B["b (默认 0.75)<br/>控制长度惩罚力度"] -.-> LN

三个因子各回答一个独立问题:这个词稀不稀有、在这篇文档里出不出现得多、这篇文档本身长不长。下面逐一拆解。

因子一:IDF——逆文档频率

IDF 衡量一个词项的稀有程度。在 10000 篇文档中只出现在 5 篇里的词(如 “elasticsearch”)比出现在 8000 篇里的词(如 “the”)信息量大得多。

IDF 的计算:

1
2
3
4
IDF = ln(1 + (N - df + 0.5) / (df + 0.5))

N = 文档总数
df = 包含这个词项的文档数

当一个词在大量文档中出现时,df 接近 N,IDF 趋近于 0——这个词对区分文档几乎没有贡献。当一个词只在少数文档中出现时,IDF 值较高——这个词有很强的区分能力。

因子二:TF 饱和——词频的边际递减

TF(term frequency)衡量一个词在文档中出现的次数。出现 5 次比出现 1 次更相关,但出现 50 次不应该比出现 5 次相关 10 倍——边际收益递减。

BM25 的 TF 饱和函数:

1
2
3
4
5
6
7
TF_saturation = (tf × (k1 + 1)) / (tf + k1 × (1 - b + b × dl / avgdl))

tf = 词项在文档中的出现次数
k1 = 词频饱和参数(默认 1.2)
b = 字段长度归一化参数(默认 0.75)
dl = 当前文档的字段长度(词项数)
avgdl = 所有文档的平均字段长度

k1 控制词频饱和的速度。k1 越大,高词频的文档获得的额外分数越多(饱和越慢)。k1 = 0 时词频完全不影响分数。

旧版 TF-IDF 使用 √tf 作为词频因子,没有上界——词频越高分数越高,没有饱和。BM25 的饱和函数在 tf 增大时趋近于 (k1 + 1),有明确的上界。

因子三:字段长度归一化

b 参数控制字段长度对分数的影响。一个词在 100 词的短文档中出现 1 次,比在 10000 词的长文档中出现 1 次更有信号量——短文档中每个词的密度更高。

  • b = 1:完全按字段长度归一化。短文档获得显著加分。
  • b = 0:忽略字段长度。所有文档不论长短,同等对待。
  • b = 0.75(默认):在两者之间取平衡。

字段长度信息存储在 segment 的 norms 文件中。如果禁用 norms("norms": false),BM25 就无法做长度归一化。

用 explain 读懂分数

explain API 会把 BM25 的计算过程展开成一棵树。多词查询时,顶层是各词项分数的加和;每个词项内部是 IDF 和 TF_saturation 的乘积。

flowchart TD
    ROOT["score = sum of term scores"]
    T1["term: 'search'"]
    T2["term: 'engine'"]
    ROOT --> T1
    ROOT --> T2

    IDF1["idf = 0.6931"]
    TF1["tf_sat = 0.4545<br/>k1=1.2, b=0.75<br/>dl=6, avgdl=7"]
    S1["term score = 0.3151"]
    T1 --> IDF1
    T1 --> TF1
    IDF1 --> S1
    TF1 --> S1

    IDF2["idf = ..."]
    TF2["tf_sat = ..."]
    S2["term score = ..."]
    T2 --> IDF2
    T2 --> TF2
    IDF2 --> S2
    TF2 --> S2

explain: true 加到搜索请求中就能拿到这棵树:

1
2
3
4
5
GET /test-inverted-index/_search
{
"explain": true,
"query": { "match": { "title": "search" } }
}

返回结果中每个 hit 会包含 _explanation 字段,结构类似:

1
2
3
4
score = 0.6931 (BM25)
├── idf = 0.6931, computed as ln(1 + (3 - 3 + 0.5) / (3 + 0.5))
├── tf = 0.4545, computed as freq=1 / (freq=1 + k1=1.2 * (1 - b=0.75 + b=0.75 * dl=6 / avgdl=7))
└── final = idf * tf = 0.3151

读 explain 输出的要点:

  • 最外层是各词项分数的加和(多词搜索时)
  • 每个词项的分数 = IDF × TF_saturation
  • TF_saturation 内部包含了 k1、b、dl(当前文档字段长度)、avgdl(平均字段长度)
  • 可以直接看到哪个因子贡献了主要分数

调整打分行为

ES 提供几种方式调整打分:

通过 index settings 修改 BM25 参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PUT /custom-bm25
{
"settings": {
"similarity": {
"custom_bm25": {
"type": "BM25",
"k1": 2.0,
"b": 0.5
}
}
},
"mappings": {
"properties": {
"content": {
"type": "text",
"similarity": "custom_bm25"
}
}
}
}

通过 function_score 在 BM25 分数基础上叠加自定义打分逻辑(如时间衰减、热度加权)。通过 script_score 用脚本完全自定义打分公式。这些高级打分机制超出本篇范围,核心要理解的是 BM25 提供了基线分数。

实验:观察长度归一化效果

写入三个不同长度的文档,搜索同一个词,用 explain 观察分数差异:

1
2
3
4
5
6
7
8
9
10
PUT /bm25-demo
{ "settings": { "number_of_shards": 1, "number_of_replicas": 0 } }

POST /bm25-demo/_bulk
{"index": {"_id": "1"}}
{"content": "search engine"}
{"index": {"_id": "2"}}
{"content": "search engine technology and information retrieval systems"}
{"index": {"_id": "3"}}
{"content": "search engine technology information retrieval systems distributed computing algorithms data structures indexing and ranking models"}

搜索 “search”:

1
2
3
4
5
GET /bm25-demo/_search
{
"explain": true,
"query": { "match": { "content": "search" } }
}

三条文档都包含 “search” 一次(tf=1),IDF 相同。分数差异完全来自字段长度归一化:doc1(2 词)的分数最高,doc3(约 16 词)的分数最低。这就是 BM25 的 b 参数在起作用——短文档中出现同一个词,权重更高。

模式提炼:统计打分模型

1
relevance = f(term_rarity, term_frequency, document_length)

BM25 属于"词袋模型"(bag of words)——它不考虑词项在文档中的位置和顺序,只考虑词项的统计特征。这是信息检索领域最成熟的统计打分框架。

因子 衡量什么 直觉
IDF 词项在全局的稀有程度 稀有的词更有区分力
TF 词项在文档中的出现频率 出现越多越相关,但有饱和上界
长度归一化 文档的字段长度 短文档中的匹配信号更强

工程迁移表

概念 Elasticsearch BM25 Solr BM25 Lucene Similarity 搜索广告 CTR 预估
基础模型 BM25(默认) BM25(默认,可切回 TF-IDF) BM25Similarity 逻辑回归 / 深度模型
词频处理 饱和函数(k1) 饱和函数(k1) 同 ES 特征工程
稀有度 IDF IDF IDF 无直接对应(CTR 特征)
长度归一化 b 参数 + norms b 参数 + norms norms 无直接对应
自定义打分 function_score / script_score boost / rerank 自定义 Similarity 模型权重

常见误解

误解一:BM25 分数可以跨查询比较。 不同查询的 BM25 分数没有可比性。查询 “search” 的 1.5 分和查询 “database” 的 2.0 分不能直接比较,因为 IDF 不同。BM25 分数只在同一次查询的结果列表内有意义。

误解二:分数高就表示文档更好。 BM25 衡量的是文本层面的词汇匹配度,不是"质量"或"权威性"。一个垃圾文档如果刚好高频包含查询词,分数可能比权威文档更高。这就是为什么实际搜索系统通常在 BM25 基础上叠加额外的排序信号(如 PageRank、热度、时效性)。

误解三:所有字段都参与打分。 只有 query context 中的查询参与打分。filter context 中的查询只做过滤,不贡献分数。

练习

  1. 创建一个单 shard 的 index,写入 5 条不同长度的文档(都包含同一个关键词),用 explain: true 搜索这个词,验证 BM25 的长度归一化效果——短文档分数更高。

  2. 修改 index 的 BM25 参数,分别设 b=0(禁用长度归一化)和 b=1(最大化长度归一化),对比搜索结果排序的变化。

  3. 写入一条文档让某个词出现 1 次、3 次、10 次、50 次,用 explain 观察 TF 饱和效果——50 次出现的分数并不比 10 次高很多。

系列导航

上一篇 下一篇
搜索执行模型:Query-Then-Fetch 的两阶段流程 Query DSL 深入:从 match 到 bool 的查询体系

参考资料