📣 极限科技诚招搜索运维工程师(Elasticsearch/Easysearch)- 全职/北京 👉 : 立即申请加入
Easysearch 布尔查询子句重排序(三)|WAND 动态剪枝

Lucene 如何用 WAND 算法实现 Top-K 的动态剪枝

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


一、回顾与引入 #

第二篇中,我们深入解析了合取查询的核心优化:ConjunctionDISI 按 cost 排序,让最稀疏的迭代器领跑。这套机制的前提是 AND 语义——所有子句都必须匹配,所以"谁最少"就决定了跳转的速度。

本文聚焦析取(SHOULD)场景。关键转变在于:不需要全部匹配,而是找 Top-K 高分文档。 优化目标从"最少匹配"变为"最高分数贡献"——cost 最低的子句不一定分数最高,而分数最高的子句才最可能帮你快速找到 Top-K。

这就是 WAND 算法的用武之地。


二、为什么析取不能简单按 cost 排序? #

合取和析取的本质差异决定了它们的优化策略必须不同:

维度合取(MUST)析取(SHOULD)
语义所有子句都匹配至少部分子句匹配
优化目标快速排除不匹配文档快速找到 Top-K 高分文档
排序依据cost(谁最少谁领头)maxScore(谁分最高谁优先推进)
关键操作跳过不匹配的文档跳过不可能进 Top-K 的文档

为什么 cost 排序在析取中不适用?看一个析取查询 should: [quantum, the, machine_learning],找 Top-10:

子句匹配文档数(cost)单次命中得分
quantum1008.0
the1000 万0.1
machine_learning5 万15.0

设想文档 D:不含 “quantum”,但同时命中 “the” 和 “machine_learning”,总分 = 0.1 + 15.0 = 15.1,远超只命中 “quantum” 的文档(8.0),理应排进 Top-10。

若按 cost 让 quantum 领头驱动,候选集就由 quantum 的倒排表决定——D 不在其中,根本不会被推进打分,Top-K 就漏掉了 15.1 分。根源在于:cost 衡量的是"匹配多少",分数衡量的是"匹配多高",两者是不同的维度。

析取需要的是感知分数的调度策略:优先推进高分子句,快速积累 Top-K,再用已有最低分剪枝。这就是 WAND(Weak AND)算法的核心思想。

WANDScorer 的触发条件 #

WANDScorer 不是总生效。Lucene 在 Boolean2ScorerSupplier.opt() 中按以下条件决定是否为 SHOULD 子句构造 WANDScorer(否则走 DisjunctionSumScorer):

if ((scoreMode == ScoreMode.TOP_SCORES && topLevelScoringClause) || minShouldMatch > 1) {
    return new WANDScorer(weight, optionalScorers, minShouldMatch, scoreMode);
} else {
    return new DisjunctionSumScorer(weight, optionalScorers, scoreMode);
}

即满足其一即可:

  1. ScoreMode.TOP_SCORES 且该析取是顶层评分子句:正常的 _search 请求默认 track_total_hits=10000,对应 TOP_SCORES(由 TopScoreDocCollector 设置)
  2. minShouldMatch > 1:此时即便非 TOP_SCORES 也用 WANDScorer 做合取推进

注意第 1 个条件里的 TOP_SCORES 是 Lucene 的内部评分模式,由请求参数 track_total_hits 间接决定用哪个 Collector,Collector 再把自己的 ScoreMode 报给 Scorer:

  • track_total_hits: false(默认 10000 亦同)→ TOP_SCORES:收集满 Top-K 后允许提前结束,并把当前最低分回传给 WANDScorer,剪枝开启
  • track_total_hits: trueCOMPLETE:必须遍历全部匹配文档以给出精确总数,从不回传阈值,剪枝关闭

所以同一条 SHOULD 查询(默认 minShouldMatch=1),只改 track_total_hits 就能让 WANDScorer 在"真正剪枝"与"改走 DisjunctionSumScorer 不剪枝"之间切换——这正是 下一篇 §三对比实验的切入点。


三、WAND 的核心逻辑:用上界剪枝 #

3.1 WAND 要解决什么:Top-K 与一条不断抬高的及格线 #

析取查询的本质:SHOULD 子句"至少匹配一个",但用户要的不是全部匹配文档,而是分数最高的 K 个(Top-K)。

既然只要 Top-K,就有一条隐形的及格线——当前已收集结果中第 K 名的分数,记作 minCompetitiveScore(最小竞争分)。超过它才可能挤进 Top-K,超不过一定落选。随着高分文档不断被收进来,这条线只会越抬越高。

于是每个候选文档只须回答一个问题:分数能不能超过及格线? 能才值得精确打分,不能就该跳过,不进行精确打分。WAND 的全部巧思都围绕上述这点,而办法就是下节介绍的"分数上界"。

3.2 从查询语句到游标:每个 SHOULD 子句是一条独立的倒排链 #

先看一条布尔查询长什么样:

{
  "bool": {
    "should": [
      { "term": { "body": "rare" } },
      { "term": { "body": "common" } },
      { "term": { "tag": "hot" } }
    ]
  }
}

里的每个 SHOULD 子句,底层都是一次独立的倒排索引查找,对应一条倒排链(posting list)——按 docID 升序排列的、所有命中该词的文档号序列。每条倒排链配一个游标 docID,指向"这条链上下一个待处理的匹配文档"。

3 个 SHOULD 子句 = 3 条倒排链 = 3 个游标。WANDScorer 就是同时管着这几个游标、协调它们推进的调度器。

下面给这三条倒排链画个框图(沿用 下一篇 §三对比实验里 body:rare / body:common / tag:hot 的语义:一个稀有词、一个常见词、一个中等标签),方便后续讲游标怎么移动。竖线是已经按 docID 升序排好的匹配文档号, 就是游标当前指向的那个文档:

        SHOULD 子句         倒排链(命中的 docID,升序排列)        游标 ▼

   ┌──────────────┐
   │  rare (稀有) │  ··· 42 ──── 87 ──── 156 ──── 203 ──── 318 ──── ···
   └──────────────┘        ▲
                           │  当前指向 doc=42

   ┌──────────────┐
   │ common (常见)│  ··· 5 ── 42 ── 87 ── 88 ── 156 ── 203 ── 318 ── 410 ── ···  (很长,省略)
   └──────────────┘       ▲
                          │  当前指向 doc=5

   ┌──────────────┐
   │  hot (标签)  │  ··· 17 ──── 87 ──── 203 ──── 318 ──── ···
   └──────────────┘        ▲
                           │  当前指向 doc=17

   ───────────────────────────────────────────────────────────────────→ docID 轴
        5     17    42         87 88         156        203   318  410

看这张图,三条游标彼此独立、各自往前走。WANDScorer 的全部工作,就是在它们之间做调度:选一个"候选文档号"(比如最小的 5),把三条游标推进到那个号去查命中,命中就打分、不命中就跳过。三堆(§3.4)就是为这个调度做的分工。

关键认知:一个文档的最终得分 = 它实际命中的那些子句的实际得分之和。子句"命中没命中",看那条倒排链里有没有这个文档号——这正是游标要查的事。

3.3 核心招式:用分数上界代替实际分数 #

最直白的判断法:把命中的子句逐个 score() 加起来跟及格线比。但 score() 很贵(要算 BM25 的 tf/idf),WAND 不想这么干。

WAND 的招式:别算实际分数,估一个上界就够了。 每个子句在构建时就算好 maxScore——“命中任何文档最多贡献多少分”(由 idf 和该词可能的最高 tf 决定,计算成本低)。于是:

文档分数上界 = 所有"可能命中"的子句的 maxScore 之和

这个上界必然 ≥ 实际分数:maxScore ≥ 实际得分(往大了估),“可能命中” ⊇ 真正命中(多算不漏算)。

判断就简单了:上界 < 及格线 → 直接跳过,一次 score() 都不调用。 只有上界 ≥ 及格线的文档才值得精确打分。WAND 的全部收益就来自这里——用大量廉价的上界比较换掉少量昂贵的精确打分(“Weak"也在这里:不强求每个子句都查实,上界够用就敢下结论)。

那么"可能命中"怎么界定?答案在游标位置里。

3.4 游标的三种位置 → 三堆 #

§3.3 留了一个问题:哪些子句算"可能命中”?答案全在游标上。

WANDScorer 手里攥着好几条倒排链的游标,各自独立推进。每一轮它选定一个候选文档号(从哪来的 §3.5 会说,眼下先当成参照点),然后看每条链的游标相对这个文档号,只可能有三种位置:

  • 游标 == 候选号:确认命中 → 上界按 maxScore
  • 游标 > 候选号:游标走过头了,确认不命中 → 贡献 0
  • 游标 < 候选号:还没走到,未知 → 上界按 maxScore 计(往乐观了估)

WANDScorer 把这三类子句分别放进三个结构,叫三堆:确认命中的进 lead,确认不命中的进 head,未知的进 tail

         ┌────────────────────────────────────────────────────────────────┐
         │                      WANDScorer 内部结构                        │
         │                                                                │
         │   tail (maxScore 最大堆) lead        head (按 docID 升序)        │
         │   ┌──────────────┐       ┌─────┐     ┌──────────────┐          │
         │   │ S4 maxScore=9│       │ S1  │     │ S3 doc=156   │          │
         │   │ S2 maxScore=5│       │ S5  │     │ S6 doc=203   │          │
         │   │ S7 maxScore=2│       │     │     │              │          │
         │   └──────────────┘       └─────┘     └──────────────┘          │
         │                                                                │
         │   游标还没到当前文档     游标已停在      游标已越过当前文档           │
         │   按 maxScore 排,       当前文档      按 docID 排,              │
         │   按需推进                            决定下个候选                │
         └────────────────────────────────────────────────────────────────┘

三个结构各有分工:

  • tailmaxScore 排最大堆,堆顶是分数最高的子句。上界不够时优先借它——最可能一把把上限抬过线。
  • lead 是简单链表,串起确认命中的子句。精筛过关后逐个 score() 算实际分。
  • headdocID 排最小堆,堆顶是文档号最小的——它就是下一个候选文档。

子句在三堆间流转:tail → lead → head。未知的推进去查,命中进 lead,不命中进 head。下一轮候选变了,部分子句又可能从 head 回到 tail。

3.5 搜索流程:粗筛、精筛与阈值回传 #

把三堆放回完整流程。WANDScorer 对外是个 Scorer,被 Collector 驱动,主循环四步:

Collector 主循环:
  while ((doc = scorer.nextDoc()) != NO_MORE_DOCS) {   // ① 粗筛
    if (twoPhase.matches()) {                          // ② 精筛
      float s = scorer.score();                        // ③ 精确打分
      collector.collect(doc, s);                       // ④ 收入结果 + 抬及格线
    }
  }
  • ① 粗筛:候选从 head 堆顶来(docID 最小者)。nextDoc() 调用 doNextCompetitiveCandidateWANDScorer.java:492-504)做一道文档级跳过:若 leadMaxScore + tailMaxScore < 及格线,说明当前 doc 凑不够分,直接推进到下一个候选文档(可能跨 block)。这里只用每子句一个的 maxScore 求和判断,不逐子句查实命中、不算精确分。
  • ② 精筛:留在候选 doc 上,逐个借 tail 堆顶推进查实命中,看实际能否过线。这是 WAND 的核心动作,§3.6 专门展开。
  • ③ 精确打分:通过精筛的,把 lead 里确认命中的子句逐个 score() 加总。
  • ④ 阈值回传:收满 K 个后,Collector 把第 K 名分数回传给 WANDScorer 抬高及格线——下一篇 §一展开。

3.6 核心剪枝:上界估计与"借 tail" #

对当前候选文档,WAND 只回答一个问题:它的分数上界够不够得着及格线?

上界 = lead 各子句 maxScore 之和(确认命中,贡献确定)+ tail 各子句 maxScore 之和(未知,按 maxScore 乐观估算)。这个上界必然 ≥ 实际分数。判断逻辑( WANDScorer.java:309-332):

while (leadMaxScore < minCompetitiveScore) {        // lead 自己不够线
    if (leadMaxScore + tailMaxScore < minCompetitiveScore)
        return false;        // 加上 tail 的乐观估计仍不够 → 必败,跳过
    advanceTail();            // 还有希望:从 tail 堆顶借一个子句去"查实"
}
return true;                  // lead 已够线 → 留下精确打分

关键在 advanceTail()——把 tail 堆顶(maxScore 最高的未知子句)推进到当前文档查实,结果只有两种:

  • 命中 → 进 lead,leadMaxScore 实打实涨上去;
  • 不命中 → 进 head,从 tail 挪走,tailMaxScore 掉下来。

每借一次,要么抬高下限(leadMaxScore),要么压低上限(tailMaxScore)。文档能不能活下来,取决于借出的子句里有多少真的命中;借遍 tail 仍够不到线就判负,全程没调用过 score()

为什么永远先借堆顶? tail 按 maxScore 排最大堆,堆顶最可能一下把 leadMaxScore 抬过线;如果连 leadMaxScore + tailMaxScore 都够不到线,低分子句就无需查实,候选文档可以直接跳过。这就是 WAND 的 “Weak”:不强求每个子句都查实,只查可能扭转局面的那几个。

3.7 数字示例:一次完整的剪枝流程 #

先给出示例的场景。3 个 SHOULD 子句,各自的 maxScore 和倒排链(命中的 docID,已升序排列)如下,及格线 minCompetitiveScore = 10

子句maxScore倒排链(命中的 docID)
S1842, 55
S2520, 55
S3330, 60

每个子句配一个游标,指向"这条链下一个待处理的 docID"。假设已经收集满 Top-K、及格线抬到了 10,本轮候选文档 = 42(S1 的游标停在 42,刚从 head 堆顶取出作为领头);S2、S3 因 maxScore 较低此前没被推进,游标还分别停在 20、30。把三个子句按"游标 vs 候选 42"的位置归堆:

  • S1 游标 42 == 42:确认命中 doc=42 → lead
  • S2 游标 20 < 42:还没走到,未知 → tail
  • S3 游标 30 < 42:还没走到,未知 → tail
  • 没有游标 > 42 的子句 → head 此刻是空的(S1 已进 lead,S2、S3 还在 tail)

于是初始状态:lead 装着 S1(确认能拿 8 分),tail 装着 S2、S3(最多还能补 5+3=8 分),head 空。

下面这张状态图把 doc=42 接下来怎么被剪掉完整画了出来。关键看右侧三个数leadMax = lead 里已确认能拿的分数,tailMax = tail 里最多还能补多少(乐观估计),二者之和就是分数上界。每借一次 tail 堆顶,三堆成员就流动一次,这三个数也跟着变:

        候选 doc=42,  及格线 = 10                          分数上界 = leadMax + tailMax

              head          lead         tail            leadMax  tailMax  上界     动作
        ┌────────────┐  ┌─────────┐  ┌──────────┐
  初始   │   (空)     │  │  S1: 8  │  │  S2: 5   │          8        8     16   ≥10
        │            │  │         │  │  S3: 3   │                              ─→ 借堆顶 S2
        ├────────────┤  ├─────────┤  ├──────────┤
  借 S2 │  S2→55     │  │  S1: 8  │  │  S3: 3   │           8        3     11   ≥10
        │            │  │         │  │          │                              ─→ 借堆顶 S3
        ├────────────┤  ├─────────┤  ├──────────┤
  借 S3 │  S2→55     │  │  S1: 8  │  │  (空)    │           8        0      8   <10
        │  S3→60     │  │         │  │          │                              ─→ ✂️ 必败
        └────────────┘  └─────────┘  └──────────┘

        表头解读:head/lead/tail 三栏列出各自的子句成员(含 maxScore);
                  右侧三个数描述【整堆】在这一轮的整体状态,不与某一格对应。

每一行发生了什么(看左栏 → 右栏):

  • 初始:lead 只有 S1 → leadMax=8 不够线(8 < 10),但加 tail 的乐观估计 tailMax=8 后上界 16 ≥ 10,还有希望 → 借 tail 堆顶 S2 查实。
  • 借 S2:把 S2 游标从 20 推进到 ≥42,结果落在 55(> 42,没命中)→ S2 进 head,tailMax 从 8 掉到 3。leadMax 还是 8(没涨,因为 S2 没命中),上界 11 ≥ 10 仍有希望 → 再借 S3。
  • 借 S3:把 S3 游标从 30 推进到 ≥42,落在 60(也没命中)→ S3 进 head,tailMax 从 3 掉到 0。上界 8 < 10 → 必败,剪掉 ✂️

读懂这张图就懂了 WAND:借出的子句都没真命中 42,所以 leadMax 三轮卡在 8 不动;而每借走一个,tailMax 就往下掉一截(8→3→0)。上界随之从 16 一路缩到 8,最后跌穿及格线——doc=42 全程没调一次 score() 就被剪掉。

再看下一个候选 doc=55——lead 自身就够线,根本不用借。doc=42 被剪掉后,S1 推进到它的下一个文档 55;S2、S3 在前面借出时已推进到 55、60。按游标位置归堆:S1、S2 游标 == 55 → lead;S3 游标 60 > 55 → head(贡献 0)。状态图只有一行:

        候选 doc=55,  及格线 = 10

              head          lead         tail            leadMax  tailMax  上界     动作
        ┌────────────┐  ┌─────────┐  ┌──────────┐
  初始   │  S3→60     │  │  S1: 8  │  │  (空)    │         13        0     13   ≥10   ─→ score()
        │            │  │  S2: 5  │  │          │                              实际 7+4=11 ✅
        └────────────┘  └─────────┘  └──────────┘

leadMax=13 一上来就够线,直接调 score() 算实际分 7+4=11 > 10 → ✅ 收入 Top-K。两图对比,分水岭就在第一行:doc=42 的 lead 只值 8,被迫借 tail 撞运气;doc=55 的 lead 自值 13,一步过关。同一个及格线下,命运全由"借出来的子句有没有真命中"决定——这正是 §3.6 那句"借堆顶"的实战含义。

3.8 cost 作为平局打破者(tiebreaker) #

当两个子句 maxScore 相同时,推进 cost 更低(更稀疏)的子句更高效——它每次 advance 跳过的文档更多。

代码解读:tail 堆的比较器 greaterMaxScore()WANDScorer.java:643-651)以 scaledMaxScore 为主键排序,maxScore 相同时用 cost 做次键:

private static boolean greaterMaxScore(DisiWrapper w1, DisiWrapper w2) {
  if (w1.scaledMaxScore > w2.scaledMaxScore) return true;
  else if (w1.scaledMaxScore < w2.scaledMaxScore) return false;
  else return w1.cost < w2.cost;   // 分数并列时,cost 更低(更稀疏)的排前面
}

小结 #

本文把 WAND 讲透了:一条不断抬高的及格线 + 用 maxScore 估上界 + 三堆分工调度游标 + 完整数字例子。核心就一句——用大量廉价的上界比较,换掉少量昂贵的精确打分

但标准 WAND 还有一个明显短板:每个子句的上界是全局的,游标走到低分区域仍按全局最高分估,剪枝不够激进。Block-Max WAND 怎么补上这块、混合查询怎么端到端跑起来、Profile API 怎么亲手验证剪枝生效——都在 下一篇(四)

标签
Easysearch x
布尔查询 x
BoolQuery x
排序 x
自主可控 x
超聚变 x
FusionOS x
信创 x
CocoAI x
Elasticsearch x
中国银行 x
搜索引擎 x
DTCC x
中国数据库技术大会 x
爱加密 x
安全 x
极限网关 x
PICC x
Gateway x
Coco AI x
信创产品评估证书 x
达梦数据库 x
CDC x
AI搜索 x
DBX x
客户端 x
可视化 x
数据库 x
达梦 x
兼容认证 x
产品更新 x
巡检 x
CCR x
语义搜索 x
向量检索 x
RAG x
knn x
2026 x
信通院 x
TDBC x
可信数据库大会 x
中国数据库产业图谱 x
ZSTD x
Bboss x
开源 x
国产化 x
Java 客户端 x
Console x
搜索平台 x
鲲鹏 x
统信UOS x
DBackup x
鼎甲数据 x
备份与恢复 x
Coco x
迁移 x
快照 x
snapshot x
向量 x
IK x
分词 x
performance x
插件 x
开发 x
自定义 x
扩展 x
AI x
幻觉 x
大模型 x
Agent x
Mem0 x
MCP x
AI Agent x
kNN x
规则引擎 x
银行 x
保险 x
风控 x
Rules x
Percolator x
国产 x
搜索 x
Lucene x
GraalVM x
JDK x
赞助 x
开源生态 x
社区 x
二等奖 x
兴智杯 x
人工智能 x
赛事 x
低空经济 x
商业化 x
数据分析 x
金猿奖 x
技术卓越奖 x
创新产品奖 x
IT168 x
APM x
Skywalking x
Easy-Es x
GitLab x
代码审核 x
石油石化 x
Gitee x
投票 x
Meilisearch x
Rust x
轻量级 x
搜索百科 x
Docker x
Docker Compose x
Easyserach x
DevOps x
国产替代 x
backup x
esdump x
source_reuse x
ignore_above x
OpenSearch x
AWS x
Solr x
Easyearch x
发明专利 x
数据分区 x
国际专利 x
一等奖 x
人工智能应用创新大赛 x
bulk x
embedding x
OpenAI x
2025 x
搜索型数据库 x
上海开源创新菁英荟 x
开源创新新星企业 x
Workshop x
AI 搜索 x
智能助手 x
Automation x
Logstash x
MongoDB x
开源中国 x
直播 x
merge x
Elasticsearch 9 x
GitCode x
Cloud x
rollup x
Kubernetes x
Operator x
Arm64 x
Snapshot x
S3 x
Grafana x
Opensearch x
Nginx x
直播活动 x
搜索客社区 x
Meetup x
ES x
企业搜索 x
DeepSeek x
certificate x
windows x
Rollup x
TopN x
Filebeat x
Ubuntu x
请求限速 x
INFINI Console x
指标 x
Kibana x
多集群 x
client x
Spring Boot x
ECE x
ES Bulk x
vector database x
Postgres x
可搜索快照 x
SDK x
官网 x
Web 开发 x
Next.js x
React x
Three.js x
Metrics x
Helm x
filter x
querycache x
practice x
localStorage x
响应式 x
时间组件 x
时区组件 x
极限科技 x
三周年 x
周年庆 x
国家高新技术企业 x
校园招聘 x
湖北工业大学 x
Tauri x
Web 开发人员 x
桌面应用开发 x
桌面端 x
Electron x
Pizza x
认证培训 x
报名 x
Scrapy x
爬虫 x
Rust开发者大会 x
docsearch x
文档搜索 x
Easyseach x
有奖征文 x
黑神话悟空 x
EKS x
征文系列 x
跨集群搜索 x
科技中小企业 x
白皮书 x
Python SDK x
数据库产业图谱 x
超大规模 x
分布式集群 x
写入限流 x
2024可信数据库发展大会 x
创新型中小企业 x
搜索数据库 x
正排索引 x
免费许可证 x
K8S x
DTC2024 x
实时搜索 x
ES国产化 x
Redis x
OOM x
测试 x
内存 x
趋势 x
AI绘画 x
Stable Diffusion x
Diffusion x
Model x
GAN x
知识图 x
向量数据库 x
中国信通院 x
星河(Galaxy) x
标杆案例 x
鲲鹏技术认证 x
日志平台 x
LDAP x
Loadgen x
中国一汽 x
国内数据库 x
墨天轮 x
监控系统 x
集成测试 x
Helm Charts x
国产适配 x
兆芯 x
Linux x
LoongArch x
信创适配 x
二维拆分算法 x
中国移动云 x
Vault x
加密 x
安全工具 x
图片搜索 x
Alerting x
SQL x
Embedding x
可信数据库 x
统信 x
海光 x
龙芯 x
restore x
Arm x
大数据企业证书 x
移动云大会 x
信通院产品评测 x
国内首家 x
数据可视化 x
北京软协 x
第十届理事会会员单位 x
Apache Arrow x
宣传片 x
大会分享 x
多集群管理 x
无缝数据迁移 x
Loadrun x
INFINI Gateway x
log4j x