StackTraces
Meilisearch从零跑通

从零跑通 Meilisearch 排名规则与相关度评分:7 步,外加 3 个容易卡住的地方

@debug_master2026-10-31

跟着这篇走完,你会在本地把 Meilisearch 的相关度排序看透:默认 7 条排名规则(rankingRules,决定搜索结果先后顺序的规则列表)从左到右逐条裁决,你用 showRankingScore 和 showRankingScoreDetails 把每条结果的分数和每条规则的裁决明细都读出来——「为什么它排前面」从此有数字可查,最后再用 rankingScoreThreshold 把低分结果整档切掉。整个过程 7 步,每步都有一条命令能验证自己做对了没有。localhost:7700 是 Meilisearch 的默认监听地址,master key(Meilisearch 的接口访问密钥)固定用 aSampleMasterKey,索引建新的 books_rank(还是系列风格的虚构书籍,每本带 title_en 和 summary 两个英文字段);你要是换了端口、key 或索引名,把示例值替换掉就行。这篇默认你已经装好并跑通了从零跑通 Meilisearch 本地搜索服务,安装、启动、异步任务轮询的套路全部复用第一篇。跟系列里排序与自定义排名那篇的分工说在前面:那篇讲「我要按什么排」——sort 参数怎么传、自定义规则怎么写;这篇讲「默认它按什么排」——引擎内置的 7 条规则各自裁决什么、分数怎么算出来,两篇拼起来才是完整的排序认知。

Meilisearch:从零跑通排名规则与相关度评分的 7 个步骤

前置条件

项 要求 说明
Meilisearch v1.36 或更高 v1.36 起默认规则从 6 条变 7 条(attribute 拆成 attributeRank + wordPosition),太低照做会踩到规则名单对不上
前置文章 已跑通 meilisearch-local-search 复用其安装/启动/master key/异步任务轮询套路;没跑通先回去补
master key aSampleMasterKey 与系列一致
curl 任意版本 全文用 curl 调 REST API

先跑这条,确认第一篇的服务还活着。

curl http://localhost:7700/health
{"status":"available"}

返回 {"status":"available"} 就满足条件。要是返回 curl: (7) Failed to connect to localhost port 7700,说明服务没起——回第一篇第一步用 meilisearch --master-key="aSampleMasterKey" 重新启动,再回来。

第一步:确认版本,对齐 7 条规则名单

本篇全程围绕默认排名规则展开,而这个名单在 v1.36 变过一次:旧版 6 条里的 attribute 一条规则管两件事(字段权重 + 词在字段里的位置),v1.36 起拆成 attributeRank(只管字段权重)和 wordPosition(只管词位置)两条。版本不对,你 GET 出来的规则名单就和正文对不上号,后面每一步都会对不上。所以第一步先把版本钉死。

curl http://localhost:7700/version -H "Authorization: Bearer aSampleMasterKey"
{"commitSha":"7ebd97e38c36882b0c78d79e433b9115b4f003b3","commitDate":"2026-10-01T11:51:22.000000000+02:00","pkgVersion":"1.54.3"}

版本号看 pkgVersion 字段,≥ 1.36 就行。本系列环境是 v1.54.3。

第二步:建 books_rank 索引 + 写 6 条书 + 看默认规则名单

读分数要先有数据可读。这一步搭数据底座:索引 books_rank,6 条虚构书籍。字段设计有个刻意的地方——除了中文书名 title,每本还带 title_en(英文书名)和 summary(英文摘要)。原因有两个:中文词经分词(把连续文字切成可检索的词条)后多是 1–2 字的短词,演示容错和词距看不真切(typo-tolerance 篇用过同款做法);而相关度演示需要「同一个词命中不同字段」的现场,title_en 和 summary 正好提供两个字段让 attributeRank 规则表演。本篇所有演示查询都用英文词,避开中文分词的变量。

索引显式指定主键(每条文档的唯一标识字段),避免自动推断翻车:

curl -X POST http://localhost:7700/indexes \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"uid":"books_rank","primaryKey":"id"}'
{
  "taskUid": 0,
  "indexUid": "books_rank",
  "status": "enqueued",
  "type": "indexCreation"
}

返回的 taskUid 是异步任务编号——Meilisearch 的写入和设置操作都是后台任务,得轮询到 succeeded 才算完,套路复用第一篇。全新环境任务号从 0 开始;要是你的环境已经跑过系列前几篇,以实际返回的 taskUid 为准。接着写 6 条书:

curl -X POST http://localhost:7700/indexes/books_rank/documents \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  --data-binary '[
    {"id": 1, "title": "三体", "title_en": "The Three-Body Problem", "author": "刘慈欣", "genres": ["科幻"], "rating": 9.3, "summary": "three civilizations collide across light years"},
    {"id": 2, "title": "黑暗森林", "title_en": "The Dark Forest", "author": "刘慈欣", "genres": ["科幻"], "rating": 9.2, "summary": "civilizations hide from silent hunters"},
    {"id": 3, "title": "球状闪电", "title_en": "Ball Lightning", "author": "刘慈欣", "genres": ["科幻"], "rating": 8.8, "summary": "a quantum ghost of lightning research"},
    {"id": 4, "title": "活着", "title_en": "To Live", "author": "余华", "genres": ["文学"], "rating": 9.1, "summary": "one man survives decades of hardship"},
    {"id": 5, "title": "万历十五年", "title_en": "1587, a Year of No Significance", "author": "黄仁宇", "genres": ["历史"], "rating": 8.9, "summary": "a quiet year that shaped the dynasty"},
    {"id": 6, "title": "影之森", "title_en": "The Forest of Shadows", "author": "夏绿", "genres": ["悬疑"], "rating": 8.5, "summary": "dark secrets buried under a quiet canopy"}
  ]'
{
  "taskUid": 1,
  "indexUid": "books_rank",
  "status": "enqueued",
  "type": "documentAdditionOrUpdate"
}

任务 succeeded 后,把本篇真正的主角请出来——默认排名规则名单:

curl http://localhost:7700/indexes/books_rank/settings/ranking-rules \
  -H "Authorization: Bearer aSampleMasterKey"
["words", "typo", "proximity", "attributeRank", "sort", "wordPosition", "exactness"]

这就是默认 7 条。验证两条:GET /indexes/books_rank/stats 返回 numberOfDocuments: 6;上面这条 GET 返回 7 条规则的完整名单。少一条或者看到 attribute 而不是 attributeRank/wordPosition,都是版本问题,回第一步。等任务的方法忘了的话,见Meilisearch 添加文档后立即搜索返回空结果。

先记住名单顺序的含义:引擎按这个顺序逐条裁决,排在前面的规则先说话,前面的规则分不出胜负才轮到后面的。这个「逐条裁决」的机制叫桶排序(bucket sort)——想象 7 个连续的筛子,全部文档先过第一个筛子,没分出胜负的再进第二个,一路筛到第七个。

第三步:第一次读分数——让排名开口说话

规则名单有了,但「words 排第一」是抽象的。这一步是全文的枢纽:发一个带 showRankingScore 和 showRankingScoreDetails 的搜索请求,把排序过程变成能读的数字。前者给每个结果一个 0.0 到 1.0 的总分(_rankingScore,1.0 是完美匹配),后者把总分拆成每条规则的小票(_rankingScoreDetails,每条规则的裁决明细)。

查询用 dark forest:dark 在《黑暗森林》的 title_en 和《影之森》的 summary 里,forest 在两本书的 title_en 里——所以《黑暗森林》两个词全中,《影之森》也两个词全中。先看这个全中的版本,下一步再制造分档:

curl -X POST http://localhost:7700/indexes/books_rank/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"dark forest","showRankingScore":true,"showRankingScoreDetails":true}'

《黑暗森林》那条结果的分数部分长这样(完整返回体还有文档字段和另一条结果):

{
  "title": "黑暗森林",
  "_rankingScore": 0.95,
  "_rankingScoreDetails": {
    "words": { "order": 0, "matchingWords": 2, "maxMatchingWords": 2, "score": 1 },
    "typo": { "order": 1, "typoCount": 0, "maxTypoCount": 2, "score": 1 },
    "proximity": { "order": 2, "score": 0.98 },
    "attributeRank": { "order": 3, "score": 1, "attributeRankingOrderScore": 1 },
    "wordPosition": { "order": 5, "score": 0.87 },
    "exactness": { "order": 6, "matchType": "noExactMatch", "score": 0.67 }
  }
}

(_rankingScore 和各 score 的具体数值依赖数据与环境,以本地实测为准;键的名单也可能因规则是否参与裁决而略有出入——比如请求不带 sort 参数时,小票里就没有 sort 键,读懂结构就行。)

逐张读小票——order 是这条规则在链里的位置(从 0 起),score 是这条规则给这个文档打的分(0 到 1):

  • words:matchingWords: 2, maxMatchingWords: 2——查询 2 个词命中 2 个,满分。这是第一裁决规则,两本书都 2/2,打平。
  • typo:typoCount: 0——零错字命中,又平。
  • proximity:词距分。dark 和 forest 在《黑暗森林》的标题里紧挨着,分高;《影之森》的 dark 在摘要、forest 在标题,跨字段的词距差一些,分低——这条是两本书分出胜负的地方。
  • attributeRank:字段权重分,看命中的词落在多重要的字段。两本书最好的命中都在 title_en,这条打平;字段的权重序(attribute ranking order)默认按字段在文档里出现的先后生成,本例是 id → title → title_en → author → genres → rating → summary。
  • wordPosition:词在字段内的位置分,越靠前越高。
  • exactness:matchType: noExactMatch——查询词没有精确等于任何字段的完整值。它有三档:exactMatch(查询恰好等于字段整个值)、matchesStart(匹配字段值开头)、noExactMatch,第六步会亲手做出前两档。

回头看总分:_rankingScore: 0.95 不是 7 个分数的平均,而是靠前的规则权重远大于靠后的——words 打平就不说了,proximity 一分高下,后面的 exactness 0.67 只在总分的零头上。这就是为什么两条分数只差 0.0x 时,你该去 details 里找是哪条规则的小票不一样。验证:同一请求的返回里,《影之森》的 _rankingScore 低于《黑暗森林》,且 proximity 的 score 更低——能对上,说明你会读了。

第四步:words 规则——命中几个词就是第一道门槛

上一步两本书都是 2/2 全中,words 没机会表演分档。这一步换查询制造分档,顺带看 words 的两个隐藏行为。查询换成 dark shadows:shadows 只在《影之森》的 title_en 里——于是《影之森》2/2,《黑暗森林》只有 dark 命中、1/2:

curl -X POST http://localhost:7700/indexes/books_rank/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"dark shadows","showRankingScore":true,"showRankingScoreDetails":true}'
hits 顺序:影之森(2/2,_rankingScore 高)→ 黑暗森林(1/2,_rankingScore 低)
影之森 words 小票:matchingWords: 2, maxMatchingWords: 2, score: 1
黑暗森林 words 小票:matchingWords: 1, maxMatchingWords: 2, score: 0.5

命中 2/2 的文档永远排在 1/2 前面——哪怕 1/2 那本评分 9.2、零错字,2/2 那本评分 8.5。这就是桶排序的词典序:规则之间没有互补,第一条规则分出胜负,后面全部规则直接免赛。一个 2/2 但错字多的文档,稳压一个 1/2 但零错字的文档。

words 还有两个细节。第一,它处理查询串是从右往左的:搜 batman dark knight 时,全中三词的文档最优先,只含 batman 的文档最后才轮到——右边的词被「保」得更紧。第二,你的查询最后一个词自动按前缀处理:搜 fore 也能命中所有含 forest 的文档(试一下就知道),这是搜索框边打边搜的体验来源。这两点官方排名规则文档都有明说,日常调查询词序时用得上。

验证:上面的请求里,《黑暗森林》的 words 小票是 matchingWords: 1 且它排在《影之森》后面——能对上,words 的分档逻辑你就拿下了。

第五步:typo 与 proximity——容错不是放水,词距不是玄学

前面几步全是零错字命中,这一步看两条「打磨质量」的规则。先看 typo(容错):查询拼错一个字母——forezt:

curl -X POST http://localhost:7700/indexes/books_rank/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"forezt","showRankingScore":true,"showRankingScoreDetails":true}'
hits 顺序:影之森 → 黑暗森林(与 q=forest 完全一致)
两本书 typo 小票:typoCount: 1, maxTypoCount: 1(forest → forezt,一个错字)
两本书 _rankingScore 比零错字的 q=forest 明显更低

两件事值得看。第一,拼错照样有结果——Meilisearch 的容错默认开着(1 个错字对 ≥5 字符的词生效;darc 这种 4 字符短词不给容错,直接搜不到,这是很多「为什么搜不到」的真凶)。第二,typo 是排序规则:两本书的 typoCount 都是 1,打平,所以顺序由后面的 wordPosition 裁决,和零错字的 q=forest 一模一样;但分数掉了。想看「0 错字稳压 1 错字」的现场,往 books_rank 再 POST 一条 title_en 里真写着 forezt 的新文档就行——0 错字的那本会压过 1 错字的,原理同第四步的 words 分档。容错阈值怎么调、哪些词定向关闭,拼写容错篇有完整操作。

再看 proximity(词距)。查询词一样,顺序不同,词距分不同——《影之森》的 title_en 是 The Forest of Shadows,forest 和 shadows 中间隔一个 of。对比两个查询:

curl -X POST http://localhost:7700/indexes/books_rank/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"forest shadows","showRankingScore":true}'
curl -X POST http://localhost:7700/indexes/books_rank/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"shadows forest","showRankingScore":true}'
q=forest shadows:影之森的 proximity 分更高,_rankingScore 更高
q=shadows forest:同一本书,词序颠倒,proximity 分下降,_rankingScore 跟着掉

词序和查询一致的文档优先——「dark knight」按序命中好过「knight …… dark」隔十词命中。两个请求里《影之森》都排第一(它 2/2,words 先分出胜负),变的是它自己的分数——对照两次响应里的 _rankingScore 就能看到 proximity 的手笔。

验证:forezt 的响应里 typo 小票是 typoCount: 1;两个词序查询的 _rankingScore 不相等——都对上,这两条规则你就见过了。

第六步:attributeRank、wordPosition、exactness——剩余三条各给一个现场

还剩三条规则,每条一个最小现场。先看 attributeRank(字段权重):同一个词 dark,《黑暗森林》命中在 title_en,《影之森》命中在 summary——正好踩进两个字段:

curl -X POST http://localhost:7700/indexes/books_rank/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"dark","showRankingScore":true,"showRankingScoreDetails":true}'
hits 顺序:黑暗森林(dark 命中 title_en)→ 影之森(dark 命中 summary)

有意思的地方在《影之森》那边:它的 dark 是 summary 的第一个词,词位第 1;而《黑暗森林》的 dark 在 title_en 的第三个词位。要是 wordPosition 先裁决,《影之森》该赢——但 attributeRank 在链上排第 4(order 3)、wordPosition 排第 6(order 5),字段权重先于词位说话,命中重要字段的文档赢。这正是 v1.36 把 attribute 拆成两条的价值:拆开后你才能在「字段权重」和「词位」之间插进 sort 或自定义规则,老版一条 attribute 管两件事就没这个操作空间(注意 attribute 不能和 attributeRank/wordPosition 同时出现在规则里,混用会报错)。

再给 wordPosition(词位)一个独立现场。查询 forest:两本书的 title_en 都含它——《影之森》The Forest of Shadows 里在第 2 个词位,《黑暗森林》The Dark Forest 里在第 3 个词位:

curl -X POST http://localhost:7700/indexes/books_rank/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"forest","showRankingScore":true,"showRankingScoreDetails":true}'
hits 顺序:影之森(forest 在第 2 词位)→ 黑暗森林(forest 在第 3 词位)
两本书 attributeRank 小票完全相同(都命中 title_en)——打平后轮到 wordPosition 裁决

《影之森》评分 8.5 反排在评分 9.2 的《黑暗森林》前面,原因就是词位靠前。和上一步合起来看:dark 查询里 attributeRank 赢了词位占优的《影之森》,forest 查询里字段权重打平、词位接手——两条规则各管各的维度,谁先谁后由链上位置定。

最后 exactness(精确度)。查询恰好等于字段完整值时触发最高档——用《万历十五年》:

curl -X POST http://localhost:7700/indexes/books_rank/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"ball lightning","showRankingScoreDetails":true}'
球状闪电 exactness 小票:matchType: "exactMatch"(查询等于 title_en 整个值 "Ball Lightning")

把查询缩成 ball,同一本书的 matchType 变成 matchesStart(查询精确匹配字段值的开头);而前面几步的 dark forest 们都是 noExactMatch。三档就是这意思:整值匹配 > 开头匹配 > 部分匹配,exactness 按档排序。

验证:dark 查询里《黑暗森林》在前、forest 查询里《影之森》在前、ball lightning 的 matchType 是 exactMatch——三个都对上,剩下 6 条规则的现场你就全部过完了(sort 那条只在请求带 sort 参数时出场,用法归排序与自定义排名篇,它不影响 _rankingScore)。

第七步:rankingScoreThreshold——把低分档整档切掉

读得懂分数,就能用分数控结果。rankingScoreThreshold(0.0 到 1.0)给搜索加一条质量线:低于阈值的文档全部不要。查询回到 dark shadows——第四步里它是 2/2 与 1/2 的分档现场,现在把线画在两档之间:

curl -X POST http://localhost:7700/indexes/books_rank/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"dark shadows","showRankingScore":true,"rankingScoreThreshold":0.5}'
无阈值:hits = 影之森(2/2)→ 黑暗森林(1/2),estimatedTotalHits: 2
加阈值 0.5:hits = 影之森(2/2),estimatedTotalHits: 1——1/2 档整档消失

阈值取值别拍脑袋,先用第三步的 showRankingScore 看你的数据在典型查询下的分数分布,把线画在「好结果档」和「差结果档」的分界上。这里有个反直觉的坑:被切掉的文档不计入 estimatedTotalHits(估算的总命中数),也不计入 facet 分布(facetDistribution,按可筛选字段统计的命中分布,比如每个分类各命中几本)——计数变小于不是索引丢数据,是引擎在第一个低于阈值的文档处停止了扫描(结果按分数降序,后面只会更低,不必再扫)。想亲手看 facet 的变化,得先补两步前置:把 genres 加进 filterableAttributes(官方要求,能做 facet 的字段必须事先声明,默认为空),再在请求里带 "facets":["genres"] 参数。这直接引出两个结论:给前端展示「约 N 条结果」时要么接受这个口径、要么另发一次不带阈值的请求拿全量数字;rankingScoreThreshold 和 page/hitsPerPage 一起用会变慢——精确分页要求扫完全部命中,停扫优化就没了。哪个数字少了、分面选项不见了,先怀疑阈值,见Meilisearch rankingScoreThreshold 让 estimatedTotalHits 骤降。

验证:同一查询去掉 rankingScoreThreshold 再发一次,estimatedTotalHits 回到 2——能对上,「读分数 → 画阈值 → 验计数」这条链你就跑通了。

核心概念

下面这张图是 7 条规则的裁决走向:全部文档从 words 进桶,逐条规则收窄,每条规则只处理前面全部打平的文档。

Meilisearch 排名规则桶排序裁决链路架构图

概念 是什么 类比
rankingRules(排名规则链) 7 条内置规则按序逐条裁决,排前面的先说话,打平才看下一条 像比赛评分:先比金牌数,同金再比银牌,一路比到第 7 项
桶排序(bucket sort) 每条规则把打平的文档分进同一桶,只对桶内继续下一条规则 像淘米:每遍筛子只处理上一遍留在筛面上的
_rankingScore 每个结果的 0–1 总分,1.0 为完美匹配,靠前规则权重远大于靠后 像高考总分:主科权重压倒副科,副科只在主科同分时改变名次
_rankingScoreDetails 每条规则的小票:order(位置)+ score(该规则给的分)+ 规则专属字段 像购物小票分项:总价对不上时逐行对
attributeRank vs wordPosition 前者只管命中字段多重要,后者只管词在字段里多靠前,v1.36 起拆开 像面试的「学历关」和「气场关」:拆开才能单独调整出场顺序
rankingScoreThreshold 搜索的质量线,第一个低于阈值的文档处停扫,其后全部排除 像录取分数线:线下的档案不看,统计口径只算线上的
matchingStrategy(隐式背景) 查询词没全中时引擎怎么放松匹配,默认从末词逐词放松 像找人的时候先要全名,找不到再退一步只要姓

要点回顾

  • 词典序没有互补——words 满分但多错字的文档,稳压 words 少命中但零错字的文档;规则之间不折衷,第一条分出胜负后面免赛。
  • 小票读数定位差异——两条结果只差 0.0x 时,_rankingScoreDetails 里逐条对 score,第一个不一样的键就是裁决规则。
  • attributeRank 先于 wordPosition——字段权重(第 4 位)先于词位(第 6 位)裁决,v1.36 拆开后才能在两者之间插规则。
  • 容错不是放水——拼错照样命中但分数掉档;1 个错字对 ≥5 字符的词生效,darc 这类 4 字符短词没有容错空间。
  • 阈值是停扫不是过滤——rankingScoreThreshold 之后的文档不计入 estimatedTotalHits 与 facet 分布,计数骤降先查阈值。

常见问题

PUT ranking-rules 后结果顺序与相关度脱钩(不报错)

现象:PUT /settings/ranking-rules 只传了自定义规则(如 ["rating:desc"]),任务 succeeded 不报错,但拼错词的文档和拼写正确的平起平坐、命中标题的不再比命中摘要的靠前。

原因:PUT 是全量覆盖语义,默认 7 条被冲掉,只剩 words 隐式兜底;typo、proximity、attributeRank、wordPosition、exactness 全部退场。

相关文章:Meilisearch PUT ranking-rules 只传自定义规则,相关度排序整体失效

加了 rankingScoreThreshold 后计数和分面骤降

现象:estimatedTotalHits 从 2 变 1,facetDistribution 里某选项整个消失,以为索引丢数据或 filter 失效。

原因:阈值是停扫语义——第一个低于阈值的文档之后全部排除,且不计入计数与分面分布,不是数据丢了。

相关文章:Meilisearch rankingScoreThreshold 让 estimatedTotalHits 骤降

invalid_settings_ranking_rules:设排名规则返回 400

现象:PUT /settings/ranking-rules 传 ["words",...,"desc(release_date)"] 返回 400,错误码 invalid_settings_ranking_rules。

原因:用了旧版函数式语法 asc()/desc(),自 v0.22 起废弃。现行写法是 属性:asc / 属性:desc,如 "release_date:desc"。

相关文章:Meilisearch 自定义排名规则旧语法报 invalid_settings_ranking_rules

下一步

拼写容错配置深入

本篇看了 typo 规则怎么排序,那篇教你调阈值、定向关闭容错、按字段配置容错行为。

Meilisearch typoTolerance 设置不生效

排序与自定义排名

sort 参数怎么传、自定义规则怎么写、规则链位置怎么决定软硬排序——本篇 sort 那一条规则的操作手册。

Meilisearch sort 属性未声明报 invalid_search_sort

混合检索中的分数

配了 embedder 之后 _rankingScore 里混进向量相似度,语义分和关键词分怎么拧在一起排序。

Meilisearch hybrid 参数校验失败排查

卡在哪一步了?

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

贡献一条解法