1. 复合查询是什么,为什么你需要它
1.1 从一次“想当然”的搜索说起
先问一个问题:你平时在搜索引擎里搜“小米手机 黑色 256G”,这个搜索请求翻译成Elasticsearch查询,是怎么做到“既匹配了小米,又匹配了手机,还要求颜色是黑色、内存是256G”的?
如果你只用match查询,写出来大概是这样的:
{ "query": { "match": { "title": "小米手机黑色256G" } } }结果是:能搜出来一批含关键词的文档,但压根没法保证颜色字段一定等于黑色,内存字段一定等于256G。因为match查询本质上是分词后做OR匹配,它只管“相关性”,不管“条件”。这就是复合查询存在的意义——把多个简单查询组合起来,用逻辑关系控制它们之间的协作方式。
我当年刚接触ES时,很长一段时间里所有查询都靠match和term硬撑,等到业务方提出“我要搜名称包含XX、分类是YY、价格在ZZ区间、但排除掉库存为0的商品”这种需求时,一台服务器都快被我折腾冒烟了。后来把bool查询吃透,才真正体会到什么叫“一个查询搞定所有约束”。
1.2 复合查询和叶子查询的区别
ES的查询DSL分为两大类:叶子查询和复合查询。
叶子查询是“单兵作战”的,比如match、term、range、exists这些,它们接收一个字段名和一个值,执行一个独立的匹配动作。
复合查询则是“指挥官”,它本身不直接匹配任何文档,而是把叶子查询以及其他复合查询包装起来,通过特定的逻辑规则决定:哪些文档进入结果集、哪些被排除、每条结果的分数怎么算。
所以你会看到,bool查询里面嵌套的还是查询,bool套bool的情况相当常见。这种结构有点像写程序时的if-else嵌套,逻辑越复杂,嵌套层级越深。理解了这一点,后面看所有的复合查询示例,你都不会觉得发怵。
2. bool 查询:复合查询的绝对核心
2.1 四个子句的定位与使用场景
bool查询是ES中最常用、最重要的复合查询,没有之一。它的核心是四个子句:must、should、must_not、filter。新手最容易搞混的就是这四个东西各自的分工,我用一句话分别概括一下:
must:必须满足,参与算分。相当于SQL里的AND,同时影响相关性得分。filter:必须满足,不参与算分。相当于AND,但只是过滤,不影响得分。should:满足最好,不满足也行。相当于OR,但如果和must或filter配合,它有条件参与算分。must_not:必须不满足,不参与算分。相当于NOT,排除文档。
看一个完整示例,假设有一个商品索引products,字段包括title、category、price、stock。我要查“标题包含'手机'、分类是'数码'、价格在3000到6000之间、但排除库存为0的商品”,同时希望“标题含'旗舰'的商品排前面一点”:
{ "query": { "bool": { "must": [ { "match": { "title": "手机" } } ], "filter": [ { "term": { "category": "数码" } }, { "range": { "price": { "gte": 3000, "lte": 6000 } } } ], "must_not": [ { "term": { "stock": 0 } } ], "should": [ { "match": { "title": "旗舰" } } ] } } }这个查询的逻辑很清楚:must保证标题必须包含“手机”,filter保证分类和价格区间精确匹配,must_not把库存为0的排掉,should给包含“旗舰”的文档额外加权。四个子句各司其职,互不干扰。
2.2 filter 与 must 的区别:不止是语义差异
很多人知道filter不算分,但没意识到这个“不算分”带来的额外收益是什么。它有两层影响:
第一层,得分不变。如果查询里所有条件都放在must里,每个文档的得分会是一个综合分数,某条文档分数高可能是“手机”这个词的贡献,也可能是“旗舰”这个词的贡献,很难判断业务语义上的优先级。而把纯粹的条件约束(比如分类、价格)放进filter后,这些条件完全不参与评分,分高就是must和should的功劳,调优时思路清晰得多。
第二层,性能更优。filter子句的结果会被ES自动缓存到节点级别的过滤器缓存中。同一段filter条件,如果被不同查询重复使用,第二次执行时直接走缓存,查询速度会有质变。must因为要参与算分,每一条命中的文档都要实时计算得分,没办法走这个缓存。
我在实际项目中就踩过这样的坑:一开始图省事,把所有条件都堆进must,结果在千万级数据量的索引上,同样的查询从几十毫秒一路涨到几百毫秒。后来把纯范围过滤、状态过滤全部挪到filter里,查询时间立刻降回到几十毫秒。这个优化几乎零成本,收益却立竿见影。
注意:
must里如果只放了一个查询,那它和filter的语义结果是一样的,区别仅在算分和缓存上。如果你确定不需要算分,请一定用filter。
2.3 minimum_should_match 的细节
should子句有一个参数叫minimum_should_match,用来指定至少需要匹配多少个should条件才算命中。这个参数如果不设,默认行为是:
- 纯
should查询(没有must和filter):至少匹配一个,相当于OR的含义。 - 有
must或filter存在时:默认minimum_should_match为0,也就是说should一个都不满足也行,它只负责加分。
这个默认行为经常让新手掉坑。比如写了一个bool查询,里面有must条件,也写了两个should条件,期望的是“至少满足一个should”,结果发现只要must满足就返回结果了,should形同虚设。
要修正,就显式设置minimum_should_match:
{ "query": { "bool": { "must": [ { "match": { "category": "数码" } } ], "should": [ { "match": { "title": "旗舰" } }, { "match": { "title": "新品" } } ], "minimum_should_match": 1 } } }这样要求必须至少满足“旗舰”或“新品”之一,才进入结果集。
这个参数还支持百分比写法,比如minimum_should_match: "50%",表示至少匹配一半以上的should条件。当should子句数量不固定时,百分比写法更稳。
3. 其他复合查询的适用场景
3.1 constant_score:只过滤不算分的场景
constant_score查询包装一个查询条件,把所有命中的文档分数固定为一个常量值(默认1.0)。它和filter的作用非常像,但有微妙的差别:constant_score查询本身会算一个_score,只是这个分数是恒定值;而filter条件最终只影响结果集,不算分。
那什么时候用constant_score?我一般用它做“弱加权”。举个例子,搜索结果里我想让“分类为数码”的商品比“分类为图书”的商品排名靠前一点,但又不希望分类差异把其他词的匹配度影响太大。于是可以对两个分类分别用constant_score包一层,数码的固定分数设2.0,图书的固定分数设1.0,然后放进should里:
{ "query": { "bool": { "must": [ { "match": { "title": "摄影" } } ], "should": [ { "constant_score": { "filter": { "term": { "category": "数码" } }, "boost": 2.0 } }, { "constant_score": { "filter": { "term": { "category": "图书" } }, "boost": 1.0 } } ] } } }这样“摄影”匹配的分数是基础分,命中了数码分类再加2.0,命中图书分类加1.0。既控制了变量,又不会让分类权重失控。这是个很实用的调优手段,尤其是当你有明确的业务倾向性时。
3.2 boosting:降低某个条件的权重
boosting查询解决的问题是“某个条件命中时,文档要降权而不是加权”。它包含三部分:positive查询(命中的留下)、negative查询(命中的要被降权)、negative_boost(降权系数,取值范围0到1)。
举个例子,搜“手机壳”,同时希望标题含“促销”的文档排名降一点,避免促销内容淹没正常搜索结果:
{ "query": { "boosting": { "positive": { "match": { "title": "手机壳" } }, "negative": { "match": { "title": "促销" } }, "negative_boost": 0.5 } } }这个查询的逻辑是:所有标题匹配“手机壳”的文档先进入结果集;如果标题同时匹配“促销”,它的得分乘以0.5;如果没匹配“促销”,得分不变。降权幅度由negative_boost控制,设0.5就是打五折。
虽然bool查询里也可以用must_not直接排除促销商品,但boosting和must_not的差别在于:must_not是彻底排除,boosting只是降低排名,商品依然能看到。这适合那种“不想完全屏蔽,但希望靠后展示”的场景,比如广告、过期内容、低质量内容。
3.3 dis_max:最佳匹配子句优先
dis_max(disjunction max query)解决的是多字段搜索时评分打架的问题。它是“取所有匹配子句中得分最高的那个”作为文档最终得分,而不是把多个子句的分数加起来。
为什么需要这个?举个例子,搜索“苹果”,在title字段和tags字段同时匹配。如果标题里有“苹果”,标签里有“手机配件”,两个字段的得分一相加,可能比一个标题里明确写着“苹果手机”的文档分数还高,这不合理。因为用户搜“苹果”时,意图就是标题里有苹果,标签里那些关联词不该喧宾夺主。
{ "query": { "dis_max": { "queries": [ { "match": { "title": "苹果" } }, { "match": { "tags": "苹果" } } ], "tie_breaker": 0.3 } } }tie_breaker参数是一个0到1的小数。设为0时,完全取最高分;大于0时,其他子句的得分也会以tie_breaker为权重累加一点。一般建议设0.3到0.5,既保留最佳匹配的主导地位,又兼顾其他字段的佐证信息。
如果你用bool的should实现同样的多字段搜索,所有匹配子句的分数都会直接累加,等于“越多字段命中分数越高”,在某些场景下反而不准。dis_max的本质就是告诉ES:别加来加去了,看最匹配的那个字段就够了。
3.4 function_score:把业务规则变成分数
function_score是复合查询里最灵活、也最容易写崩的一个。它允许你在原始查询分数的基础上,通过自定义函数重新计算得分。常用的函数包括weight、field_value_factor、random_score、script_score等。
我重点说两个最常见的用法。
第一个,用field_value_factor结合业务数值字段调分。比如搜索结果里,销量越高的商品排名越靠前,幅度要温和:
{ "query": { "function_score": { "query": { "match": { "title": "手机" } }, "field_value_factor": { "field": "sales", "factor": 0.1, "modifier": "log1p", "missing": 1 }, "boost_mode": "multiply" } } }其中field_value_factor计算方式是log1p(0.1 * sales),log1p是取对数加一,可以让大销量的边际效应递减,不至于销量百万的商品分数碾压一切。missing指定字段缺失时的默认值。boost_mode可选multiply(相乘)、sum(相加)、replace(替换)。这个查询的效果是:商品本身文本匹配度依然是基础,但销量会作为加权因子温和地影响排序。
第二个,用random_score做个性化推荐排序。
{ "query": { "function_score": { "query": { "match": { "category": "数码" } }, "random_score": { "seed": 2024001, "field": "user_id" }, "boost_mode": "replace" } } }random_score支持指定种子和字段。指定seed和field后,同一用户每次查询结果顺序稳定,不同用户得到不同的随机顺序。这很适合“猜你喜欢”这类需要打散排序结果的场景。注意,field最好选择高基数的数值字段,否则随机效果不好。
function_score里的函数可以叠加多个,每个函数都能设置weight。叠加的规则、不同函数的配合方式,是把这玩意儿写崩溃的重灾区。我的经验是,先用最简单的配置跑通,再逐步加函数,每次只改一个变量。
4. 复合查询的性能与调优
4.1 查询上下文与过滤上下文
ES里存在两种上下文:查询上下文和过滤上下文。一个查询语句放在must里就是查询上下文,放在filter或must_not里就是过滤上下文。
查询上下文的任务是回答“文档有多匹配”。它计算_score,这是一个相对昂贵的操作,涉及词频、逆文档频率等一系列统计量的计算。过滤上下文的任务是回答“文档是否匹配”。它只输出布尔结果,是还是否,所以可以大量利用缓存和位集合运算。
这两种上下文的性能差距,我在前面提到过一次,这里再补充一个具体对比。同一套查询条件,全部放must和全部放filter,在千万级文档索引上实测,耗时差距通常在2到5倍之间。条件越复杂,差距越明显。
所以调优的第一原则是:凡是业务上不需要参与排序的硬性条件,一律放filter。分类、状态、时间范围、地理位置、权限过滤这些,都是典型的filter候选。
4.2 复合查询的缓存策略
filter子句的缓存机制值得单独说。ES的节点查询缓存会为filter条件生成一个位数组(bitset),每个文档对应一个bit,命中为1,未命中为0。当多个查询复用同一个filter条件时,直接复用这个bitset,不用重新扫描倒排索引。
缓存的内存占用和数据量有关,默认每个节点最多缓存10000个查询。如果业务上有大量不同的filter条件轮流查询,缓存淘汰会比较频繁,这很正常,不必追求所有条件都缓存住。
要注意的是,must_not虽然也走过滤上下文,也能被缓存,但它的语义是“排除”,所以命中率通常比正向过滤低。能写成正向filter的条件,尽量不要用双重否定或复杂嵌套的must_not,否则不仅可读性差,缓存收益也低。
4.3 嵌套层级与查询体量的把控
复合查询可以任意嵌套,但嵌套越深,查询解析和执行的额外开销越大。虽然ES在解析阶段会做一定的优化,但一个10层嵌套的bool查询,排查和维护都是灾难。
我见过不少同事写出过5层以上的bool套bool,逻辑复杂到换个人根本不敢改。这里有几个实操经验供参考:
- 能用
filter合并的平铺条件,不要嵌套。多个平级的filter条件在语义上本来就是AND关系,没必要再包一层。 - 查询条件很多时,优先把通用约束(状态、分类、权限)抽出来放到外层
filter,把与用户输入相关的动态条件放到内层。这样动态条件变化时,外层filter缓存还能复用。 - 如果单个查询体量真的特别大(比如几十个子句),考虑拆分查询或用
search_after分页处理,别让一个请求背负所有逻辑。
提示:ES的
_validate接口可以辅助检查查询语法,但查不出逻辑问题。最好在写复杂复合查询前,先在Kibana的Dev Tools里用一小批样本数据反复验证结果。
5. 常见问题与排查技巧实录
5.1 must和filter结果一样,为什么耗时差那么多
这个问题我在技术交流群里被问过很多次。返回结果一样,但耗时不一样,原因就是前面提到的查询上下文和过滤上下文的分工差异。must需要对每篇文档实时计算词频、文档频率等统计值,filter走缓存位集合运算。
排查方法很简单:用Kibana的Search Profiler功能分别跑两版查询,看每个子句的耗时占比。通常你会发现must模式下的时间统计栏里有大量score计算耗时,而filter模式下这一项几乎为零。
5.2 _score全部相同或为null
出现这个情况,一般有两个原因。
第一,查询压根没有匹配算分条件,只有filter。ES规定,结果集全部由过滤上下文决定时,_score默认返回0.0,甚至在某些版本里显示为null。这属于正常现象,如果业务上不依赖分数排序,不用管。
第二,使用了constant_score且boost都设成相同值。这当然分数一样。需要区分的是,你到底是想彻底不算分,还是想算分但当前查询条件没生效。拿explain=true跑一次,能很清楚地看到文档的得分来源。
5.3 想要的排序结果和预想的不一致
排序不符合预期,90%的情况是算分逻辑没吃透。比如你认为A文档更相关,结果ES把B文档排前面了。这时要做两件事。
先用explain查看B文档的得分明细,拆解每个查询子句贡献了多少分。你会发现,往往是一个你意料之外的should子句匹配了,或者某个字段的boost设得过高。
再检查分词效果。同一个词,标准分词器、IK分词器、自定义分词器产生的结果完全不同。例如搜“苹果手机”时,如果标题字段用的是粗粒度分词,可能会把“苹果”和“手机”算成两个独立term,得分叠加逻辑完全不一样。
5.4 bool嵌套太深,查询超时
查询超时最直接的原因是嵌套太多、每个子句都要扫描大量文档。优化思路从两个方向入手。
一是从结构上剪枝。把能合并的条件合并,能提到filter的不要放must,能用constant_score的不要用大段function_score。
二是从数据上收敛。检查是否所有子句都查询了全索引。比如某个内部bool其实是按用户ID过滤的,加一个前缀过滤或路由,扫描范围会小很多。ES支持在查询时指定routing,可以在分片级别直接跳过无关数据,对高延迟查询效果显著。
5.5 should子句神秘失效
前面说过,当bool里同时存在must或filter时,should默认不强制匹配。如果你希望should成为硬条件,必须设置minimum_should_match。如果希望它仍然只是加权,那它“看起来失效”其实就是正常行为。
遇到这种情况,建议先把查询拆小,单独跑一次纯should查询,确定每个子句单独能命中哪些文档。然后再组合进完整的bool里逐个加条件,观察每一步结果集和分数的变化。这个方法虽然笨,但排查复合查询逻辑问题时最有效。
6. 复合查询实战建议
我在实际项目里总结了一些适合多数场景的组合套路,分享给需要的朋友。
第一,查询主体用bool,把用户的输入条件放进must,把权限、状态、时间这类系统级约束全部放进filter,把业务倾向性条件放进should并设好minimum_should_match。这是最稳妥、最通用的一套组合,适合80%的搜索场景。
第二,当需要精确控制某几个字段的得分权重时,用multi_match加boost替代多个should或者dis_max。multi_match的自定义boost写法更简洁,而且支持type: "best_fields",本质就是dis_max的快捷方式。
第三,需要结合业务数值排序时,才考虑引入function_score。不要用它替代常规的bool查询,它更适合作为最外层的一个“加分壳”,内部仍然包着完整的bool查询。
第四,排查线上查询问题,优先看_explain和Search Profiler,不要靠猜。复杂查询没有捷径,逐步拆解和验证是唯一可靠的方法。
复合查询是ES搜索能力的核心地带,把bool吃透,把filter和must的差异刻在脑子里,再把dis_max、boosting、function_score的适用场景分清,日常开发里绝大部分查询需求都能轻松应对。