2663 字
约 8 分钟
14
S13-X08 · 相关度打分:BM25 与 boost 的调法

排序不对,第一反应通常是"加个 boost"。但 boost 只是乘数,放大的是已经算出来的分;字段没切对词、或压根没进查询,乘多少都是零。顺序应该是:先确认召回,再确认字段,最后才动权重。

下面按"分怎么算 → 怎么读 → 怎么调 → 怎么验"走:BM25 结构 → 跨查询不可比 → explain → 四种加权 → function_score → filter 上下文 → 评测。


1. BM25 由什么组成

score(D,Q) = Σ IDF(qi) · tf · (k1 + 1) / ( tf + k1 · (1 - b + b · dl / avgdl) )
IDF(qi)    = ln(1 + (N - n + 0.5) / (n + 0.5))
组成 作用 受什么影响
TF 项 词频越高分越高,但饱和 k1 控制饱和速度
IDF 词越罕见权重越高 全库文档数 N 与含该词的文档数 n
长度归一化 文档越长,命中同词含金量越低 b 与 dl/avgdl
参数 默认 调大 调小
k1 1.2 更奖励重复词,长文占优 更快饱和,近似"出现即满分"
b 0.75 更重罚长文档 长文档不吃亏(0 = 不归一化)
PUT /article/_settings
{ "index": { "similarity": { "title_bm25": { "type": "BM25", "k1": 1.6, "b": 0.4 } } } }

PUT /article/_mapping
{ "properties": { "title": { "type": "text", "similarity": "title_bm25" } } }

8.x 默认 BM25。classic 是 Lucene 的 TF-IDF 实现,仍可配,但新索引不必再用。


2. _score 为什么不能跨查询比较

分数依赖四样:当次查询的 term 集合(决定 IDF 的 N 与 n)、命中文档集、各 clause 的 boost、查询归一化。换查询,N/n 全变,分数没有共同基准。

用法 是否成立 说明
同一查询内按 _score 排序 成立 分数的唯一正当用途
用 _score > 5 当过滤条件 不成立 换 query 就失效
两个查询的分数相加比较 不成立 IDF 基数不同
跨索引比较分数 不成立 各索引 N 不同

需要"绝对阈值"的场景(比如只保留高质量命中),应该用 min_score 在同一查询内试,或者干脆换成显式的业务字段过滤。


3. explain 怎么读

GET /article/_search
{ "explain": true, "size": 2, "_source": false,
  "query": { "multi_match": { "query": "倒排索引", "fields": ["title^3", "body"] } } }

返回的 _explanation 是一棵树:value 是该层贡献,description 说明算法,details 是子项。读的顺序是从叶子往上加:

看到的行 含义
weight(title:倒排 in 12) [BM25Similarity], result of: 该 term 在该字段的得分
score(freq=2.0), computed as boost * idf * tf from: 三个因子相乘
boost 该 clause 的 boost 乘子(title^3 会体现在这里)
idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) 词的罕见程度
tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) 饱和后的词频
max of: / sum of: dis_max 取最大或 bool 求和

读法:boost 一样大而 idf 很小 → 词太常见,加 boost 没用;tf 接近 1 而分仍低 → 命中词太少,该查分词;某字段根本没出现在树里 → 没进查询,或 fields 拼错。

explain 逐文档计算,只对小结果集开,线上别常开。


4. 字段权重的四种方式

方式 写法 特点
查询期 boost match 里的 "boost": 2 最直接,只影响该 clause
multi_match 字段权重 fields: ["title^3", "body"] 等价于该字段的 boost,可读性好
function_score 按字段值、脚本、衰减函数加权 能表达"越新越高""越热越高"
索引期 boost 8.x 已移除 5.x 起废弃,无法再用

multi_match 的 type 决定多字段怎么合并:best_fields(默认,取最高分字段)适合"任一处强命中";most_fields(各字段分相加)适合"多处命中更好";cross_fields 把多字段当一个整体,适合"姓在 title、名在 body"这类拆分字段。

GET /article/_search
{ "query": { "multi_match": {
  "query": "倒排索引", "fields": ["title^3", "summary^1.5", "body"],
  "type": "best_fields", "tie_breaker": 0.3 } } }

tie_breaker 让非最佳字段也贡献一部分分数(0.3 表示按 30% 计入),避免"只有 title 命中"与"title+body 都命中"完全同分。


5. function_score:把业务信号掺进分数

GET /article/_search
{ "query": { "match": { "title": "倒排索引" } },
  "function_score": {
    "functions": [
      { "gauss": { "created": { "origin": "now", "scale": "7d", "offset": "1d", "decay": 0.5 } } },
      { "field_value_factor": { "field": "pv", "factor": 0.1, "modifier": "log1p", "missing": 1 } }
    ],
    "score_mode": "sum", "boost_mode": "multiply", "max_boost": 10 } }
参数 取值 作用
score_mode multiply/sum/avg/max/min/first 多个 functions 之间怎么合并
boost_mode multiply/replace/sum/avg/max/min 合并结果与查询分怎么合并
max_boost 数值 封顶,防止某个因子把分数拉爆
min_score 数值 低于阈值直接丢弃

三种常见用法:时间衰减(gauss/exp/linear,origin/scale/offset/decay 描述"多久算旧");热度加权(field_value_factor + log1p,避免大数值线性碾压);随机打散(random_score + seed,同 seed 可复现)。

boost_mode: replace 会丢弃相关度,只用函数分——业务排序完全覆盖相关度时才用。field_value_factor 里 modifier 选 none 会让 pv=10000 直接压过一切,几乎总要配 log1p 或 sqrt。


6. constant_score 与 filter 上下文

GET /article/_search
{ "query": { "bool": {
  "filter": [ { "term": { "status": "published" } },
              { "range": { "created": { "gte": "now-30d" } } } ],
  "must":   [ { "match": { "title": "倒排索引" } } ] } } }
上下文 是否算分 能否缓存 用途
query 是 否 影响排序的条件
filter 否 是(filter cache) 纯筛选:状态、时间、权限
constant_score 固定 1(或指定 boost) 内部仍是 filter 只要"是否命中",不要分

状态、时间、租户这类条件放 filter 收益有两层:不打分(省 CPU)、结果可缓存。bool 里"该不该出现"的条件全进 filter,"该排多前"的才进 must。


7. minimum_should_match 与召回

写法 含义
"75%" 至少命中 75% 的 term(向下取整)
"2" 至少命中 2 个 term
"-25%" 允许缺 25%,即至少 75%
"3<90%" term 数 > 3 时按 90%,否则全部必须命中
"2<-25% 9<-3" 条件组合:≤2 个全命中,3–9 个允许缺 25%,>9 个允许缺 3 个
{ "match": { "title": { "query": "分布式 倒排 索引 原理", "minimum_should_match": "75%" } } }

这是召回与精度的旋钮:调高噪声少但长尾可能零结果,调低召回足但排序被弱命中污染。中文 query 常只有 2–4 个 token,百分比语义很粗,用绝对数字更可控。

注意 bool 里同时有 must/filter 时,should 默认只加分不强制。要它变成硬条件,必须显式写 minimum_should_match: 1。


8. 排序与打分:什么时候干脆不算分

GET /article/_search
{ "sort": [ { "pv": "desc" }, { "_score": "desc" } ], "track_scores": true }
需求 写法 代价
纯业务排序(时间、价格) 只给 sort _score 变 null,省打分
业务优先、相关度兜底 sort 里带 _score 需要 track_scores: true
相关度优先、业务微调 function_score 打分照算,多一次函数求值
只要"命中/不命中" constant_score + filter 完全不算分

业务排序完全覆盖相关度时,用 sort 让 _score 变 null,能省掉整条打分链路——最容易拿、最少人拿的性能收益。


9. 怎么验证调参有效

方法 做法 看什么
top-k 命中率 每个 query 人工标 1 个"必须在前 k"的文档 命中比例
NDCG@10 分级标注(0/1/2/3) 排序整体质量,越靠前权重越大
MRR 只看第一个正确结果的位置 首条准确度
A/B 对照 同一 query 集跑两套参数 指标差异是否稳定

流程:攒 50–200 条真实 query(数量按业务规模定),人工标出"应该排前面的文档",改一次参数跑一遍,只信指标整体变化。没有标注集就调 boost,等于在噪声上做梯度下降。


10. 排错

现象 原因 处理
加 boost 后排序几乎没变 分差主要来自 IDF/分词,boost 只放大已有分 先查分词与 fields
某字段完全不贡献分 fields 拼错、字段是 keyword、或没进 must 用 explain 看树里有没有它
分数为 0 或恒定 查询在 filter 上下文 / constant_score 换成 query 上下文
function_score 后分数爆表 modifier: none + 大数值 改用 log1p,加 max_boost
排序后 _score 是 null 有 sort 且未开 track_scores 加 track_scores: true
加了 should 但没生效 有 must 时 should 只加分 显式 minimum_should_match: 1
长尾 query 零结果 minimum_should_match 太严 调低,或按 token 数分级设置

11. 边界

场景 该用什么 理由
精确匹配优先(编号、SKU) term + constant_score 不需要相关度
多字段语义差异大 multi_match + cross_fields 整体当一篇文档打分
强业务排序(销量、时间) sort 相关度是干扰项
语义相似 dense_vector + kNN,或混合检索 BM25 只看词,不懂同义
个性化排序 离线模型 + 线上 script_score BM25 无法表达用户特征

一句话:boost 只改"谁更重要",改不了"谁被召回";先把召回和分词修对,权重才有意义。


12. 自测

  1. BM25 里 k1 调大,分数曲线在哪个方向变?对长文档重复词有什么影响?
  2. 为什么 _score > 5 这种阈值跨查询不可用?它依赖哪几个量?
  3. explain 树里某字段完全没出现,最可能是哪两类原因?
  4. multi_match 的 best_fields 与 most_fields 分别在什么场景更合适?
  5. boost_mode: replace 与 multiply 的区别是什么?什么时候该用前者?
  6. 有 must 的情况下 should 为什么不强制命中?怎么让它强制?
S13-X08 · 相关度打分:BM25 与 boost 的调法
https://www.clxhxhhr.top/posts/5824/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。