排序不对,第一反应通常是"加个 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. 自测
- BM25 里
k1调大,分数曲线在哪个方向变?对长文档重复词有什么影响? - 为什么
_score > 5这种阈值跨查询不可用?它依赖哪几个量? explain树里某字段完全没出现,最可能是哪两类原因?multi_match的best_fields与most_fields分别在什么场景更合适?boost_mode: replace与multiply的区别是什么?什么时候该用前者?- 有
must的情况下should为什么不强制命中?怎么让它强制?