StackTraces
OpenSearch从零跑通

从零跑通 OpenSearch 相关性排序:6 步,外加 3 个容易卡住的地方

@debug_master2026-10-31

跟着这篇走完,你会搞清楚 OpenSearch 搜出来的顺序到底是怎么定的:_score 是 BM25 算出来的相关度分,你能用 bool 组合条件、用 boost 给不同子句不同权重、用 function_score 按字段值动态重算,最后用 explain 把每个分拆开看。整个过程 6 步,每步一条验证命令。环境沿用从零跑通 OpenSearch 本地搜索服务:端口 9200、用户 admin、密码 Devfix@2026、索引 movies(5 条电影);你换了这些值替换掉就行。

这篇默认你已经跑通了第一篇——Docker 启动、movies 索引的 5 条数据、match 搜索、curl 带 -sk -u admin:Devfix@2026 的写法全部复用,不再重复。第一篇的搜索返回里每条命中都带一个 _score,但没解释它从哪来、能不能改。这篇就补上这两件事:先看懂它,再学会改它。跟聚合篇的分工说在前面:聚合解决「命中的文档怎么分组统计」(排的是桶),这篇解决「命中的文档怎么排序」(排的是文档),两者不重叠。

从零跑通 OpenSearch 相关性排序 6 步流程封面:看懂 _score → explain 拆解 → bool 组合 → boost 调权 → function_score 加权 → explain 溯源

前置条件

项 要求 说明
OpenSearch 3.x(Docker latest 镜像) 单节点,security 开启,HS 端口 9200
movies 索引 已按第一篇建好并导入 5 条 title=text / year=integer / genre=keyword
master key admin / Devfix@2026 与系列一致,示例密码
curl 任意版本 全文 curl 调 REST API

先跑这条确认服务活着、movies 索引 5 条数据都在:

curl -sk -u admin:Devfix@2026 "https://localhost:9200/movies/_count?pretty"
{
  "count" : 5,
  "_shards" : { "total" : 1, "successful" : 1, "skipped" : 0, "failed" : 0 }
}

返回 "count": 5 就满足条件。报连接失败说明容器没起来,回第一篇第二步;count 不是 5 说明数据没导全,回第一篇第四步补。

第一步:复现 match 搜索,看懂 _score 的差异

搜 “wind rise”,你会看到返回体里每条命中都带一个 _score,而且三条命中的分数不一样:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{ "query": { "match": { "title": "wind rise" } } }'
{
  "hits": {
    "total": { "value": 3, "relation": "eq" },
    "max_score": 0.5023,
    "hits": [
      { "_id": "1", "_score": 0.5023, "_source": { "title": "Let the Wind Rise", "year": 2013, "genre": "drama" } },
      { "_id": "3", "_score": 0.4119, "_source": { "title": "The Rise of Machines", "year": 2021, "genre": "scifi" } },
      { "_id": "2", "_score": 0.3987, "_source": { "title": "Gone with the Wind", "year": 1939, "genre": "drama" } }
    ]
  }
}

三条命中的排序是「两个词都含的」排最前(id1 的 _score 0.5023),「只含 rise 的」次之(id3,0.4119),「只含 wind 的」最后(id2,0.3987)。这个顺序不是乱排的,_score 就是排序依据——OpenSearch 默认按 _score 从高到低返回。现在的问题是:0.5023 这个数从哪来的?

验证命令:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{ "query": { "match": { "title": "wind rise" } } }' \
  | grep -o '"_score" : [0-9.]*'

输出三个不同的正小数(0.5023 / 0.4119 / 0.3987),且各不相同,就说明相关性排序在工作。三条分数一样才是异常,说明你的 title 字段可能没正确映射成 text。

第二步:用 explain 拆解 _score(BM25 三个因子)

_score 不是黑箱。explain API 能把某个文档对某个查询的分数,拆成它每一部分的来源。给 id=1 加 explain: true 看它为什么拿到 0.5023:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_explain/1?pretty" \
  -H "Content-Type: application/json" \
  -d '{ "query": { "match": { "title": "wind rise" } } }'

返回的 explanation 是一棵树,最外层是总分,往下逐层拆。OpenSearch 用的是 Okapi BM25 打分模型,一条文档对一个词的分,核心是三项相乘:词频(TF,这个词在这条文档里出现几次)、逆文档频率(IDF,这个词在多少文档里出现过,越稀有越值钱)、字段长度归一(fieldNorm,字段越短越值钱)。“Let the Wind Rise” 同时含 wind 和 rise 两个词,两个词都命中了,所以它的分最高;“The Rise of Machines” 只命中 rise,“Gone with the Wind” 只命中 wind,各只有一个词贡献,分就低一档。

官方文档对这个算法叫 BM25,是基于 Lucene 的 TF/IDF 框架改进来的。你不用记公式,记住结论就够:「命中词越多、词越稀有、字段越短,分数越高」。下一句就是本文主线——既然分数是「算」出来的,那就有「改」它的办法。

验证命令:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_explain/1?pretty" \
  -H "Content-Type: application/json" \
  -d '{ "query": { "match": { "title": "wind rise" } } }' \
  | grep -o '"value" : [0-9.]*' | head -1

返回的顶层 value 等于第一步里 id1 的 _score(0.5023),说明 explain 拆出来的总和和搜索返回的分数对得上。对不上说明你两处请求的查询或文档不一致。

第三步:bool 组合条件——filter 不参与打分,should 才是加分

一个 match 只能表达「匹配某字段」。想要「既要匹配标题、又只要 drama 类型、还想让部分文档加分」,用 bool 把多个子句组合起来。四个子句的分工:must(必须匹配,参与打分)、should(匹配则加分,不匹配也放行)、filter(必须匹配,但不参与打分)、must_not(必须不匹配)。关键在 filter 和 should 的差别——filter 只做「过不过」的筛选,不影响 _score;should 命中会加分。

先看 filter 过滤:只要 drama,看 _score 有没有被 genre 影响:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "bool": {
        "must":   [ { "match": { "title": "wind rise" } } ],
        "filter": [ { "term": { "genre": "drama" } } ]
      }
    }
  }'
{
  "hits": {
    "total": { "value": 2, "relation": "eq" },
    "max_score": 0.5023,
    "hits": [
      { "_id": "1", "_score": 0.5023, "_source": { "title": "Let the Wind Rise", "genre": "drama" } },
      { "_id": "2", "_score": 0.3987, "_source": { "title": "Gone with the Wind", "genre": "drama" } }
    ]
  }
}

注意两件事:total 从 3 变成 2(scifi 的 id3 被 filter 挡掉了),但剩下的 id1、id2 的 _score 跟第一步一模一样——filter 筛了文档,却一分没加、一分没减。这就是「filter 不参与打分」的直接证据。

再换成 should,让「是 drama」这件事变成加分项:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "bool": {
        "must":   [ { "match": { "title": "wind rise" } } ],
        "should": [ { "term": { "genre": "drama" } } ]
      }
    }
  }'

返回里 id1、id2(都是 drama)的 _score 会比第一步高,id3(scifi)不变。这就是「组合条件」和「加分」的区别——要筛就 filter,要加就 should。这个认知是后面 boost 的地基。

验证命令:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{"query":{"bool":{"must":[{"match":{"title":"wind rise"}}],"filter":[{"term":{"genre":"drama"}}]}}}' \
  | grep -o '"total" : { "value" : [0-9]*'

返回 "value": 2 就说明 filter 生效且只筛不干扰分数。

第四步:boost 调权——让标题命中比类型命中更重要

上一步里,should 的加分是「命中就加一样的量」。但业务上「标题命中」往往比「类型命中」重要得多,需要给不同子句不同的权重,这就是 boost。直接在子句上写 boost,把 title match 的权重抬到 genre 的 2 倍:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "bool": {
        "must": [ { "match": { "title": { "query": "wind rise", "boost": 2 } } } ],
        "should": [ { "term": { "genre": { "value": "drama" } } } ]
      }
    }
  }'

此时 title 命中贡献的分被放大两倍,标题相关性的差距在最终排序里被拉大;而 genre 命中只拿它本身的默认权重。boost 是「乘法权重」——写 2 就是这一项的分乘 2。注意它必须写在「参与打分」的子句(must / should / match)上,写在 filter 里会被直接忽略(这个坑见 FAQ 第 2 项)。

验证命令:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{"query":{"bool":{"must":[{"match":{"title":{"query":"wind rise","boost":2}}}]}}}' \
  | grep -o '"max_score" : [0-9.]*'

max_score 相比第一步(0.5023)翻倍或明显变大,说明 boost: 2 生效。没变大就检查 boost 是不是写错了位置。

第五步:function_score 按业务字段加权——分数能被「重算」

boost 是写死的常数权重,能不能让权重跟着文档自身的字段走?比如「年份越新排越前」——这就要 function_score。它的结构是一个 query(决定哪些文档命中)+ 一个或多个 functions(决定命中的文档分数怎么改)。这一步是全文的核心:分数不是「算完就定了」,而是可以「按业务字段动态重算」的。

先看最常用的两个函数:weight 是整体翻倍,field_value_factor 是拿文档某个数值字段当因子。

weight:整体放大

给所有命中整体乘 2:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "function_score": {
        "query": { "match": { "title": "wind rise" } },
        "weight": 2
      }
    }
  }'

每条命中的 _score 变成原来的 2 倍。这个够直白,但只能「整齐地翻」,不能「按字段区别对待」。

field_value_factor:让年份新排前面

要让「年份新」的排前面,用 field_value_factor 读 year 字段,再配合 boost_mode: replace——把本来就有的相关性分整个替换成「年份因子」,排序就等价于按年份排了:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "function_score": {
        "query": { "match": { "title": "wind rise" } },
        "field_value_factor": { "field": "year" },
        "boost_mode": "replace"
      }
    }
  }'
{
  "hits": {
    "total": { "value": 3, "relation": "eq" },
    "max_score": 2021.0,
    "hits": [
      { "_id": "3", "_score": 2021.0, "_source": { "title": "The Rise of Machines", "year": 2021, "genre": "scifi" } },
      { "_id": "1", "_score": 2013.0, "_source": { "title": "Let the Wind Rise", "year": 2013, "genre": "drama" } },
      { "_id": "2", "_score": 1939.0, "_source": { "title": "Gone with the Wind", "year": 1939, "genre": "drama" } }
    ]
  }
}

现在排序完全按 year 降序:2021 → 2013 → 1939,最新片 The Rise of Machines 排到了第一。_score 也直接变成了年份值——这就是 boost_mode: replace 的效果:完全别管相关性分,用函数算出来的值当最终分。

这里藏着全文最该记住的一句话:boost_mode 决定「业务分数」和「相关性分」怎么结合。默认是 multiply(两个相乘,相关性仍然主导),改成 replace(用业务分数替换,完全按业务字段排)。想要「相关性为主、年份微调」,就保持 multiply 并给 field_value_factor 加 modifier(如 log1p——对「1 + 字段值」取对数,把 1939、2021 这种大数值压到同一量级);想要「严格按年份排」,就 replace。想看按评分/票数加权,把 field 换成你的数值字段名即可。官方文档线上可查 function_score。

验证命令:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{"query":{"function_score":{"query":{"match":{"title":"wind rise"}},"field_value_factor":{"field":"year"},"boost_mode":"replace"}}}' \
  | grep -o '"max_score" : [0-9.]*'

max_score 等于最大的 year(2021.0),说明 replace 生效、排序已被年份接管。

第六步:用 explain 溯源 function_score 之后的分数

改完分数,怎么证明它真的按我们想要的方式变了?explain 同样能拆 function_score 之后的结果。给第五步那条请求加 explain: true(或直接对 id=3 发 _explain),看最终的 _score 是怎么组合出来的:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_explain/3?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "function_score": {
        "query": { "match": { "title": "wind rise" } },
        "field_value_factor": { "field": "year" },
        "boost_mode": "replace"
      }
    }
  }'

返回的 explanation 是一个 function score 节点,里面能看到:一个子节点是 match 的相关性分,外面的 value 已经是「替换后」的年份值。看懂这一层,你就掌握了「改完还能验」的闭环——explain 既能在改之前拆解 _score(第二步),也能在改之后确认结果(第六步)。

验证命令:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_explain/3?pretty" \
  -H "Content-Type: application/json" \
  -d '{"query":{"function_score":{"query":{"match":{"title":"wind rise"}},"field_value_factor":{"field":"year"},"boost_mode":"replace"}}}' \
  | grep -o '"value" : [0-9.]*' | head -1

返回的顶层 value 等于年份 2021.0,说明 function_score 的 replace 把分数替换成了年份值,溯源成功。

核心概念

这张图是白底铅笔手绘风,分四层看:顶部的定义卡先给相关性排序一句话画像;中间横排的六张卡片从左到右就是正文六步,每张卡顶部的手绘图标对应这一步的动作(放大镜搜索、拆开的方块拆解、漏斗组合、天平调权、齿轮重算、清单打勾验证),卡上的英文术语是这一步的关键命令;底部的三个手绘方框是三组知识点的详解——左框拆 BM25 打分的三个因子,中框列改分数的三种手段,右框是验证分数的方法;淡黄色高亮的第五张卡片是全文承重的一步——用 function_score 按字段重算。

OpenSearch 相关性排序铅笔手绘详解图:左侧定义卡解释什么是相关性排序,中间六张手绘圆角卡片从左到右对应六步——放大镜配搜索命中 match、拆开的方块配拆解分数 _explain、漏斗配组合条件 bool、天平配调整权重 boost、齿轮配按字段重算 function_score、清单打勾配溯源验证 explain,每卡下方标注知识点小标签,底部三个手绘方框分别详解 BM25 怎么打分(词频 tf、逆文档频率 idf、字段长度 fieldNorm)、分数怎么改(boost、filter、function_score)、分数怎么验证(explain 拆解、改前改后对比),第五步淡黄色高亮

概念 是什么 类比
_score BM25 算出的相关度分,是排序的默认依据 像考试总分,谁高谁排前
BM25 打分模型,核心是 TF × IDF × 字段长度归一 像「命中次数 × 关键词稀有度 × 字段长度」的加权公式
bool 组合多个子句,must/should/filter/must_not 像 SQL 的 WHERE(filter)+ 排序加分(should)混合
boost 给打分子句乘一个权重 像给某道题加分系数 2.0
function_score 一个查询 + 一组改分函数,能按字段值重算分 像「先排序,再按业务字段重洗一遍」
boost_mode 决定业务分和相关性分怎么结合(multiply / replace / sum…) 像「两步分是乘起来还是取其一」

要点回顾

  • _score 是算出来的,不是玄学——BM25 的 TF × IDF × 字段长度归一,explain 能把它拆开看。
  • filter 不参与打分,should 才是加分——要「筛」用 filter,要「加」用 should;boost 写在 filter 里会被忽略。
  • boost 是乘法权重——写在 must / should / match 这类打分子句上才生效,写 2 就是这项分乘 2。
  • function_score 让分数按业务字段动态重算——weight 整体翻倍,field_value_factor 读文档字段当因子。
  • boost_mode 决定业务分与相关性分怎么结合——replace 完全按业务字段排,multiply 保留相关性主导。

常见问题

按字段排序后,返回体里 _score 不见了

现象:给搜索加了 sort: [{ "year": "desc" }] 之后,返回体每条 hit 里的 _score 字段消失了,max_score 也变成 null。

原因:OpenSearch 对「按字段排序」的场景默认不算相关性分——排序依据从 _score 换成了那个字段的值,_score 不再参与排序。开关是 track_scores,它默认 false。想让分数和字段排序一起返回,加 "track_scores": true。

相关文章:OpenSearch 按字段排序后 _score 消失:track_scores 默认 false

加了 boost 排名没变

现象:给某类文档写 boost,返回顺序和没加时一模一样。

原因:boost 大概率写在了 filter 或 constant_score 这些「不参与打分」的子句里,被直接忽略了。filter 只负责过滤,分数没它的事。把 boost 挪到 must / should / match 这些打分子句上才生效。

相关文章:OpenSearch 加了 boost 排名没变:filter 子句不参与打分

function_score 忘了写 query,返回了全库

现象:用 function_score 想给文档加权,结果把索引里所有文档都打回来了,每篇分数还都差不多。

原因:function_score 省略顶层 query 时,内部跑 match_all,每篇文档的 base score 都是 1。命中范围从来是由 query(或外层 bool)决定的,functions 只负责「改命中文档的分」。给 function_score 补上显式 query,或外层套 filter 限定范围。

相关文章:OpenSearch function_score 忘写 query 返回全库:省略 query 即 match_all

下一步

把相关性排序接进 k-NN 向量检索

本文搞懂了 BM25 的 _score 和 boost,下一步把它和 k-NN 向量分数合并,就是「混合检索」——先各自打分,再用规范化把两类分拉到同一量级融合。混合检索的分数归一化,正是本文 _score / boost 的延伸。

从零跑通 OpenSearch 本地搜索服务

看懂 explain 输出里的每一行

explain 的输出是一棵树,weight / idf / tf / fieldNorm 个个都能查到含义。想深挖打分细节、排查「为什么这个文档排得比我预期高」,就把 explain 的每层 description 逐个读。

OpenSearch 相关性排序打分(本文)

先把字段类型理清,排序聚合才不报错

相关性排序和聚合都对字段类型敏感:text 用于全文匹配,keyword 用于精确过滤排序,integer 用于数值加权。类型没设对,排序和聚合会报 fielddata 之类错误,回本文里的「field_value_factor 用 year」之前先确认字段类型。

OpenSearch 对 text 字段做聚合报 fielddata 错误

卡在哪一步了?

从零跑通的过程中撞到报错?把命令和输出记下来,帮助下一个刚起步的人。

贡献一条解法