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

📅 2026/6/26 22:56:20
Easysearch 布尔查询优化(上)|写法不影响顺序,结构才影响性能
这个“上下”两篇的系列文章是另外四篇的精简版从一个常见误解说起must 里把匹配少的条件写前面真的更快吗一、一个常见的误解“must 里面是不是应该把匹配文档少的条件写在前面这样能提前过滤掉大量文档性能更好”这个直觉来得很自然但它是错的。用户的直觉 must: [高频词, 低频词] → 慢 must: [低频词, 高频词] → 快 实际情况 两种写法性能完全相同 Easysearch 执行时会自动按代价重排子句Easysearch 在执行时会按代价自动重排子句顺序与你写查询时的顺序无关。但子句顺序不重要不代表怎么写都一样——理解引擎自动优化的边界在哪里才能设计出更合理的查询结构。本系列分上下两篇。本篇讲前半程从你发出 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%、375%等规格字符串转成引擎认识的整数。一句话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全部升级为 mustshould: [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 / filterAND 语义的核心优化是按 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) 落在 20378没命中→ 领头跳到 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万篇 领头 → doc42 → 第二确认✓ → 其余确认✓ → 匹配 领头 → doc78 → 第二确认✗ → 跳过其余根本不用查 领头 → doc203 → 第二确认✓ → 其余确认✓ → 匹配如果反过来让高频词领头后面条件就得确认一大堆无效文档——慢。这就是为什么引擎要按 cost 重排而不是用你写的顺序。一个延伸验证也要按成本排序有些查询是两阶段的——先粗筛出可能匹配的文档再精确验证。比如短语查询先找到包含全部词的文档再检查这些词的相对位置对不对。引擎会让便宜的验证先做失败就直接短路。 类比先查身份证快再查指纹慢而不是反过来。身份证不对指纹根本不用查。六、用 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_countadvance_count角色category:ai911低 cost产生候选领头status:published091跟随候选用 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 时尽量手动拍平或让引擎自动拍平。嵌套结构会让引擎看不清内层子句估算偏松剪枝打折。❌ 不必刻意调整子句顺序底层会自动按 cost合取或 maxScore析取排序。先写高频词还是低频词执行时的迭代顺序完全相同。✅ 理解 cost 的含义cost 是匹配文档数的估算不是执行时间。一个高 cost 的 term 查询可能因为倒排表连续存储而很快一个低 cost 的范围查询反而可能要更贵的验证。Profile 里的 advance 次数才是真实性能的反映。 想翻源码合取路径的选型和 cost 排序在 Lucene 的Boolean2ScorerSupplier决定走哪条路和ConjunctionDISI构造时对迭代器按cost()排序代价最小的领头。下篇预告本篇讲的是所有条件都得满足的合取查询——谁最少谁领头。下篇讲析取should场景不需要全部匹配而是找分数最高的 K 个文档。优化目标从最少匹配变成最高分数引擎会换一套完全不同的剪枝策略——WAND 算法登场并且有一个开关track_total_hits决定剪枝开不开。