从零构建现代搜索引擎(11):查询为什么不必扫描全部候选
上一篇把索引从内存搬到了磁盘。现在对一个两词查询 “Java GC”,BM25 需要遍历两个词项的全部 posting list、对每个候选文档计算精确分数、维护 Top-K 堆。“Java” 的 posting list 有 5,000 条,“GC” 有 800 条——OR 查询的候选集最多 5,800 个文档,每个都要算一次 BM25。
但用户只看前 10 条结果。为了找到 Top-10,穷举 5,800 个文档是否必要?
答案是不必要。如果已经找到了分数很高的 10 篇文档,后面的文档只要能证明"它不可能比当前第 10 名更高",就可以直接跳过。这就是查询剪枝的核心思路。
穷举的成本
穷举 Top-K 流程回顾
第 09 篇的 Top-K 算法对所有候选文档计算 BM25:
1 | |
时间复杂度 O(N × Q × log K),其中 N 是候选文档数,Q 是查询词项数,K 是返回结果数。对 Web 规模(N 可能数百万),这个成本不可接受。
实际观察
对一个两词查询,实际进入 Top-10 的文档只有 10 篇。剩下的 5,790 篇都白算了——它们的分数不够高。如果能提前判断"这个文档不可能进入 Top-10",跳过它的 BM25 计算,查询就能快很多。
Skip List:跳过不相关的 Posting
问题
AND 查询的双指针交集算法遍历两个 posting list,时间 O(m + n)。当 “the”(m = 100,000)和 “NullPointerException”(n = 50)做 AND 时,“the” 的 posting list 中 99,950 条都不会出现在结果中,但线性扫描还是要一条一条走。
Skip Pointer
在 posting list 上每隔固定距离放一个跳跃指针。假设 “the” 的 posting list 是:
1 | |
每隔 4 个放一个 skip pointer:
1 | |
交集算法的改进:当短列表指向 docID = 33,长列表指向 docID = 5 时,检查 skip:下一个 skip 目标是 15(< 33),跳过 [5, 8, 12];再检查 skip:下一个 skip 目标是 33(= 33),跳过 [15, 20, 24, 28],直接落在 33 上。
1 | |
Skip 间距选择
间距太小——skip pointer 本身占空间,每次判断是否跳也有成本。间距太大——跳过的机会少,退化为线性扫描。
理论最优间距:sqrt(L),L 是 posting list 长度。直觉是:平衡了"跳跃次数"和"每次跳过的距离",类似于分块查找的最优块大小。
| posting 长度 | 最优间距 | skip pointer 数量 |
|---|---|---|
| 100 | 10 | 10 |
| 10,000 | 100 | 100 |
| 1,000,000 | 1,000 | 1,000 |
实际系统(如 Lucene)使用固定间距(128),不按 sqrt(L) 调整——实现简单,效果足够。
DAAT:Document-At-A-Time
两种查询执行策略
TAAT(Term-At-A-Time):逐词项处理。先遍历 “java” 的全部 posting,为每个 docID 在一张评分表(accumulator)中累加 BM25 贡献;再遍历 “gc” 的全部 posting 做同样的事。最后从评分表中选 Top-K。
问题:评分表大小等于所有出现过的文档数(OR 语义下可能很大),内存开销不可预测。
DAAT(Document-At-A-Time):同时维护所有词项的 posting 指针,按 docID 从小到大推进。每次处理一个 docID,计算该文档在所有词项上的完整分数,立即决定是否进入 Top-K 堆。
1 | |
DAAT 不需要全量 accumulator——一次只处理一个文档,内存只需要 Top-K 堆和各词项的指针。更重要的是,DAAT 天然适配剪枝:因为始终知道当前 Top-K 堆的最低分(threshold),可以用这个阈值判断后续文档是否值得计算。
WAND:安全的 Top-K 剪枝
核心观察
BM25 的每个词项对一篇文档的贡献有上界。“gc” 在任何文档中的最大可能 BM25 贡献不会超过某个值 UB_gc——这个值在最短文档、最高词频情况下取得。
如果某个文档在所有查询词项上的贡献上界之和仍然低于当前 Top-K 阈值,这个文档不可能进入结果集,可以安全跳过。
上界分数计算
对词项 t,预计算全局上界:
1 | |
粗略上界:UB_t ≈ idf(t) × (k1 + 1),因为 BM25 的 TF 饱和项上界是 k1 + 1 = 2.2。
WAND 算法
WAND(Weak AND)由 Broder 等人在 2003 年提出。它在 DAAT 框架内增加了一步:在计算精确分数之前,先用上界判断是否值得计算。
1 | |
手算示例
查询 “java gc”,Top-3,UB_java = 1.2,UB_gc = 1.8。当前堆最低分 threshold = 1.5。
1 | |
剪枝效果
WAND 跳过的文档不会影响结果正确性——它跳过的都是"上界都不够"的文档,精确分数只会更低。
对典型 Web 查询,WAND 可以跳过 80-95% 的候选文档评分计算。效果取决于:
- 查询词的 IDF 分布:高 IDF 词的上界更大,更容易成为 pivot
- Top-K 堆的阈值:阈值越高,越多文档被剪枝
BlockMaxWAND
WAND 的上界是全局的——UB_java 是 “java” 在所有文档中的最大可能贡献。但对一个特定的 posting list 区间,实际最大贡献可能远低于全局上界。
BlockMaxWAND(Ding & Suel, 2011)把 posting list 切成固定大小的 block(如 128 个 posting),预计算每个 block 内的最大贡献。
1 | |
当 threshold = 1.6 时,java 的 block2(max=0.3)加上 gc 的 block0(max=1.5)= 1.8 >= 1.6,需要检查。但 java 的 block2(max=0.3)加上 gc 的 block1(max=0.9)= 1.2 < 1.6,整个 block 可以跳过。
BlockMaxWAND 的上界比 WAND 更紧,跳过更多文档。Lucene 从 8.0 版本开始默认使用这种策略。
本篇不实现 BlockMaxWAND——它需要修改段文件格式来存储每个 block 的最大分数。但理解它的思路有助于后续阅读 Lucene 源码。
验证
| 场景 | 验证方法 |
|---|---|
| Skip list 正确性 | 有 skip 和无 skip 的 AND 查询返回相同 docID 集合 |
| DAAT 正确性 | 与穷举方式返回相同的 Top-K 和分数 |
| WAND 正确性 | 与穷举 DAAT 返回相同的 Top-K,分数精确到小数点后三位 |
| 剪枝效果 | 记录 WAND 实际计算 BM25 的文档数 vs 候选总数 |
关键验证指标:对 10 个查询,记录穷举访问的文档数和 WAND 访问的文档数,计算跳过比例。
1 | |
当前局限
- Skip list 间距是固定的,没有根据查询模式自适应
- WAND 实现是教学级的,排序用 Arrays.sort 而非原地维护
- 没有实现 BlockMaxWAND——第 15 篇迁移到 Lucene 后自动获得
- 上界计算比较粗糙,更紧的上界需要更多预计算
- 多词项查询(3+ 个词项)的 WAND 效果更好,但本篇的小数据集可能体现不出优势
练习
- 对一个 1,000 条的 posting list,分别用 skip 间距 10、32、100 做 AND 查询,记录比较次数
- 构造一个 WAND 可以跳过大量文档的场景:一个高 IDF 词和一个低 IDF 词的 OR 查询
- 对同一组查询,分别用穷举 Top-10 和 WAND Top-10,验证结果一致并记录时间差异
- 将 threshold 固定为 0(空堆),观察 WAND 退化为穷举的行为——理解 threshold 对剪枝效果的关键作用
- 阅读 Lucene 源码中
MaxScoreBulkScorer的注释,理解 BlockMaxWAND 在工程上的实现选择
延伸阅读
- Introduction to Information Retrieval, Chapter 2.3: Faster postings list intersection via skip pointers
- Broder, A. Z. et al. (2003). Efficient Query Evaluation using a Two-Level Retrieval Process. CIKM.
- Ding, S. & Suel, T. (2011). Faster Top-k Document Retrieval Using Block-Max Indexes. SIGIR.
- Lucene 源码:
org.apache.lucene.search.MaxScoreBulkScorer






