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.0the1000 万0.1machine_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/idfWAND 不想这么干。WAND 的招式别算实际分数估一个上界就够了。每个子句事先算好一个 maxScore——“命中任何文档最多贡献多少分”。于是文档的分数上界 它可能命中的那些子句的 maxScore 之和这个上界必然不低于实际分数往大了估多算不漏算。判断就简单了上界 及格线 → 直接跳过一次精确打分都不调用。WAND 的全部收益就来自这里——用大量廉价的上界比较换掉少量昂贵的精确打分。三堆分工先看 WANDScorer 手里攥着什么。每个 SHOULD 子句对应一条倒排链——按文档号升序排列的、所有命中该词的文档号序列每条链配一个游标指向下一个待处理的文档子句 倒排链命中的 docID升序 游标 ┌──────────┐ │ S1 max8 │ ··· 42 ──── 55 ──── ··· ▼ 停在 42 └──────────┘ ┌──────────┐ │ S2 max5 │ ··· 20 ──── 55 ──── ··· ▼ 停在 20 └──────────┘ ┌──────────┐ │ S3 max3 │ ··· 30 ──── 60 ──── ··· ▼ 停在 30 └──────────┘ ───────────────────────────────────────────→ docID 轴 20 30 42 55 60WANDScorer 就是协调这些游标推进的调度器。每一轮它选一个候选文档号然后看每条链的游标相对这个号在什么位置只有三种游标停在候选号确认命中 → 计入上界游标走过头确认不命中 → 贡献 0游标还没到未知 → 按上界乐观估算引擎把这三类子句分别放进三个结构tail 堆未知的子句按 maxScore 排堆顶是分数最高的。上界不够时优先借它查实——最可能一把把上限抬过线。lead 链确认命中的子句。过关后逐个精确算分。head 堆确认不命中的子句按文档号排堆顶最小的就是下一个候选文档。以候选 doc42 为例S1 命中、S2/S3 还没查实三堆长这样tail游标候选未知 lead游标候选命中 head游标候选不命中 ┌──────────────────┐ ┌──────────┐ ┌──────────┐ │ S2 max5 ←堆顶 │ │ S1 max8 │ │ 空 │ │ S3 max3 │ └──────────┘ └──────────┘ └──────────────────┘ 按 maxScore 乐观估 确认贡献 8 确认贡献 0 上界不够时借堆顶查实 过关后精确算分 堆顶下个候选 分数上界 lead(8) tail 乐观估(53) 16和及格线 10 比较决定去留子句在三堆间流转未知推进去查命中进 lead不命中进 head。核心动作“借 tail”对当前候选文档引擎只回答一个问题它的分数上界够不够得着及格线只要 lead 的分数还不够线 如果 lead 分数 tail 乐观估计 还不够线 → 必败跳过 否则从 tail 堆顶借一个子句去查实 够线了 → 留下精确打分借出的子句只有两种结果命中→ 进 lead已确认的分数实打实涨上去不命中→ 进 head乐观估计掉下来。每借一次要么抬高下限已确认分数要么压低上限乐观估计。借遍 tail 仍够不到线就判负——全程没调用过一次精确打分。 为什么永远先借堆顶tail 按 maxScore 排堆顶最可能一下把分数抬过线如果连已确认 堆顶都够不到线低分子句就无需查实直接跳过。这就是 WAND 的精髓不强求每个子句都查实只查可能扭转局面的那几个。一个数字例子三个 should 子句maxScore 分别是 S18、S25、S33及格线 10。某文档 42 只确认命中了 S1能拿 8 分不够线S2、S3 还没查未知。对照上面的游标示意图S2 的倒排链是20 → 55、S3 是30 → 60——42 都不在其中所以一旦查实就是没命中上界 8已确认 53乐观估 16 ≥ 10还有戏 → 借 S2 查实推进到 ≥42 落在 5542没命中上界降到 8311还有戏 → 借 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 → 跳过整个 block128 篇差异就在这同一个低分 block标准 WAND 还按全局 max9 估以为可能过线就逐文档查保守Block-Max 看本块实际最高分 2一眼判定整块无望直接跳过激进。一次比较换掉 128 次逐文档推进。四、剪枝的反馈闭环越往后越激进把上面几层串起来看剪枝是一个自适应加速的闭环Collector 收满 K 个结果 │ 把第 K 名分数回传 ▼ WAND 收到及格线抬高 │ 用上界跳过低分候选 / 用分段上界替换全局上界 ▼ 块级跳过整个 block 最高分都 及格线 → 跳过 128 篇 │ ▼ 迭代更快 → 更快碰到高分文档 → Collector 收到更高分 │ 再次回传 ▼ 循环及格线越抬越高剪枝越来越激进一开始及格线低、剪枝保守还没见过高分文档不敢贸然跳随着高分结果不断收集线越抬越高剪枝越来越激进。这个闭环是双向反馈Collector 把及格线往下传给 WANDWAND 再往下传给块级跳过。五、一个开关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.0track_total_hits falseWAND 生效 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最关键的三类指标set_min_competitive_score_count反馈闭环的心跳。从 0 变非零说明 Collector 把第 K 名分数回传给了 WAND剪枝机制已触发。score_count真正调用score()的次数。本例从 20100 降到 100说明大量低分文档在打分前被跳过。shallow_advance_count/compute_max_score_countBlock-Max 的证据。它们出现在TermQuery叶子节点上表示底层做了块级定位和分段上界计算。⚠️ 两个读 profile 的避坑要看次数用带_count后缀的字段。profile 的 breakdown 里每个操作有两条记录不带_count的是纳秒耗时带_count的才是调用次数。shallow_advance_count要看叶子节点。BooleanQuery 顶层节点上经常是 0真正的 Block-Max 调用挂在具体的 TermQuery 子句上。耗时对比收益来自少打分把track_total_hits改成true再查一次对比score耗时纳秒单次运行波动大建议多次取中位数指标track_total_hits: falseWAND 生效track_total_hits: true剪枝关闭score耗时ns≈ 37,000≈ 2,640,000set_min_competitive_score_count10score_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_advancescore耗时打分成本不带_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 用分段上界替换全局上界整块低分文档一次跳过。剪枝的反馈闭环让越往后越激进。给用户的核心建议无需关心子句书写顺序——底层自动优化关注查询结构设计——用 filter 替代不需要评分的 must拍平嵌套 should善用 track_total_hits——不需要精确总数时用 false让 WAND 剪枝生效善用 Profile API——set_min_competitive_score_count看剪枝机制是否触发score_count看实际少打了多少分叶子节点的shallow_advance_count/compute_max_score_count看 Block-Max 是否参与注意区分耗时和次数 想翻源码析取剪枝在WANDScorer块级跳过在ImpactsDISI读取MaxScoreCache里的分段上界。