AIGC标识 Easysearch 布尔查询优化(上)|写法不影响顺序,结构才影响性能

(此“上下”两篇的系列文章,是之前发布的一系列四篇的精简版)

INFINI Easysearch 是一款专注于企业级场景的分布式近实时搜索与分析引擎。它以 Apache Lucene 为内核进行了增强,在保持与行业主流搜索协议及开发生态完全兼容的同时,重点强化了安全性、稳定性、压缩率及信创兼容性。本系列以 Easysearch 为例,看一条布尔查询从发出到执行,引擎在背后替你做了哪些自动优化。


一、一个常见的误解

"must 里面,是不是应该把匹配文档少的条件写在前面?这样能提前过滤掉大量文档,性能更好?"

这个直觉来得很自然,但它是错的。

用户的直觉:
  must: [高频词, 低频词] → 慢
  must: [低频词, 高频词] → 快

实际情况:
  两种写法性能完全相同!
  Easysearch 执行时会自动按代价重排子句

Easysearch 在执行时会按代价自动重排子句顺序,与你写查询时的顺序无关。但"子句顺序不重要"并非处处成立,它有一条跟版本挂钩的边界——must/filter 里混入 prefix/wildcard 这类多词查询时,老版本引擎会重新对顺序敏感(见 §五末尾)。而且"子句顺序不重要"也不代表"怎么写都一样"——理解引擎自动优化的边界在哪里,才能设计出更合理的查询结构。

本系列分上下两篇。本篇讲前半程:从你发出 JSON 到查询开始执行,引擎做了哪些自动优化,以及合取(must / filter)查询是怎么提速的。下篇讲析取(should)查询的剪枝与实战验证。


二、一次布尔查询的完整旅程

先把全局地图建立起来。一条布尔查询就像一个包裹进入工厂流水线,经过三道工序:

                  你的 JSON bool 查询
                         │
                         ▼
   ┌──────────────────────────────────────┐
   │ 第一道  Easysearch 层                  │
   │ 质检:结构合法化,不碰子句顺序          │  ← §三
   └──────────────────┬───────────────────┘
                      ▼
   ┌──────────────────────────────────────┐
   │ 第二道  Lucene 改写层                   │
   │ 工艺:去重 / 提升 / 拍平,等价改写       │  ← §四
   └──────────────────┬───────────────────┘
                      ▼
   ┌──────────────────────────────────────┐
   │ 第三道  执行层(代价排序在此发生)        │
   │                                      │
   │     must / filter        should       │
   │      "都得满足"        "取最高分"       │
   │          │                │           │
   │      合取路径           析取路径         │
   │      cost 排序         WAND 剪枝        │
   │      最稀疏领头       高分优先推进       │
   │          │                │           │
   │       上篇 §五          下篇全文         │
   └──────────────────────────────────────┘

对应到文字,三道工序是:

  • 第一道(Easysearch 层):质检员检查包裹格式是否合规、缺不缺东西,但不重新排列里面的物品顺序。
  • 第二道(Lucene 改写层):工艺师合并重复部件、去掉矛盾组合、把"可选"升级为"必选"——改变的是包裹的内容结构,不是物品顺序。
  • 第三道(执行层):调度员拿到最终包裹,按每个部件的"处理成本"自动安排加工顺序——这才是代价排序发生的地方。在这里 must / filter 走合取路径,should 走析取路径。

一个关键认知:代价排序发生在第三道工序(执行层)。前两道只做逻辑等价改写——合并重复、升级类型、展平嵌套,但不改变查询结果。

顺带提一个容易忽略的回流:should 本来走右边的析取路径,但第二道改写里有条规则——当 should 的数量恰好等于 minimum_should_match 时,会把它们全部转成 must(见 §四规则三),于是又回到左边的合取路径。所以图里这条"分流"不是一成不变的,改写层有可能把子句从右边挪到左边。

本篇讲前两道工序,加上第三道里合取查询(must / filter)的部分。析取路径(should)留给下篇。


三、第一道工序:结构合法化,不碰顺序

你发出的 JSON 首先被 Easysearch 解析成内部查询对象。这一层很克制:保证查询结构合法,但不改变子句顺序。

它会做几类"修补":

  • 空查询处理:如果整个 bool 查询为空,退化成"匹配全部文档";如果某个 must / filter 子句注定匹配不到文档,整个查询直接返回空结果,省得白跑一趟。
  • 纯否定查询补全:如果查询只有 must_not、没有任何正向条件,引擎不知道"从哪些文档里排除",于是自动补一个"全部文档"作为基础集合。这个修补默认开启。
  • 解析 minimum_should_match:把你写的 "2"、"75%"、"3<75%" 等规格字符串,转成引擎认识的整数。

一句话:Easysearch 层不改变子句顺序,只做合法化修补。


四、第二道工序:自动改写查询形态

查询进入 Lucene 后,会经过一轮"逻辑等价改写"——不改结果,只改形态,为后续执行铺路。这一层有十几条规则,大部分是"打扫卫生"式的防御性规则(去重、删矛盾、删冗余),不必逐条记忆。我们只需要了解几条对性能影响较大的。

规则一:同一个条件既是"可选"又是"必选",就升级为"必选"

如果同一个子查询同时出现在 should 和 filter 里,引擎会把它从 should 提升为 must。

💡 类比:一个人同时是"候选人"(should)又是"已入职"(filter)。既然已经入职,直接列入正式编制(must)。

优化前:                      优化后:
  SHOULD: [term:A]              MUST:   [term:A]   ← 提升了
  FILTER: [term:A]              SHOULD: [term:B]
  SHOULD: [term:B]

这一条很关键,它直接改变了执行路径:提升后,这个条件从一个"可选加分"的角色,进入更高效的合取路径。

规则二:把嵌套的 should 拍平

如果 should 里又套了一个全是 should 的 bool,引擎会把内层子句全部提升到外层,变成同级。

优化前:                      优化后:
  SHOULD: term:A                SHOULD: term:A
  SHOULD: (内层 bool)           SHOULD: term:B
           SHOULD: term:B       SHOULD: term:C
           SHOULD: term:C

💡 为什么要拍平?拍平后,引擎能看清每个子句各自能贡献多少分,估算更紧,剪枝更狠。不拍平的话,内层 bool 是个黑盒,引擎只能按它最宽松的上界估,剪不动。下篇讲析取剪枝时会用到这点。

规则三:should 数量恰好等于 minimum_should_match,全部升级为 must

should: [A, B, C],minimum_should_match: 3
       ↓  等价于"三个都得满足"
must: [A, B, C]

💡 类比:开会时如果"3 个可选发言人必须全部到场",那"可选"就没意义了,等价于"3 个必须到场"。

这条规则在动态拼接查询时经常悄悄帮你:比如你把用户的多个筛选条件塞进 should,又把 minimum_should_match 设成条件数,引擎会自动转成更高效的 must 查询,无需手动改写。

规则四:重复子句合并权重

should 或 must 里出现相同子句时,引擎会把它们的权重相加合并,而不是各跑一遍。

should: [hello^1.5, hello^2.0, world]  →  should: [hello^3.5, world]

💡 类比:一个学生选了同一门课两次,一次记 1.5 学分,一次记 2 学分。不必上两次课,合并成 3.5 学分即可。

除这几条外,其余规则大多是去重、删矛盾、删冗余这类"打扫卫生",理解大意即可。

一条改写如何改变执行路径

看个具体例子:

{
  "bool": {
    "should": [
      { "term": { "status": "published" }},
      { "term": { "category": "ai" }}
    ],
    "filter": [
      { "term": { "status": "published" }}
    ]
  }
}

status:published 同时出现在 should 和 filter——规则一会把它提升为 must。优化前后对比:

没有优化(假设):               优化后(实际):
  status:published 出现两次:       status:published 只出现一次:
  • filter 里遍历一遍(只过滤)       • 作为 must,一次迭代同时
  • should 里再遍历一遍(评分)         完成过滤和评分
  = 同一个词被两个迭代器各跑一遍,浪费

一条改写规则,改变了执行路径的选择。它不做代价排序,但决定了哪些子句有资格走更高效的路径。


五、第三道工序(合取部分):让最稀疏的条件领跑

进入执行层后,每个 must / filter 子句会变成一个"文档迭代器"——你可以理解成这个条件对应的匹配文档列表的游标,负责告诉引擎"下一个匹配的文档号是谁"。

合取查询(must / filter,AND 语义)的核心优化是:按 cost 排序,让匹配文档最少的迭代器领头。

生活类比

找"已签收、发往北京、备注里有易碎品"的包裹。"已签收"和"发往北京"的包裹可能很多,但"备注里有易碎品"的很少。先从"易碎品"开始查,再确认它是否发往北京、是否已签收,比先遍历所有已签收包裹更快。

在 AND 查询里,谁的结果最少,谁最有"话语权"——因为所有条件都得满足,结果最少的那个条件能最快排除不满足的文档,其余条件只需确认即可。

执行过程

引擎把所有迭代器按 cost(预估匹配文档数)从小到大排序:

  • 领头(lead1):代价最小的迭代器,负责领头,它最稀疏,每次跳转跳过的文档最多。
  • 第二(lead2):代价第二小的,负责二次确认。
  • 其余:只在领头、第二都停下时才被调用。

执行就是"不断对齐":领头停在某个文档号,第二个跳过去看在不在这;如果第二个跳过了,说明这个文档不匹配,领头直接跳到第二个的位置继续——不会去逐个检查中间那些文档。

把三个迭代器画出来,按 cost 从小到大排好,对齐过程一目了然:

   迭代器(按 cost 升序)        命中的 docID(升序)         游标

   ┌──────────────────┐
   │ author:sam  500篇 │  ··· 42 ── 78 ── 203 ── ···      ▼ lead1 领头
   └──────────────────┘
   ┌──────────────────┐
   │ category:ai  1万 │  ··· 42 ──────── 203 ── ···        ▼ lead2 确认
   └──────────────────┘
   ┌──────────────────┐
   │ status:pub 100万 │  ··· 42 ── 78 ── ··· ── 203 ──    ▼ others 兜底
   └──────────────────┘

   ───────────────────────────────────────────→ docID 轴
                     42     78          203

   领头跳到 42 → lead2 在 42 ✓ → others 在 42 ✓ → 三者对齐,匹配!
   领头跳到 78 → lead2 advance(78) 落在 203(>78,没命中)→ 领头跳到 203
   领头跳到 203 → lead2 在 203 ✓ → others 追到 203 ✓ → 匹配!
   78 这篇 lead2 不在,直接跳过,others 根本没被叫起来
查询:must: [status:published(100万), category:ai(1万), author:sam(500)]

排序后:
  领头: author:sam     500 篇    ← 最稀疏,领头
  第二: category:ai    1万篇
  其余: status:published 100万篇

  领头 → doc=42 → 第二确认✓ → 其余确认✓ → 匹配!
  领头 → doc=78 → 第二确认✗ → 跳过(其余根本不用查)
  领头 → doc=203 → 第二确认✓ → 其余确认✓ → 匹配!

如果反过来让高频词领头,后面条件就得确认一大堆无效文档——慢。这就是为什么引擎要按 cost 重排,而不是用你写的顺序。

一个延伸:验证也要按成本排序

有些查询是"两阶段"的——先粗筛出可能匹配的文档,再精确验证。比如短语查询:先找到"包含全部词"的文档,再检查这些词的相对位置对不对。引擎会让便宜的验证先做,失败就直接短路。

💡 类比:先查身份证(快),再查指纹(慢),而不是反过来。身份证不对,指纹根本不用查。

一条边界:老版本里混入多词查询,顺序重新变敏感

前面的自动重排有个隐含前提:调度阶段能以极小代价逐个"试探"每个子句。TermQuery 满足——借助预建的 TermStates 可以 O(1) 判断某段有无匹配,没有就返回 null,直接短路掉还没轮到的子句。但 prefix / wildcard / regexp / range-on-keyword / fuzzy 这类多词查询在 Lucene 9.5 之前不满足:它们一旦被调度,就同步干重活——枚举全部 term、读倒排、建 bitset——干完才轮到下一个子句。

在一个 2900 万文档、23 个主分段的索引上实测,must 里放一个极稀疏的 term(命中 12 篇,只落在 6 个段)和一个极稠密的 prefix(展开约 11.7 万个 term):

must 写法 prefix.build_scorer_count 稳态耗时
[prefix, term] 36 ≈ 92 ms
[term, prefix] 6 ≈ 35 ms

两条查询逻辑等价,却差了近 3 倍:prefix 写前面时,其余 17 个段的 term 返回 null 触发整段短路,但 prefix 的 bitset 已经白白建好又扔掉;term 写前面时,这些段根本轮不到 prefix 出场。profile 里 build_scorer_count 从 36 掉到 6,就是证据。

💡 结论:稀疏廉价的子句写在前面,让它先行短路,昂贵的多词查询就不会被无效触发。

分界线是 Lucene 9.5(PR #12055):它为多词查询的 wrapper 实现了轻量的 ScorerSupplier,调度阶段只估成本不干活,重活推迟到真正取迭代器时才做,顺序自此真正无关。对应到版本:Easysearch 1.x 基于 Lucene 8.11,存在此问题,这类混合查询请手动把稀疏 term 放在前面;Easysearch 2.x 基于 Lucene 9.12,已包含修复,无需关心顺序。


六、用 Profile API 观察

理论不如动手跑。Easysearch 的 Profile API 能直接暴露改写后的查询形态。准备一个含 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 的查询描述里,+ 表示 must,没有符号表示 should。status:published 前面有 +,说明改写已经把它提升为 must 了。

description 三个符号速查:

符号 含义
+ must
无前缀 should
- must_not

再看一个例子:把 must 顺序反过来

上面看的是改写规则。再看 cost 排序的实证——用一个最简单的两个 must 查询,故意把高频词写前面:

GET /products/_search
{
  "profile": true,
  "query": {
    "bool": {
      "must": [
        { "term": { "status": "published" }},   // 约 900 篇
        { "term": { "category": "ai" }}         // 约 100 篇
      ]
    }
  }
}

在测试索引里 status:published 约 900 篇、category:ai 约 100 篇。实测 profile 的子节点(Easysearch 2.2.0):

子句 next_doc_count advance_count 角色
category:ai 91 1 低 cost,产生候选(领头)
status:published 0 91 跟随候选,用 advance 确认

category:ai 命中少,成了领头,不断用 nextDoc() 产生候选;status:published 命中多,只跟在后面用 advance() 确认——和你在 JSON 里把谁写在前面完全无关。

把两个 must 的顺序对调再查,profile 结果一模一样:category:ai 仍然是领头,status:published 仍然跟随。description 字段会保留你写的展示顺序(先 status 后 category),但真正执行时的迭代器顺序由 cost 排序决定。

这就是本篇强调那句话的实证:用户在 JSON 里先写谁,不等于执行时谁先跑。


七、本篇要点:写查询时该注意什么

既然子句顺序不影响性能,那写查询时该关注什么?

✅ 用 filter 代替 must,当不需要评分时

filter 不参与评分,走更高效的路径,也不会增加评分子句的调度开销。需要过滤但不需要算相关性分数时,用 filter。

✅ 不必手动把 should 改成 must

当 should 数量恰好等于 minimum_should_match 时,引擎会自动把所有 should 提升为 must。只要语义等价,引擎会替你做正确的选择,不用手动"帮"它。

✅ 避免嵌套过深的布尔查询

should 里嵌套 should 时,尽量手动拍平或让引擎自动拍平。嵌套结构会让引擎看不清内层子句,估算偏松,剪枝打折。

❌ 不必刻意调整子句顺序(Easysearch 2.x)

底层会自动按 cost(合取)或 maxScore(析取)排序。先写高频词还是低频词,执行时的迭代顺序完全相同。唯一例外见 §五:Easysearch 1.x(Lucene 8.11)上 must/filter 混有多词查询时,仍应把稀疏 term 写在前面;Easysearch 2.x(Lucene 9.12)已随 Lucene 9.5 的修复免疫此问题。

✅ 理解 cost 的含义

cost 是"匹配文档数的估算",不是执行时间。一个高 cost 的 term 查询可能因为倒排表连续存储而很快,一个低 cost 的范围查询反而可能要更贵的验证。Profile 里的 advance 次数才是真实性能的反映。


🔍 想翻源码:合取路径的选型和 cost 排序在 Lucene 的 Boolean2ScorerSupplier(决定走哪条路)和 ConjunctionDISI(构造时对迭代器按 cost() 排序,代价最小的领头)。


下篇预告

本篇讲的是"所有条件都得满足"的合取查询——谁最少谁领头。下篇讲析取(should)场景:不需要全部匹配,而是找分数最高的 K 个文档。优化目标从"最少匹配"变成"最高分数",引擎会换一套完全不同的剪枝策略——WAND 算法登场,并且有一个开关(track_total_hits)决定剪枝开不开。


想在自己的数据上验证这些优化?下载 Easysearch,建个索引、把本文的 Profile 示例跑一遍——改写规则和 cost 排序的现象都能直接观察到。Easysearch 兼容 Elasticsearch 查询 DSL,手头的 ES 查询可以直接拿来跑。

posted @ 2026-06-24 13:54  tlfeng  阅读(18)  评论(0)    收藏  举报