StackTraces
Meilisearch从零跑通

从零跑通 Meilisearch 分面搜索与过滤:7 步,外加 4 个容易卡住的地方

@debug_master2026-10-04

跟着这篇走完,你会在终端把 Meilisearch 的分面搜索与过滤跑通:声明可过滤属性、写 filter(搜索的过滤条件)组合与范围条件、拿回分面分布(facetDistribution)与数值统计(facetStats),再用 facet-search 做分面值自动补全。整个过程 7 步,每步都有一条命令能验证自己做对了没有。

动手前先交代下环境。localhost:7700 是 Meilisearch 的默认监听地址,master key 固定用 aSampleMasterKey(与第一篇一致),索引(Meilisearch 的独立文档集合,相当于一张数据表)固定用 books(本篇新建,不动第一篇的 movies),示例文档是 6 条虚构书籍;你要是换了端口、master key 或索引名,把示例值替换掉就行。这篇默认你已经装好并跑通了从零跑通 Meilisearch 本地搜索服务,还没跑起来先回去把服务起了再回来,安装、启动、异步任务轮询的套路全部复用第一篇。另外提醒一句:本文所有 filter 一律用官方字符串语法——显式 AND/OR/NOT 关键词加括号,不存在逗号或竖线操作符;范围过滤的写法是 price 10 TO 20(空格分隔,没有冒号)。

Meilisearch:从零跑通分面搜索与过滤的 7 个步骤

前置条件

项 要求 说明
Meilisearch v1.54.3 或更高 2026-10-01 发布的当前 stable,与第一篇一致;低于此版本 API 行为可能有差异
前置文章 已跑通 meilisearch-local-search 本文复用其安装/启动/master key/异步任务轮询套路;没跑通先回去补
curl 任意版本 全文用 curl 调 REST API;jq 可选,用于美化输出
资源 2 GB 内存、1 GB 磁盘空闲 与第一篇相同,本篇 books 数据集极小,无额外负担

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

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

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

第一步:创建 books 索引(不动第一篇的 movies)

本篇换一套独立数据集 books,跟第一篇的 movies 索引互不干扰,后面几步就拿它当分面与过滤的靶子。索引走显式创建,直接把主键字段指定为 id——自动推断失败会报 index_primary_key_no_candidate_found,显式声明最稳。

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

创建索引是异步任务,返回体里的 taskUid 就是这次任务的编号,后面靠它查进度。全新环境任务号从 0 开始;要是你的环境已经跑过其他任务,以实际返回的 taskUid 为准。

验证一下。

curl http://localhost:7700/tasks/0 -H "Authorization: Bearer aSampleMasterKey"
{
  "uid": 0,
  "indexUid": "books",
  "status": "succeeded",
  "type": "indexCreation",
  "details": { "primaryKey": "id" }
}

返回里 status 是 succeeded、type 是 indexCreation,且 details.primaryKey 是 id,这一步就成了。万一任务 failed 且错误里带 primary_key,多半是文档结构让主键推断失败,排查入口见Meilisearch primary key 自动推断失败;返回 401 则是 auth 头没带或 master key 写错,见Meilisearch production 模式不设 master key 拒绝启动。

第二步:声明 filterableAttributes(filter 与 facets 的前提)

filter 和 facets(分面统计参数)有个共同前提:属性得先进 filterableAttributes 白名单。这个设置默认是空数组——啥都不声明的话,filter/facets 一律不工作。下面用子路由 PUT 一次声明五类字段,把后面会碰到的三种类型都覆盖掉:字符串数组(genres/language)、数值(price/rating),外加日期字符串(release_date)。

curl -X PUT http://localhost:7700/indexes/books/settings/filterable-attributes \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  --data-binary '["genres","price","release_date","rating","language"]'
{
  "taskUid": 1,
  "indexUid": "books",
  "status": "enqueued",
  "type": "settingsUpdate"
}

这一步有个容易忽略的点:设置更新也是异步任务(type 为 settingsUpdate),还会重索引该索引的全部数据,耗时跟数据量成正比。必须等任务 succeeded 之后,filter/facets 才真正可用——没等完就发带 filter 的搜索,会直接报 invalid_search_filter。

验证任务结果(taskUid 按实际返回为准):

curl http://localhost:7700/tasks/1 -H "Authorization: Bearer aSampleMasterKey"
{
  "uid": 1,
  "indexUid": "books",
  "status": "succeeded",
  "type": "settingsUpdate"
}

返回里 status 是 succeeded、type 是 settingsUpdate,说明属性声明生效了。等不到 succeeded 就发带 filter 的搜索,会报 Attribute "xx" is not filterable——要么是这一步没等完,要么属性名写错,排查路径见Meilisearch filter 报 Attribute is not filterable。

第三步:灌一批带分类/价格/日期字段的文档

往 books 索引写 6 条虚构书籍。字段类型是刻意设计的,别乱改——第五步、第六步的验证结果都依赖它们。genres 是字符串数组,用来测 = 匹配数组任一元素、IN 取并集;price/rating 是 JSON number,用来测 TO 范围与 facetStats 的 min/max;release_date 则是 YYYY-MM-DD 格式的字符串,测的是词典序范围比较。

curl -X POST http://localhost:7700/indexes/books/documents \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  --data-binary '[
    {"id": 1, "title": "The Way of Kings", "genres": ["Fantasy", "Adventure"], "price": 19.9, "release_date": "2021-08-15", "rating": 4.8, "language": "English"},
    {"id": 2, "title": "Project Hail Mary", "genres": ["Science Fiction"], "price": 24.5, "release_date": "2022-05-01", "rating": 4.7, "language": "English"},
    {"id": 3, "title": "The Silent Patient", "genres": ["Thriller", "Mystery"], "price": 12.0, "release_date": "2019-02-19", "rating": 4.2, "language": "English"},
    {"id": 4, "title": "三体", "genres": ["Science Fiction"], "price": 29.0, "release_date": "2023-01-10", "rating": 4.9, "language": "Chinese"},
    {"id": 5, "title": "Dune", "genres": ["Science Fiction", "Adventure"], "price": 15.5, "release_date": "2020-10-01", "rating": 4.5, "language": "English"},
    {"id": 6, "title": "The Hobbit", "genres": ["Fantasy"], "price": 9.9, "release_date": "1997-06-26", "rating": 4.6, "language": "English"}
  ]'
{
  "taskUid": 2,
  "indexUid": "books",
  "status": "enqueued",
  "type": "documentAdditionOrUpdate"
}

文档添加同样是异步任务,POST 返回的 taskUid 只表示入队,文档还没被索引。等对应任务 succeeded 再往下走。

确认数据到位。

curl -X GET http://localhost:7700/indexes/books/stats \
  -H "Authorization: Bearer aSampleMasterKey"
{
  "numberOfDocuments": 6,
  "isIndexing": false,
  "fieldDistribution": {
    "id": 6, "title": 6, "genres": 6, "price": 6,
    "release_date": 6, "rating": 6, "language": 6
  }
}

返回里 numberOfDocuments 是 6、isIndexing 是 false,fieldDistribution 里能看到 id/title/genres/price/release_date/rating/language 六个字段,说明文档全部索引完。要是 numberOfDocuments 是 0 或 isIndexing 还是 true,多半是任务没等完,回第一篇把轮询套路捡起来,见Meilisearch 添加文档后立即搜索返回空结果。

第四步:filter 单条件与组合查询(AND / OR / 括号 / IN)

先分清请求 body 里的 q(查询词)和 filter(过滤条件),这两个参数是两条独立的检索通道,别混。

q 走的是全文相关性搜索:在 searchableAttributes(可搜索属性,默认是所有字段)里按词匹配,带容错和前缀匹配,结果按相关性打分排序——注意它不是子串匹配,搜 Fantas 不会命中 Fantasy。几种常见用法:

  • 不传 q 或传 "":不做全文检索、返回全部文档,配合 filter 就是纯条件筛选(本步用的就是这种)。
  • 多词:"q": "dune fantasy",按词匹配后统一做相关性排序,同时命中全部词的文档排在前、命中不全的靠后。
  • q 只决定命中和排序,不决定哪些字段能被过滤;要收窄可搜字段得去改 searchableAttributes。

filter 走的是结构化条件筛选:只对 filterableAttributes 里登记过的字段做条件判断,文档要么符合要么被排除,不参与相关性打分。常用操作符和写法:

  • 比较:=、!=、>、<、>=、<=,以及范围 值 TO 值(如 price 10 TO 20)。
  • 集合与存在:IN 取并集(如 genres IN [Fantasy, Science Fiction])、EXISTS 判断字段是否存在。
  • 组合:AND/OR/NOT 加括号,AND 优先级高于 OR,跨条件组合务必显式加括号。

两者能叠加——同时传 q 和 filter 时,先用 filter 缩小文档集合,再对剩下这批用 q 排序。本步只关心筛选,所以下面的 q 一律传空字符串,意思是“不做全文检索,把 filter 命中的全部文档都给我”。参数细节见官方 Search API(POST) 与 Filter expression reference。

先跑单条件看看。genres 是数组,= 匹配的是数组中任一元素等于目标值的文档:

curl -X POST http://localhost:7700/indexes/books/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"","filter":"genres = Fantasy"}'
{
  "hits": [
    {"id": 1, "title": "The Way of Kings", "genres": ["Fantasy", "Adventure"], "language": "English"},
    {"id": 6, "title": "The Hobbit", "genres": ["Fantasy"], "language": "English"}
  ],
  "estimatedTotalHits": 2
}

hits(命中的文档列表)里应该是 id 1(The Way of Kings)和 id 6(The Hobbit)两条(上面返回值按可读性精简了其余字段)。单条件通了,再上组合。这里先记住一点:括号是强制的:

curl -X POST http://localhost:7700/indexes/books/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"","filter":"(genres = Fantasy OR genres = Science Fiction) AND language = English"}'
{
  "hits": [
    {"id": 1, "title": "The Way of Kings", "language": "English"},
    {"id": 2, "title": "Project Hail Mary", "language": "English"},
    {"id": 5, "title": "Dune", "language": "English"},
    {"id": 6, "title": "The Hobbit", "language": "English"}
  ],
  "estimatedTotalHits": 4
}

这条就是验证命令。先说为什么会这样:字符串形式的 filter 只有显式 AND/OR/NOT 关键词加括号这一种写法(没有逗号或竖线操作符),其中 AND 优先级高于 OR。把括号去掉,表达式会被解析成 genres = Fantasy OR (genres = Science Fiction AND language = English),language 约束只作用于 Science Fiction 那一支,结果自然不符预期。

组合规则不复杂:同一属性内多选值用 OR,跨属性约束用 AND,跨组的话括号别省。取并集还有等价写法 genres IN [Fantasy, Science Fiction];filter 也可以传数组形式(外层数组元素之间是 AND、内层是 OR,嵌套深度上限 2)。

期望输出:hits 里 4 条,id 1/2/5/6——每条都同时满足 genres 属于 {Fantasy, Science Fiction} 且 language 是 English;id 4 三体是 Chinese 被排除,id 3 两个条件都不满足也被排除。estimatedTotalHits(命中文档总数)应该也是 4。结果里要是混进了 language 不是 English 的文档,多半是 OR 把 AND 的约束吞了、括号没加,见Meilisearch filter 组合 AND/OR 优先级坑;要是报 Attribute "xx" is not filterable,回第二步,见Meilisearch filter 报 Attribute is not filterable。

第五步:范围过滤(数字 TO / 日期 / 字符串数组)

范围条件有三种形态,先分别看一眼写法:

# 数字范围:TO 等价于 >= AND <=,空格分隔、无冒号
curl -X POST http://localhost:7700/indexes/books/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"","filter":"price 10 TO 20"}'

# 日期范围:按字符串词典序比较,格式必须统一(YYYY-MM-DD)
curl -X POST http://localhost:7700/indexes/books/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"","filter":"release_date > 2020-01-01"}'

# 字符串数组并集:IN 写法(等价于 genres = Fantasy OR genres = Science Fiction)
curl -X POST http://localhost:7700/indexes/books/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"","filter":"genres IN [Fantasy, Science Fiction]"}'

三条命令的返回(按可读性精简了文档字段):

# price 10 TO 20
{"hits":[{"id":1,"title":"The Way of Kings"},{"id":3,"title":"The Silent Patient"},{"id":5,"title":"Dune"}],"estimatedTotalHits":3}
# release_date > 2020-01-01
{"hits":[{"id":1,"title":"The Way of Kings"},{"id":2,"title":"Project Hail Mary"},{"id":4,"title":"三体"},{"id":5,"title":"Dune"}],"estimatedTotalHits":4}
# genres IN [Fantasy, Science Fiction]
{"hits":[{"id":1,"title":"The Way of Kings"},{"id":2,"title":"Project Hail Mary"},{"id":4,"title":"三体"},{"id":5,"title":"Dune"},{"id":6,"title":"The Hobbit"}],"estimatedTotalHits":5}

范围语法是 属性 值 TO 值,没有冒号,TO 等价于 >= AND <=,也可以拆成比较符组合。日期走的是字符串词典序,所以格式必须统一——混用 2020-1-1 和 2020-01-01 会比较出错。顺带提一句:比较型操作符(< > TO)计算资源密集,生产数据量大时可以考虑用 granular filterable attributes(对象数组形式)按需开启。

再验证一条组合,让三种条件同时生效:

curl -X POST http://localhost:7700/indexes/books/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"","filter":"price >= 10 AND price <= 20 AND release_date > 2020-01-01"}'
{
  "hits": [
    {"id": 1, "title": "The Way of Kings", "price": 19.9, "release_date": "2021-08-15"},
    {"id": 5, "title": "Dune", "price": 15.5, "release_date": "2020-10-01"}
  ],
  "estimatedTotalHits": 2
}

期望输出:hits 是 id 1 和 id 5 两条——价格落在 10~20 元区间且发布日期晚于 2020-01-01;id 2 价 24.5 超上限被排除,id 3/6 则是发布日期不满足被排除。要是数字范围完全不生效,先查字段类型:facetStats 与范围统计只认 JSON number,字符串形式的数字(如 "42")会被忽略,把字段转成 number 再试。

第六步:faceted search——拿 facetDistribution 与 facetStats

分面统计靠搜索请求里的 facets 参数触发,下面这条命令同时也是验证命令:

curl -X POST http://localhost:7700/indexes/books/search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  -d '{"q":"","facets":["genres","price","rating"]}'
{
  "facetDistribution": {
    "genres": {"Science Fiction": 3, "Fantasy": 2, "Adventure": 2, "Thriller": 1, "Mystery": 1}
  },
  "facetStats": {
    "price": {"min": 9.9, "max": 29.0},
    "rating": {"min": 4.2, "max": 4.9}
  }
}

带 facets 后,响应里会多出两个对象(上面只展示分面统计对象,hits 与 price/rating 分面分布省略)。facetDistribution 是每个分面属性 → 每个分面值 → 匹配文档数(比如 "genres": {"Fantasy": 2, ...});数值分面额外有 facetStats(比如 "price": {"min": 9.9, "max": 29.0}),前端画价格/评分区间滑杆就靠它。每个分面默认最多返回 100 个值(faceting.maxValuesPerFacet),要是 UI 只展示 Top 10/20,可以在 PATCH /settings/faceting 调小。

期望输出分三块对一下。facetDistribution.genres 里各分类计数应为 Science Fiction 3、Fantasy 2、Adventure 2、Thriller 1、Mystery 1;facetStats.price 是 min 9.9 / max 29.0;facetStats.rating 是 min 4.2 / max 4.9。

这里有个反直觉的坑要提前说:facets 里传入未声明为 filterable 的属性会被静默忽略、不报任何错——它不会出现在 facetDistribution 里。UI 上某个分面栏消失或为空,根因十有八九就是忘了声明,这跟 filter 参数直接报 invalid_search_filter 的行为截然不同,排查入口见Meilisearch facets 请求未声明属性导致 facetDistribution 为空。另外,facetStats 缺 min/max,多半是字段被存成了字符串数字,回第五步末尾的字段类型说明处理。

第七步(可选进阶):facet-search 分面值自动补全

这一步是给前端筛选器用的:输入分类关键字,下拉联想分面值。调 POST /indexes/{uid}/facet-search,这条命令同时也是验证命令:

curl -X POST http://localhost:7700/indexes/books/facet-search \
  -H "Authorization: Bearer aSampleMasterKey" \
  -H "Content-Type: application/json" \
  --data-binary '{"facetName":"genres","facetQuery":"sci"}'
{"facetHits":[{"value":"Science Fiction","count":3}],"facetQuery":"sci"}

facetName(要搜索的分面属性名)必填,还必须在索引的 filterableAttributes 里(拼错或忘声明直接报错)。facetQuery(输入的分面关键字)只匹配分面值的第一个词:官方示例里搜 fic 能命中 Fiction,搜 Austen 匹配不到 Jane Austen——我们的数据集没有 Fiction,但 sci 能命中 Science Fiction。还有一条限制要记住:数值型分面值不可用 facet-search 搜索,需要可搜索就把数字转成字符串。

响应是 facetHits 数组(每项 value + count),默认按字典序升序;要是索引把设置 sortFacetValuesBy(分面值的排序方式)配成了 count,就按计数降序。

期望输出:返回值就是上面的样子(facetHits 一项、value 是 Science Fiction、count 是 3),看到这个就全通了。出错时按错误码对号入座:返回 400 invalid_facet_search_facet_name,是 facetName 拼错或没声明为 filterable,见Meilisearch facet-search 报 facetName 无效;invalid_facet_search_query 说明 facetQuery 传了非 string/null;facet_search_disabled 则是索引把 facetSearch 设置关掉了(默认是 true)。

核心概念

这张图是整条调用链路的走向,从左到右就是一次请求的完整路径。

Meilisearch 分面搜索与过滤调用链路架构图

概念 是什么 类比
filterableAttributes 声明后可被 filter/facets 使用的属性白名单,默认空数组;更新会重索引全部文档 像数据库里“可查询字段”白名单,没登记就查不了也分不了面
filter 表达式 搜索的过滤条件,属性 操作符 值,操作符含 = != > < TO IN EXISTS,用 AND/OR/NOT + 括号组合;AND 优先级高于 OR 像 SQL 的 WHERE 子句,属性得先登记(filterableAttributes)才能用
q 与 filter 的区别 q 是在 searchableAttributes(默认所有字段)里按词做全文检索,带容错和前缀匹配、按相关性排序(非子串匹配);filter 是对 filterableAttributes 做结构化筛选(命中/排除,不排序)。可叠加:filter 先缩小集合,q 再排序 像电商搜索:q 是搜索框里输的关键词,模糊匹配按相关度排;filter 是左侧筛选器,点分类/价格区间精确过滤
facetDistribution 分面分布统计:每个分面属性 → 每个分面值 → 匹配文档数,由 facets 参数触发 像电商左侧分类计数,每个分类下面挂着有多少件商品
facetStats 数值分面的 min/max 统计,只统计 JSON number 类型的字段 像价格滑杆的两端刻度,前端拿它画区间筛选器
facet-search POST /indexes/{uid}/facet-search,分面值的自动补全/下拉搜索,facetName 必须在 filterableAttributes 像搜索框的联想下拉,输入 “fic” 出 “Fiction” 及计数
sortableAttributes 声明后可被 sort 使用的属性白名单,与 filterableAttributes 相互独立;一个字段可同时进两个列表 像 SQL 的 ORDER BY 字段白名单,filter 和 sort 各管各的授权

常见问题

facetDistribution 里某个分面缺失/为空,且服务端没有任何报错

现象:搜索请求带了 facets 参数,响应里 facetDistribution 缺了某个属性,或整个为空;Meilisearch 不报 4xx。

原因:facets 里传入的属性没有加入 filterableAttributes,Meilisearch 对分面属性静默忽略(与 filter 报 invalid_search_filter 截然不同),UI 上表现为分面栏消失或为空。

相关文章:Meilisearch facets 请求未声明属性导致 facetDistribution 为空

filter 组合结果不符预期,OR 把 AND 的约束“吞”掉了

现象:genres = Fantasy OR genres = Science Fiction AND language = English 返回的结果里混入了 language 不是 English 的文档。

原因:AND 优先级高于 OR,去掉括号的表达式被解析为 genres = Fantasy OR (genres = Science Fiction AND language = English);跨条件组合必须显式加括号。

相关文章:Meilisearch filter 组合 AND/OR 优先级坑

invalid_search_sort:filter 正常,一加 sort 就报错

现象:filter 搜索返回正常,往 body 里加 "sort":["price:desc"] 后返回 400,错误码 invalid_search_sort。

原因:sort 用到的属性未在 sortableAttributes 里声明——filterable 与 sortable 是两套独立白名单,一个字段可同时出现在两个列表(filterableAttributes 与 sortableAttributes 都含 price 才能做 filter+sort 组合)。

相关文章:Meilisearch sort 属性未声明报 invalid_search_sort

invalid_facet_search_facet_name

现象:POST /indexes/books/facet-search 返回 400,错误码 invalid_facet_search_facet_name。

原因:facetName 不在该索引的 filterableAttributes 列表中(属性名拼错,或忘了声明);facet-search 的前置条件是分面属性必须可过滤。

相关文章:Meilisearch facet-search 报 facetName 无效

下一步

filter 属性未声明报错排查

filter/facets 全部依赖 filterableAttributes,属性没声明就搜索直接报 invalid_search_filter——本系列出现频率最高的报错之一,值得把报错原文、排查路径单独记下来。

Meilisearch filter 报 Attribute is not filterable

异步任务等待与轮询

本篇第二步的 settingsUpdate 会重索引全部数据、第三步的文档导入也是异步任务——任务没 succeeded 就搜索,拿到的是空结果或旧配置。把任务轮询的套路单独过一遍,后面每个写操作都用得上。

Meilisearch 添加文档后立即搜索返回空结果

系列回顾:从零跑通 Meilisearch 本地搜索服务

本篇默认你已经装好并跑通第一篇(安装、启动、建索引、导文档、基础搜索)。没跑过或想回顾 CRUD 操作,回去看系列第一篇。

从零跑通 Meilisearch 本地搜索服务

卡在哪一步了?

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

贡献一条解法