StackTraces
Meilisearch从零跑通

从零跑通 Meilisearch 混合检索(hybrid search):7 步,外加 3 个容易卡住的地方

@debug_master2026-10-12

跟着这篇走完,你会在本地把 Meilisearch 的混合检索(hybrid search)跑通:配好内置 embedder 后,同一个查询词分别用语义占比 0 / 0.5 / 1.0 三档去搜,亲眼看到“关键词命中”和“语义命中”是怎么被拧到一起排序的,再用 _rankingScore 看出每一条结果到底是靠哪条腿排上来的。整个过程 7 步,每步都有一条命令能验证自己做对了没有。localhost:7700 是 Meilisearch 的默认监听地址,master key 固定用 aSampleMasterKey,索引建新的 books_hybrid(还是系列那 6 本虚构书籍),模型用 BAAI/bge-small-zh-v1.5——你要是换了端口、key、索引名或模型,把示例值替换掉就行。这篇默认你已经装好并跑通了从零跑通 Meilisearch 本地搜索服务,安装、启动、异步任务轮询的套路全部复用第一篇。跟系列前两篇的区别说在前面:meilisearch-semantic-search 和 meilisearch-embedded-embedder 演示的都是 semanticRatio: 1.0 的纯语义模式,这篇专门玩中间值——语义分与关键词分合并排序、比例怎么调,正是混搜跟纯语义不一样的地方。

Meilisearch:从零跑通混合检索的 7 个步骤

前置条件

项 要求 说明
Meilisearch v1.54.3 或更高 内置 embedder 和 hybrid 检索都已是稳定特性,低于这代版本接口行为可能有差异
前置文章 已跑通 meilisearch-local-search 复用其安装/启动/master key/异步任务轮询套路;没跑通先回去补
网络 首次需能访问 HuggingFace 模型首次使用时自动下载;完全离线或内网环境会卡在下载这一步
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" 重新启动,再回来。

第一步:建 books_hybrid 索引,传 6 本纯文本书(不带向量)

混合检索要做两件事:查关键词、查语义。语义这条路依赖向量,向量得挂在某个索引上,所以第一步先把索引和基础数据准备好——跟 meilisearch-embedded-embedder 篇一样,文档就是普通 JSON,向量后面由服务端自动生成,这一步不需要碰 _vectors。

索引走显式创建,指定主键 id,沿用系列惯例:

curl -X POST http://localhost:7700/indexes \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"uid":"books_hybrid","primaryKey":"id"}'
{
  "taskUid": 40,
  "indexUid": "books_hybrid",
  "status": "enqueued",
  "type": "indexCreation",
  "enqueuedAt": "2026-10-12T10:00:00.000Z"
}

接着传 6 本书。字段有 id、title、author 和 summary(书的简介,后面 documentTemplate 要把它拼进嵌入文本,决定语义能搜到什么):

curl -X POST http://localhost:7700/indexes/books_hybrid/documents \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  --data-binary '[
    {"id":1,"title":"三体","author":"刘慈欣","summary":"仰望星空,写人类文明与三体文明星际博弈的科幻巨作"},
    {"id":2,"title":"球状闪电","author":"刘慈欣","summary":"以球状闪电为引的科幻小说,物理奇观与人生选择"},
    {"id":3,"title":"活着","author":"余华","summary":"写普通人在苦难中活下去的故事,农村家庭的伦理与温情"},
    {"id":4,"title":"许三观卖血记","author":"余华","summary":"写小人物靠卖血养家的故事,底层生活的坚韧与荒诞"},
    {"id":5,"title":"明朝那些事儿","author":"当年明月","summary":"用通俗笔法写明朝三百年历史,官场斗争与王朝兴衰"},
    {"id":6,"title":"万历十五年","author":"黄仁宇","summary":"从万历十五年切入,剖析明朝制度的运转与深层困境"}
  ]'
{
  "taskUid": 41,
  "indexUid": "books_hybrid",
  "status": "enqueued",
  "type": "documentAdditionOrUpdate",
  "enqueuedAt": "2026-10-12T10:01:00.000Z"
}

上传是异步的,任务队列(Meilisearch 把每个写操作排进一个任务队列,按 uid 逐个执行,写完状态变 succeeded)跑完才落库。等 taskUid: 41 变成 succeeded:

curl http://localhost:7700/tasks/41 -H "Authorization: Bearer aSampleMasterKey"
{
  "uid": 41,
  "indexUid": "books_hybrid",
  "status": "succeeded",
  "type": "documentAdditionOrUpdate",
  "details": {
    "receivedDocuments": 6,
    "indexedDocuments": 6
  }
}

看到 indexedDocuments: 6 就说明 6 条都落库了。注意:现在这个索引还没有向量,只能做关键词检索——语义那条腿要等第二步配了 embedder 才有。

第二步:配置 huggingFace embedder(default),等全量自动嵌入完成

光有纯文本,混合检索的“语义腿”是空的。要让 Meilisearch 自己转向量,得在索引设置里声明一个 embedder。embedder(嵌入器)是一条“向量由谁生成、用什么模型、拿文档的哪些字段去嵌入”的配置;这里用 huggingFace 源——模型跑在你本机,不需要任何 API key,首次使用自动从 HuggingFace 下载。这一步不配,后面任何带 hybrid 参数的搜索请求都会直接返回 400。

curl -X PATCH http://localhost:7700/indexes/books_hybrid/settings/embedders \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"default":{"source":"huggingFace","model":"BAAI/bge-small-zh-v1.5","documentTemplate":"中文书籍,书名《{{doc.title}}》,作者 {{doc.author}},简介 {{doc.summary}}"}}'
{
  "taskUid": 42,
  "indexUid": "books_hybrid",
  "status": "enqueued",
  "type": "settingsUpdate",
  "enqueuedAt": "2026-10-12T10:05:00.000Z"
}

default 是这条 embedder 的名字,后面搜索时 hybrid.embedder 要跟它逐字一致。documentTemplate(文档模板)用 Liquid 模板语法把每条文档拼成一段要送去嵌入的文本:{{doc.title}} 取文档的 title 字段。这里的 doc 是模板引擎的固定变量名,指“当前正在嵌入的这条文档”——建索引、传文档时都不用声明它,Meilisearch 渲染模板时自动把每条文档注入成 doc,所以模板里的字段名必须跟文档 JSON 的键一一对应,写成文档里不存在的键,整批嵌入会失败(见常见问题第 3 条)。语义检索比对的就是这段渲染文本,模板里放哪些字段,决定了能被什么样的查询搜到——把简介拼进去,“按内容搜”才有真实依据。

等模型下载 + 6 条文档全部嵌入完成(bge-small 权重约 90 多 MB,首次可能要几分钟)。轮询刚才的 taskUid:

curl http://localhost:7700/tasks/42 -H "Authorization: Bearer aSampleMasterKey"
{
  "uid": 42,
  "indexUid": "books_hybrid",
  "status": "succeeded",
  "type": "settingsUpdate",
  "enqueuedAt": "2026-10-12T10:05:00.000Z",
  "startedAt": "2026-10-12T10:05:01.000Z",
  "finishedAt": "2026-10-12T10:08:32.000Z"
}

验证一条:把 embedder 设置读回来,确认配置落进去了。

curl http://localhost:7700/indexes/books_hybrid/settings/embedders -H "Authorization: Bearer aSampleMasterKey"
{
  "default": {
    "source": "huggingFace",
    "model": "BAAI/bge-small-zh-v1.5",
    "documentTemplate": "中文书籍,书名《{{doc.title}}》,作者 {{doc.author}},简介 {{doc.summary}}"
  }
}

读到 {"default":{"source":"huggingFace",...}} 就对了。任务没 succeeded 之前去搜,索引还没有向量,搜出来跟普通关键词没区别——这个坑后面常见问题里专门有一条。

第三步:第一次混合搜索——semanticRatio 0.5 默认档,看 _rankingScore

前两步证明了“向量生成了”,但这只说明语义腿有腿可走,还没证明混搜真的在混合。混合检索(hybrid search)是 Meilisearch 把关键词检索和语义(向量)检索并行跑一遍,再把两路结果合并排序的模式——不是简单拼接,而是一套能自动判断“这查询该偏哪边”的评分机制(官方叫 smart scoring)。这一步用一个既有字面命中、又有语义命中的查询,打开分数开关,看两路是怎么合并的。

查询「明朝三百年历史的通俗书」:文档里《明朝那些事儿》(id 5)的简介“用通俗笔法写明朝三百年历史”,四个词全都字面命中;《万历十五年》(id 6)只有“明朝”沾边。这是个关键词和语义大概率一致选 id 5 的查询,正好用来观察分数结构。

curl -X POST http://localhost:7700/indexes/books_hybrid/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"明朝三百年历史的通俗书","hybrid":{"embedder":"default","semanticRatio":0.5},"showRankingScore":true,"showRankingScoreDetails":true}'

hybrid(混合检索参数)对象里 embedder 指名用哪条嵌入器(就是第二步配的 default),semanticRatio(语义占比)控制关键词与语义结果的比例:0 是纯关键词,1 是纯语义,0.5 是默认的均衡混合。showRankingScore: true 让每条结果带上 _rankingScore(相关性分数,0.0 到 1.0,越高越相关;v1.8 起语义分并入这个字段);showRankingScoreDetails: true 再附加分项明细。返回体按官方文档结构大致长这样(具体键名和分值以你本地实测为准):

{
  "hits": [
    {
      "id": 5,
      "title": "明朝那些事儿",
      "_rankingScore": 0.9,
      "_rankingScoreDetails": {
        "words": {"order": 0, "score": 0.9},
        "typo": {"order": 1, "score": 0.9},
        "vector": {"order": 6, "score": 0.62}
      }
    },
    {
      "id": 6,
      "title": "万历十五年",
      "_rankingScore": 0.78,
      "_rankingScoreDetails": {
        "words": {"order": 0, "score": 0.52},
        "typo": {"order": 1, "score": 0.52},
        "vector": {"order": 6, "score": 0.74}
      }
    }
  ],
  "query": "明朝三百年历史的通俗书",
  "processingTimeMs": 4,
  "estimatedTotalHits": 2
}

看 _rankingScoreDetails 里的两个分项就能理解排序:id 5 靠 words(关键词)拿了 0.9 的高分——四个查询词它全有;id 6 关键词分只有 0.52(就“明朝”一个词),但 vector(语义)分 0.74 高于 id 5 的 0.62,因为“从万历十五年切入剖析制度运转”和“三百年历史的通俗书”意思上更贴。混合模式下最终分是两路合并的结果,这就是“同时看字面和意思”的含义。

验证:确认返回里带 _rankingScore 且 estimatedTotalHits 非 0。要是返回 400,先看错误码——hybrid.embedder 写错名字报 invalid_search_embedder,hybrid 结构写错报 invalid_search_hybrid_query,排查入口见Meilisearch semanticRatio 越界或 embedder 名写错报错。

第四步:拧旋钮对照——同一查询在 0 / 0.5 / 1.0 三档下的命中差异

0.5 的均衡档看不出每一路的贡献,得把旋钮拧到两头对照。这一步是整篇的核心:用一个关键词只能按字面勉强捞到、语义能按意思补全的查询,对比“关键词命中”和“语义命中”的差异。

查询「仰望星空,讲人类文明命运的科幻巨作」。先看纯关键词档(semanticRatio: 0——不查 embedder,纯关键词):

curl -X POST http://localhost:7700/indexes/books_hybrid/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"仰望星空,讲人类文明命运的科幻巨作","hybrid":{"embedder":"default","semanticRatio":0},"showRankingScore":true}'
{
  "hits": [
    {
      "id": 1,
      "title": "三体",
      "author": "刘慈欣",
      "_rankingScore": 0.9
    },
    {
      "id": 2,
      "title": "球状闪电",
      "author": "刘慈欣",
      "_rankingScore": 0.31
    }
  ],
  "query": "仰望星空,讲人类文明命运的科幻巨作",
  "processingTimeMs": 1,
  "estimatedTotalHits": 2
}

关键词档命中两条,但排位和分数说明问题:《三体》简介里「仰望星空、人类文明、科幻巨作」字面全在,拿 0.9;《球状闪电》简介里只有「科幻」一个词沾边,按默认的 last 匹配策略(从查询末词往前逐词放松)在最后一层才被捞进来,只有 0.31。关键词要的是字面重合,一句话里大多数字对不上,就把文档压在底部——这正是语义检索要补的短板:词汇鸿沟(用户说的话和文档写的话对不上)在《球状闪电》身上最典型。(具体命中与分值以本地实测为准。)

再把旋钮拧到纯语义档(semanticRatio: 1.0):

curl -X POST http://localhost:7700/indexes/books_hybrid/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"仰望星空,讲人类文明命运的科幻巨作","hybrid":{"embedder":"default","semanticRatio":1.0},"showRankingScore":true}'
{
  "hits": [
    {
      "id": 1,
      "title": "三体",
      "author": "刘慈欣",
      "_rankingScore": 0.82
    },
    {
      "id": 2,
      "title": "球状闪电",
      "author": "刘慈欣",
      "_rankingScore": 0.71
    }
  ],
  "query": "仰望星空,讲人类文明命运的科幻巨作",
  "processingTimeMs": 3,
  "estimatedTotalHits": 2
}

语义档同样命中这两条,但排序依据换了:查询词自动转向量,跟库里每条文档的向量比相似度——《三体》简介里有“人类文明、星际博弈、科幻巨作”,《球状闪电》有“科幻小说、物理奇观”,查询描述的内容跟它们的意思对得上,靠的是第二步 documentTemplate 拼进去的 summary 字段,不是书名的先验。

轮到均衡档 semanticRatio: 0.5:

curl -X POST http://localhost:7700/indexes/books_hybrid/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"仰望星空,讲人类文明命运的科幻巨作","hybrid":{"embedder":"default","semanticRatio":0.5},"showRankingScore":true}'
{
  "hits": [
    {
      "id": 1,
      "title": "三体",
      "author": "刘慈欣",
      "_rankingScore": 0.81
    },
    {
      "id": 2,
      "title": "球状闪电",
      "author": "刘慈欣",
      "_rankingScore": 0.7
    }
  ],
  "query": "仰望星空,讲人类文明命运的科幻巨作",
  "processingTimeMs": 3,
  "estimatedTotalHits": 2
}

0.5 档也命中同样的两条,分数跟 1.0 档接近——《三体》字面也命中,关键词腿照样给它记分;《球状闪电》字面只沾「科幻」,分数主要靠语义腿贡献。合并方式不是简单的加权平均,而是把两路分数做可比化之后综合(Meilisearch 会对语义分做分布归一化,让它能跟关键词分同台比较)。三个档位对照下来:0 档按字面排序(球状闪电沉底)、1.0 档按语义排序(两条差距缩小)、0.5 档两者兼顾,语义腿的贡献一眼就分得出。

验证:三档各搜一次,0 档《三体》居首、《球状闪电》垫底,0.5 和 1.0 档两条分数差距明显缩小。semanticRatio 超出 0 到 1 的范围会直接 400 invalid_search_semantic_ratio,同样内链Meilisearch semanticRatio 越界或 embedder 名写错报错。

第五步:写入一条新文档,等自动嵌入完成后立即混合查询

前面验证的都是初始 6 条数据,混搜对之后写入的新数据还灵不灵?配好 embedder 后,新增文档入库时 Meilisearch 会同样自动转向量,这一步就验证这条写入路径。传一本新书《围城》,还是纯文本、不带向量:

curl -X POST http://localhost:7700/indexes/books_hybrid/documents \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"id":7,"title":"围城","author":"钱钟书","summary":"写婚姻与人生的围城困局,知识分子的困惑与讽刺"}'
{
  "taskUid": 43,
  "indexUid": "books_hybrid",
  "status": "enqueued",
  "type": "documentAdditionOrUpdate",
  "enqueuedAt": "2026-10-12T10:10:00.000Z"
}

新文档也要先过一遍嵌入,照旧轮询任务号:

curl http://localhost:7700/tasks/43 -H "Authorization: Bearer aSampleMasterKey"
{
  "uid": 43,
  "indexUid": "books_hybrid",
  "status": "succeeded",
  "type": "documentAdditionOrUpdate",
  "details": {
    "receivedDocuments": 1,
    "indexedDocuments": 1
  }
}

验证:用 0.5 档混搜一个能命中《围城》的查询——「婚姻是围城,困住人的故事」这个词,关键词腿能命中“婚姻、围城”(简介里都有),语义腿也指向围城,两条腿应该都把它顶上来:

curl -X POST http://localhost:7700/indexes/books_hybrid/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"婚姻是围城,困住人的故事","hybrid":{"embedder":"default","semanticRatio":0.5},"showRankingScore":true}'
{
  "hits": [
    {
      "id": 7,
      "title": "围城",
      "author": "钱钟书",
      "_rankingScore": 0.85
    }
  ],
  "query": "婚姻是围城,困住人的故事",
  "processingTimeMs": 3,
  "estimatedTotalHits": 1
}

hits 里出现 id: 7 的《围城》就对了——围城的 summary 写的是“写婚姻与人生的围城困局”,跟查询说的是同一件事,关键词腿和语义腿都支持它。写入路径验证通过。要是任务没跑完就去搜,新文档会搜不到,排查见Meilisearch 添加文档后立即搜索返回空结果。

第六步:更新一条文档,等自动重嵌入完成后再次查询

更新路径同理:改文档内容 → Meilisearch 重新给这条文档生成向量、替换旧向量。这一步把第五步的 id: 7 从《围城》改成《边城》,然后查两次验证。更新就是“同一条 id 再传一次”,POST /documents 对已存在的 id 是覆盖更新:

curl -X POST http://localhost:7700/indexes/books_hybrid/documents \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"id":7,"title":"边城","author":"沈从文","summary":"写湘西边陲小镇的乡土爱情,纯净而忧伤的故事"}'
{
  "taskUid": 44,
  "indexUid": "books_hybrid",
  "status": "enqueued",
  "type": "documentAdditionOrUpdate",
  "enqueuedAt": "2026-10-12T10:12:00.000Z"
}
curl http://localhost:7700/tasks/44 -H "Authorization: Bearer aSampleMasterKey"
{
  "uid": 44,
  "indexUid": "books_hybrid",
  "status": "succeeded",
  "type": "documentAdditionOrUpdate",
  "details": {
    "receivedDocuments": 1,
    "indexedDocuments": 1
  }
}

验证一:搜《边城》的查询,id: 7 以新书名命中:

curl -X POST http://localhost:7700/indexes/books_hybrid/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"湘西边陲小镇的乡土爱情故事","hybrid":{"embedder":"default","semanticRatio":0.5},"showRankingScore":true}'
{
  "hits": [
    {
      "id": 7,
      "title": "边城",
      "author": "沈从文",
      "_rankingScore": 0.83
    }
  ],
  "query": "湘西边陲小镇的乡土爱情故事",
  "processingTimeMs": 3,
  "estimatedTotalHits": 1
}

验证二:回搜第五步那个「婚姻是围城」的查询,id: 7 不再出现——旧向量已被替换:

curl -X POST http://localhost:7700/indexes/books_hybrid/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"婚姻是围城,困住人的故事","hybrid":{"embedder":"default","semanticRatio":0.5},"showRankingScore":true}'
{
  "hits": [],
  "query": "婚姻是围城,困住人的故事",
  "processingTimeMs": 2,
  "estimatedTotalHits": 0
}

id: 7 从“围城命中”变成“边城命中”,是更新触发自动重嵌入的直接证据:全程只改了文档文本,向量一次都没手动碰过。到这里,写入、更新、查询三条路径的混合检索全部验证完。

第七步(进阶):分场景调 semanticRatio,用 rankingScoreThreshold 过滤低分

不同场景的查询习惯不一样:搜书名、型号这类精确词,希望关键词腿主导(低 ratio);搜“我想要什么”这类描述,希望语义腿主导(高 ratio)。semanticRatio 是搜索时参数,同一个索引不同请求可以各用各的,不用改配置。这一步用同一个查询演示两档差异,再加一层低分过滤。

查询「底层小人物养家糊口的艰难」。先看低语义档 semanticRatio: 0.2——这个查询里的「底层」「小人物」「养家」在《许三观卖血记》(id 4)简介里全都字面存在,关键词腿应该精确锁定它:

curl -X POST http://localhost:7700/indexes/books_hybrid/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"底层小人物养家糊口的艰难","hybrid":{"embedder":"default","semanticRatio":0.2},"showRankingScore":true}'
{
  "hits": [
    {
      "id": 4,
      "title": "许三观卖血记",
      "author": "余华",
      "_rankingScore": 0.88
    }
  ],
  "query": "底层小人物养家糊口的艰难",
  "processingTimeMs": 2,
  "estimatedTotalHits": 1
}

低语义档下结果收敛:只有 id 4,因为「养家糊口」里“养家”两个字面命中,分数主要由关键词分贡献。再看高语义档 semanticRatio: 0.8:

curl -X POST http://localhost:7700/indexes/books_hybrid/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"底层小人物养家糊口的艰难","hybrid":{"embedder":"default","semanticRatio":0.8},"showRankingScore":true}'
{
  "hits": [
    {
      "id": 4,
      "title": "许三观卖血记",
      "author": "余华",
      "_rankingScore": 0.86
    },
    {
      "id": 3,
      "title": "活着",
      "author": "余华",
      "_rankingScore": 0.43
    }
  ],
  "query": "底层小人物养家糊口的艰难",
  "processingTimeMs": 3,
  "estimatedTotalHits": 2
}

语义占比拉高后,《活着》(id 3)进来了——它简介里“普通人在苦难中活下去”,跟“底层艰难生活”意思相近,但「底层」「小人物」「养家」一个词都没有,纯靠语义腿,所以分数只有 0.43,明显低于字面全中的 id 4。这就是语义腿“放进来”的候选:有价值,但分低。

低分候选多了会稀释结果页。用 rankingScoreThreshold(相关性分数阈值,0.0 到 1.0,低于阈值的结果直接不返回,官方建议从 0.5 起步)把 0.43 的《活着》过滤掉:

curl -X POST http://localhost:7700/indexes/books_hybrid/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"底层小人物养家糊口的艰难","hybrid":{"embedder":"default","semanticRatio":0.8},"rankingScoreThreshold":0.5}'
{
  "hits": [
    {
      "id": 4,
      "title": "许三观卖血记",
      "author": "余华"
    }
  ],
  "query": "底层小人物养家糊口的艰难",
  "processingTimeMs": 3,
  "estimatedTotalHits": 1
}

0.43 < 0.5 的被阈值拦下,只剩 id 4。到这一步,混搜的比例调优、低分过滤、写入/更新路径全部验证完。

核心概念

下面这张图是整条调用链路的走向:一个查询进 Meilisearch,关键词和语义两条路并行跑,分数合并后统一排序输出。

Meilisearch 混合检索调用链路架构图

概念 是什么 类比
混合检索(hybrid search) 关键词检索与语义(向量)检索并行执行后合并排序的搜索模式 查重名再查意思,两边都查
semanticRatio 搜索时参数,0.0 到 1.0,控制关键词与语义结果的比例,默认 0.5 音量旋钮,拧到哪头听哪头
embedder 索引设置里声明“向量由谁生成、用什么模型、嵌入哪些字段”的配置项 快递柜储物格,声明了谁送货、货多长
smart scoring 精确词自动偏关键词、描述词自动偏语义的合并评分机制 分诊台,根据症状分科室
_rankingScore 每条结果 0.0 到 1.0 的综合相关性分数,v1.8 起语义分并入其中 体检总分
词汇鸿沟 用户查询用词与文档用词对不上的现象 你说“爆米花机”,文档里写“玉米膨化设备”
rankingScoreThreshold 低于阈值的低分结果直接不返回的过滤参数 及格线,线下不发榜

常见问题

invalid_search_semantic_ratio / invalid_search_embedder

现象:带 hybrid 的搜索返回 400,错误码 invalid_search_semantic_ratio 或 invalid_search_embedder。

原因:前者是 semanticRatio 超出了 0.0 到 1.0 的范围(比如写了 1.5 或字符串 "0.5");后者是 hybrid.embedder 写的名字跟索引 settings 里配置的 embedder 名对不上(配置叫 default 却写了 "Default")。两个都是请求参数校验失败,同步 400,改完重发即可。

相关文章:Meilisearch semanticRatio 越界或 embedder 名写错报错

配完 embedder 立刻搜索,结果跟普通关键词一样

现象:第二步配完 embedder 马上搜,hits 是纯关键词结果甚至为空,语义完全没生效。

原因:嵌入是异步任务,模型下载加全量嵌入还没跑完你就去搜了,索引里还没有向量。settingsUpdate / documentAddition 任务没到 succeeded,混搜的语义腿等于空跑。轮询 taskUid 到 succeeded 再搜。

相关文章:Meilisearch 添加文档后立即搜索返回空结果

template_rendering_error

现象:提交 embedder 配置或改 documentTemplate 后任务变成 failed,error.code 是 template_rendering_error,message 含 “missing field in document”。

原因:documentTemplate 里用 {{doc.xxx}} 引用了文档中不存在的字段,整批嵌入失败。修法:把模板改回只引用真实存在的字段,或用 {% if doc.xxx %} 包一层守卫,改完任务 succeeded 再继续。

相关文章:Meilisearch documentTemplate 引用缺失字段报 template_rendering_error

下一步

回到纯语义版,看看“只查意思”长什么样

这篇的 0.5 档是混搜,之前的语义搜索文章演示的是 semanticRatio: 1.0 纯语义模式——那篇从手动算向量讲起,可以对照理解“语义腿”单独工作的原理。

从零跑通 Meilisearch 纯向量语义搜索

把向量交给服务端:内置 embedder 全流程

混搜的语义腿由 embedder 提供,这篇只配了一个。内置 embedder 那篇讲了自动嵌入的完整链路:写入/更新自动转向量、改模板触发重嵌入、多 embedder 并存切换,是混搜的下游依赖。

从零跑通 Meilisearch 内置 embedder 自动嵌入

给混搜加排序规则和分面

混搜负责“召回和打分”,排完序还能按业务规则再排、按字段做分面过滤。想理解混搜结果在排序规则里的位置,这两篇是顺路的。

从零跑通 Meilisearch 排序规则 · 从零跑通 Meilisearch 分面搜索

卡在哪一步了?

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

贡献一条解法