从 TF-IDF 到 BM25:Elasticsearch 相关性评分的字段长度归一化与 boost 调参边界

从 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 基础上做了两处修正:

  1. 词频饱和(TF saturation) :词频升高时得分增长会逐渐变慢,趋向一个上限,参数 k1 控制饱和速度。
  2. 字段长度归一化(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. 排障清单

当出现"排序不符合预期"时,按顺序检查:

  1. 查询是否真的命中了预期字段?先看 matched。
  2. 是 IDF 低还是 TF 被长度归一化压低?看 explain 分项。
  3. 分片数是否与上次实验一致?分片变化会改变 IDF。
  4. 是否有手写 boost 干扰?逐层关掉 boost 复测。
  5. 字段类型是否正确?text 与 keyword 行为完全不同。
  6. b 和 k1 是否被自定义 similarity 覆盖?
  7. 是否需要业务信号?若是,改用 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》中关于排序与调参的工程实践章节
相关推荐
程序猿_极客1 小时前
【免费】分享一套优质的基于SpringBoot的旅游管理系统的设计与实现(带智能推荐算法功能),源码+文档+视频详解(讲解)
java·spring boot·课程设计·旅游管理系统·计算机专业项目
Wang's Blog1 小时前
Java 中间件之 RabbitMQ 快速入门: RabbitMQ 介绍与安装部署
java·中间件·java-rabbitmq
SL-staff1 小时前
技术实践:将Excel自动升级为可搜索的知识节点(JVS平台实现)
mongodb·elasticsearch·知识图谱·jvs平台·语义解析·企业文档管理·excel结构化
Elasticsearch1 小时前
2026 年唯一获得端点预防与响应(EPR)满分的厂商是 Elastic
elasticsearch
sibylyue1 小时前
SQLite 运行模式
java·jvm·sqlite
专业程序开发源1 小时前
django便利店外卖后台管理系统19650-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·python·django·课程设计
JAVA面经实录9172 小时前
Java高级后端 · 架构组件开发 + CodeReview(本岗位差异化核心)
java·架构·代码复审
toooooop82 小时前
宝塔Python项目管理器:环境变量踩坑记录
java·jvm·python
代码山河2 小时前
Java语言特点:跨平台、面向对象、安全性详解
java·学习·架构·教程·面向对象·项目