提问:布和纸怕什么?

【搜索客社区日报】第2307期 (2026-10-09)

社区日报 • Fred2000 发表了文章 • 0 个评论 • 323 次浏览 • 1 天前 • 来自相关话题

1、CQ-SID:LLM生成式检索在手猫搜索召回中的探索与应用
https://mp.weixin.qq.com/s/USAPkWw031i8d2D37jBqtg

2、用 Jev 做搜索重排:基准与实现
https://mp.weixin.qq.com/s/tnRBIVW0dB9UsDjOc-z1gQ

3、Easysearch 原生集成国密算法:打造全链路自主可控搜索底座(二)
https://infinilabs.cn/blog/202 ... pto2/

4、Doris 直查 Paimon 索引:快手湖上向量检索的共建与落地
https://my.oschina.net/selectdb/blog/19757897

5、Elasticsearch 中使用 NVIDIA cuVS GPU 加速构建向量索引:1.38 亿个向量不到 10 分钟
https://mp.weixin.qq.com/s/gEuiL5Nf5P-jaS2TNr-9sA

编辑:Fred
更多资讯:http://news.searchkit.cn

Easysearch 布尔查询子句重排序(二)|ConjunctionDISI 按 cost 排序源码解析

Easysearch • INFINI Labs 小助手 发表了文章 • 0 个评论 • 4827 次浏览 • 2026-09-29 19:01 • 来自相关话题

![](https://infinilabs.cn/img/blog ... er.png)

Lucene 如何让最稀疏的迭代器领跑,最大化跳过无效文档

_[INFINI Easysearch](https://easysearch.cn/) 是一款专注于企业级场景的分布式近实时搜索与分析引擎。它以 Apache Lucene 为内核进行了增强,在保持与行业主流搜索协议及开发生态完全兼容的同时,重点强化了安全性、稳定性、压缩率及信创兼容性。_

---

一、回顾与引入


在[第一篇](https://infinilabs.cn/blog/202 ... art-1/)中,我们走完了布尔查询从用户 JSON 到 Lucene 执行的构建层旅程:Easysearch 的 BoolQueryBuilder 会保留同类子句的书写顺序,Lucene 的 BooleanQuery.rewrite() 则通过等价改写简化查询结构。

其中,和本文关系最直接的是 SHOULD + FILTER → MUST:一个原本只是“可选加分”的 SHOULD 子句,如果同时出现在 FILTER 中,就会被改写成 MUST。它的执行路径也随之改变——从可选评分路径,进入更直接的合取路径。

所谓合取路径,就是按 AND 语义执行查询:所有必选条件都要同时满足,任意一个不满足就可以跳过该文档。对于 MUST / FILTER 这类合取子句,真正影响性能的不是用户在 JSON 里先写谁、后写谁,而是 Lucene 在执行层如何安排它们的检查顺序。

这就是本文要讲的“布尔查询子句重排序”:进入 Scorer 构造阶段后,每个 MUST / FILTER 子句会变成对应的文档迭代器,Lucene 再按 cost() 对这些迭代器重新排序,让匹配文档最少的迭代器先领跑。

一个直觉可以帮助我们理解:在 AND 查询中,谁的结果最少,谁最有"话语权"。因为 AND 语义要求所有条件都满足,所以结果最少的那个条件能最快地排除不满足的文档,剩下的条件只需要确认即可。

本文就专注于这条合取路径的核心机制:ConjunctionDISI 如何按 cost 排序,让最稀疏的迭代器领跑整场迭代。

---

二、前置:什么情况下走 ConjunctionScorer?


上面已经说明,rewrite 可能会把子句带入合取路径;但并不是所有 bool 查询都会走这条路径。本节先界定范围:哪些查询会进入合取路径,哪些不会。Lucene 在 Boolean2ScorerSupplier.getInternal() 中会根据子句组合做这个判断。

| 查询形态 | 是否进入合取路径 | 简单理解 |
| ----------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------- |
| 多个 MUST / FILTER | 是:ConjunctionScorer → ConjunctionDISI | 标准 AND 查询:所有条件都必须满足 |
| 只有一个 MUST / FILTER | 不需要 | 只有一个迭代器,没必要做求交和排序 |
| MUST / FILTER + SHOULD,且 minShouldMatch = 0 | 必选部分进入 | 先用 MUST / FILTER 圈定候选集,SHOULD 只负责加分 |
| MUST / FILTER + SHOULD,且 minShouldMatch > 0 | 整体进入 | 除了必选条件,还要求 SHOULD 至少命中 N 个 |
| 只有 SHOULD | 否 | 这是 OR 查询,走 WANDScorer 或 DisjunctionSumScorer,不是本文的合取路径 |

对本文来说,记住一个判断就够了:只要查询里出现多个“必须同时满足”的条件,Lucene 就需要做交集计算,ConjunctionDISI 的 cost 排序就有发挥空间。

那么 ConjunctionDISI 内部到底做了什么?让我们深入源码。

---

三、核心揭秘:ConjunctionDISI 按 cost 排序


这是合取查询最核心的优化。

3.1 生活类比


可以把合取查询理解成多条件筛选:先用结果最少的条件缩小候选集,再让其他条件确认,通常最省事。

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

3.2 源码解析


先解释一下“迭代器”,比如下文的 DocIdSetIterator。迭代器可以理解为某个查询条件对应的匹配文档列表游标。它负责在自己的列表里向前移动,告诉 Lucene:下一个匹配这个条件的 docID 是谁。

比如 author:sam 的迭代器里可能是 [42, 78, 203],category:ai 的迭代器里可能是 [12, 42, 100, 203]。执行 AND 查询时,Lucene 要做的就是不断推进这些游标,找到它们共同出现的 docID,比如这里的 42 和 203。

所以,本文说的“重排序”不是改写用户 JSON 里的子句顺序,而是在执行阶段对这些子句对应的迭代器排序:谁的 cost() 更小,谁就更靠前执行。

核心逻辑在 Lucene 的 ConjunctionDISI.java([ConjunctionDISI.java:154-163](https://github.com/apache/luce ... 4-L163)):

java<br /> private ConjunctionDISI(List<? extends DocIdSetIterator> iterators) {<br /> assert iterators.size() >= 2;<br /> <br /> // Sort the array the first time to allow the least frequent DocsEnum to<br /> // lead the matching.<br /> CollectionUtil.timSort(iterators,<br /> (o1, o2) -> Long.compare(o1.cost(), o2.cost()));<br /> lead1 = iterators.get(0); // 代价最小 → 领头<br /> lead2 = iterators.get(1);<br /> others = iterators.subList(2, iterators.size()).toArray(new DocIdSetIterator[0]);<br /> }<br />

三行代码,逻辑清晰:

  1. 按 cost() 升序排序:cost() 返回迭代器预估的匹配文档数,越少越"便宜"(注:cost 是预估值,实际匹配数可能有偏差,但排序逻辑依然成立)
  2. lead1:代价最小的迭代器,负责领头跳转——它最稀疏,每次跳转跳过的文档最多
  3. lead2:代价第二小的迭代器,负责二次确认
  4. others:其余迭代器,仅在 lead1 和 lead2 都停下时才被调用

    3.3 执行过程


    核心迭代方法 doNext()([ConjunctionDISI.java:165-199](https://github.com/apache/luce ... 5-L199)):

    java<br /> private int doNext(int doc) throws IOException {<br /> advanceHead:<br /> for (; ; ) {<br /> // doc 是当前 lead1 停住的位置;lead1 是最稀疏的迭代器,负责给出候选 docID<br /> assert doc == lead1.docID();<br /> <br /> // 先让 lead2 追上 lead1:advance(doc) 会跳到 >= doc 的第一个文档<br /> final int next2 = lead2.advance(doc);<br /> if (next2 != doc) {<br /> // lead2 跳过了当前 doc,说明当前候选不匹配;lead1 跳到 lead2 的位置作为新起点<br /> doc = lead1.advance(next2);<br /> if (next2 != doc) {<br /> // 仍没对齐,说明 lead1 又跳到了更后面,重新开始对齐<br /> continue;<br /> }<br /> }<br /> <br /> // lead1 和 lead2 对齐后,再检查其他迭代器<br /> for (DocIdSetIterator other : others) {<br /> // other 可能在上一轮已经被推进过;只有落后时才需要追赶<br /> if (other.docID() < doc) {<br /> final int next = other.advance(doc);<br /> if (next > doc) {<br /> // other 跳过了当前 doc,当前候选失败;lead1 追到新的最大 docID<br /> doc = lead1.advance(next);<br /> continue advanceHead;<br /> }<br /> }<br /> }<br /> <br /> // 所有迭代器都停在同一个 doc 上 → 这个文档满足所有条件<br /> return doc;<br /> }<br /> }<br />

    这段代码的核心就是“不断对齐”:谁跳到了更大的 docID,其他迭代器就追上去;直到所有迭代器停在同一个 docID,才算匹配成功。

    例如 lead1 停在 78,而 lead2.advance(78) 返回 100,就说明 78 不可能匹配。Lucene 会直接把 lead1 推进到 100 附近,而不是继续检查 79、80、81……这些中间文档。

    3.4 直观示例


    为突出 cost 的量级差异,下面用夸张的数量级示意(§六会给出真实测试索引的实测数据,数量级更小,但结论一致):

    <br /> 查询:must: [status:published(100万), category:ai(1万), author:sam(500)]<br /> <br /> 迭代器按 cost 排序后:<br /> lead1: author:sam cost=500 ← 最稀疏,领头跳转<br /> lead2: category:ai cost=10,000<br /> other: status:published cost=1,000,000<br /> <br /> 执行过程:<br /> lead1 → doc=42 → lead2确认✓ → other确认✓ → 匹配!<br /> lead1 → doc=78 → lead2确认✗ → 跳过(不用查 other)<br /> lead1 → doc=203 → lead2确认✓ → other确认✓ → 匹配!<br /> <br /> 如果没有 cost 排序,让高频词先给出候选,后续条件就要确认大量无效文档。<br />

    cost排序的效果:Lucene 实际执行时,lead1 一定是 cost 最小的迭代器(即最低频)。低频迭代器先领头,可以显著减少候选文档数量。

    3.5 子合取展平


    还有一个值得注意的细节:当嵌套的 ConjunctionDISI 作为子句出现时,会被"拆包"展平([ConjunctionDISI.java:54-77](https://github.com/apache/luce ... 54-L77)):

    java<br /> } else if (disi.getClass() == ConjunctionDISI.class) {<br /> // 发现子句本身也是一个 ConjunctionDISI,也就是嵌套的 AND 查询<br /> ConjunctionDISI conjunction = (ConjunctionDISI) disi;<br /> <br /> // 不把整个子 ConjunctionDISI 当成一个黑盒,<br /> // 而是把它内部已经拆好的 lead1、lead2、others 全部取出来<br /> allIterators.add(conjunction.lead1);<br /> allIterators.add(conjunction.lead2);<br /> Collections.addAll(allIterators, conjunction.others);<br /> }<br />

    这些迭代器取出来后,会和外层迭代器一起进入统一排序流程。也就是说,Lucene 不会把内层 AND 当成一个整体参与外层排序,而是先展平,再让所有迭代器按 cost 统一排序。

    这确保了基于 cost 估算的全局最优迭代顺序——如果内层有一个极稀疏的迭代器,它也有机会“越级”成为全局 lead1,而不是被锁在内层。

    注意这里使用了精确类检查 disi.getClass() == ConjunctionDISI.class,而不是 instanceof。这是为了确保只拆包原始的 ConjunctionDISI,不误拆子类(如 BitSetConjunctionDISI,它有自己的优化路径)。

    ---

    四、延伸:候选文档确定后,验证也要按成本排序


    前面讲的是 cost() 排序:在多个迭代器之间,先让匹配文档最少的迭代器领头,尽量少产生候选文档。它背后的思想是:把执行成本低、过滤能力强的步骤放在前面,尽早排除不匹配的文档。

    matchCost() 排序是这个思想在“验证阶段”的延伸:cost() 决定“谁先领头找候选”,matchCost() 决定“先做哪个精确验证”。

    为什么候选文档还要验证?因为有些迭代器是"近似的"——先用低成本方式定位可能匹配的文档,再用精确方式二次确认。典型场景是短语查询:先对短语中的每个词做倒排合取,得到“包含全部词”的候选文档,再检查这些词是否出现在符合要求的相对位置上。前者用 posting list 快速缩小范围,后者才是真正的精确匹配。

    Lucene 用 TwoPhaseIterator 表示这种两阶段验证,而 ConjunctionTwoPhaseIterator 会按 matchCost() 从低到高排序,让便宜的验证先执行;一旦失败,就不用再执行后面更贵的验证([ConjunctionDISI.java:317-357](https://github.com/apache/luce ... 7-L357)):

    java<br /> CollectionUtil.timSort(twoPhaseIterators,<br /> (o1, o2) -> Float.compare(o1.matchCost(), o2.matchCost()));<br />

    类比:先查身份证(快,matchCost 低),再查指纹(慢,matchCost 高),而不是反过来。如果身份证就不对,指纹根本不用查。

    与 cost() 不同,matchCost() 没有统一公式:它是每个 TwoPhaseIterator 子类按自身验证逻辑估算的“简单操作数”(如 DocValues 查 bitset 约记为 3 次操作)。这个值比较粗略,实际排序时用到的只是相对大小——让便宜的验证先跑。

    在实际代码中,ConjunctionDISI.createConjunction() 会把执行过程拆成两个阶段来看:

    • 找候选 docID:用 allIterators 完成。普通迭代器会直接放进来;如果某个查询是两阶段查询,就把它的“近似迭代器”放进来,先参与 cost() 排序和合取对齐。
    • 验证候选是否真的匹配:用 twoPhaseIterators 完成。这里保存的不是另一批文档列表,而是候选 docID 命中后要执行的 matches() 验证逻辑,并按 matchCost() 排序。

      所以可以理解为:allIterators 负责“先找可能匹配的文档”,twoPhaseIterators 负责“再确认这些文档是否真的匹配”。前者按 cost() 排序,目标是少产生候选;后者按 matchCost() 排序,目标是少做昂贵验证。

      ---

      五、进阶补充:cost 排序还会影响子查询策略


      前面讲的是 cost 排序对“当前这一层”的影响:选出最稀疏的迭代器作为 lead1,减少候选文档。但 cost 排序选出的 lead1 还会带来一个连锁效应:它把代价信息继续传给子查询,让子查询也能根据外层的调用频率选择更合适的执行方式。

      这个连锁效应的起点是:在合取查询里,外层最终会由最稀疏的迭代器领头,所以其他子查询被推进的次数通常不会超过这个领头迭代器的规模。这个规模就是向外传播给子查询的代价上限。Lucene 在 Boolean2ScorerSupplier.getInternal() 中用 Math.min(leadCost, cost()) 给这个估计加了一个上限([Boolean2ScorerSupplier.java:126](https://github.com/apache/luce ... 23L126)):如果上层传来的 leadCost 过大,就用当前查询自己的 cost() 压住,避免子查询误判使用模式。对于纯合取查询来说,这个值通常接近 lead1.cost()。

      这个向外传播的代价上限,就是 leadCost。它可以理解为上层给子查询的一个提示:“接下来你大概要被推进这么多次”。它不是 ConjunctionDISI 内部的概念,而是 ScorerSupplier.get(long leadCost) 的参数。

      这里的“推进”指的是:上层会不断要求子查询的迭代器向后移动,要么调用 nextDoc() 走到下一个匹配文档,要么调用 advance(target) 直接跳到 >= target 的文档。

      为什么这个提示有用?因为不同实现适合不同使用方式:如果会被频繁推进,就适合用支持高效跳转的结构;如果只会被少量推进,就可以选择初始化成本更低的结构。一个典型例子是 IndexOrDocValuesQuery。它内部同时持有索引结构(点数据 / term 查询)和 DocValues 两种策略,会根据 leadCost 动态选择([IndexOrDocValuesQuery.java:176-186](https://github.com/apache/luce ... 6-L186)):

      java<br /> public Scorer get(long leadCost) throws IOException {<br /> final long threshold = cost() >>> 3; // cost / 8<br /> if (threshold <= leadCost) {<br /> return indexScorerSupplier.get(leadCost); // 推进频繁:用索引结构<br /> } else {<br /> return dvScorerSupplier.get(leadCost); // 推进较少:用 DocValues<br /> }<br /> }<br />

      简单说:外层通过 cost 排序估算推进频率,再把这个信息传给子查询;子查询只需要判断“我会被频繁推进,还是只会偶尔确认”,就能做出局部最优选择。

      ---

      六、一个 MUST 查询是如何被重新排序的


      用一个简单的 MUST 查询,把第一篇的 rewrite 和本文的 cost 排序串起来。下面这个例子中,status:published 写在前面,category:ai 写在后面:

      json<br /> GET /bool_cost_profile_test/_search<br /> {<br /> "profile": true,<br /> "query": {<br /> "bool": {<br /> "must": [<br /> { "term": { "status": "published" }},<br /> { "term": { "category": "ai" }}<br /> ]<br /> }<br /> }<br /> }<br />

      完整过程可以拆成四步:

      text<br /> ① Easysearch 层:按用户写法构建 BooleanQuery<br /> profile description 仍显示:+status:published +category:ai<br /> <br /> ② Lucene rewrite:2 个 MUST,无重复、无矛盾<br /> 查询形态保持为两个 MUST 子句<br /> <br /> ③ Scorer 构造:<br /> Boolean2ScorerSupplier.req() → new ConjunctionScorer(...)<br /> ConjunctionScorer 内部创建 ConjunctionDISI<br /> ConjunctionDISI 再按 cost 对迭代器排序<br /> <br /> ④ 执行:<br /> 低 cost 的 category:ai 负责产生候选 docID<br /> status:published 通过 advance(candidate) 追赶确认<br />

      在本地 Easysearch 2.2.0 / Lucene 9.12.2 的测试索引中,status:published 约 900 条,category:ai 约 100 条。实际 profile 结果是:

      text<br /> 子句 next_doc_count advance_count 说明<br /> ──────────────────────────────────────────────────────────────<br /> category:ai 91 1 低 cost,产生候选<br /> status:published 0 91 跟随候选,用 advance 确认<br />

      把 JSON 里的两个 MUST 顺序反过来再查,profile 仍然显示 category:ai 通过 nextDoc() 产生候选,status:published 通过 advance() 确认。也就是说,description 会保留查询展示顺序,但真正执行时的迭代器顺序由 cost 排序决定。

      这就是本文的关键点:用户在 JSON 中先写谁,不等于执行时谁先跑。对于合取查询,Lucene 会在 Scorer 构造阶段按 cost 重新安排迭代器顺序,让更稀疏的条件领头。唯一的例外是多词查询混入 must/filter 且版本较老的场景,见 §八末尾的边界说明。

      双条件场景验证了"重排序确实发生",接下来看三条件场景如何用 Profile 观察。

      ---

      七、如何用 Profile API 观察 cost 排序效果


      现在扩展到三条件,重点看一个更极端的对比:author:sam 只有 5 条,status:published 有 900 条。Profile 不会直接告诉你 lead1 是谁,但可以通过 next_doc_count 和 advance_count 的分布间接推断。

      在同一个测试索引上,查三个 MUST:

      json<br /> "must": [<br /> { "term": { "status": "published" }},<br /> { "term": { "category": "ai" }},<br /> { "term": { "author": "sam" }}<br /> ]<br />

      BooleanQuery 的子节点大致如下:

      <br /> 子句 next_doc_count advance_count 说明<br /> ──────────────────────────────────────────────────────────────<br /> author:sam 5 1 最稀疏,负责产生候选 docID<br /> category:ai 0 6 跟随候选,用 advance 对齐<br /> status:published 0 5 跟随候选,用 advance 对齐<br />

      注意跟随迭代器的 advance_count 未必完全相等,这和实际匹配过程中发生的追赶次数有关,不影响"谁是领头"的判断。

      如何看这份 Profile


      Profile 不会直接打印 lead1,但指标和源码是对应的:ConjunctionDISI.nextDoc() 会调用 lead1.nextDoc() 产生下一个候选;进入 doNext() 后,再通过 lead2.advance(doc) 和 other.advance(doc) 让其他迭代器追赶。next_doc_count 和 advance_count 统计的正是这些底层调用。

      可以总结成一条简单观察规律:

    • 领头迭代器:next_doc_count 通常更高,它要不断产生候选
    • 跟随迭代器:advance_count 通常更高,它只在候选 docID 上确认

      需要注意:Profile 只提供观察线索,不同 Lucene 版本、查询类型(DocValues、PointRange 等)和数据分布,都可能让 next_doc / advance 的表现有所不同。特别是当查询走了 BitSet 优化路径或 BlockMaxConjunctionScorer 时,指标分布会不一样。解读时要结合 description、type 和各子节点的 breakdown 一起判断。

      ---

      八、小结与预告


      回到本系列的主题:布尔查询子句重排序。本文讲的是其中最典型的一种——MUST / FILTER 这类合取子句进入执行层后,会从“用户书写顺序”转换为“按 cost 排序的迭代器执行顺序”。

      本文着重介绍的核心机制包括:

    • ConjunctionDISI 按 cost 排序:最稀疏的迭代器领头,其他迭代器仅做确认,最大化跳过无效文档
    • 两阶段验证按 matchCost 排序:最便宜的验证先执行,失败即可短路
    • leadCost 传播:代价信息从外向内传播,子查询据此做局部最优策略选择(如 IndexOrDocValuesQuery 的自适应切换)
    • 子合取展平:嵌套的 ConjunctionDISI 被拆包到同一层级,确保全局最优的迭代顺序

      这些机制的共同特点是:代价感知 + 动态决策——排序在 Scorer 构造时完成,与用户书写顺序无关。

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


      "与书写顺序无关"有个前提:每个子句在调度阶段都能被轻量地试探。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 出场。

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

      分界线是 Lucene 9.5([PR #12055](https://github.com/apache/lucene/pull/12055)):多词查询的 wrapper 自此实现了轻量的 ScorerSupplier,调度阶段只估成本不干活,重活推迟到真正取迭代器时才做,顺序自此真正无关。对应到版本:Easysearch 1.x 基于 Lucene 8.11,存在此问题;Easysearch 2.x 基于 Lucene 9.12,已包含修复——本文的全部结论在其上均成立。

      但合取查询的优化目标很明确:所有子句都要匹配,找"最少"的那个领头即可。析取(SHOULD)场景完全不同:不需要全部匹配,而是找 Top-K 高分文档。优化目标从"最少匹配"变为"最高分数贡献",WAND 算法登场——第三篇详解。

      作者:冯田立,极限科技(INFINI Labs)Easysearch 搜索引擎研发专家,曾在亚马逊 AWS 有多年的 Elasticsearch 开源插件和 OpenSearch 的开发经验和客户集群的运维经验,并有幸参与 OpenSearch 的创立。

Easysearch 布尔查询子句重排序(一)|你的 BoolQuery 写法,真的影响性能吗?

Easysearch • INFINI Labs 小助手 发表了文章 • 0 个评论 • 4856 次浏览 • 2026-09-29 18:58 • 来自相关话题

![](https://infinilabs.cn/img/blog ... er.png)

从 Easysearch 到 Lucene,查询构建层的 11 条优化规则

INFINI Easysearch 是一款专注于企业级场景的分布式近实时搜索与分析引擎。它以 Apache Lucene 为内核进行了增强,在保持与行业主流搜索协议及开发生态完全兼容的同时,重点强化了安全性、稳定性、压缩率及信创兼容性。

---

一、开篇:一个常见的误解


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

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

<br /> ┌─────────────────────────────────────┐<br /> │ 用户的直觉: │<br /> │ must: [高频词, 低频词] → 慢 │<br /> │ must: [低频词, 高频词] → 快 │<br /> │ │<br /> │ 实际情况: │<br /> │ 两种写法性能完全相同! │<br /> │ Lucene 执行时自动按 cost 排序 │<br /> └─────────────────────────────────────┘<br />

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

本文是系列第一篇,聚焦构建层:从你发出 JSON 到查询进入执行引擎,中间经历了哪些变换?哪些优化在这个阶段完成?哪些要留到执行层?后续三篇将分别深入合取查询的 cost 排序、析取查询的 WAND 剪枝,以及 Block-Max 块级剪枝与实战验证。

---

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


先建立一张全局地图,再深入每一层。

举个例子:一条布尔查询就像一个包裹进入工厂流水线,经过三道工序:

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

    用技术语言描述,这三道工序对应的是(注意①和②③分属不同阶段):

    <br /> 用户 JSON<br /> │<br /> ▼<br /> ┌──────────────────────────────────────────┐<br /> │ Easysearch 层(构建层) │<br /> │ ① doRewrite() — 递归重写 + 早期终止 │<br /> │ ② applyMinimumShouldMatch │<br /> │ ③ fixNegativeQueryIfNeeded │<br /> │ (①在 rewrite 阶段,②③在 doToQuery()内)│<br /> │ 职责:结构合法化,不改子句顺序 │<br /> └────────────────┬─────────────────────────┘<br /> │ toQuery() → BooleanQuery<br /> ▼<br /> ┌────────────────────────────────────────────┐<br /> │ Lucene rewrite 层(逻辑重写层) │<br /> │ ④ BooleanQuery.rewrite() │<br /> │ (IndexSearcher 中 rewrite→createWeight) │<br /> │ 职责:11条逻辑等价改写,不改结果只改形态 │<br /> └────────────────┬───────────────────────────┘<br /> │ createWeight()<br /> ▼<br /> ┌──────────────────────────────────────────┐<br /> │ Scorer 构造层(执行层) │<br /> │ ⑤ ConjunctionDISI:按 cost() 排序 ✅ │<br /> │ ⑥ WANDScorer:按 maxScore 动态重排 ✅ │<br /> │ 职责:代价感知,真正的性能优化在这里 │<br /> └────────────────┬─────────────────────────┘<br /> │<br /> ▼<br /> 执行查询,返回结果<br />

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

    本文讲前两层(①~④),后三篇讲第⑤⑥步。

    ---

    三、Easysearch 层:结构合法化,不碰顺序


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

    3.1 子句是如何被添加的


    BoolQueryBuilder.doToQuery() 按照固定顺序把子句添加到 Lucene 的 BooleanQuery.Builder 中:

    <br /> must → mustNot → should → filter<br />

    不同类型之间的添加顺序是固定的(无论你的 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 关闭):

    <br /> 输入:must_not: [term:spam]<br /> 处理:加入 MatchAllDocsQuery (作为 FILTER)<br /> 输出:filter: [MatchAll] + must_not: [term:spam]<br /> = "所有文档 除了 spam"<br />

    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 导致整体短路 |

    <br /> 规则 2 示例:<br /> BooleanQuery { MUST: [TermQuery(status:published)] }<br /> ↓ rewrite<br /> TermQuery(status:published)<br /> <br /> BooleanQuery { FILTER: [TermQuery(status:published)] }<br /> ↓ rewrite<br /> BoostQuery(ConstantScoreQuery(TermQuery(status:published)), 0)<br /> <br /> 规则 3 示例:<br /> must: [MatchNoDocsQuery], should: [TermQuery(A)]<br /> ↓ rewrite(MUST 中含 MatchNoDocs → 整体短路)<br /> MatchNoDocsQuery<br />

    ---

    规则 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()) |

    <br /> 规则 4 示例:<br /> filter: [term:active, term:active, range:age>18] → filter: [term:active, range:age>18]<br /> <br /> 规则 6 示例:<br /> must: [term:active], filter: [term:active, range:age>18] → must: [term:active], filter: [range:age>18]<br />

    ---

    规则 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 是否归零。

    <br /> 优化前:<br /> ┌───────────────────────┐<br /> │ SHOULD: [term:A] │<br /> │ FILTER: [term:A] │<br /> │ SHOULD: [term:B] │<br /> └───────────────────────┘<br /> ↓ rewrite<br /> 优化后:<br /> ┌───────────────────────┐<br /> │ MUST: [term:A] │ ← 提升为 MUST!<br /> │ SHOULD: [term:B] │<br /> └───────────────────────┘<br />

    💡 类比:一个人同时是"候选人"(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 会创建两套独立的迭代器,都遍历相同的文档列表,浪费翻倍。

    <br /> should: [term:hello^1.5, term:hello^2.0, term:world]<br /> ↓ rewrite<br /> should: [term:hello^3.5, term:world]<br /> (两个 hello 的权重合并:1.5 + 2.0 = 3.5)<br />

    💡 类比:一个学生选了同一门课两次,一次记 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 只能看到内层查询的总体上界(黑盒),剪枝不够狠。

    <br /> 优化前:<br /> BoolQuery (外层)<br /> / \<br /> SHOULD SHOULD<br /> (term:A) (内层 BoolQuery)<br /> / \<br /> SHOULD SHOULD<br /> (term:B) (term:C)<br /> <br /> ↓ rewrite<br /> <br /> 优化后:<br /> BoolQuery<br /> / | \<br /> SHOULD SHOULD SHOULD<br /> (term:A)(term:B)(term:C)<br />

    实践建议:如果你在 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 排序,更高效)

    <br /> should: [A, B],minimum_should_match: 3<br /> ↓ rewrite(2 < 3,不可能满足)<br /> MatchNoDocsQuery<br /> <br /> should: [A, B, C],minimum_should_match: 3<br /> ↓ rewrite(3 == 3,等价于全部 MUST)<br /> must: [A, B, C]<br />

    💡 类比:开会时,如果"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 规则,如何直接影响执行层的路径选择。

    假设你有一个查询:

    json<br /> {<br /> "bool": {<br /> "should": [<br /> { "term": { "status": "published" } },<br /> { "term": { "category": "ai" } }<br /> ],<br /> "filter": [{ "term": { "status": "published" } }]<br /> }<br /> }<br />

    status:published 同时出现在 should 和 filter——规则 7 会把它提升为 MUST,并下调 minShouldMatch。下图假设提升后 minShouldMatch=0,执行路径因此发生根本变化:

    <br /> ┌─────────────────────────────────────────────────────────────────┐<br /> │ 没有 rewrite 优化(假设) │<br /> │ minShouldMatch=1,走 conjunction-disjunction mix 路径 │<br /> │ │<br /> │ ┌────────────────────────────┐ │<br /> │ │ ConjunctionScorer │ │<br /> │ │ ├─ FilterScorer │ published 出现两次: │<br /> │ │ │ published (score=0) │ • FILTER 里遍历一遍(只过滤) │<br /> │ │ └─ DisjunctionSumScorer │ • SHOULD 里再遍历一遍(评分) │<br /> │ │ ├─ published │ = 同一个 term 被两个迭代器 │<br /> │ │ └─ ai │ 各跑一遍,浪费! │<br /> │ └────────────────────────────┘ │<br /> └─────────────────────────────────────────────────────────────────┘<br /> <br /> ┌─────────────────────────────────────────────────────────────────┐<br /> │ rewrite 优化后(实际) │<br /> │ minShouldMatch=0,走 req+opt 路径 │<br /> │ │<br /> │ ┌────────────────────────────┐ │<br /> │ │ ReqOptSumScorer │ published 只出现一次: │<br /> │ │ ├─ req: published (有评分) │ • 作为 MUST,一次迭代同时 │<br /> │ │ └─ opt: ai (可选加分) │ 完成过滤和评分 │<br /> │ └────────────────────────────┘ │<br /> └─────────────────────────────────────────────────────────────────┘<br />

    优化前,status:published 被两个迭代器各遍历一遍;优化后,一次迭代同时完成过滤和评分——一条 rewrite 规则,改变了执行路径的选择。它不做代价排序,但决定了哪些子句有资格进入更高效的路径。

    ---

    六、动手验证:用 Profile API 观察 rewrite 效果


    理论再多,不如自己跑一遍。Easysearch 的 Profile API 可以直接暴露 rewrite 后的查询形态,不需要读源码,几秒钟就能验证。

    6.1 验证规则 7:SHOULD + FILTER → MUST 提升


    准备好一个含有 status 和 category 字段的索引,执行:

    json<br /> GET /products/_search<br /> {<br /> "profile": true,<br /> "query": {<br /> "bool": {<br /> "should": [<br /> { "term": { "status": "published" }},<br /> { "term": { "category": "ai" }}<br /> ],<br /> "filter": [<br /> { "term": { "status": "published" }}<br /> ]<br /> }<br /> }<br /> }<br />

    找到响应中 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


    json<br /> GET /products/_search<br /> {<br /> "profile": true,<br /> "query": {<br /> "bool": {<br /> "should": [<br /> { "term": { "status": "published" }},<br /> { "term": { "category": "ai" }}<br /> ],<br /> "minimum_should_match": 2<br /> }<br /> }<br /> }<br />

    profile 的 description 应该显示 +status:published +category:ai——两个 term 都带 + 前缀,说明全部提升为 MUST,这个查询实际上会走第二篇要讲的 ConjunctionDISI 路径,而非 WANDScorer。

    6.3 理解 Profile 响应的结构


    一个完整的 Profile 响应包含大量信息,但读懂核心字段只需关注三个位置:

    json<br /> {<br /> "profile": {<br /> "shards": [{<br /> "searches": [{<br /> "query": [{<br /> "type": "BooleanQuery",<br /> "description": "+status:published +category:ai",<br /> "breakdown": {<br /> "next_doc": 12750, "next_doc_count": 1,<br /> "advance": 0, "advance_count": 0,<br /> "create_weight": 375375, "create_weight_count": 1,<br /> "build_scorer": 248958, "build_scorer_count": 2<br /> },<br /> "children": [<br /> { "type": "TermQuery", "description": "status:published", ... },<br /> { "type": "TermQuery", "description": "category:ai", ... }<br /> ]<br /> }],<br /> "rewrite_time": 146958<br /> }]<br /> }]<br /> }<br /> }<br />

    快速解读三个关键位置:

    | 字段 | 含义 | 怎么看 |
    | --------------------------- | ----------------------- | --------------------------------------------------------- |
    | 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 实测,把这个机制讲透。

    作者:冯田立,极限科技(INFINI Labs)Easysearch 搜索引擎研发专家,曾在亚马逊 AWS 有多年的 Elasticsearch 开源插件和 OpenSearch 的开发经验和客户集群的运维经验,并有幸参与 OpenSearch 的创立。

【搜索客社区日报】第2305期 (2026-09-29)

社区日报 • God_lockin 发表了文章 • 0 个评论 • 5216 次浏览 • 2026-09-29 08:52 • 来自相关话题

1. Elasticsearch Serverless 还是自托管?高性能搜索架构选型指南(需要梯子)  
https://medium.com/%4020sanyog ... 66ad5

2. 什么是异常检测?——Elasticsearch 实战解读(需要梯子)  
https://medium.com/%40seymadog ... 218a8

3. IT 大佬亲述:AI 落地路上的 7 条血泪经验  
https://www.elastic.co/blog/ai ... aders

4. Elastic Stack 8.19.22 正式发布  
https://www.elastic.co/blog/el ... eased

5. 信任,但要验证:用原子事实核查对抗大模型幻觉  
https://www.elastic.co/blog/at ... tions


编辑:斯蒂文  
更多资讯:http://news.searchkit.cn

【搜索客社区日报】第2304期 (2026-09-23)

社区日报 • kin122 发表了文章 • 0 个评论 • 7613 次浏览 • 2026-09-23 18:42 • 来自相关话题

1. Jev 深度测评:它能颠覆 Agent 搜索吗?
https://mp.weixin.qq.com/s/A4kriC5guo0iMOMMDVl89g

2.从 Data Lake 到 State Lake:面向 Agent 时代的存储基础设施重构
https://mp.weixin.qq.com/s/qakhw_mXrCO7Fcb3tikv4A

3. Milvus HNSW 量化索引选型指南:从 SQ、PQ、PRQ 到 Refine 实测
https://mp.weixin.qq.com/s/7098J-CAJ3PVCdsCM1Pb_A

4.没有小到无法记录的日志文件:Elastic Agent 如何跟踪低于 1 KiB 阈值的文件
https://elasticstack.blog.csdn ... 18021



编辑:kin122    
更多资讯:http://news.searchkit.cn

〖搜索客社区日报〗第2302期 (2026-09-21)

社区日报 • Muses 发表了文章 • 0 个评论 • 8041 次浏览 • 2026-09-22 21:05 • 来自相关话题

1. 让AI离开温室,走向动态世界:MineExplorer揭示顶级多模态大模型被忽视的能力断层
https://tech.meituan.com/2026/ ... .html

2. 正式开源!美团 LongCat-2.0 同步开放国产卡推理代码
https://tech.meituan.com/2026/ ... .html

3. 同样 15,000 条重规则,Percolate Query 比 Easysearch 慢 21.8 倍——Heavy-OR 场景实测
https://infinilabs.cn/blog/202 ... mark/

4. Easysearch BKD Merge 异常排查实录:最终定位到旧版 GraalVM JIT 运行时
https://infinilabs.cn/blog/202 ... -jit/

5. OpenSearch 中的 FIPS 140-3 支持(需要梯子)
https://opensearch.org/blog/fi ... arch/

编辑:Muse
更多资讯:http://news.searchkit.cn

【搜索客社区日报】第2303期 (2026-09-22)

社区日报 • God_lockin 发表了文章 • 0 个评论 • 8424 次浏览 • 2026-09-22 08:52 • 来自相关话题

1. 从倒排索引到分布式查询:手把手教你设计一个分布式文档搜索引擎(需要梯子)  
https://medium.com/%40raunk325 ... 2314f

2. 我们删掉了 Elasticsearch,结果发现 Postgres 全文搜索真香(需要梯子)  
https://medium.com/%40danielva ... 3023f

3. Elastic Serverless 跨项目搜索正式 GA:不搬一个字节,就能查询所有关联项目  
https://www.elastic.co/blog/cr ... ss-ga

4. Elasticsearch 能做,不代表我们就该这么用(需要梯子)  
https://medium.com/%40prachy.p ... ed971

5. 当 Elasticsearch 不再只是一个搜索索引(需要梯子)  
https://medium.com/%40prachy.p ... 1ef79


编辑:斯蒂文  
更多资讯:http://news.searchkit.cn

Easysearch 原生集成国密算法:打造全链路自主可控搜索底座(二)

Easysearch • INFINI Labs 小助手 发表了文章 • 0 个评论 • 9012 次浏览 • 2026-09-20 18:32 • 来自相关话题


![](https://infinilabs.cn/img/blog ... er.jpg)

从 2.1.0 版本开始,INFINI Easysearch 内置了对国密(SM2/3/4)算法的支持,实现了全链路国密合规,满足等保及商用密码应用安全性评估要求。本篇以 Kylin-Server V11 操作系统和 Easysearch 2.3.0 进行演示。

Linux 主机安装 Tongsuo


Easysearch 基于 [铜锁(Tongsuo)](https://release.infinilabs.com ... ngsuo/)提供国密 TLCP 双证书能力,支持 SM2/SM3/SM4 加密套件。下载完 Easysearch 软件包后解压进目录,执行脚本安装铜锁。

bash<br /> bin/install-tongsuo-local.sh --version 8.4.0<br />

![](https://infinilabs.cn/img/blog ... /1.png)

安装完后会有提示切换到铜锁。

![](https://infinilabs.cn/img/blog ... /2.png)

能正常查看版本信息,说明安装、切换都正常。

![](https://infinilabs.cn/img/blog ... /3.png)

一键初始化(单节点 TLCP)


如需在单节点场景快速完成证书生成、TLCP 配置写入和初始密码设置,可直接使用:

bash<br /> EASYSEARCH_INITIAL_ADMIN_PASSWORD='EasysearchP@ssw0rd!' bin/initialize.sh --tlcp -s<br />

执行上面的脚本会自动生成证书,修改 easysearch.yml 证书设置。

![](https://infinilabs.cn/img/blog ... /4.png)

![](https://infinilabs.cn/img/blog ... /5.png)

![](https://infinilabs.cn/img/blog ... /6.png)

启动 Easysearch


从 Easysearch 的启动日志中可看到国密(Chinese SM algorithms)正常加载,并用来创建 SSLContext 。

![](https://infinilabs.cn/img/blog ... /7.png)

客户端:用国密栈访问


curl 默认链接的 OpenSSL 密码库不支持国密协议(TLCP),因此 curl 访问 Easysearch 服务时加密协议最终回落到标准 TLS 1.3,套件为国际通用的 TLS_AES_128_GCM_SHA256。
![](https://infinilabs.cn/img/blog ... /8.png)

用 Easysearch 发行包自带的 bin/tlcp-curl.sh 访问 Easysearch,可以看到加密套件是国密 TLS_SM4_GCM_SM3。

![](https://infinilabs.cn/img/blog ... /9.png)

Java 应用默认使用的 OpenJDK 没有内置国密算法支持,所以不能直接走国密加密;要么换成支持国密的 JDK,要么在现有 JDK 中额外引入并注册国密算法实现。

![](https://infinilabs.cn/img/blog ... gf.png)

---

相关阅读

Easysearch 2.4.0 发布:原生 HNSW 与混合检索,管理体验全面升级

资讯动态 • INFINI Labs 小助手 发表了文章 • 0 个评论 • 8995 次浏览 • 2026-09-20 18:23 • 来自相关话题


![release](https://infinilabs.cn/img/blog/release/banner.png)

INFINI Easysearch 2.4.0 正式发布。本版本将向量搜索纳入内核:基于 Lucene 的原生 HNSW,关键词与语义向量可混合检索。控制台新增可视化 Mapping 编辑器,集群设置可同时查看临时、持久、默认三层配置。生命周期策略、可搜索快照、密钥库与插件管理同步更新,高并发下的字段使用统计和磁盘用量分析也更稳定。

主要更新如下:

功能特性 (Features)


原生 HNSW 向量搜索与混合检索


  • 内置基于 Lucene 的 HNSW 向量索引。新建索引可直接使用 dense_vector、query-level knn 和顶层 knn,无需安装 k-NN 插件。
  • 支持 1~4096 维 float 向量,以及 cosine、dot_product、l2_norm、max_inner_product 四种相似度算法。
  • 支持通过 m、ef_construction 和 num_candidates 调整索引构建与查询效果;省略 index_options 时默认使用 hnsw(m=16, ef_construction=100)。
  • 支持一个顶层关键词 query 与一个顶层 knn 并列执行,通过结果并集与加权评分实现关键词、语义向量的混合召回。
  • 兼容 Elasticsearch 8.19.17 的 float HNSW mapping、Bulk、kNN 查询和 wire 行为,以及 Elasticsearch Java 8.19.17、Python 8.19.3 官方客户端,现有应用可直接对接。

    更多用法及兼容边界请参阅[原生 HNSW 搜索文档](https://docs.infinilabs.com/ea ... -hnsw/)。

    可视化 Mapping 编辑器


    新增索引页提供可视化 Mapping 编辑器。字段按层级排列成树,可直接编辑类型和参数,无需手写 Mapping JSON。

    ![](https://infinilabs.cn/img/blog ... ex.png)

  • 可以从索引模板或已有索引导入配置。创建索引时,也可以关联集群里已有的别名。
  • 覆盖全部字段类型。支持多字段,也可为 object / nested 添加子字段,同类型字段可批量添加。
  • 可视化编辑与 JSON 编辑可随时切换,内容保持同步。dynamic_templates、_source、_meta 等高级内容在切换后仍会保留。

    操作步骤见[新增索引](https://docs.infinilabs.com/ea ... index/)。

    集群设置页


    在「设置」中打开集群设置页,查看和修改 _cluster/settings 中的动态配置。页面按节点、索引、分片、断路器、生命周期分组,支持按名称或 key 搜索,也可按临时、持久、默认来源过滤。

    ![](https://infinilabs.cn/img/blog ... gs.png)

  • 每个设置有三层值:临时、持久、默认。优先级是临时 > 持久 > 默认。徽标标出当前生效的那一层。
  • 布尔和枚举使用下拉框,数字使用数字输入,时间、大小等带单位的值直接填写,例如 60s、40mb。临时层与持久层可一次保存。支持清除单层,也可一键清空全部临时设置。
  • 日志级别、分片分配属性、断路器等名称不固定的设置,可先添加实例,再填写取值。

    ![](https://infinilabs.cn/img/blog ... rs.png)

    操作步骤见[集群设置](https://docs.infinilabs.com/ea ... tings/)。

    数据管理与分析体验


  • 数据探索改用 PIT(Point in Time)分页导出,导出数量不再受 max_result_window 限制。
  • 生命周期策略编辑器可配置的 action 更多,未填写的字段保持原有语义。
  • 快照恢复支持直接恢复为可搜索快照,索引列表增加可搜索快照标识。
  • 节点页面新增密钥库管理,插件列表支持从文件夹或 ZIP 包手动上传插件。这两项需先在右上角连接 Agent 后使用。
  • 创建 API Token 或角色时,索引权限支持下拉选择和搜索。
  • rate 聚合新增 sum、value_count 计算模式,并支持在包含单一日期源的 composite 聚合中计算速率。
  • DevTools 现支持 SQL 关键词高亮、补全和多行语句解析,查询结果默认返回 CSV。
  • 生命周期(ILM)策略管理: 对生命周期策略编辑 UI 进行了全面增强,新建与编辑策略现已支持完整的 action 配置,覆盖 Hot、Warm、Cold、Delete 四个阶段

    改进优化 (Improvements)


  • 重构 _disk_usage 同步分析链路,引入有界请求调度和独立线程池,降低并发磁盘用量分析对普通分析请求的影响。
  • 重构 _field_usage_stats 的 shard 级字段访问采集链路,按每次查询会话对相同字段和访问类型去重,完善 stored fields、term vectors、can-match、refresh/reopen 及主副分片场景的统计覆盖。
  • 优化字段使用统计在高并发查询和大字段 registry 场景下的会话提交与快照生成,减少处理开销和临时对象分配。

    问题修复 (Bug Fixes)


  • 统一重构 Model Provider、Rules 与 Audit Log 的受保护内部索引权限过滤机制,修复直连、通配符、混合索引及数据流后备索引等场景下过滤语义不一致的问题;无权限用户直接访问受保护资源时会被拒绝,在混合或通配符请求中则隐藏相关资源,同时保留远程索引表达式和 Audit Log 的角色授权语义。
  • 修复 Remote Reindex 对远端 Elasticsearch REST 协议版本判断不准确的问题,恢复 Elasticsearch 6.8.x、7.x 和 8.x 场景下 scroll 与 clear-scroll 请求的正确格式。
  • 修复删除关联多个索引的别名失败的问题。
  • 修复授权弹窗打开后「授权提示」页签可能自动消失的问题。
  • 修复未授权状态显示为不规范英文的问题,现按界面语言显示「未授权 / Unlicensed」。
  • 修复创建用户、角色、管道、模板、远程集群、快照仓库、自动跟随模式、规则库时重名可能覆盖已有配置的问题,现在会提示名称已存在。
  • 修复编辑管道时可修改名称导致旧管道残留的问题,编辑时名称改为不可修改。
  • 修复删除管道确认弹窗文案误写为「模板」的问题。
  • 修复列表页左侧聚合过滤侧边栏输入关键字后无法筛选的问题。
  • 修复分片列表点击刷新不重新加载数据的问题。
  • 修复热点线程页面选择不支持的 gpu/mem 类型后列表为空且无提示的问题,下拉现仅提供受支持的类型。
  • 修复创建角色无法选择粗粒度 action group,以及集群权限与索引权限被强制必填的问题。
  • 修复数据探索导出超过 10000 条文档失败的问题。
  • 移除生命周期策略编辑器中错误的节点标签缺失实时提醒。
  • 修复节点详情插件页显示集群级安装状态的问题:插件只装在部分节点时,未安装的节点也会显示「已安装」;现在按当前节点过滤。
  • 修复删除生命周期策略时确认弹窗不显示策略名的问题。
  • 修复删除快照确认弹窗误用「策略」且不显示快照名的问题。
  • 修复创建生命周期策略时前端重名校验不生效的问题。

    升级提示


  • 原生 HNSW 的已索引 dense_vector 字段只能添加到由 2.4.0 或更高版本创建的索引,且所有数据节点均需升级到 2.4.0 或更高版本;旧索引需新建目标索引后通过 Reindex 迁移。
  • 旧 k-NN 插件的 knn_dense_float_vector、knn_sparse_bool_vector 和 knn_nearest_neighbors 接口继续保留,但新旧字段与查询语法不能混用。
  • Easysearch 2.4.0 使用未量化的 float HNSW,当前不支持 int8_hnsw 等量化向量类型。通过 Elasticsearch 8.19 官方客户端访问时,需在所有节点设置 elasticsearch.api_compatibility: true 和 elasticsearch.api_compatibility_version: "8.19.17",并重启节点。

    ---

    以上为本版本重点更新摘要,完整变更与技术细节请查看 Easysearch 产品 [Release Notes](https://docs.infinilabs.com/ea ... -09-10) 或联系我们的技术支持团队

    获取新版本


    INFINI Easysearch v2.4.0 已正式发布,欢迎升级体验:

  • 下载地址:<https://infinilabs.cn/download/>;
  • 快速开始:<https://docs.infinilabs.com/ea ... gt%3B

    关于 Easysearch


    ![](https://infinilabs.cn/img/blog ... v2.png)

    INFINI Easysearch 是一款分布式搜索引擎,支持结构化和非结构化的数据检索、全文检索、向量检索、空间地理位置信息检索、组合查询、多语种支持、语义分析和聚合分析等多种功能。

    Easysearch 基于 Lucene 构建,紧跟 Lucene 最新版本持续迭代更新,采用商用友好协议,企业可自由部署、二次开发和商业化,无协议合规风险。

    Easysearch 致力于为企业提供轻量、安全、自主可控的搜索平台,不断完善产品能力,满足更多企业级需求。

    官网:<https://easysearch.cn>;

    ![](https://infinilabs.cn/img/blog ... ty.png)

【搜索客社区日报】第2301期 (2026-09-18)

社区日报 • Fred2000 发表了文章 • 0 个评论 • 10240 次浏览 • 2026-09-18 11:05 • 来自相关话题

1、超越关键词匹配:Easysearch 如何用向量检索实现语义搜索
https://easysearch.cn/knowledg ... uide/

2、Elastic CLI + AI Agent:搜索引擎开始成为 Agent 的工具
https://www.elastic.co/search- ... gents

3、Elasticsearch Vector Database:向量搜索 + Hybrid Search
https://www.elastic.co/search- ... rless

4、Elasticsearch 8 ,混合搜索的技术演进与实战拆解
https://mp.weixin.qq.com/s/9E5gHDiZKjpCvJimLq4THg

5、Easysearch 布尔查询子句重排序(一)|你的 BoolQuery 写法,真的影响性能吗?
https://infinilabs.cn/blog/202 ... rt-1/

编辑:Fred
更多资讯:http://news.searchkit.cn

【搜索客社区日报】第2299期 (2026-09-15)

社区日报 • God_lockin 发表了文章 • 0 个评论 • 10471 次浏览 • 2026-09-17 18:59 • 来自相关话题

1. 两行公式搞定新鲜度排序,老内容也不再被埋没(需要梯子)  
https://medium.com/%40oleber_6 ... 16b6d

2. 大规模场景下的邻近搜索该怎么设计?(需要梯子)  
https://medium.com/%40develope ... e41d3

3. 驯服搜索里的德语复合词,还不把其他功能搞崩(需要梯子)  
https://medium.com/%40oleber_6 ... 062db

4. Elastic 进化史:从搜索引擎到 AI 原生数据平台(需要梯子)  
https://sanjmo.medium.com/how- ... 66e86

5. 分布式日志系统设计:现代平台如何采集数十亿行日志(需要梯子)  
https://medium.com/%40raunk325 ... 3c879


编辑:斯蒂文  
更多资讯:http://news.searchkit.cn

【搜索客社区日报】第2280期 (2026-08-04)

社区日报 • God_lockin 发表了文章 • 0 个评论 • 10432 次浏览 • 2026-09-17 18:59 • 来自相关话题


1. 避坑干货!从零手撕 Elasticsearch,盘点官方文档里没写的硬核暗坑!(需要梯子)  
https://medium.com/%40kashishg ... 82cba

2. 安全防护实战!手把手教你将 RelayShield 威胁情报丝滑接入 Elastic Security!(需要梯子)  
https://medium.com/%40relayshi ... 0b5ed

3. AI 赋能后端!如何用 Elastic AI Assistant 打造高可用的后端 API?(需要梯子)  
https://medium.com/%40juricavo ... 44cb2

4. 搜索架构演进!为什么几乎所有大厂的搜索功能最终都选择了 ES?(需要梯子)  
https://medium.com/%40basukina ... 7ef99

5. 蓝队实战演练!手把手带你从零搭建 ELK SIEM 威胁猎捕实验室!(需要梯子)  
https://medium.com/%40aadithch ... 5bf23


编辑:斯蒂文  
更多资讯:http://news.searchkit.cn

【搜索客社区日报】第2274期 (2026-07-27)

社区日报 • Muses 发表了文章 • 0 个评论 • 10152 次浏览 • 2026-09-17 18:59 • 来自相关话题

1. INFINI Easysearch 向量搜索实战(二)
https://infinilabs.cn/blog/202 ... ch-2/

2. How Elasticsearch detects multiple change points in time series with 0.99 recall(需挂梯子)
https://www.elastic.co/search- ... -esql

3. 4 NVIDIA AI tasks, 1 Elasticsearch API: Embeddings, chat, completion, and rerank(需挂梯子)
https://www.elastic.co/search- ... rence

4. INFINI Easysearch 向量搜索实战(一)
https://infinilabs.cn/blog/202 ... ch-1/

5. 信创环境下部署 INFINI Console:一站式搭建搜索基础设施统一管控平台
https://infinilabs.cn/blog/202 ... form/

编辑:Muse
更多资讯:http://news.searchkit.cn

深圳某数字化巨头-Senior ES Engineer 急招

回复

求职招聘 • WeasleyWang 发起了问题 • 1 人关注 • 0 个回复 • 6266 次浏览 • 2026-09-17 18:58 • 来自相关话题

【搜索客社区日报】第2300期 (2026-09-17)

社区日报 • Se7en 发表了文章 • 0 个评论 • 5806 次浏览 • 2026-09-17 16:47 • 来自相关话题

1.一个人、两周、数百美元,如何训出登顶 Hugging Face 的模型|42章经
https://mp.weixin.qq.com/s/CqdSjavI9U6_aEkHkq8W5w
2.将 GPU 推理冷启动时间从 8 分钟缩短至 1 分钟以内
https://mp.weixin.qq.com/s/obJXovZEPlr5E8qUW2u6Uw
3.深度解读大规模LLM推理
https://mp.weixin.qq.com/s/qHWM48IVozLOH39SCg5g5g
4.Elasticsearch 9.5 替代 Prometheus
https://mp.weixin.qq.com/s/pPG_dR6xNX0X3x31Q1W1bA

编辑:Se7en
更多资讯:http://news.searchkit.cn