{"term": {"title": "索引"}} 一条都搜不到,换成 match 全出来了;同一个 bool 里的 filter 和 must 结果一样,性能却差一截。这两件事都指向同一个概念:ES 查的是"词",不是你写进去的那个字符串。
这篇按"为什么查不到 → 怎么查对 → 怎么算分 → 怎么翻页聚合"的顺序走一遍。
1. 查询语境与过滤语境
| 语境 | 写法 | 算不算分 | 用途 |
|---|---|---|---|
| query | query.bool.must / should |
算 _score |
相关性排序 |
| filter | query.bool.filter |
不算分,可缓存 | 精确条件、范围、时间窗 |
ES 8.x 里两者都写在 query 下,用子句区分。filter 不参与打分,所以能用 filter 的判断一律放 filter:既省算分开销,又命中 filter cache。下面两段结果集相同,代价不同:
{"query": {"bool": {"must": [{"term": {"status": "PAID"}}]}}}
{"query": {"bool": {"filter": [{"term": {"status": "PAID"}}]}}}
2. 七种查询的语义边界
| 查询 | 是否分析查询词 | 匹配单元 | 典型场景 |
|---|---|---|---|
match |
是 | 分词后任一词命中(默认 or) | 全文搜索 |
match_phrase |
是 | 分词后顺序相邻 | 短语、固定搭配 |
term |
否 | 索引里的原词 | keyword 字段、枚举、id |
terms |
否 | 多个原词任一命中 | 多值枚举、状态列表 |
range |
否 | 区间比较 | 时间、金额、数值 |
prefix |
否 | 词典前缀 | 补全、少量前缀匹配 |
wildcard |
否 | 通配(* ?) |
兜底,慎用 |
"term 当等于用"是第一个坑:text 字段写入时被 analyzer 切成词,倒排索引里存的是词;查询 term 又不过 analyzer,拿整个原字符串去对词,自然对不上。要精确匹配就建 keyword 字段。
3. analyzer 在写入和查询两端
写入:title = "学习索引原理"
→ analyzer 切词 → [学习, 索引, 原理] → 倒排索引记录这三个词
查询:{"match": {"title": "索引"}}
→ 同一个 analyzer 切词 → [索引] → 命中
查询:{"term": {"title": "学习索引原理"}}
→ 不切词 → 拿整串找词 → 无命中
| 阶段 | 是否走 analyzer | 用哪个 analyzer |
|---|---|---|
| 写入(index) | 是 | 字段的 analyzer |
match 查询 |
是 | 字段的 search_analyzer,未设则同 analyzer |
term / terms |
否 | — |
| 中文 | 需 ik 或内置 smartcn |
按插件版本与字段配置核对 |
两端不一致是"写入能搜到一半、搜另一半为空"的根因:写入用 ik_max_word(细粒度)而查询用 ik_smart(粗粒度)时,词表对不上就会漏。
4. bool 四子句与打分
| 子句 | 是否必须 | 是否算分 | 说明 |
|---|---|---|---|
must |
必须 | 算 | 参与 _score 计算 |
filter |
必须 | 不算 | 可缓存,优先用 |
should |
见下 | 算 | 命中可加分 |
must_not |
必须不满足 | 不算 | 只做排除,不参与分 |
should 的默认行为随上下文变化,这是第二个大坑(ES 7.x / 8.x 行为一致,按版本核对):
| 上下文 | should 的默认语义 |
|---|---|
同级还有 must 或 filter |
只需加分,不要求命中 |
同级只有 should |
至少命中一个 |
所以要"必须命中其一"却又有别的 must 时,必须显式写 minimum_should_match:
{"query": {"bool": {
"must": [{"term": {"status": "PAID"}}],
"should": [{"match": {"title": "索引"}}, {"match": {"title": "分词"}}],
"minimum_should_match": 1
}}}
5. 多字段查询:best_fields 与 most_fields
| 类型 | 取分方式 | 效果 |
|---|---|---|
best_fields(默认) |
取单个字段的最高分 | 强调"某一个字段最匹配" |
most_fields |
各字段分数相加 | 强调"多个字段都有命中" |
cross_fields |
把字段视作一个整体 | 词分散在不同字段也能拼起来 |
{"query": {"multi_match": {
"query": "索引 原理",
"fields": ["title^3", "summary", "content"],
"type": "best_fields"
}}}
^3 是字段加权,只影响打分不影响命中。
6. 排序与分页
| 方式 | 写法 | 代价 |
|---|---|---|
| 浅分页 | from + size |
每个分片都要取 from+size 条再归并 |
| 深分页 | from 很大 |
内存与协调节点开销随 from 增长 |
| 游标 | search_after + sort |
无深翻页代价,只能顺序往后 |
| 导出 | PIT + search_after |
稳定快照,适合全量遍历 |
{"size": 20,
"query": {"range": {"created_at": {"gte": "now-7d"}}},
"sort": [{"created_at": "desc"}, {"_id": "asc"}],
"search_after": [1735689600000, "doc-100"]}
排序键必须唯一且稳定(通常加 _id 兜底),否则同一时间戳的一组文档顺序会漂移,翻页漏数据或重复。
7. 聚合的基本结构
{"size": 0,
"aggs": {
"by_day": {
"date_histogram": {"field": "created_at", "calendar_interval": "day"},
"aggs": {"by_status": {"terms": {"field": "status", "size": 10}}}
}
}}
| 要点 | 说明 |
|---|---|
size: 0 |
只要聚合结果,不取 hits |
terms |
桶数由 size 控制,默认只回前若干桶 |
date_histogram |
calendar_interval 按日历切,fixed_interval 按固定时长 |
| 嵌套 aggs | 内层在每个外层桶内各跑一次 |
| 基数爆炸 | 高基数字段做嵌套 terms,桶数相乘会拖垮协调节点 |
聚合字段必须可用 doc values(text 默认关闭),keyword 与数值类型默认开启。
8. _source 与 docvalue_fields
| 需求 | 手段 | 代价 |
|---|---|---|
| 回原始文档 | _source(默认全回) |
网络传输大 |
| 只回部分字段 | "_source": {"includes": [...]} |
需反序列化 |
| 只取排序/展示字段 | docvalue_fields |
列存读取,更快更省 |
{"_source": {"includes": ["title", "created_at"]},
"docvalue_fields": ["status"],
"query": {"match_all": {}}}
9. explain 与 profile 的区别
| 工具 | 回答的问题 | 开销 |
|---|---|---|
"explain": true |
为什么这条文档得这个分 | 每条命中都算分细节,较重 |
"profile": true |
这次查询每个子句花了多久 | 逐子句打点,更重 |
调相关性用 explain,查慢在哪个子句用 profile。两者都只该在单条或小批量查询里开。
10. 最小可跑示例
PUT /blog
{"mappings": {"properties": {
"title": {"type": "text", "analyzer": "standard", "fields": {"kw": {"type": "keyword"}}},
"status": {"type": "keyword"},
"created_at": {"type": "date"}
}}}
POST /blog/_doc/1
{"title": "学习索引原理", "status": "PAID", "created_at": "2026-01-05T10:00:00Z"}
GET /blog/_search
{"query": {"match": {"title": "索引"}}} # 命中
GET /blog/_search
{"query": {"term": {"title": "索引"}}} # 不命中:term 不切词
GET /blog/_search
{"query": {"term": {"title.kw": "学习索引原理"}}} # 命中:整串对 keyword
把三种写法对着跑一遍,比记十遍规则都管用。
11. 三个按版本核对过的细节
| 细节 | 行为 | 影响 |
|---|---|---|
index.sort 未设 |
段内顺序不稳定 | 同一 search_after 可能重复/漏 |
track_total_hits 默认(7.x 起) |
上限 10000 后不再精确计数 | 分页总数要显式设 true |
date_histogram 空桶 |
min_doc_count 默认 0,回空桶 |
画图时出现断点或锯齿 |
search_after 需要排序值可比较且唯一;只按 created_at 排序时,同一秒内的多条会互相顶掉。加一个稳定的 tiebreaker 字段是硬要求,不是优化。
命中数为 10000 是分页脚本"总数永远显示 1 万"的典型来源:需要精确总数就写 "track_total_hits": true,代价是多走一次计数。
12. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
term 查中文为空 |
字段是 text,值被切词 |
用 keyword 子字段或改 match |
should 不生效 |
同级有 must,should 只加分 |
加 minimum_should_match |
| 深分页变慢或 OOM | 大 from 归并开销 |
改 search_after,控制 size |
| 前缀通配很慢 | wildcard/prefix 前置通配要扫词典 |
改用 ngram 或 completion |
聚合报 fielddata disabled |
text 字段做聚合 |
聚合 keyword 子字段 |
| 写入后立刻搜不到 | 段未刷新(默认约 1 秒量级) | 测试用 refresh=wait_for,生产不要频繁 refresh |
13. 边界
| 需求 | 更合适的做法 | 理由 |
|---|---|---|
| 精确等值、唯一键 | 关系库 / get by id |
全文检索堆不擅长点查 |
| 事务、强一致 | 关系库 | ES 是近实时、无事务 |
| 复杂关联与 join | 应用层拼装 / 反范式 | join 字段限制多、代价高 |
| 词级相关性调优 | 自定义 similarity、rerank |
默认 BM25 够用但不够细 |
| 海量日志归档 | 冷热分层 + 快照 | 单索引硬扛成本高 |
14. 自测
term查一个text字段为什么常常空结果?must与filter结果相同,为什么还建议把判断放filter?- 同级已有
must时,单独的should会不会强制命中? best_fields与most_fields在多字段查询里各取什么分?- 深分页为什么比浅分页贵?替代写法是什么?
- 聚合
text字段报错时,字段层面的正确改法是什么?