文档里明明有那个词,却排在第五位;把 boost 从 2 调到 10,结果更乱。这不是引擎排不准,而是三件事被混在一起:BM25 算"这段文字有多像查询",boost 只是在乘常数,重排才是你想要的业务判断。分开看,每一层都能验证。
1. BM25 在算什么
| 组成 | 作用 | 直觉 | 参数 |
|---|---|---|---|
| 词频 TF | 词在这篇文档出现几次 | 越多越相关,但增益递减 | k1(默认 1.2) |
| 逆文档频率 IDF | 这个词在全库多稀有 | 越稀有越有区分度 | 由语料决定 |
| 长度归一 | 这篇比平均长多少 | 长文档天然词多,要打折 | b(默认 0.75) |
score(q,d) = Σ IDF(qi) · tf·(k1+1) / ( tf + k1·(1 - b + b·|d|/avgdl) )
IDF(qi) = ln( 1 + (N - n + 0.5) / (n + 0.5) )
两条结论直接读自公式:k1 控制饱和速度(越小越快顶格),b 控制长度惩罚(0 不罚、1 按比例罚)。停用词与全库常见词的 IDF 接近 0,加多少次都不动排序。按 Elasticsearch 8.x 核对,这两个默认值成立,BM25 是默认相似度。
2. 词频为什么不是线性加分
线性加分等于说"出现 10 次 = 相关 10 倍"。真实语料里立刻破产:讲 Redis 的长文出现 40 次,不代表比出现 8 次的短文相关 5 倍,它只是更长。
| 出现次数 | 增益趋势(k1=1.2 定性) |
说明 |
|---|---|---|
| 1 → 2 | 明显上升 | 从"提到"到"在讲" |
| 2 → 5 | 缓慢上升 | 主题确认,边际递减 |
| 5 → 20 | 几乎不动 | 已饱和 |
| 20 → 40 | 可视为不动 | 排序转由 IDF 与长度归一定 |
查询 "redis 持久化":A 是 3000 字长文(redis 出现 30 次),B 是 400 字短文(6 次)
A 词频高,但 |d|/avgdl 打折;B 词频低,长度归一几乎不惩罚。
"词频高"≠"排前面",这是"明明有这个词却排后面"的第一个原因。
3. 字段权重与多字段叠加
每个字段单独跑一次 BM25,bool 的 should 再把分数相加。
GET /articles/_search
{ "query": { "multi_match": { "query": "redis 持久化",
"fields": ["title^3", "tags^2", "body"], "type": "best_fields" } } }
| 写法 | 打分行为 | 用在哪 |
|---|---|---|
best_fields |
取单字段最高分,不累加 | 关键词集中在一处,标题命中即最优 |
most_fields |
多字段分数累加 | 同一信息分散在多个字段 |
cross_fields |
把多字段当一个大字段 | 查询词分散("redis"在标题、"持久化"在正文) |
title^3 |
该字段分数乘 3 | 标题命中应显著优先 |
best_fields 与 most_fields 选错,是"分数有但排序不对"的高频原因:A 只在标题命中一次,B 在标题和正文各命中一次,most_fields 会把 B 顶到 A 前面,而你想要的通常是 A。
4. boost 的三种位置
| 用法 | 写法 | 生效时机 | 代价 |
|---|---|---|---|
| 查询期 | "title^3"、"boost": 2.0 |
每次查询 | 改完即生效 |
| 索引期 | mapping 内字段 boost(旧版) | 建索引时 | 8.x 已废弃 |
| 函数加权 | function_score 乘系数 |
每次查询,可在算分后介入 | 不能进 filter 缓存 |
{ "query": { "function_score": {
"query": { "match": { "body": "redis 持久化" } },
"functions": [
{ "filter": { "term": { "is_pinned": true } }, "weight": 2 },
{ "gauss": { "publish_at": { "origin": "now", "scale": "30d", "decay": 0.5 } } } ],
"score_mode": "sum", "boost_mode": "multiply" } } }
score_mode 管多个函数之间怎么合,boost_mode 管函数分与原查询分怎么合(multiply 最常用,replace 会完全丢弃 BM25)。
boost 调到 5 以上还没解决,问题就不在 boost。 大倍数的后果是"某几篇永远第一",该上来的一篇仍上不来——缺口来自 analyzer、字段映射或查询结构,乘常数补不回来。
5. 同义词与停用词怎么影响打分
| 配置 | 对 TF | 对 IDF | 后果 |
|---|---|---|---|
| 同义词归一 | 词频合并,TF 上升 | 文档数变多,IDF 下降 | 召回变多,排序被摊平 |
| 同义词展开 | 每词项独立计分 | 各自 IDF | 分数虚高,需 minimum_should_match 收敛 |
| 停用词移除 | 少算几个词项 | 不再占分 | 总分变低,相对排序不变 |
| 换 analyzer | 词项粒度变,TF 全变 | IDF 全变 | 新旧 _score 不可比 |
最后一行最易忽略:换分词器等于换了一套打分基准,改完不要拿新旧分数对比,只能重跑评测集看排序。
6. 排序与过滤分工:filter 不打分
| 子句 | 算分 | 可缓存 | 用途 |
|---|---|---|---|
must / should |
是 | 否 | 影响相关性 |
filter |
否 | 是 | 硬条件:状态、时间、租户 |
must_not |
否 | 是 | 排除 |
{ "query": { "bool": {
"must": [ { "match": { "body": "redis 持久化" } } ],
"filter": [ { "term": { "status": "published" } },
{ "range": { "publish_at": { "gte": "now-1y" } } } ] } } }
"已发布""近一年"放进 must 会参与算分:文档因为"状态是 published"而加分,这对相关性纯属噪声。凡不影响"像不像"的条件,一律进 filter。
7. 二次重排:插在哪、值不值
| 手段 | 插入位置 | 代价 | 适合 |
|---|---|---|---|
rescore |
首轮取 Top-N 后 | 只重算 N 篇 | 精细的 script_score |
| 业务加权 | function_score 内 |
每篇参与,不可缓存 | 置顶、加权规则 |
| 时效衰减 | 高斯/线性衰减 | 每篇参与 | 资讯流、帖子流 |
| 外部 rerank | 应用层拿 Top-50 | 一次额外调用 | 语义模型、跨字段规则 |
分页陷阱:重排只覆盖首轮 Top-N。N=50 而要第 3 页时,
第 51 名之后没被重排,翻页结果会"跳变"。
做法:把 N 提到覆盖可翻页范围,或用 search_after 限制深翻。
8. 可解释:explain 怎么读,_score 能不能比
weight(body:redis in 12) -- 2.31
score(doc=12, freq=4.0), product of:
2.31 = idf <- 这个词有多稀有,主要贡献者
1.00 = tfNorm <- 由 (freq*(k1+1))/(freq + k1*(1-b+b*len/avgLen)) 算出
读法:先 idf(词重不重要)→ 再 tfNorm(是否饱和)→ 最后长度归一
| 误解 | 事实 |
|---|---|
两个查询的 _score 能比大小 |
不能,换查询就换基准 |
12 比 6 相关一倍 |
不成立,BM25 无绝对单位 |
| 同一查询内不同文档可比 | 可比,这是唯一可比范围 |
| 跨页分数可比 | 同查询内可比,但深翻 + rescore 会破坏排序稳定 |
9. 规则排序与学习排序
| 维度 | 规则排序 | 学习排序(LTR) |
|---|---|---|
| 上线成本 | 改配置即生效 | 标注 + 特征管道 + 模型部署 |
| 可解释性 | 高(explain 逐项拆) | 低(权重难归因) |
| 迭代速度 | 分钟级 | 天到周级 |
| 适用阶段 | 数据少、需求常变 | 有稳定点击/转化标注 |
判断标准:说不清"什么算相关"时,先别上 LTR。 规则排序的价值不只是效果,它把"相关性定义"写了下来——那正是 LTR 的标注规范。
10. 最小可跑示例
curl -s -XPUT localhost:9200/art -H 'Content-Type: application/json' -d '{
"mappings":{"properties":{"title":{"type":"text"},"body":{"type":"text"},
"publish_at":{"type":"date"},"hot":{"type":"integer"}}}}'
curl -s -XPOST 'localhost:9200/art/_bulk?refresh=true' -H 'Content-Type: application/json' --data-binary '
{"index":{"_id":"1"}}
{"title":"Redis 持久化详解","body":"RDB 与 AOF 的取舍","publish_at":"2026-11-01","hot":5}
{"index":{"_id":"2"}}
{"title":"缓存杂谈","body":"redis redis redis 持久化 持久化 持久化 等等","publish_at":"2026-11-01","hot":1}
{"index":{"_id":"3"}}
{"title":"面试题集","body":"redis 持久化 常见问题","publish_at":"2024-01-01","hot":50}'
curl -s 'localhost:9200/art/_search?explain=true' -H 'Content-Type: application/json' \
-d '{"query":{"match":{"body":"redis 持久化"}},"size":3}'
观察:文档 2 靠词频堆量可能压过 1,而 1 的标题才是强信号。第二步改结构,而不是调大 body 的 boost:
{ "query": { "multi_match": { "query": "redis 持久化",
"fields": ["title^4", "body"], "type": "best_fields" } } }
第三步加时效衰减,让老文档不再靠 hot 压新人,再对照 explain 里的 idf/tfNorm,确认排序变化来自结构而非常数。
11. 排错:六个高频坑
| 现象 | 根因 | 处理 |
|---|---|---|
跨查询比 _score |
把分数当绝对相关度 | 只看同查询内排序,跨查询用 NDCG/点击率 |
| boost 越调越乱 | 用常数补结构缺陷 | 回查 analyzer、字段映射、best/most_fields |
| 业务规则全塞进打分 | 用 function_score 做置顶+屏蔽 |
硬条件走 filter,置顶走独立排序层 |
| 换分词器后排序全变 | TF/IDF 基准变了 | 重建索引后重跑评测集 |
过滤条件写成 must |
状态与时间参与了算分 | 改 filter,顺带拿缓存 |
| 深翻页结果跳变 | rescore 只覆盖首轮 Top-N | 调大窗口或 search_after |
12. 边界:打分之外的手段
| 场景 | 该用的东西 | 理由 |
|---|---|---|
| 精确匹配、枚举过滤 | term + filter |
要命中,不要相关性 |
| 语义相近但字面不同 | 向量 / 混合检索 | BM25 只认词项 |
| 稳定业务顺序 | 显式 sort |
排序字段优先于打分 |
| 千人千面 | LTR 或外部重排 | 规则表达不了用户维度 |
| 需要归因 | 保留 explain + 规则排序 |
模型分难拆解 |
13. 自测
- BM25 的三块各解决什么问题?去掉长度归一后长文档会怎样?
k1调大后,"词频高"的文档更容易还是更难排前面?best_fields与most_fields在多字段下差在哪?- 为什么"已发布"写进
must会污染排序?该改成什么? - 两个不同查询的
_score为什么不可比?可比范围有多大? rescore只作用于 Top-N,翻到第 3 页会出现什么?怎么规避?