ES查询语法从入门到实战:match、term、bool与filter核心解析
ES查询语法说穿了就是你往_search接口里塞的那段JSON请求体。我接触过不少刚开始学ES的同事很多人能顺利建索引、写映射、增删改文档但一到查询接口就开始发怵match和term看着差不多用起来却完全两码事想多条件组合弄不清must、filter、should之间是与还是或最难受的是明明写对了语法结果查出来跟预期完全不一样。这篇我按自己日常写查询的顺序把这套语法拆开讲清楚重点放在每个子句到底解决什么问题以及实践中容易踩的坑上。1. 先理解查询上下文和过滤上下文ES查询语法的核心思维1.1 一个search请求的骨架长什么样先看一个最简单的例子后面所有复杂语法都是在这个骨架上叠加POST /eshop/_search { query: { match: { name: 跑步鞋 } }, from: 0, size: 10 }POST /索引名/_search就是查询入口query里放的是核心查询子句from和size控制分页。ES返回的hits里通常包含total总数、max_score最大得分以及hits数组里的文档详情。响应里有个_score字段这个值的含义后面会讲。除了上面这几个部件_search的请求体还支持sort排序、_source裁剪返回字段、highlight高亮、aggs聚合等。我建议新手先只把query、from、size玩熟再去碰其他部件否则信息量太大反而容易乱。1.2 query context和filter context的本质区别很多人学查询语法容易死记API却忽略了一个贯穿全文的概念上下文。同样是筛选条件写在不同的上下文里运行方式完全不同。query context查询上下文不仅判断文档是否匹配还要计算相关性得分_score并参与排序。用得越多计算开销越大。filter context过滤上下文只判断文档是否匹配不算分。因为结果只有是/否两种ES可以对过滤结果做缓存重复使用时性能很好。可以打个比方filter像硬性门槛——你说只要红色那红色之外的直接淘汰不需要打分query像面试官打分——每个人都要评估一下匹配程度然后排序。日常工作里价格区间、状态、品牌这类结构化条件完全不需要参与打分放进filter既清晰又高效。我给初学者的第一个建议是判断一个条件放哪先问自己这个条件需要影响排序吗。不需要评分、只做筛选就放filter上下文需要参与相关度排序才放query上下文。1.3 相关性得分到底怎么来的ES默认按_score降序排列结果所以理解评分很重要。ES 5.0之后默认的相似度算法是BM25一句话概括文档里包含的查询词越多、这个词在全局越少见得分越高。你不用背公式但要知道这里的核心逻辑是词频和逆文档频率。举个例子搜跑步鞋时一篇商品标题里反复出现跑步鞋的文档得分通常比只在描述里偶尔提一句的文档高。这个设计是合理的因为我们要的是排序后的相关性而不是随机抓一批匹配文档。2. 拆开最常用的查询子句match、term、range与它们背后的分词差异2.1 match查询全文搜索的主角match是全文搜索里使用频率最高的查询子句。它的特点是查询词会被分词器拆分。比如搜索手机充电器ES会先把手机充电器分词通常拆成手机充电器等词项再用这些词项去倒排索引里匹配。POST /eshop/_search { query: { match: { name: 手机充电器 } } }默认情况下只要文档命中任意一个词项就会被返回这相当于OR逻辑。如果你想要求所有词都命中可以用operator参数{ query: { match: { name: { query: 手机充电器, operator: and } } } }operator: and在某些场景很好用比如搜索跑步鞋男士你希望三个词都出现否则结果太泛。但也要注意要求过严会漏掉合理的变体描述。实际项目里我一般是默认OR如果发现搜索词噪音太大再考虑加AND或minimum_should_match后面会讲。2.2 term/terms/exists走精确匹配路线term和match最大的区别是term不会分词。它会拿着你给的值直接去索引里找完全相等的词项。所以term适合keyword类型、数值类型、日期类型、布尔类型等不做分词的字段。POST /eshop/_search { query: { term: { brand: Rasphone } } }上面这个查询要求brand字段必须精确等于Rasphone。如果你在text字段上强行用term很容易查不到数据这个坑我在第5章会专门展开。terms是term的复数版本表示匹配其中任意一个值{ query: { terms: { brand: [Zowear, Noiray] } } }这在品牌筛选标签筛选场景非常常见效果好、语义也直观。还有一个容易忽略的exists查询用来查某个字段是否存在。它最常见的用途是排除空值比如查询所有有description描述的商品{ query: { exists: { field: description } } }2.3 range查询数值和日期的边界处理range用于范围匹配支持gt大于、gte大于等于、lt小于、lte小于等于。对数值、日期都能用。{ query: { range: { price: { gte: 100, lte: 500 } } } }日期范围稍微特殊一点除了传绝对时间还支持日期计算表达式。比如查询最近30天的数据{ query: { range: { created_at: { gte: now-30d/d, lte: now } } } }这里的now-30d/d意思是当前时间减去30天再向下取整到天。日期边界是新手容易迷糊的地方gte是包含等于的gt是不包含的两个同时出现时ES会正确处理边界。如果字段是long类型存的时间戳毫秒记得先将映射改成date类型或直接传数值范围否则日期计算表达式不生效。2.4 还有哪些小兵prefix、wildcard、ids除了match、term、range还有一组使用频率相对低、但特定场景很实用的子句prefix前缀匹配适合keyword字段比如搜Ras能匹配Rasphone。wildcard通配符匹配?匹配单个字符*匹配任意字符。ids直接按文档ID查适合精确指定一批文档。fuzzy模糊匹配可以容忍一定程度的拼写差异适合纠错场景。这里要特别提醒wildcard虽然方便却容易成为性能杀手。尤其是*关键词*这种两侧都带通配符的写法可能触发大量倒排索引扫描。能用match分词检索的就不要用wildcard。3. bool查询把多个条件拼成搜索逻辑的万能拼装台3.1 四个子句各自的分工单个查询子句只能解决一个条件真实查询往往是多个条件的组合。ES里组合查询的标准姿势是bool查询。bool下面有四个常见子句理解它们的分工就掌握了组合逻辑子句作用是否算分典型场景must必须匹配是关键词全文搜索filter必须匹配否价格区间、状态、品牌筛选should可选匹配是匹配则加分提升某类结果的排序权重must_not必须不匹配否排除某类内容看一个完整例子POST /eshop/_search { query: { bool: { must: [ { match: { name: 跑步鞋 } } ], filter: [ { range: { price: { gte: 100, lte: 500 } } }, { term: { status: on_sale } } ], must_not: [ { term: { color: 红色 } } ], should: [ { term: { tags: 新品 } }, { range: { rating: { gte: 4.5 } } } ] } } }这个查询表达的逻辑是必须匹配跑步鞋价格100到500在售排除红色如果商品有新品标签或评分4.5分以上会适当提高排名。注意must和filter虽然都是必须满足但must参与算分、filter不算分所以纯筛选条件放filter更省资源。3.2 minimum_should_match控制可选条件的底线should里的条件默认是可选的但具体可选到什么程度是有讲究的。当你只写should、没有must和filter时ES默认要求至少命中一个should条件但当你同时存在must或filter时should默认变成纯加分项命中几个都无所谓。如果想强制should至少要命中某个数量就用minimum_should_match{ query: { bool: { must: [ { match: { name: 运动鞋 } } ], should: [ { term: { tags: 跑步 } }, { term: { tags: 透气 } }, { term: { tags: 轻量 } } ], minimum_should_match: 2 } } }这段逻辑是必须匹配运动鞋同时在跑步透气轻量三个标签里至少命中两个。minimum_should_match可以写数字也可以写百分比比如75%它可以根据should条件总数动态换算适合标签数量不固定的场景。3.3 把真实需求翻译成bool的完整案例下面我来演示一个完整的翻译过程。假设要做电商搜索页需求如下搜索关键词运动鞋 跑步两个词都要命中价格100到600元品牌是Zowear或Noiray之一排除停售商品最近30天上架有新品标签或评分4.5以上优先展示。对应的完整DSLPOST /eshop/_search { query: { bool: { must: [ { match: { name: { query: 运动鞋 跑步, operator: and } } } ], filter: [ { range: { price: { gte: 100, lte: 600 } } }, { terms: { brand: [Zowear, Noiray] } }, { range: { created_at: { gte: now-30d/d } } }, { term: { status: on_sale } } ], should: [ { term: { tags: 新品 } }, { range: { rating: { gte: 4.5 } } } ], minimum_should_match: 1 } } }来拆解一下关键词涉及相关度排序所以用match放进must同时用operator: and保证两个词都出现。价格、品牌、创建时间、售卖状态都是硬性条件不需要影响排序统一放进filter。优先展示本质是提升权重所以放进should并用minimum_should_match: 1要求至少满足一个才算优先。这个结构就是我对bool的理解must管核心匹配filter管硬性筛选should管权重微调must_not管排除。把这套逻辑理顺大部分搜索需求都能翻译成可落地的DSL。4. 控制返回结果分页、排序、字段裁剪与高亮4.1 from/size分页与深分页的坑查询结果默认返回前10条分页由from和size控制{ from: 20, size: 10, query: { match_all: {} } }上面的意思是跳过前20条取10条也就是第3页。很多人会直接把from设到很大比如从100000条开始取这在ES里非常危险。因为ES分页需要把前from size条结果全部取出来再排序深度分页的代价是陡峭上升的。默认情况下from size超过10000就会直接报错。遇到翻页很深的场景应该考虑search_after方案这是基于上一页最后一条的排序值做游标翻页性能稳定得多。如果你只是想把数据导出那就用scroll别拿from硬翻。4.2 排序sort与字段类型的约定排序用sort子句可以指定一个或多个字段{ sort: [ { price: { order: asc } }, { created_at: { order: desc } } ], query: { match_all: {} } }使用sort之后ES默认不再对_score排序除非你显式混排。这里有个特别容易踩的坑text类型字段默认不能排序。因为在索引分词后text字段的值不再是一个完整的字符串对其进行字典序排序没有意义而且为排序生成的fielddata数据会占用大量内存。如果你要对商品名称排序应该给text字段配一个keyword子字段然后用name.keyword来排序。后面第5章会讲具体排查过程。4.3 只看想看到的字段_source裁剪有时一个文档很大但页面只需要其中几个字段可以通过_source来控制返回内容{ _source: [name, price, brand], query: { match_all: {} } }_source还可以用includes和excludes做更灵活的裁剪{ _source: { includes: [name, price], excludes: [description] }, query: { match_all: {} } }这招在字段多、网络带宽紧张的场景很实用尤其是日志类索引动辄几十个字段全量返回既浪费带宽又拖慢页面。4.4 高亮显示highlight的基本用法搜索页里把命中词标红靠的是highlight子句{ query: { match: { name: 跑步鞋 } }, highlight: { fields: { name: {} }, pre_tags: [b], post_tags: [/b] } }这样返回结果的highlight字段里会带上b跑步鞋/b这样的片段。实际应用里还可以配置fragment_size控制高亮片段长度number_of_fragments控制返回几个片段。默认的em标签在很多前端框架里不太好看所以我习惯自己指定pre_tags和post_tags。5. 排查查不到、报错、变慢的实战经验5.1 用term查text字段为什么查不到这是最经典的一个坑。假设索引里有条文档name字段是text类型值为Pro 跑步鞋 男士透气。你写下面这个查询期待能查到它{ query: { term: { name: 跑步鞋 } } }结果却为空。原因在于name是text类型文档写入时被分词器拆成了若干个词项比如pro跑步鞋男士透气等而term查询拿跑步鞋这个完整词去倒排索引里找除非分词器恰好保留了跑步鞋整体作为词项否则肯定匹配不上。正确的排查链路是这样的第一步确认字段映射看它到底是text还是keywordGET /eshop/_mapping第二步用_analyze接口查看该字段的分词效果POST /eshop/_analyze { field: name, text: 跑步鞋 }这个请求会返回分词后的token列表你一眼就能看出是否有跑步鞋这个完整词项。第三步根据业务诉求调整方案如果要做全文搜索改用match如果要做精确匹配那应该在映射里给text字段配备keyword子字段然后对name.keyword执行term查询{ query: { term: { name.keyword: 跑步鞋 } } }注意动态映射不一定为每个text字段自动生成keyword子字段生产环境建议在创建索引时显式设计。5.2 text字段排序或聚合报错fielddata问题如果你直接对text字段排序一般会收到类似Text fields are not optimised for operations that require per-document field data...的报错。原因是text字段的倒排索引结构并不适合按整个字段内容排序和聚合。我见过不少同学一看到这个报错就搜fielddata然后贸然开启字段的fielddata属性。这不是个好主意fielddata会加载字段的所有分词到堆内存量大时直接OOM。正确做法是排序、聚合走keyword子字段即name.keyword如果确实需要按原始字符串排序一开始就把这个字段设计成keyword或keyword子字段只有少数特定场景才考虑开启fielddata且必须评估内存。还要注意name.keyword排序是按整个字符串的字典序排对中文名称可能不是你直觉上的拼音序或笔画序这属于业务排序需求可以在应用层另做处理。5.3 查询越写越慢常见的性能隐患查询变慢通常不是ES本身不行而是某些写法把性能浪费在不必要的地方。我归纳了几个高频原因滥用通配符wildcard、regexp这类查询扫描开销大能用分词检索就用分词检索。过滤条件放在must里明明只是结构化筛选却放进must参与算分白白浪费CPU。改成filter之后不仅更快还能用结果缓存。深度分页from开得太大导致每次请求都要收集大量数据。改用search_after或scroll。大结果集返回不裁剪_source一次返回几十个字段的大文档。先确认业务真正需要哪些字段。排查慢查询时我一般先看ES监控里的查询耗时分布再逐条检查查询DSL把不需要评分的条件移到filter、把不必要的字段裁剪掉往往立竿见影。5.4 调试查询语法的两个实用接口语法写错时ES的报错有时不够友好。我习惯先用校验接口看看查询是否合法POST /eshop/_validate/query?explain { query: { match: { name: 跑步鞋 } } }如果返回valid为true再通过explain字段查看执行的查询描述。想知道某条文档为什么匹配或没匹配可以用_explain接口它会展示评分计算过程和词项命中情况。遇到结果不符合预期的情况不要盲目改查询先用这几个接口定位是语法问题、分词问题还是评分权重问题效率会高很多。6. 练出查询语感给新手的练习建议读到这里语法点基本都过了一遍但说实话ES查询语法跟游泳一样看十篇教程不如自己上手写一次。我的建议是先建一个测试索引塞几百条随机文档字段刻意混用text、keyword、数值、日期然后从这几个练习目标开始用match做关键词搜索观察_score变化在同一个query里尝试把关键词match放进must、把价格范围放进filter体会响应和评分差异加一个should加分项再用minimum_should_match调整可选项的底线主动写一个term查text字段的查询走一遍_analyze排查流程这个坑踩过一次就记住了。练习过程中始终带着一个心智模型先看字段类型再选查询子句只做筛选、不参与排序的条件尽量放进filter。掌握这套思路之后再去看ES官方文档你会发现那些API细节都只是工具真正值钱的是你脑子里那套怎么把业务需求翻译成查询逻辑的能力。写到这这篇查询语法的入门内容就算完整了。下一篇我打算聊聊聚合分析那是把ES从搜索引擎推向分析引擎的关键一步我们到时候再见。