--- title: "Easysearch 布尔查询子句重排序(四)|Block-Max、实战与验证" date: 2026-08-28 lastmod: 2026-08-28 description: "本文是系列第四篇,Block-Max WAND 块级剪枝、混合查询端到端追踪、Profile API 亲手验证" tags: ["Easysearch", "布尔查询", "BoolQuery", "排序"] summary: "Block-Max WAND 块级剪枝、混合查询端到端追踪、Profile API 亲手验证 INFINI Easysearch 是一款专注于企业级场景的分布式近实时搜索与分析引擎。它以 Apache Lucene 为内核进行了增强,在保持与行业主流搜索协议及开发生态完全兼容的同时,重点强化了安全性、稳定性、压缩率及信创兼容性。 引言:从标准 WAND 到块级剪枝 # 前三篇我们走完了 Easysearch 布尔查询优化的前半程: 第一篇讲 rewrite 11 条规则, 第二篇讲合取的 cost 排序, 第三篇讲析取的 WAND 算法——用 maxScore 上界 + 三堆调度,把"上界够不到及格线的文档"在打分前就剪掉。 但标准 WAND 留了个明显短板:每个子句的上界是全局的——“这个词在全索引最多能打多少分”。游标走到低分文档区,上界仍按全局最高估,剪枝不够激进。本文就围绕这块补全,并把前面所有内容串起来做实战和验证: 一 Block-Max WAND——把全局上界换成"按块分段"的上界,整块低分文档一次跳过 二 实战——MUST + SHOULD 混合查询的端到端追踪,看前几篇内容怎么协作 三 Profile API——用对比实验亲手验证 WAND 生效,避开常见误读 四 开发者启示 五 全系列总结 一、Block-Max WAND 的增强 # 1.1 一个类比:为什么全局上界会"过乐观" # 想象一场考试,每个学生最多能考 100 分。现在老师想知道"有没有人超过 90 分"。" --- > Block-Max WAND 块级剪枝、混合查询端到端追踪、Profile API 亲手验证 _INFINI Easysearch 是一款专注于企业级场景的分布式近实时搜索与分析引擎。它以 Apache Lucene 为内核进行了增强,在保持与行业主流搜索协议及开发生态完全兼容的同时,重点强化了安全性、稳定性、压缩率及信创兼容性。_ --- ## 引言:从标准 WAND 到块级剪枝 前三篇我们走完了 Easysearch 布尔查询优化的前半程:[第一篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-1/)讲 rewrite 11 条规则,[第二篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-2/)讲合取的 cost 排序,[第三篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-3/)讲析取的 WAND 算法——用 maxScore 上界 + 三堆调度,把"上界够不到及格线的文档"在打分前就剪掉。 但标准 WAND 留了个明显短板:每个子句的上界是**全局**的——"这个词在全索引最多能打多少分"。游标走到低分文档区,上界仍按全局最高估,剪枝不够激进。本文就围绕这块补全,并把前面所有内容串起来做实战和验证: - **一 Block-Max WAND**——把全局上界换成"按块分段"的上界,整块低分文档一次跳过 - **二 实战**——MUST + SHOULD 混合查询的端到端追踪,看前几篇内容怎么协作 - **三 Profile API**——用对比实验亲手验证 WAND 生效,避开常见误读 - **四 开发者启示** - **五 全系列总结** --- ## 一、Block-Max WAND 的增强 ### 1.1 一个类比:为什么全局上界会"过乐观" 想象一场考试,每个学生最多能考 100 分。现在老师想知道"有没有人超过 90 分"。 - **标准 WAND 的做法**:每个学生头顶都贴着"100"(全局上限)。老师得把所有人都叫过来核对,因为光看标签谁都"可能"过 90。 - **聪明的做法**:把学生按班级分组,**每个班只记一个"本班最高分"**。如果某班最高才 60,整班直接跳过,不用一个个核对。 Block-Max WAND 就是这个"按班级分组"的做法。倒排链在物理存储上本来就是**分块**的——Lucene 把每 **128 篇文档**压缩成一个 block([`ForUtil.BLOCK_SIZE = 128`](https://github.com/apache/lucene/blob/releases/lucene/9.12.2/lucene/core/src/java/org/apache/lucene/codecs/lucene912/ForUtil.java#L32)),写入时顺手记下"这个 block 内最高能打多少分"。这个**分段级上界**比全局 maxScore 紧得多——因为它只看本 block 的数据,不会被远处的高分文档带偏。 > **一句话区分**:标准 WAND 的上界是"这个词在**全索引**最多能打多少分";Block-Max 的上界是"这个词在**当前这个 block**最多能打多少分"。后者永远 ≤ 前者,所以更容易触发剪枝。 ### 1.2 分段上界 vs 全局上界:一张图看懂 ``` 标准 WAND Block-Max WAND ┌──────────────┐ ┌────────────────────────────────┐ docID │全局上界=9 │ │块0 doc 0-127 max=9 高分段 │ ↑ │不管游标到哪 │ ├────────────────────────────────┤ │上界都是=9 │ │块1 doc 128-255 max=5 中分段 │ │ │ ├────────────────────────────────┤ │ │ │块2 doc 256-383 max=2 低分段★ │ └──────────────┘ └────────────────────────────────┘ 及格线 = 8,游标已进入 Block 2(低分段): 标准 WAND:上界估 9(全局),9 ≥ 8 → 不剪,逐文档查 Block-Max:上界估 2(本块), 2 < 8 → 跳过整个 block ``` 两种策略的对比一目了然——**同一个及格线 8、同一个 Block 2**:标准 WAND 还用全局 max=9 估计,以为"可能过线"就逐文档查(保守);Block-Max 用本块 max=2 估计,一眼看出整块无望直接跳过(激进),**一次比较换掉 128 次 advance**。 ### 1.3 源码层面:三个动作怎么串起来 Block-Max 在标准 WAND 之外多调了两个底层方法,理解它们的名字就抓住了主线。这两个方法由每个子句的 `Scorer` 实现(如 `TermWeight` 内部的 `ImpactsScorer`),WANDScorer 通过自己的私有协调方法 [`WANDScorer.updateMaxScores()`](https://github.com/apache/lucene/blob/releases/lucene/9.12.2/lucene/core/src/java/org/apache/lucene/search/WANDScorer.java) 把它们串进三堆调度: - **`advanceShallow(target)`** —— "浅推进"。只跳到 target 所在的 block 边界,**不逐文档解码**。开销远低于真正的 `advance(target)`,相当于"瞄一眼那个班的最高分标签"。 - **`getMaxScore(upTo)`** —— 返回"当前 block 内"的最高分上界。这是上面图里 `max=2` 那个数的来源,比全局 maxScore 紧得多。 - **`updateMaxScores()`** —— 协调者:游标进入新 block 时,用上述两个方法重算每个子句的分段 maxScore,替换掉之前用的全局值,重新累加上界。发现 tail 上界已经够线,就把高分 essential 子句推进到 head 去实际查。 三者配合后,[第三篇 §3.7](/blog/2026/easysearch-boolean-query-clause-reordering-part-3/#37-数字示例一次完整的剪枝流程) 那张状态图右侧的 `tailMax` 数值会**逐 block 变小**——因为用的是分段上界而不是全局上界,更容易跌破及格线。 ### 1.4 最底层:ImpactsDISI 的块级跳过 分段信息从哪来?写入索引时就备好了。Lucene 的 **Impacts** 数据结构在 flush 阶段为每个 block 预计算"本块内各 (freq, norm) 组合对应的最高分",查询时由 `MaxScoreCache`([`MaxScoreCache.java:72-78`](https://github.com/apache/lucene/blob/releases/lucene/9.12.2/lucene/core/src/java/org/apache/lucene/search/MaxScoreCache.java#L72-L78))读出来。 真正执行跳过的是 [`ImpactsDISI`](https://github.com/apache/lucene/blob/releases/lucene/9.12.2/lucene/core/src/java/org/apache/lucene/search/ImpactsDISI.java)。它内嵌一个 `minCompetitiveScore`,每次游标要 `advance(target)` 时先做一道判断([`ImpactsDISI.java:68-100`](https://github.com/apache/lucene/blob/releases/lucene/9.12.2/lucene/core/src/java/org/apache/lucene/search/ImpactsDISI.java#L68-L100)): ```java upTo = maxScoreCache.advanceShallow(target); // 找到 target 所在 block 的末尾 docID maxScore = maxScoreCache.getMaxScoreForLevelZero(); // 本块分段上界 while (maxScore < minCompetitiveScore) { // 本块最高分都够不到及格线 target = maxScoreCache.getSkipUpTo(...) + 1; // 直接跳到下一个可能有戏的 block upTo = maxScoreCache.advanceShallow(target); maxScore = maxScoreCache.getMaxScoreForLevelZero(); } in.advance(target); // 只在"有戏"的 block 内才真正推进 ``` 效果:**跳过的单位从"单个文档"升级到"整个 block(128 篇)"**。一次 `advanceShallow` 比较,省掉最多 128 次 `advance`。 > **名词对照**:`advanceShallow` / `getMaxScore` / `Impacts` / `ImpactsDISI` / `MaxScoreCache` 是源码里的正式名字;"分段上界""块级跳过""block"是本文用的通俗说法,指的是同一回事。 ### 1.5 剪枝的反馈闭环:从"猜"到"越来越准" 把第一章的几层串起来看,剪枝是一个**自适应加速**的闭环: ``` Collector 收满 K 个结果 │ │ setMinCompetitiveScore(kthBestScore) ▼ WANDScorer 收到及格线(抬高) │ │ ① matches():用 lead+tail 的 maxScore 上界跳过低分候选 │ ② updateMaxScores():用分段上界替换全局上界,上界变紧 ▼ ImpactsDISI 收到分段级 minCompetitiveScore │ │ 整个 block 最高分都 < 及格线 → 跳过 128 篇 ▼ 迭代更快 → 更快碰到高分文档 → Collector 收到更高分 │ │ setMinCompetitiveScore(...) 再次抬高 ▼ (循环)及格线越抬越高,剪枝越来越激进 ``` 这个闭环的关键在于**双向反馈**:Collector 把及格线往下传给 WANDScorer(决定哪些文档跳过),WANDScorer 再往下传给 ImpactsDISI(决定哪些 **block** 跳过)。一开始及格线低、剪枝保守(还没见过高分文档,不敢贸然跳);随着高分结果不断收集,线越抬越高,剪枝越来越激进——这就是 §三里 `set_min_competitive_score_count` 持续增长的来源。 --- ## 二、实战:MUST + SHOULD 混合查询的端到端追踪 结合前面几篇内容,用一个混合查询走完整路径: ```json GET /products/_search { "query": { "bool": { "must": [ { "term": { "status": "published" } }, { "term": { "category": "ai" } } ], "should": [ { "term": { "author": "sam" } }, { "term": { "tag": "featured" } } ], "minimum_should_match": 1 } } } ``` ### 2.1 一张图看清 Scorer 嵌套结构 这个查询最终会被组装成一棵 Scorer 树。先看树的样子,再逐层解释: ``` BooleanScorer(顶层,协调 MUST 与 SHOULD 的关系) │ ┌───────────────┴────────────────┐ │ │ MUST 侧(合取) SHOULD 侧(析取,size=10 → TOP_SCORES) │ │ ConjunctionScorer WANDScorer (按 cost 排序,最稀疏领头) (按 maxScore 调度,剪枝省打分) │ │ ┌───────┴────────┐ ┌───────┴────────┐ │ │ │ │ category:ai status:published author:sam tag:featured (cost≈1万) (cost≈100万) (maxScore 高) (maxScore 低) ↑ lead1 ↑ lead2 └── tail 堆按 maxScore 排 (最稀疏,领头跳) (高分优先推进查实) ``` 这张树图把前面几篇内容串到了一起——每个 Scorer 的选型都有明确理由: - **MUST 侧(合取)**:两个条件都要满足,用 `ConjunctionScorer`([第二篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-2/))。按 cost 升序排列,让 `category:ai`(只命中 1 万篇,最稀疏)当 lead1 领头跳,`status:published`(命中 100 万篇)当 lead2 跟进。**lead1 跳得快,整个 MUST 侧就快**。 - **SHOULD 侧(析取)**:两个条件满足一个就行,且查询是 Top-10(`size=10`),走 `WANDScorer`([第三篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-3/))。两个 SHOULD 子句按 maxScore 进 tail 堆,高分优先推进。 - **顶层 BooleanScorer**:把 MUST 的命中文档交给 SHOULD 侧打分。MUST 过滤掉绝大部分文档后,WAND 只需对剩下的少数文档做上界判断,二者职责互补。 ### 2.2 执行追踪:一篇文档怎么走过这棵树 假设 MUST 侧的 lead1(`category:ai`)跳到 doc=X,整个追踪如下: ``` ① MUST 侧 ConjunctionScorer: lead1 (category:ai) advance 到 doc=X → lead2 (status:published) 确认 doc=X 也在 → MUST 命中 ✓ ② 顶层 BooleanScorer 把 doc=X 交给 SHOULD 侧打分 ③ SHOULD 侧 WANDScorer: 把 author:sam、tag:featured 的游标推进到 doc=X → 上界判断(leadMax + tailMax vs 及格线) → 通过 → score() 算实际分 ④ Collector 收集 doc=X 的分数,必要时抬高 minCompetitiveScore → 反馈回 WANDScorer 和 ImpactsDISI(第一章所说的反馈闭环) ``` ### 2.3 几篇内容怎么协作 回看整个过程,几篇内容各管一段,缺一不可: - **[第一篇] rewrite** 保证了查询结构简洁(去重、提升、展平)——这一步决定 Scorer 树的**形态**。 - **[第二篇] cost 排序** 让 MUST 侧高效迭代(最稀疏的 `category:ai` 领头)——决定**合取侧**的跳过效率。 - **[第三篇] WAND 调度** 让 SHOULD 侧高效剪枝(高分优先 + 动态上界)——决定**析取侧**的打分开销。 - **本文 Block-Max** 让 SHOULD 侧的上界更紧、剪枝更激进——决定**析取侧**的块级跳过力度。 - **顶层合取** 把两者组合:MUST 先把文档数砍到很小,WAND 再在这个小集合里挑 Top-K,各自发挥所长。 --- ## 三、动手验证:Profile API 观察 WAND 生效 用对比实验验证 WAND 的反馈闭环:`track_total_hits: false` 切入 `TOP_SCORES` 模式(WAND 生效),`track_total_hits: true` 强制 `COMPLETE` 模式(WAND 剪枝关闭),对比 profile 的 `breakdown`。 ### breakdown 字段含义 profile 中每个查询节点的 `breakdown` 是一个 Map。每个操作有两条记录:**不带 `_count` 后缀的是纳秒耗时,带 `_count` 后缀的是调用次数**。与 WAND/Block-Max 最相关的几个: | 字段 | 含义 | 与 WAND/Block-Max 的关系 | | --------------------------------------------------------------- | --------------------------------- | ------------------------------- | | `match` / `match_count` | TwoPhaseIterator `matches()` 验证 | WAND 的上界判断在此发生 | | `score` / `score_count` | `score()` 实际打分 | 仅对通过 `matches()` 的命中调用 | | `shallow_advance` / `shallow_advance_count` | `advanceShallow()` 块级定位 | **Block-Max 独有**:块级跳过 | | `compute_max_score` / `compute_max_score_count` | `getMaxScore()` 计算分段上界 | **Block-Max 独有** | | `set_min_competitive_score` / `set_min_competitive_score_count` | Collector 回传最低分阈值 | **WAND 反馈闭环的直接证据** | ### 构造一个能体现剪枝的数据集 WAND 的文档级剪枝要体现为 `score_count` 下降,需要"**少量高分文档 + 海量低分文档**"的分布:高分文档先把 Top-K 的及格线抬到高位,低分文档的上界够不到线,于是被整批跳过。为此建一个专门的索引 `wand_msm`: - **100 篇** `body = "rare common"` —— 同时命中 `rare`(高 idf)和 `common`,分数 ≈ 5.30 - **20000 篇** `body = "common extra"` —— 命中 `common` + `extra`,两个都是高频低 idf 词,分数 ≈ 0.01 > 为什么加 `minimum_should_match: 2`?这是触发 `WANDScorer` 的关键。Lucene 的 [`Boolean2ScorerSupplier.opt()`](https://github.com/apache/lucene/blob/releases/lucene/9.12.2/lucene/core/src/java/org/apache/lucene/search/Boolean2ScorerSupplier.java#L262-L263) 在 `minShouldMatch > 1` 时返回 `WANDScorer`;而**纯析取**(无 `minimum_should_match` 或 ≤1)走的是另一条 `MaxScoreBulkScorer` 路径([BooleanWeight.java:224](https://github.com/apache/lucene/blob/releases/lucene/9.12.2/lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java#L224))。两者同属 Block-Max 动态剪枝家族,但要直接观察 `WANDScorer` + Block-Max 的剪枝,用 `minimum_should_match: 2` 最干净。 ```json GET /wand_msm/_search { "profile": true, "track_total_hits": false, "size": 10, "query": { "bool": { "should": [ { "term": { "body": "rare" }}, { "term": { "body": "common" }}, { "term": { "body": "extra" }} ], "minimum_should_match": 2 } } } ``` ### 真实 profile 输出(Easysearch 2.2.0 / Lucene 9.12.2 实测) ``` ############ track_total_hits = false(WAND 生效)############ BooleanQuery (body:rare body:common body:extra)~2 breakdown: next_doc_count 101 match_count 100 score_count 100 ← 只给 100 篇高分候选打了分 set_min_competitive_score_count 1 ← 反馈闭环生效! body:common (TermQuery) advance_count 100 ← common 链只推进到高分文档区 shallow_advance_count 3 ← Block-Max 块级定位 compute_max_score_count 3 ← Block-Max 分段上界计算 body:extra (TermQuery) advance_count 1 ← extra 子句几乎整链跳过! ############ track_total_hits = true(WAND 剪枝关闭)############ BooleanQuery breakdown: next_doc_count 20101 match_count 20100 score_count 20100 ← 给全部 20100 篇命中文档都打了分 set_min_competitive_score_count 0 ← COMPLETE 模式,从不回传阈值 body:common advance_count 20100 body:extra advance_count 20001 (shallow_advance_count / compute_max_score_count 均为 0) ``` 两组的 `score_count` 相差悬殊——**false 模式 100、true 模式 20100,差了 200 倍**。WAND 把 20000 篇低分的 `common extra` 文档在打分前就跳过了,只对真正可能进 Top-10 的 100 篇高分候选调用了 `score()`。两组返回的 Top-10 完全相同(全是 `h0`–`h9`,`score=5.2984`)——**剪枝只省功,不改结果**。 ### 怎么读这份输出 把两组数据并排看: | 字段 | `track_total_hits: false`(WAND 生效) | `track_total_hits: true`(WAND 关闭) | 解读 | | ---------------------------------------- | :------------------------------------: | :-----------------------------------: | ---------------------------------------------------------------------- | | **`score_count`** | **100** | **20100** | **文档级剪枝的直接证据**:WAND 跳过 99.5% 的低分文档,只对高分候选打分 | | **`set_min_competitive_score_count`** | **1** | **0** | 反馈闭环:只有 false 模式下 Collector 才回传阈值 | | `shallow_advance_count`(common 子句) | **3** | **0** | **Block-Max 铁证**:只在 false 模式做块级 `advanceShallow` | | `compute_max_score_count`(common 子句) | **3** | **0** | **Block-Max 铁证**:只在 false 模式算分段上界 | | `advance_count`(extra 子句) | **1** | 20001 | 低分上界子句被 WAND 几乎整链跳过 | | `next_doc_count` | 101 | 20101 | false 模式连外层迭代都提前结束了 | | `hits.total` | 省略 | `{"value":20100,"relation":"eq"}` | false 模式不精确计数、允许早退 | 三条证据互相印证: - **`set_min_competitive_score_count` 从 0 变 1**:Collector 在收集到第 K 个高分结果后,把当前最低分回传给 WANDScorer(`Scorer.setMinCompetitiveScore`),这是 §一"剪枝反馈闭环"在 profile 里的落地。`track_total_hits: true` 时 `TopScoreDocCollector` 的 `hitsThresholdChecker` 是 no-op(`isThresholdReached()` 恒为 false),永不回传,所以为 0。 - **`score_count` 从 20100 暴跌到 100**:20000 篇 `common extra` 文档的上界(≈0.01)够不到及格线(≈5.30),在 `score()` 之前就被跳过——这正是 WAND"用廉价的上界比较换掉昂贵的打分"的直接体现。 - **`shallow_advance_count` / `compute_max_score_count` 只在 false 模式非零**:这正是 §一所讲 Block-Max 的 `advanceShallow()` / `getMaxScore()` 在底层被调用的痕迹。注意它们挂在 **TermQuery 叶子节点**上、而不是 BooleanQuery 顶层节点——后者这两个字段恒为 0。 > ⚠️ 另需注意:`score_count` 反映的是进入 `collect()` 的打分次数,只有在"高分文档先出现、低分文档上界够不到及格线"的分布下才会明显下降——若所有命中文档分数接近(例如每篇内容雷同),及格线抬不上去,`score_count` 就不会降。 ### 对比实验:观察耗时差异 将 `track_total_hits` 改为 `true`,同样查询再次执行,对比 `score` 耗时(纳秒,单次运行波动较大,下为多次中位数): ``` track_total_hits=false: score ≈ 37,000 ns score_count = 100 track_total_hits=true: score ≈ 2,640,000 ns score_count = 20100 (约 70 倍) ``` 本例剪枝比例高达 99.5%,false 模式的 `score` 耗时约只有 true 模式的 1/70——因为它只对 1% 的文档真正打分。数据集越大、剪枝比例越高,WAND 的收益越显著。(profile 本身有额外开销、绝对数字仅供参考,关键看两种模式的相对差距。) ### 小结 判断 Block-Max WAND 是否生效,看 `set_min_competitive_score_count` 是否从 0 变非零(机制是否触发);评估收益大小,看 `score_count` 的下降幅度与 `score` 耗时的相对差距。 --- ## 四、开发者启示:写查询时该注意什么? 既然子句顺序在 Easysearch 2.x 上无需操心,那开发者应该关注什么? ### ✅ 用 filter 代替 must 当不需要评分时 filter 不参与评分,走 `ConstantScoreQuery` 路径更高效。同时,filter 子句不会增加 WANDScorer 的调度开销——它们被提升到 MUST 后走合取路径,与评分子句的 WAND 调度互不干扰。 ### ✅ 让 Lucene 做 minShouldMatch 优化 当 should 数量恰好等于 `minimumShouldMatch` 时,所有 SHOULD 子句自动提升为 MUST([第一篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-1/)规则 10),走 ConjunctionScorer 而非 WANDScorer。这意味着:**你不需要手动把 should 改成 must 来"帮助"优化器——只要语义上等价,Lucene 会自动做正确的选择。** ### ✅ 避免嵌套过深的布尔查询 展平 SHOULD 嵌套能让 WAND 看到更多子句([第一篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-1/)规则 9)。嵌套结构下,内层 BooleanQuery 的子句对外层 WANDScorer 不可见,maxScore 上界估计不够紧,剪枝效果打折。**手动展平**或让 rewrite 自动展平,都能提升 WAND 效率。 ### ✅ 理解 cost 的含义 cost ≈ 匹配文档数的估算,不是执行时间。一个高 cost 的 TermQuery 可能有极佳的缓存局部性(因为倒排列表是连续存储的),而一个低 cost 的 PointRangeQuery 可能需要更昂贵的验证逻辑。Profile API 的 `advance` 次数分布才是实际性能的真实反映。 ### ❌ 不必刻意调整子句顺序(Easysearch 2.x) 底层会自动按 cost(合取)或 maxScore(析取)排序。无论是先写高频词还是低频词,执行时的迭代顺序完全相同。唯一例外见[第二篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-2/) §八的边界说明:Easysearch 1.x(Lucene 8.11)上 must/filter 混有多词查询时,仍应把稀疏 term 写在前面;Easysearch 2.x(Lucene 9.12)已随 Lucene 9.5 的修复免疫此问题。 ### 🔧 Easysearch 的额外能力 在 Lucene 标准优化栈之外,EasySearch 还提供一个面向特定场景的增强能力: **fast_terms 插件**:在高基数字段(如 user_id、tag_id 数万量级)上,用 RoaringBitmap 位图集合替代 N 个独立 TermQuery,将 N 次倒排索引查找压缩为 1 次 bitmap 交集。查询 DSL 名为 `fast_terms`,但其在 Profile 中对应的 `type` 是底层 Query 类的简单名 **`FilterQuery`**(`type` 取自 `query.getClass().getSimpleName()`,而插件实现类是 `org.infinilabs.query.FilterQuery`)。底层 `FilterQuery` 走 `ConstantScoreWeight` + 位图迭代器,`advance` 调用次数从 O(N·命中数) 降到 O(命中数)。 它走自己的 Scorer 体系、不参与 `BooleanQuery` 的 rewrite / cost 排序 / WAND 剪枝路径,可按需叠加。 --- ## 五、全系列总结 四篇文章,从构建层到执行层,从合取到析取,我们完整走过了 EasySearch 布尔查询的优化全景: 1. **构建层([第一篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-1/)):11 条 rewrite 规则** Easysearch + Lucene 的构建层**不做代价排序**,但通过 11 条代数规则自动简化查询结构。其中对执行性能影响最大的两条——**规则 7(SHOULD+FILTER→MUST 提升)**和**规则 9(SHOULD 嵌套展平)**——改变了子句的执行路径,为后续优化铺路。Profile API 可以直接观察 rewrite 后的查询形态(`+` 前缀 = MUST 提升成功)。 2. **执行层·合取([第二篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-2/)):cost 排序 + leadCost 传播** ConjunctionDISI 按 cost 排序,让最稀疏的迭代器领头——它是跳过无效文档速度最快的那个。两阶段验证按 matchCost 排序,leadCost 实现从外向内的策略传播(如 IndexOrDocValuesQuery 的自适应切换)。Profile 的 `advance` 次数分布是最直接的佐证:lead1 的 advance 次数远大于 followers。 3. **执行层·析取 I([第三篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-3/)):WAND 动态调度** WANDScorer 按 maxScore 动态调度子句,高分优先推进——这是 Top-K 查询能早退的根本保障。三堆(head/lead/tail)分工合作,用大量廉价的上界比较换掉少量昂贵的精确打分。 4. **执行层·析取 II(本文):Block-Max 块级剪枝** Block-Max WAND 在块级别进一步剪枝:用分段级 maxScore 替换全局 maxScore,整块低分文档一次跳过。`shallow_advance` 字段可验证其效果。剪枝的反馈闭环——Collector → WANDScorer → ImpactsDISI——实现自适应加速:越到后面,剪枝越激进。 ### 给开发者的核心建议 1. **无需关心子句书写顺序**——底层自动优化。Easysearch 2.x(Lucene 9.12)确实如此;1.x 基于 Lucene 8.11,must/filter 混有 prefix/wildcard 等多词查询时,仍建议把稀疏的 term 子句写在前面(见[第二篇](/blog/2026/easysearch-boolean-query-clause-reordering-part-2/) §八的边界说明) 2. **关注查询结构设计**——用 filter 替代不需要评分的 must,展平嵌套 SHOULD 3. **善用 Profile API**——`set_min_competitive_score_count`(反馈闭环是否生效)、`advance`/`shallow_advance` 次数(跳过力度)、`score` 耗时(打分成本)是核心观测指标;注意不带 `_count` 后缀的是纳秒耗时,带 `_count` 的才是次数 4. **理解 WAND 的触发条件**——`track_total_hits: false` 切入 `TOP_SCORES` 开启剪枝(纯析取走 `MaxScoreBulkScorer`,`minimum_should_match > 1` 才走 `WANDScorer`),`track_total_hits: true` 走 `COMPLETE` 关闭剪枝;对比 `set_min_competitive_score_count` 是否从 0 变非零是判断剪枝是否生效最直接的方式,`score_count` 的下降幅度则量化了实际收益