Rust in Everything
基于 tantivy 0.27.0

(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。

💬 关联讨论

发起讨论
加载中...