从 TF-IDF 到 BM25:Elasticsearch 相关性评分的字段长度归一化与 boost 调参边界
1. 先看一次真实的排序投诉
假设你在一家电商公司负责搜索。运营反馈:用户搜"无线 蓝牙 耳机",排在前面的永远是一个只有六个字的商品标题"无线蓝牙耳机",而一个标题很长、但把品牌、型号、降噪特性都写全的商品只能排到第三页。运营的结论是"相关性算法有问题"。
先别急着改算法。这个现象背后其实有两个独立的因素在起作用:第一,Elasticsearch 默认用 BM25 打分,BM25 会惩罚字段过长的文档;第二,标题短的文档在词频和长度归一化上天然占便宜。也就是说,这不是 bug,而是评分模型按设计运行的结果。真正需要判断的是:这个业务场景下,我们希望"短标题精准命中"优先,还是希望"信息完整的标题"优先。
这篇文章要解决的问题就是:把 TF-IDF 到 BM25 的评分链路讲清楚,重点落在字段长度归一化(length normalization)和 boost 调参(权重提升)上,并告诉你 boost 能改什么、改不了什么、加到什么时候就该停手。
2. 一句话模型与整体框架
可以先记住一个最小模型:BM25 的最终得分,等于每个查询词在每篇文档上的"词频饱和值",乘以"逆文档频率",再乘以"字段长度惩罚",最后乘以你手写的 boost 权重。
把整体拆成四部分,它们是这样串起来的:
text
用户查询 "无线 蓝牙 耳机"
|
v
[1] 分词器 analyzer:把查询切成 term(无线 / 蓝牙 / 耳机)
|
v
[2] 倒排索引:每个 term 找到命中的文档列表,得到 docFreq、termFreq
|
v
[3] BM25 打分:TF 饱和 * IDF * 长度归一化
|
v
[4] boost 叠加:字段级 boost、查询子句级 boost、function_score 等
|
v
排序结果(_score 从高到低)
这里最容易误解的是:很多人以为 boost 是"覆盖打分",其实绝大多数 boost 是乘在 BM25 分数上的一层系数。如果 BM25 给某篇文档打了 0 分(没命中),你乘多大的 boost 都是 0。
再看一次请求完整走一遍的时序关系:
text
Client(Java) --> REST/Transport --> 协调节点(coordinating node)
|
v
每个分片(shard)本地打分
|
v
返回 top-N(docId, score)
|
v
协调节点全局归并排序
|
v
返回 _hits
注意:分片是本地打分的,全局 IDF 在默认情况下是每个分片各自计算的。这就是为什么"分片数量变化会导致排序漂移",后面会专门讲。
3. 索引与映射:先把字段长度这件事摆上台面
在讨论打分之前,必须先看映射设计,因为字段长度归一化的"长度"就是从这里来的。
看一个最小可复现的索引定义:
json
PUT /products_v1
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word"
},
"brand": {
"type": "text",
"analyzer": "ik_smart"
},
"sales": {
"type": "integer"
}
}
}
}
这里 title 是 text 类型,会被分词并计算 BM25;sales 是数值类型,不参与全文打分,只能靠排序字段或 function_score 使用。很多初学者把销量直接塞进 title,结果既污染了分词,又让字段变长触发了长度惩罚。
| 字段类型 | 是否参与 BM25 | 长度归一化是否生效 | 典型用途 |
|---|---|---|---|
| text | 是 | 是 | 全文检索、标题、描述 |
| keyword | 否(term 精确匹配) | 否 | 过滤、聚合、排序 |
| integer/long | 否 | 否 | 数值排序、range 查询 |
| date | 否 | 否 | 时间范围过滤 |
这张表要表达的一件事是:长度归一化只会作用在 text 字段上 。如果你希望某段文本不参与相关性打分,就不要把它定义成 text。
4. 从 TF-IDF 到 BM25:为什么需要换一个公式
4.1 TF-IDF 的直觉与缺陷
TF-IDF 由两部分组成。词频 TF 表示"某个词在这篇文档里出现几次",逆文档频率 IDF 表示"这个词在整个语料里有多稀有"。一个词在本篇出现越多、在全语料越少见,得分越高。
它的缺陷是线性的:一个词出现 1 次和出现 100 次,得分差 100 倍。这在实际文本里不合理------关键词出现 5 次往往已经足够表达主题,出现第 50 次很可能只是关键词堆砌。TF-IDF 还会被长文档"薅羊毛",因为长文档天然包含更多词。
4.2 BM25 的两处关键修正
BM25 在 TF-IDF 基础上做了两处修正:
- 词频饱和(TF saturation) :词频升高时得分增长会逐渐变慢,趋向一个上限,参数
k1控制饱和速度。 - 字段长度归一化(length normalization) :字段越长,得分越被压低,参数
b控制压低的强度。
用公式表达(简化形式):
text
score(D, Q) = Σ IDF(qi) * [ f(qi, D) * (k1 + 1) ]
/ [ f(qi, D) + k1 * (1 - b + b * |D| / avgdl) ]
其中:
f(qi, D) = 词 qi 在文档 D 中的频次
|D| = 文档 D 的字段长度(term 数)
avgdl = 所有文档该字段的平均长度
k1 = 词频饱和参数,默认 1.2
b = 长度归一化参数,默认 0.75
先记住两点:b=0 表示完全不做长度归一化,短标题不再占便宜;b=1 表示完全按长度比例惩罚。Elasticsearch 默认 b=0.75,属于"比较强但没拉满"的惩罚。
| 参数 | 取值 | 效果 | 适用场景 |
|---|---|---|---|
| k1 | 0 | 词频完全不饱和(退化为只看命中与否) | 极少用,特殊排序实验 |
| k1 | 1.2(默认) | 词频适度饱和 | 大多数通用全文检索 |
| k1 | 2.0 左右 | 词频影响更明显 | 短文本、关键词密度可信 |
| b | 0 | 不做长度归一化 | 标题长度差异极大且不想被惩罚 |
| b | 0.75(默认) | 中等强度长度惩罚 | 通用场景 |
| b | 1 | 完全长度惩罚 | 等长文本、日志类数据 |
5. 字段长度归一化:短标题为什么总是赢
5.1 数值推演
假设索引里标题的平均长度 avgdl = 10。文档 A 标题只有 4 个 term,文档 B 标题有 40 个 term,两个文档都命中了查询词,且该词频都是 1。取 k1=1.2, b=0.75:
text
文档 A: |D|/avgdl = 4/10 = 0.4
分母项 = 1 + 1.2 * (1 - 0.75 + 0.75 * 0.4)
= 1 + 1.2 * (0.25 + 0.3)
= 1 + 0.66 = 1.66
文档 B: |D|/avgdl = 40/10 = 4.0
分母项 = 1 + 1.2 * (0.25 + 0.75 * 4.0)
= 1 + 1.2 * 3.25
= 1 + 3.9 = 4.9
同样的词频,文档 A 的分母更小,得分更高,比值大约是 4.9 / 1.66 ≈ 2.95,也就是短标题得分接近长标题的 3 倍。这就是运营看到的"短标题霸榜"的数学来源。
5.2 它能帮助理解什么,不能替代什么
"短标题像小房间,喊一声回音更响"这个类比能帮你记住长度惩罚的方向。但它不能替代真实机制:BM25 惩罚的不是"标题短",而是"命中词的密度在长字段里被稀释"。如果你的长标题里同一个关键词出现了多次,词频项会部分抵消长度惩罚。
5.3 不改查询,先改打分参数的场景
当业务确认"标题长度差异大,但每个词命中都同等重要"时,可以调整 b:
json
PUT /products_v2
{
"settings": {
"index": {
"similarity": {
"custom_bm25": {
"type": "BM25",
"k1": 1.2,
"b": 0.3
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"similarity": "custom_bm25"
}
}
}
}
把 b 从 0.75 降到 0.3,长度惩罚明显减弱,长标题有机会抬头。边界是 :b 是索引级/字段级设置,改了以后需要重建索引或 reindex,不能对已有索引热改生效。
6. boost 调参:它能改什么,改不了什么
6.1 boost 的三种位置
boost 可以加在不同层级,进入打分的位置不同,效果也不同:
text
query 层级
|
+-- 子句级: multi_match 里某个 field 的 ^3
|
+-- 子句级: bool.should 里某个子句的 boost
|
+-- 函数级: function_score 里 field_value_factor / script_score
看一个完整查询(带 boost 的真实 Query DSL):
json
GET /products_v1/_search
{
"query": {
"bool": {
"should": [
{
"multi_match": {
"query": "无线 蓝牙 耳机",
"fields": ["title^3", "brand^1"],
"type": "best_fields"
}
},
{
"match": {
"title": {
"query": "降噪",
"boost": 2
}
}
}
],
"minimum_should_match": 1
}
}
}
这里 title^3 表示把 title 字段上命中的分数乘 3,boost: 2 表示把"降噪"这个子句的分数乘 2。它们都是乘法系数。
6.2 为什么 boost 加到一定程度就"失效"
先说明一个现象:当你把一个子句的 boost 从 100 加到 1000,排序前几名往往纹丝不动。原因是排序是比较相对大小,不是绝对分数。如果所有候选文档都命中了这个被提权的子句,大家同乘一个系数,相对顺序不变。
这里最容易误解的是:boost 并不会让"没命中该子句"的文档凭空加分。它只会放大已命中文档之间的差距。真正要让某些文档整体抬升,需要用 function_score 做加法型或衰减型加权。
| 调参手段 | 作用形式 | 能改变什么 | 不能改变什么 |
|---|---|---|---|
| 字段 boost | 乘法 | 字段间权重差异 | 未命中字段的文档 |
| 子句 boost | 乘法 | 子句间权重差异 | 整体命中集合 |
| function_score | 加法/乘法/衰减 | 销量、时间、距离权重 | 未命中查询的文档 |
| 调整 b / k1 | 改变 BM25 本体 | 长度惩罚与词频饱和 | 索引外字段 |
7. explain API:把黑盒变成可读的算式
当排序不符合预期,最有效的工具是 explain。它会返回每一层的分数贡献,帮你判断问题出在 IDF、长度归一化还是 boost。
json
GET /products_v1/_explain/1
{
"query": {
"match": {
"title": "蓝牙 耳机"
}
}
}
返回内容里会看到类似结构(节选,字段名可能随版本略有差异):
json
{
"matched": true,
"explanation": {
"value": 2.3418,
"description": "sum of:",
"details": [
{
"value": 1.2039,
"description": "weight(title:蓝牙 in 2), product of:",
"details": [
{"value": 0.2876, "description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5))"},
{"value": 1.0, "description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl))"}
]
}
]
}
}
读这段输出的顺序是:先看 value 总分的量级,再看每个 details 里是 idf 还是 tf 拉低了分数。如果 tf 的分母里 dl / avgdl 很大,说明长度归一化在起作用;如果 idf 很小,说明这个词太常见,区分度低。
| explain 关键字段 | 含义 | 排查方向 |
|---|---|---|
| value | 当前层得分 | 定位是哪一层差异 |
| idf | 逆文档频率 | 词是否过于常见 |
| tf | 归一化后的词频 | 长度惩罚是否过强 |
| boost | 乘法系数 | 是否被手写权重干扰 |
| dl / avgdl | 字段长度与平均长度比 | 字段是否过长 |
8. 三个可运行示例:从验证规则到生产排障
8.1 示例一:最小 Java 示例验证 BM25 分数差异
目标 :用同一批文档,观察短标题与长标题在同一查询下的分数差异。
前置 :JDK 17、Maven 依赖 co.elastic.clients:elasticsearch-java 与 jakarta.json:jakarta.json-api,本地启动单节点 Elasticsearch 8.x。
xml
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.13.4</version>
</dependency>
<dependency>
<groupId>jakarta.json</groupId>
<artifactId>jakarta.json-api</artifactId>
<version>2.1.3</version>
</dependency>
java
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.BulkRequest;
import co.elastic.clients.elasticsearch.core.BulkResponse;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import java.util.Map;
public class Bm25LengthDemo {
public static void main(String[] args) throws Exception {
RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200, "http")).build();
RestClientTransport transport = new RestClientTransport(
restClient, new JacksonJsonpMapper());
ElasticsearchClient client = new ElasticsearchClient(transport);
try {
client.indices().delete(d -> d.index("bm25_demo"));
} catch (Exception ignored) {
// 索引不存在时忽略
}
client.indices().create(c -> c
.index("bm25_demo")
.settings(s -> s.numberOfShards("1").numberOfReplicas("0"))
.mappings(m -> m
.properties("title", p -> p.text(t -> t))));
BulkRequest.Builder bulk = new BulkRequest.Builder();
bulk.operations(o -> o.index(i -> i
.index("bm25_demo").id("1")
.document(Map.of("title", "蓝牙耳机"))));
bulk.operations(o -> o.index(i -> i
.index("bm25_demo").id("2")
.document(Map.of("title",
"品牌旗舰无线蓝牙耳机主动降噪长续航运动防水 "
+ "适配安卓苹果多设备切换入耳式"))));
BulkResponse resp = client.bulk(bulk.build());
if (resp.errors()) {
throw new IllegalStateException("批量写入失败: " + resp.items());
}
SearchResponse<Map> search = client.search(s -> s
.index("bm25_demo")
.query(q -> q.match(m -> m.field("title").query("蓝牙 耳机")))
.explain(true),
Map.class);
search.hits().hits().forEach(h ->
System.out.println(h.id() + " -> " + h.score()));
client._transport().close();
restClient.close();
}
}
关键步骤与结果 :先建单分片索引,保证 IDF 在同一分片内计算;再写入一条短标题和一条长标题;查询后打印 _score。预期输出里 id=1 的分数明显高于 id=2,因为两条文档都命中了词,但 id=2 的 dl / avgdl 更大。
容易改错的地方 :不要用 keyword 字段做这个实验,那样走的是精确匹配,_score 恒为 1.0,观察不到 BM25 行为;也不要开多分片,否则 IDF 会按分片计算,实验结论不稳。
8.2 示例二:带字段 boost 的业务查询与 explain 输出
目标 :在同一索引上比较 title^3, brand^1 与不加 boost 的排序差异。
前置:沿用示例一的客户端初始化方式。
java
SearchResponse<Map> boosted = client.search(s -> s
.index("bm25_demo")
.query(q -> q.multiMatch(m -> m
.query("蓝牙 耳机")
.fields("title^3", "brand^1")))
.explain(true),
Map.class);
boosted.hits().hits().forEach(h -> {
System.out.println("id=" + h.id() + " score=" + h.score());
if (h.explanation() != null) {
System.out.println(" reason=" + h.explanation().description());
}
});
结果解读 :title 上调 3 倍后,命中 title 的文档分数整体抬高;但注意,如果两条文档都只命中 title,相对顺序可能不变------这正是前面说的"乘法提升同乘一个系数无效"的真实表现。
适用场景 :品牌词、类目词这类业务上更该优先的字段。边界 :multi_match 的 best_fields 和 most_fields 对多字段 boost 的反应不同,most_fields 更容易被多字段命中累加放大,实验时要确认 type。
8.3 示例三:生产排障------判断是 IDF 问题还是长度问题
目标 :当线上某次搜索排序异常时,用 explain 快速定位是词太常见(IDF 低)还是字段太长(TF 分母大)。
前置:准备好线上真实文档 id 和查询语句,在只读副本或影子索引上执行,不要直接打生产主节点。
java
import co.elastic.clients.elasticsearch.core.ExplainResponse;
ExplainResponse<Map> explain = client.explain(e -> e
.index("products_v1")
.id("42")
.query(q -> q.match(m -> m.field("title").query("蓝牙 耳机"))),
Map.class);
if (!explain.matched()) {
System.out.println("文档未命中查询,boost 再大也没用");
} else {
System.out.println("总分: " + explain.explanation().value());
explain.explanation().details().forEach(d ->
System.out.println(" 分项: " + d.description() + " = " + d.value()));
}
结果解读 :如果分项里 idf 很小,说明查询词在语料里太常见,应该考虑换词或叠加业务权重;如果 tf 分母里长度项占比大,说明长度归一化在压制长标题,应评估调整 b 或对标题做字段裁剪。
常见改错 :把 _explain 直接对高 QPS 线上的热点文档反复调用,会造成额外开销;排查应在低峰或影子环境做。
9. 完整打分链路走一遍
现在把前面所有零件装回去。用户查询"无线 蓝牙 耳机",请求进入协调节点:
text
1. 查询解析: multi_match 展开为 title 和 brand 两个字段
2. 分词: "无线 蓝牙 耳机" -> [无线, 蓝牙, 耳机]
3. 每个分片本地检索: 对每个 term 取倒排链,做 OR/AND 组合
4. 每个命中文档计算 BM25:
对每个 term 求 TF 饱和值
乘以该 term 的 IDF
乘以字段长度归一化因子
5. 叠加 boost: title^3 把 title 部分分数乘 3
6. bool.should 把各子句分数相加
7. 分片返回 top-N,协调节点归并、排序、返回
理解这条链路后,你就能回答一个关键问题:"我想让某个字段更重要",到底是改 boost,还是改 b,还是改 boosting 之外的结构?
- 如果字段之间重要性不同,且都命中查询:用字段 boost。
- 如果字段长度差异大导致短文档霸榜:调
b。 - 如果想让销量、时间参与排序但不想污染文本相关性:用
function_score。 - 如果只是想让某类文档整体靠前:用
pinned query或独立的业务排序层,不要硬塞 boost。
10. 常见误区
误区一:boost 越大越相关。 实际上 boost 只是乘法系数,且排序看相对大小。同一批文档同乘一个大系数,顺序不变。
误区二:_score 可以直接跨查询比较。 _score 只对同一次查询的同一批候选文档有比较意义,跨查询、跨索引没有可比性。
误区三:把长文本切成 keyword 就能提升相关性。 keyword 字段不参与 BM25,_score 恒为 1,反而丢失了长度归一化的判断能力。
误区四:改 b 是热更新。 相似度配置属于索引设置,改动需要重建或 reindex。
| 误区 | 真实机制 | 正确做法 |
|---|---|---|
| boost 无上限 | 乘法且看相对顺序 | 用 explain 验证,别盲加 |
| 分数可跨查询比 | 分数依赖查询与分片 | 只比同查询候选 |
| keyword 更"精准" | keyword 不打分 | 文本用 text,过滤用 keyword |
| b 可热改 | 索引级相似度配置 | reindex 或新建索引 |
11. 生产实践建议
第一,先固定分片数再调参 。默认 IDF 按分片计算,分片数变化会让排序漂移。如果业务强依赖跨分片一致的 IDF,可以考虑只读索引设置 search 之外的全局统计方案,但要清楚它的代价。
第二,调参要有对照组。每次只改一个变量(b、k1、boost 之一),保留 explain 输出和 top-20 对比,否则你无法判断是哪个改动生效。
第三,boost 上限要写进规范。经验上字段 boost 在 2 到 5 之间已经能覆盖大多数业务权重差异,加到 10 以上基本是"用权重掩盖建模问题"。
第四,用 function_score 而不是无限 boost。销量、时效这类非文本信号应该走加法或衰减函数,而不是继续乘在文本分数上。
12. 排障清单
当出现"排序不符合预期"时,按顺序检查:
- 查询是否真的命中了预期字段?先看
matched。 - 是 IDF 低还是 TF 被长度归一化压低?看 explain 分项。
- 分片数是否与上次实验一致?分片变化会改变 IDF。
- 是否有手写 boost 干扰?逐层关掉 boost 复测。
- 字段类型是否正确?
text与keyword行为完全不同。 b和k1是否被自定义 similarity 覆盖?- 是否需要业务信号?若是,改用
function_score而不是继续调 boost。
13. 面试与复盘问题
- 为什么 BM25 要引入词频饱和?如果不饱和会发生什么?
b=0和b=1分别在什么业务下合理?- 为什么把 boost 从 10 加到 100 排序可能不变?
- explain 输出里同时看到低 IDF 和大
dl/avgdl,你会先动哪个参数? - 分片数从 1 改成 5,为什么同一个查询的排序可能变化?
- 销量、点击率这类信号,应该用 boost 还是 function_score?为什么?
14. 总结
把全文收成一张决策图:
text
排序不符合预期
|
v
是不是没命中? --是--> 检查分词/字段类型
|否
v
看 explain: 是 IDF 低还是长度惩罚大?
| |
IDF 低 长度惩罚大
| |
换词/调 k1 调 b 或裁字段
| |
+----------> 仍不满足 <-----------+
|
v
用 function_score 加业务信号
|
v
最后才考虑调 boost
核心结论有三条:BM25 的字段长度归一化是短标题胜出的数学原因;boost 只是乘在分数上的一层系数,改不了"没命中"和"相对顺序";只有先用 explain 定位是 IDF、长度还是业务信号的问题,调参才有边界、才有意义。
15. 参考资料
- Elasticsearch 官方文档:Similarity module(BM25 参数 k1、b 的说明)
- Elasticsearch 官方文档:Explain API 与
_explain端点 - Elasticsearch 官方文档:Function score query 与 boosting 相关章节
- Elasticsearch 官方文档:Mapping 中 text / keyword 类型说明
- Lucene 官方文档:BM25Similarity 类说明
- 《Elasticsearch: The Definitive Guide》中相关性评分章节
- 《Relevant Search》中关于排序与调参的工程实践章节