Easysearch 布尔查询优化(下)|找 Top-K 时,如何跳过注定落选的文档

Easysearch 布尔查询优化(下)|找 Top-K 时,如何跳过注定落选的文档

析取查询的 WAND 剪枝、块级跳过,以及一个决定剪枝开关的参数


上篇开头的总图画了查询的三道工序,其中第三道"执行层"里,must / filter 走合取路径(按 cost 排序,最稀疏的领头)、should 走析取路径。本篇就专讲析取这一支。

一、析取和合取的根本不同

合取查询(must / filter)要求所有条件都满足,引擎按 cost 排序让最稀疏的条件领头。析取查询(should)完全不同:不需要全部匹配,而是找分数最高的 K 个文档(Top-K)。优化目标从"最少匹配"变成"最高分数贡献"——cost 最低的条件不一定分数最高,而分数最高的条件才最可能帮你快速找到 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 算法。


二、WAND:一条不断抬高的及格线

及格线是什么

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

于是每个候选文档只需回答一个问题:分数能不能超过及格线?能才值得精确打分,不能就该跳过。

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

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

WAND 的招式:别算实际分数,估一个上界就够了。每个子句事先算好一个 maxScore——“命中任何文档最多贡献多少分”。于是:

文档的分数上界 = 它可能命中的那些子句的 maxScore 之和

这个上界必然不低于实际分数(往大了估,多算不漏算)。判断就简单了:

上界 < 及格线 → 直接跳过,一次精确打分都不调用。

WAND 的全部收益就来自这里——用大量廉价的上界比较,换掉少量昂贵的精确打分。

三堆分工

先看 WANDScorer 手里攥着什么。每个 SHOULD 子句对应一条倒排链——按文档号升序排列的、所有命中该词的文档号序列;每条链配一个游标,指向"下一个待处理的文档":

子句 倒排链(命中的 docID,升序) 游标 ┌──────────┐ │ S1 max=8 │ ··· 42 ──── 55 ──── ··· ▼ 停在 42 └──────────┘ ┌──────────┐ │ S2 max=5 │ ··· 20 ──── 55 ──── ··· ▼ 停在 20 └──────────┘ ┌──────────┐ │ S3 max=3 │ ··· 30 ──── 60 ──── ··· ▼ 停在 30 └──────────┘ ───────────────────────────────────────────→ docID 轴 20 30 42 55 60

WANDScorer 就是协调这些游标推进的调度器。每一轮它选一个候选文档号,然后看每条链的游标相对这个号在什么位置,只有三种:

  • 游标停在候选号:确认命中 → 计入上界
  • 游标走过头:确认不命中 → 贡献 0
  • 游标还没到:未知 → 按上界乐观估算

引擎把这三类子句分别放进三个结构:

  • tail 堆:未知的子句,按 maxScore 排,堆顶是分数最高的。上界不够时优先"借"它查实——最可能一把把上限抬过线。
  • lead 链:确认命中的子句。过关后逐个精确算分。
  • head 堆:确认不命中的子句,按文档号排,堆顶最小的就是下一个候选文档。

以候选 doc=42 为例(S1 命中、S2/S3 还没查实),三堆长这样:

tail(游标<候选,未知) lead(游标==候选,命中) head(游标>候选,不命中) ┌──────────────────┐ ┌──────────┐ ┌──────────┐ │ S2 max=5 ←堆顶 │ │ S1 max=8 │ │ (空) │ │ S3 max=3 │ └──────────┘ └──────────┘ └──────────────────┘ 按 maxScore 乐观估 确认贡献 8 确认贡献 0 上界不够时借堆顶查实 过关后精确算分 堆顶=下个候选 分数上界 = lead(8) + tail 乐观估(5+3) = 16,和及格线 10 比较决定去留

子句在三堆间流转:未知推进去查,命中进 lead,不命中进 head。

核心动作:“借 tail”

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

只要 lead 的分数还不够线: 如果 lead 分数 + tail 乐观估计 还不够线 → 必败,跳过 否则从 tail 堆顶借一个子句去"查实" 够线了 → 留下精确打分

借出的子句只有两种结果:

  • 命中→ 进 lead,已确认的分数实打实涨上去;
  • 不命中→ 进 head,乐观估计掉下来。

每借一次,要么抬高下限(已确认分数),要么压低上限(乐观估计)。借遍 tail 仍够不到线就判负——全程没调用过一次精确打分

💡 为什么永远先借堆顶?tail 按 maxScore 排,堆顶最可能一下把分数抬过线;如果连"已确认 + 堆顶"都够不到线,低分子句就无需查实,直接跳过。这就是 WAND 的精髓:不强求每个子句都查实,只查可能扭转局面的那几个。

一个数字例子

三个 should 子句,maxScore 分别是 S1=8、S2=5、S3=3,及格线 10。某文档 42 只确认命中了 S1(能拿 8 分,不够线),S2、S3 还没查(未知)。对照上面的游标示意图,S2 的倒排链是20 → 55、S3 是30 → 60——42 都不在其中,所以一旦查实就是"没命中":

  • 上界 = 8(已确认)+ 5+3(乐观估)= 16 ≥ 10,还有戏 → 借 S2 查实:推进到 ≥42 落在 55(>42,没命中),上界降到 8+3=11,还有戏 → 借 S3 查实:落在 60(也没命中),上界降到 8 < 10 →必败,剪掉

全程没算一次精确分数,文档 42 就被跳过了。


三、Block-Max:按班级分组,整班跳过

标准 WAND 还有个明显短板:每个子句的上界是全局的——“这个词在全索引最多能打多少分”。游标走到低分文档区,上界仍按全局最高估,剪枝不够狠。

一个类比

想象一场考试,每个学生最多考 100 分。老师想知道"有没有人超过 90 分"。

  • 标准 WAND:每个学生头顶贴着"100"(全局上限)。光看标签谁都"可能"过 90,得把人都叫过来核对。
  • 聪明的做法:按班级分组,每个班只记一个"本班最高分"。某班最高才 60,整班直接跳过,不用一个个核对。

Block-Max 就是这个"按班级分组"的做法。倒排链在物理存储上本来就是分块的——每 128 篇文档压成一个 block,写入时顺手记下"这个 block 内最高能打多少分"。这个分段级上界比全局值紧得多,因为它只看本 block 的数据,不会被远处的高分文档带偏。

一句话区分:标准 WAND 的上界是"这个词在全索引最多打多少分";Block-Max 的上界是"这个词在当前这个 block最多打多少分"。后者永远不超过前者,所以更容易触发剪枝。

换个场景看对比(这里及格线设为 8,全局最高分 9、本块最高分 2):

游标已进入低分段 Block: 标准 WAND:上界估 9(全局),9 ≥ 8 → 不剪,逐文档查 Block-Max:上界估 2(本块), 2 < 8 → 跳过整个 block(128 篇)

差异就在这:同一个低分 block,标准 WAND 还按全局 max=9 估,以为"可能过线"就逐文档查(保守);Block-Max 看本块实际最高分 2,一眼判定整块无望直接跳过(激进)。一次比较换掉 128 次逐文档推进。


四、剪枝的反馈闭环:越往后越激进

把上面几层串起来看,剪枝是一个"自适应加速"的闭环:

Collector 收满 K 个结果 │ 把第 K 名分数回传 ▼ WAND 收到及格线(抬高) │ 用上界跳过低分候选 / 用分段上界替换全局上界 ▼ 块级跳过:整个 block 最高分都 < 及格线 → 跳过 128 篇 │ ▼ 迭代更快 → 更快碰到高分文档 → Collector 收到更高分 │ 再次回传 ▼ (循环)及格线越抬越高,剪枝越来越激进

一开始及格线低、剪枝保守(还没见过高分文档,不敢贸然跳);随着高分结果不断收集,线越抬越高,剪枝越来越激进。这个闭环是双向反馈:Collector 把及格线往下传给 WAND,WAND 再往下传给块级跳过。


五、一个开关:track_total_hits 决定剪枝开不开

这是写查询时最容易忽略、却最影响析取性能的一个参数。

WAND 的剪枝能工作,前提是 Collector 愿意把"第 K 名分数"回传给引擎。而这是由track_total_hits控制的:

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

也就是说,同一条 should 查询,只改track_total_hits就能让 WAND 在"真正剪枝"和"不剪枝"之间切换。

💡 实际选择:如果你的业务只需要前 N 条结果、不关心命中总数是否精确,用track_total_hits: false能让 WAND 剪枝生效,省掉大量打分。如果你必须给出精确的命中总数(比如分页要显示"共 N 条"),才需要true,代价是剪枝关闭。


六、用 Profile API 验证 WAND 生效

怎么确认 WAND 真的生效了?用对比实验:track_total_hits: false开启剪枝,track_total_hits: true关闭剪枝,对比 profile 的 breakdown。

为了让剪枝效果看得清楚,测试数据故意做成"少量高分文档 + 海量低分文档":

  • 100 篇body = "rare common":同时命中稀有词rare和高频词common,分数高
  • 20000 篇body = "common extra":只命中两个高频词,分数低

查询用minimum_should_match: 2,让 Lucene 走WANDScorer路径:

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}}}

实测对比(Easysearch 2.2.0):

track_total_hits = false(WAND 生效): set_min_competitive_score_count = 1 ← 反馈闭环生效! score_count = 100 ← 只给高分候选打分 body:common shallow_advance_count = 3 ← Block-Max 块级定位 body:common compute_max_score_count = 3 ← Block-Max 分段上界 body:extra advance_count = 1 ← 低分子句几乎整链跳过 track_total_hits = true(剪枝关闭): set_min_competitive_score_count = 0 ← 从不回传阈值 score_count = 20100 ← 全部命中文档都打分 body:common shallow_advance_count = 0 body:common compute_max_score_count = 0 body:extra advance_count = 20001

这组数字很直观:track_total_hits: false时,WAND 只对 100 篇高分候选调用score()track_total_hits: true时,则给 20100 篇命中文档全部打分。两组返回的 Top-10 完全相同——剪枝只省功,不改结果。

怎么读这份 profile

最关键的三类指标:

  1. set_min_competitive_score_count:反馈闭环的"心跳"。从 0 变非零,说明 Collector 把第 K 名分数回传给了 WAND,剪枝机制已触发。
  2. score_count:真正调用score()的次数。本例从 20100 降到 100,说明大量低分文档在打分前被跳过。
  3. shallow_advance_count/compute_max_score_count:Block-Max 的证据。它们出现在TermQuery叶子节点上,表示底层做了块级定位和分段上界计算。

⚠️ 两个读 profile 的避坑

  1. 要看次数用带_count后缀的字段。profile 的 breakdown 里,每个操作有两条记录:不带_count的是纳秒耗时,带_count的才是调用次数。

  2. shallow_advance_count要看叶子节点。BooleanQuery 顶层节点上经常是 0,真正的 Block-Max 调用挂在具体的 TermQuery 子句上。

耗时对比:收益来自少打分

track_total_hits改成true再查一次,对比score耗时(纳秒,单次运行波动大,建议多次取中位数):

指标track_total_hits: false(WAND 生效)track_total_hits: true(剪枝关闭)
score耗时(ns)≈ 37,000≈ 2,640,000
set_min_competitive_score_count10
score_count10020,100

本例 false 模式只给 100 篇文档打分,true 模式要给 20100 篇文档打分,所以score耗时差出几十倍。判断 WAND 是否生效,看set_min_competitive_score_count是否非零;评估收益大小,看score_count的下降幅度和score耗时的相对差距。


七、本篇要点 + 全系列总结

写查询时该注意的(本篇补充)

✅ 理解 WAND 的触发条件

track_total_hits: false切入 TOP_SCORES 开启剪枝;track_total_hits: true走 COMPLETE 关闭剪枝。纯析取会走MaxScoreBulkScorerminimum_should_match > 1会走WANDScorer。不需要精确命中总数时,优先用 false。

✅ 善用 Profile API 的核心指标
  • set_min_competitive_score_count:是否非零 = 剪枝机制是否触发
  • score_count:打分次数是否下降 = 剪枝收益有多大
  • advance_count/shallow_advance_count:跳过力度(合取看 advance,块级跳过看叶子节点的 shallow_advance)
  • score耗时:打分成本
  • 不带_count的是纳秒耗时,带_count的才是次数
🔧 Easysearch 的额外能力:fast_terms 插件

在 Lucene 标准优化栈之外,Easysearch 还提供一个面向特定场景的增强:在高基数字段(如 user_id、tag_id 数万量级)上,用位图集合替代 N 个独立 term 查询,把 N 次倒排查找压缩成 1 次位图运算。查询 DSL 名为fast_terms,适合大量 term 的查询场景,可按需叠加。

全系列总结

两篇文章,从构建到执行,从合取到析取,我们走过了 Easysearch 布尔查询的优化全景:

构建层(上篇前半):十几条改写规则自动简化查询结构,其中对性能影响最大的是"should + filter 提升为 must"和"should 嵌套拍平"——它们改变子句的执行路径。Profile 里看+前缀就知道提升成功没。

执行层·合取(上篇后半):按 cost 排序,让最稀疏的迭代器领头——它是跳过无效文档最快的那个。Profile 里领头的 advance 次数远大于跟随者。

执行层·析取(本篇):WAND 按 maxScore 调度,高分优先推进,用上界比较换掉精确打分;Block-Max 用分段上界替换全局上界,整块低分文档一次跳过。剪枝的反馈闭环让"越往后越激进"。

给用户的核心建议

  1. 无需关心子句书写顺序——底层自动优化
  2. 关注查询结构设计——用 filter 替代不需要评分的 must,拍平嵌套 should
  3. 善用 track_total_hits——不需要精确总数时用 false,让 WAND 剪枝生效
  4. 善用 Profile API——set_min_competitive_score_count看剪枝机制是否触发,score_count看实际少打了多少分,叶子节点的shallow_advance_count/compute_max_score_count看 Block-Max 是否参与,注意区分耗时和次数

🔍 想翻源码:析取剪枝在WANDScorer,块级跳过在ImpactsDISI(读取MaxScoreCache里的分段上界)。