(TopDocs, Count) 一起收,剪枝没了
主线位置:查询主线第四步。每个段上,
Searcher调一次collector.collect_segment(weight, ...)。按分数的TopDocs和Count都覆盖了这个方法,各走一条捷径;元组没有覆盖,捷径就都没了。机制和回调次数的实测见收集器,本页只讲撞上以后的代价和改法。
flowchart TB t["TopDocs 单独"] --> p["for_each_pruning"] p --> skip["整块跳过低分文档"] c["Count 单独"] --> wc["weight.count"] wc --> df["读 doc_freq"] tu["TopDocs 和 Count 元组"] --> d["默认 collect_segment"] d --> fe["for_each 逐篇算分"] fe --> all["每篇都交给两个成员"]
实验在 exp-tantivy/pitb-lab,bin p22_tuple(release):hdfs.json 的 10 万行日志,body 是 TEXT,一个线程写成一个段。每种写法跑 30 次取最小值,运行时负载 3.6 到 4.2(其他写手的实验在并行)。M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0。
现象
一个常见的写法,想一次查询同时拿到第一页和总数:
let (top, total) = searcher.search(&q, &(TopDocs::with_limit(10).order_by_score(), Count))?;
结果没错,只是慢:
segments=1 docs=100000
terminating hits= 31563 top10 0.003 ms + count 0.000 ms = 0.003 ms | tuple 0.117 ms | same_top10=true
receiving src dest hits= 63120 top10 0.029 ms + count 0.305 ms = 0.334 ms | tuple 0.576 ms | same_top10=true
+packetresponder +terminating hits= 31563 top10 0.005 ms + count 0.120 ms = 0.125 ms | tuple 0.252 ms | same_top10=true
每行左边是分开两次 search 的时间和,右边是元组。单个词项 terminating 分开查合计 3 µs,元组要 117 µs,慢约 40 倍;OR 查询慢 1.7 倍,AND 查询慢 2 倍。前 10 名完全一样(same_top10=true)。
原因
Collector::collect_segment 的默认实现调 weight.for_each:每篇匹配的文档都算 BM25、都交给收集器。两个成员各自覆盖了它:
- 按分数的
TopDocs走collect_segment_top_k,调weight.for_each_pruning,把第 K 名的分数交还给 scorer,分数不够的文档整块跳过; Count::collect_segment直接问weight.count(reader),单个词项在没有删除的段上只读词典里的doc_freq,一条倒排表都不读。
(Left, Right) 的 Collector 实现只写了 for_segment、requires_scoring、merge_fruits 等几个方法,collect_segment 用的是默认实现。于是整个元组走一遍 for_each:剪枝没了,计数也从读一个数变成了逐篇数。三元组、四元组和 MultiCollector 都是一样。
单个词项差得最多:分开查时两边都几乎不干活(count 0.000 ms 就是读了一个 doc_freq),元组却要把 31563 篇逐篇解码、算分、塞进堆。OR、AND 查询本来就要扫,Count 单独查也要 0.1 到 0.3 ms,差距小一些。
修法
分两次 search,用同一个 Searcher:
let searcher = reader.searcher();
let top = searcher.search(&q, &TopDocs::with_limit(10).order_by_score())?;
let total = searcher.search(&q, &Count)?;
两次用的是同一个快照(IndexReader 与 Searcher),中间的提交不会让总数和第一页对不上。Query 会被转成 Weight 两次,BM25 统计也会查两遍,上面的时间里已经包含了这部分。
Count 对 OR 查询仍要逐篇数。界面上如果只需要「有没有下一页」,用 TopDocs::for_doc_range(offset..offset + size + 1) 多取一条判断即可,不必为精确总数付这 0.3 ms。
怎么检查
- 在代码里搜收集器元组和
MultiCollector,成员里有按分数的TopDocs或Count的,就是这个坑。 - 想确认剪枝有没有生效,给查询包一层
Weight,数for_each_pruning和for_each被调了几次,收集器一页给了这样的实测。 - 自己写的收集器如果有捷径(覆盖了
collect_segment),放进元组时同样失效。
小结
- 元组和
MultiCollector不覆盖collect_segment,走默认的for_each;TopDocs的剪枝和Count的weight.count都被绕过。 - 结果相同、时间变长:hdfs 10 万篇、一个段上,单个词项慢约 40 倍,OR、AND 查询慢 1.7 到 2 倍。
- 修法:同一个
Searcher上分两次search。