Easysearch 布尔查询子句重排序(一)|你的 BoolQuery 写法,真的影响性能吗?
从 Easysearch 到 Lucene,查询构建层的 11 条优化规则
一、开篇:一个常见的误解
"must 里面,是不是应该把匹配文档少的条件写在前面?这样能提前过滤掉大量文档,性能更好?"
这个直觉来得很自然,但它是错的。
┌─────────────────────────────────────┐
│ 用户的直觉: │
│ must: [高频词, 低频词] → 慢 │
│ must: [低频词, 高频词] → 快 │
│ │
│ 实际情况: │
│ 两种写法性能完全相同! │
│ Lucene 执行时自动按 cost 排序 │
└─────────────────────────────────────┘
Easysearch 执行时会按 cost 自动重排子句顺序,与你写查询时的顺序无关。不过,"子句顺序不重要"不代表"怎么写都一样"——理解引擎自动优化的边界在哪里,才能设计出更合理的查询结构。
本篇是系列第一篇,聚焦构建层:从你发出 JSON 到查询进入执行引擎,中间经历了哪些变换?哪些优化在这个阶段完成?哪些要留到执行层?后续两篇将分别深入合取查询的 cost 排序和析取查询的 WAND 剪枝。
二、全景:一次布尔查询的完整旅程
先建立一张全局地图,再深入每一层。
举个例子:一条布尔查询就像一个包裹进入工厂流水线,经过三道工序:
- 第一道(Easysearch 层):质检员检查包裹格式是否合规,缺不缺东西,但不重新排列里面的物品顺序
- 第二道(Lucene rewrite):工艺师合并重复部件、去掉矛盾组合、把"可选"升级为"必选"——改变的是包裹的内容结构,不是物品顺序
- 第三道(Scorer 层):调度员拿到最终包裹,按每个部件的"处理成本"自动安排加工顺序——这才是代价排序发生的地方
用技术语言描述,这三道工序对应的是(注意①和②③分属不同阶段):
用户 JSON
│
▼
┌──────────────────────────────────────────┐
│ Easysearch 层(构建层) │
│ ① doRewrite() — 递归重写 + 早期终止 │
│ ② applyMinimumShouldMatch │
│ ③ fixNegativeQueryIfNeeded │
│ (①在 rewrite 阶段,②③在 doToQuery()内)│
│ 职责:结构合法化,不改子句顺序 │
└────────────────┬─────────────────────────┘
│ toQuery() → BooleanQuery
▼
┌────────────────────────────────────────────┐
│ Lucene rewrite 层(逻辑重写层) │
│ ④ BooleanQuery.rewrite() │
│ (IndexSearcher 中 rewrite→createWeight) │
│ 职责:11条逻辑等价改写,不改结果只改形态 │
└────────────────┬───────────────────────────┘
│ createWeight()
▼
┌──────────────────────────────────────────┐
│ Scorer 构造层(执行层) │
│ ⑤ ConjunctionDISI:按 cost() 排序 ✅ │
│ ⑥ WANDScorer:按 maxScore 动态重排 ✅ │
│ 职责:代价感知,真正的性能优化在这里 │
└────────────────┬─────────────────────────┘
│
▼
执行查询,返回结果
一个关键认知:代价排序发生在第⑤步(Scorer 层)。前两道工序只做逻辑等价改写——合并重复、升级类型、展平嵌套,但不改变查询结果。
本篇讲前两层(①~④),后两篇讲第⑤⑥步。
三、Easysearch 层:结构合法化,不碰顺序
你发出的 JSON,首先被 Easysearch 的 BoolQueryBuilder 解析成内部的查询对象。这一层做的事情很克制:保证查询结构合法,但不改变子句顺序。
3.1 子句是如何被添加的
BoolQueryBuilder.doToQuery() 按照固定顺序把子句添加到 Lucene 的 BooleanQuery.Builder 中:
must → mustNot → should → filter
不同类型之间的添加顺序是固定的(无论你的 JSON 里先写 should 还是先写 must),但同一类型内的子句顺序与 JSON 书写顺序一致——不过这都不影响性能,因为代价排序发生在更下游的 Scorer 层。
3.2 三种特殊处理
Easysearch 层会做三类结构合法化处理:
doRewrite() 的早期终止:
- 如果整个 BoolQuery 为空(没有任何子句),退化为
MatchAllQueryBuilder(等价于 Lucene 的MatchAllDocsQuery) - 如果任何
must或filter子句重写后变为MatchNoneQueryBuilder,整个 BoolQuery 直接返回该MatchNoneQueryBuilder——不需要继续执行 - 如果没有
must/filter子句,但所有should子句都重写为MatchNoneQueryBuilder,整个 BoolQuery 也退化为MatchNoneQueryBuilder——没有必须匹配的子句,所有可选子句又都匹配零文档,结果必然为空
fixNegativeQueryIfNeeded():
当查询只有 must_not 子句、没有任何正向匹配条件时,Lucene 的 BooleanQuery 不知道"从哪些文档里排除"。Easysearch 自动插入一个 MatchAllDocsQuery 作为基础集合。该修复受 adjust_pure_negative 开关控制(默认为 true,可设为 false 关闭):
输入:must_not: [term:spam]
处理:加入 MatchAllDocsQuery (作为 FILTER)
输出:filter: [MatchAll] + must_not: [term:spam]
= "所有文档 除了 spam"
applyMinimumShouldMatch():
把用户设置的 minimum_should_match 规格字符串(支持整数 "2"、百分比 "75%"、条件式 "3<75%" 等)解析为 int,写入 Lucene 的 BooleanQuery.setMinimumNumberShouldMatch()。
总结:Easysearch 层不改变子句顺序,只做合法化修补。真正的优化交给下游。
四、Lucene rewrite 层:11 条逻辑等价改写规则
查询经过 toQuery() 变成 Lucene 的 BooleanQuery 对象后,会调用 BooleanQuery.rewrite()。这是本篇的核心章节。
这一层不做代价排序,而是通过 11 条规则改写查询的形态——去重、提升、展平——但保证改写前后查询结果完全一致,为后续执行层的高效优化铺路。
📌 本文按理解难度递进排列规则编号。源码中
BooleanQuery.rewrite()实际包含 12 个步骤,本文将其中 SHOULD 去重和 MUST 去重合并为规则 8,并按逻辑将 MatchAll→ConstantScore 编为规则 11,因此源码实际执行顺序按本文编号为:1→2→3→4→5→6→7→8→11→9→10(ConstantScore 转换在展平和 minShouldMatch 对齐之前执行)。
规则 1-3:消除不可能、去重、矛盾检测
这三条是防御性规则,含义很容易理解,快速过一遍:
| # | 规则 | 触发条件 | 行为 |
|---|---|---|---|
| 1 | 空查询消除 | 没有任何子句 | → MatchNoDocsQuery |
| 2 | 单子句拆包 | 只有 1 个子句 | 拆掉 BooleanQuery 外壳,直接用内部查询。SHOULD/MUST 直接返回内部查询;FILTER 包裹为 BoostQuery(ConstantScoreQuery(query), 0)(确保得分为零);MUST_NOT → MatchNoDocsQuery |
| 3 | 递归重写 + MatchNoDocs 短路 | 子句重写后变化,或含 MatchNoDocs | 递归简化每个子句(FILTER/MUST_NOT 先包裹 ConstantScoreQuery 再重写再剥壳,SHOULD/MUST 直接重写);SHOULD/MUST_NOT 中的 MatchNoDocs 直接移除,MUST/FILTER 中的 MatchNoDocs 导致整体短路 |
规则 2 示例:
BooleanQuery { MUST: [TermQuery(status:published)] }
↓ rewrite
TermQuery(status:published)
BooleanQuery { FILTER: [TermQuery(status:published)] }
↓ rewrite
BoostQuery(ConstantScoreQuery(TermQuery(status:published)), 0)
规则 3 示例:
must: [MatchNoDocsQuery], should: [TermQuery(A)]
↓ rewrite(MUST 中含 MatchNoDocs → 整体短路)
MatchNoDocsQuery
规则 4-6:去重、矛盾检测、冗余移除
继续快速过:
| # | 规则 | 触发条件 | 行为 |
|---|---|---|---|
| 4 | FILTER/MUST_NOT 去重 | 相同子句重复出现 | HashSet 自动去重(基于 Query.equals())。SHOULD/MUST 用 Multiset 保留重复(以便后续规则 8 做 boost 求和) |
| 5 | 矛盾检测 | MUST 或 FILTER 与 MUST_NOT 含同一子句,或 MUST_NOT 含 MatchAll | → MatchNoDocsQuery |
| 6 | 冗余 FILTER 移除 | FILTER 与 MUST 重叠,或 FILTER 含 MatchAll | FILTER/MUST 重叠:无条件移除冗余 FILTER;FILTER 含 MatchAll:仅当移除后仍有正向子句时移除(filters.size() > 1 || !mustClauses.isEmpty()) |
规则 4 示例:
filter: [term:active, term:active, range:age>18] → filter: [term:active, range:age>18]
规则 6 示例:
must: [term:active], filter: [term:active, range:age>18] → must: [term:active], filter: [range:age>18]
规则 7:SHOULD + FILTER → MUST 提升 ⭐
触发条件:同一个子查询同时出现在 SHOULD 和 FILTER 子句中。
行为:将该子查询的 SHOULD 子句改为 MUST(原 FILTER 子句直接丢弃,因为 MUST 已隐含了 FILTER 的过滤语义)。源码里会同时下调 minimumNumberShouldMatch(每提升一个子句 minShouldMatch--,循环结束后统一 Math.max(0, minShouldMatch) 确保不低于 0):
- 若提升后
minShouldMatch == 0,mix 路径走 req+opt(ReqOptSumScorer) - 若提升后
minShouldMatch > 0,仍走 conjunction-disjunction mix(ConjunctionScorer(req,opt))
也就是说,规则 7 一定会改变查询形态,但是否切到 ReqOptSumScorer 取决于 minShouldMatch 是否归零。
优化前:
┌───────────────────────┐
│ SHOULD: [term:A] │
│ FILTER: [term:A] │
│ SHOULD: [term:B] │
└───────────────────────┘
↓ rewrite
优化后:
┌───────────────────────┐
│ MUST: [term:A] │ ← 提升为 MUST!
│ SHOULD: [term:B] │
└───────────────────────┘
💡 类比:一个人同时是"候选人"(SHOULD)又是"已入职"(FILTER)。既然已经入职,直接列入正式编制(MUST)。
⚠️
minShouldMatch联动:每将一个 SHOULD 提升为 MUST,minimumNumberShouldMatch相应减 1(循环结束后Math.max(0, minShouldMatch)兜底),确保语义等价。例如原有minimum_should_match: 2且两个 SHOULD 中有一个被提升,重写后minShouldMatch变为 1。
规则 8:SHOULD / MUST 去重(boost 求和)
触发条件:SHOULD 或 MUST 中出现了相同的子句(解包 BoostQuery 后底层查询相同)。注意:SHOULD 去重仅在 minimumNumberShouldMatch ≤ 1 时触发;若 minShouldMatch > 1,Lucene 不会合并重复的 SHOULD 子句(多个重复出现在高 minShouldMatch 场景下语义不可简单合并)。MUST 去重则没有此限制,无论 minShouldMatch 为何值都会合并重复的 MUST 子句。
行为:合并重复子句,将它们的 boost 相加。FILTER 和 MUST_NOT 的去重由规则 4 处理(直接删除),而 SHOULD 和 MUST 的重复是有意义的——不同的 boost 意味着不同的评分权重,所以求和保留。不合并的话,同一个 term 会创建两套独立的迭代器,都遍历相同的文档列表,浪费翻倍。
should: [term:hello^1.5, term:hello^2.0, term:world]
↓ rewrite
should: [term:hello^3.5, term:world]
(两个 hello 的权重合并:1.5 + 2.0 = 3.5)
💡 类比:一个学生选了同一门课两次,一次记 1.5 学分,一次记 2 学分。不需要上两次课,合并为 3.5 学分即可。
规则 9:SHOULD 嵌套展平 ⭐
触发条件:一个 bool 查询的 SHOULD 子句里,嵌套了另一个纯 SHOULD 的 bool 查询(即内层没有 MUST、FILTER、MUST_NOT,只有 SHOULD,且 minimum_should_match ≤ 1)。
行为:把内层 SHOULD 子句全部"提升"到外层,展平为同一级别的 SHOULD 子句。展平后 WAND 能看到每个子句的独立 maxScore,估算更紧,剪枝更激进;如果不展平,WAND 只能看到内层查询的总体上界(黑盒),剪枝不够狠。
优化前:
BoolQuery (外层)
/ \
SHOULD SHOULD
(term:A) (内层 BoolQuery)
/ \
SHOULD SHOULD
(term:B) (term:C)
↓ rewrite
优化后:
BoolQuery
/ | \
SHOULD SHOULD SHOULD
(term:A)(term:B)(term:C)
实践建议:如果你在 should 里嵌套了多层 bool,且内层全是 SHOULD 子句,EasySearch 会自动展平。但如果内层有 minimum_should_match >= 2,则不会展平(语义不等价),这类情况应尽量手动展平或重构查询结构。
💡 为什么展平能更激进地剪枝?假设内层 bool 有两个子句,maxScore 分别是 8 和 3。展平前,WAND 只看到"这个内层查询最多得 8+3=11 分",不管当前文档匹配了哪些子句,上界永远是 11;展平后,WAND 逐子句检查——某个文档如果不匹配 maxScore=8 的子句,只剩 maxScore=3 的子句可能匹配,上界从 11 降到 3。如果录取线是 5,3 < 5,这个文档不可能入选——直接跳过,不用再算分了。
规则 10:SHOULD 数量与 minimumShouldMatch 的对齐
触发条件:SHOULD 子句数量与 minimum_should_match 的大小关系。
行为:分两种情况:
- SHOULD 数量 < minimumShouldMatch:不可能满足 → 直接返回
MatchNoDocsQuery,省去无用计算 - SHOULD 数量 == minimumShouldMatch:所有 SHOULD 提升为 MUST,从 WANDScorer(调度开销大)转入 ConjunctionDISI(cost 排序,更高效)
should: [A, B],minimum_should_match: 3
↓ rewrite(2 < 3,不可能满足)
MatchNoDocsQuery
should: [A, B, C],minimum_should_match: 3
↓ rewrite(3 == 3,等价于全部 MUST)
must: [A, B, C]
💡 类比:开会时,如果"3 个可选发言人必须全部到场"——那"可选"就没意义了,等价于"3 个必须到场"。如果要求"3 人到场但只有 2 人可选"——不可能,直接取消会议。
一个容易忽略的场景:在动态拼接查询时(例如从用户的多个筛选条件生成 should,然后设置 minimum_should_match 等于条件数量),这条规则会自动把它转化为更高效的 MUST 查询,无需手动改写。
规则 11:MatchAll + FILTER → ConstantScoreQuery
触发条件:BooleanQuery 恰好只有一个 MUST 子句且为 MatchAllDocsQuery,且至少有一个 FILTER 子句。
行为:将所有 FILTER + MUST_NOT 组成内部 BooleanQuery,整体包裹为 ConstantScoreQuery(绕过评分,返回固定分数),作为外层 MUST 加入;SHOULD 子句加回外层(不丢弃);原始 MatchAllDocsQuery 被消耗。MatchAllDocsQuery 在 BooleanQuery 框架里有额外调度开销,ConstantScoreQuery 执行路径更直接。
⚠️ 注意:纯
filter查询没有 MUST 子句,不满足musts.size() == 1的前提,不触发本规则。FILTER 子句直接进入合取路径,由ConjunctionDISI按 cost 排序处理。
在 11 条规则中,标 ⭐ 的规则 7(SHOULD+FILTER→MUST)和规则 9(SHOULD 嵌套展平)对执行性能影响最大——前者决定子句能否进入 ConjunctionDISI 的 cost 排序路径,后者决定 WANDScorer 能否做全局剪枝。
五、实战:一条 rewrite 规则如何改变执行路径
前面列了 11 条规则,这一节用具体例子展示:构建层的一条 rewrite 规则,如何直接影响执行层的路径选择。
假设你有一个查询:
{
"bool": {
"should": [
{ "term": { "status": "published" }},
{ "term": { "category": "ai" }}
],
"filter": [
{ "term": { "status": "published" }}
]
}
}
status:published 同时出现在 should 和 filter——规则 7 会把它提升为 MUST,并下调 minShouldMatch。下图假设提升后 minShouldMatch=0,执行路径因此发生根本变化:
┌─────────────────────────────────────────────────────────────────┐
│ 没有 rewrite 优化(假设) │
│ minShouldMatch=1,走 conjunction-disjunction mix 路径 │
│ │
│ ┌────────────────────────────┐ │
│ │ ConjunctionScorer │ │
│ │ ├─ FilterScorer │ published 出现两次: │
│ │ │ published (score=0) │ • FILTER 里遍历一遍(只过滤) │
│ │ └─ DisjunctionSumScorer │ • SHOULD 里再遍历一遍(评分) │
│ │ ├─ published │ = 同一个 term 被两个迭代器 │
│ │ └─ ai │ 各跑一遍,浪费! │
│ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ rewrite 优化后(实际) │
│ minShouldMatch=0,走 req+opt 路径 │
│ │
│ ┌────────────────────────────┐ │
│ │ ReqOptSumScorer │ published 只出现一次: │
│ │ ├─ req: published (有评分) │ • 作为 MUST,一次迭代同时 │
│ │ └─ opt: ai (可选加分) │ 完成过滤和评分 │
│ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
优化前,status:published 被两个迭代器各遍历一遍;优化后,一次迭代同时完成过滤和评分——一条 rewrite 规则,改变了执行路径的选择。它不做代价排序,但决定了哪些子句有资格进入更高效的路径。
六、动手验证:用 Profile API 观察 rewrite 效果
理论再多,不如自己跑一遍。Easysearch 的 Profile API 可以直接暴露 rewrite 后的查询形态,不需要读源码,几秒钟就能验证。
6.1 验证规则 7:SHOULD + FILTER → MUST 提升
准备好一个含有 status 和 category 字段的索引,执行:
GET /products/_search
{
"profile": true,
"query": {
"bool": {
"should": [
{ "term": { "status": "published" }},
{ "term": { "category": "ai" }}
],
"filter": [
{ "term": { "status": "published" }}
]
}
}
}
找到响应中 profile.shards[0].searches[0].query[0].description 字段(具体格式可能因版本略有不同):
- 你写的:SHOULD + FILTER 并存(两个地方都有
status:published) - 实际执行:
+status:published category:ai
注意 + 前缀——在 Lucene 的查询 description 语法中,+ 表示 MUST,没有符号表示 SHOULD。status:published 前面有 +,说明 rewrite 已经把它提升为 MUST,查询形态已经发生了变化。
6.2 验证规则 10:SHOULD 数量 == minimumShouldMatch → 全部提升为 MUST
GET /products/_search
{
"profile": true,
"query": {
"bool": {
"should": [
{ "term": { "status": "published" }},
{ "term": { "category": "ai" }}
],
"minimum_should_match": 2
}
}
}
profile 的 description 应该显示 +status:published +category:ai——两个 term 都带 + 前缀,说明全部提升为 MUST,这个查询实际上会走中篇要讲的 ConjunctionDISI 路径,而非 WANDScorer。
6.3 理解 Profile 响应的结构
一个完整的 Profile 响应包含大量信息,但读懂核心字段只需关注三个位置:
{
"profile": {
"shards": [{
"searches": [{
"query": [{
"type": "BooleanQuery",
"description": "+status:published +category:ai",
"breakdown": {
"next_doc": 12750, "next_doc_count": 1,
"advance": 0, "advance_count": 0,
"create_weight": 375375, "create_weight_count": 1,
"build_scorer": 248958, "build_scorer_count": 2
},
"children": [
{ "type": "TermQuery", "description": "status:published", ... },
{ "type": "TermQuery", "description": "category:ai", ... }
]
}],
"rewrite_time": 146958
}]
}]
}
}
快速解读三个关键位置:
| 字段 | 含义 | 怎么看 |
|---|---|---|
description |
rewrite 后的查询形态 | + 表示 MUST,无前缀表示 SHOULD,- 表示 MUST_NOT |
advance / advance_count |
迭代器跳转的耗时 / 次数 | 中篇核心指标。count 越小 = 跳转越少 = cost 排序效果越好 |
rewrite_time |
rewrite 阶段的总耗时 | 本篇 11 条规则的总执行时间,通常很小(微秒级) |
💡 小技巧:
breakdown里每个指标都有两个 key——xxx是耗时(纳秒),xxx_count是调用次数。想看"做了多少次"看_count,想看"花了多少时间"看不带_count的。
七、小结与预告
我们走完了布尔查询在执行前的两个构建层:
Easysearch 层做的是合法化处理——保持子句顺序,修补极端情况(纯否定、空查询、MatchNone 短路),设置 minShouldMatch。它不会改变你查询的"形状"。
Lucene rewrite 层通过 11 条逻辑等价改写规则改变查询的"形态":
- 规则 1-6 是防御性规则,消除空查询、冗余和矛盾
- 规则 7 和规则 10 是提升性规则,把更多子句导入 ConjunctionDISI 的合取路径,为 cost 排序创造更大的发挥空间
- 规则 9 是为析取优化准备的,展平 SHOULD 嵌套让 WANDScorer 能做全局剪枝
- 规则 8 是评分优化(合并重复 boost),规则 11 绕过不必要的评分计算
这两层都不做代价排序,但它们决定了哪些子句有资格进入更高效的执行路径。
下一篇预告
当 MUST 子句进入 Scorer 构造层,Lucene 会创建 ConjunctionDISI,对所有迭代器按 cost() 升序排序——最稀疏的放在第一位,承担"领头"角色,最大限度地用跳转(advance())跳过不满足条件的文档。
匹配文档最少的迭代器,为什么反而被选来驱动整个遍历?——它产生的候选集最小,所有迭代器的验证次数因此被压到最低。这个看似"以弱领强"的设计,正是合取查询性能优化的核心。我们在中篇,通过源码、图解和 Profile API 实测,把这个机制讲透。
浙公网安备 33010602011771号