收集器
主线位置:查询主线第四、五步。
Searcher::search(&query, &collector)在每个段上调一次collector.collect_segment,得到一个「果实」(fruit),最后collector.merge_fruits把各段的果实合成一个结果。TopDocs::with_limit(10).order_by_score()在这一步维护前 10 名,并把第 10 名的分数交还给 scorer 做剪枝。
sequenceDiagram
participant S as Searcher
participant C as Collector
participant W as Weight
participant SC as SegmentCollector
S->>C: requires_scoring
loop 每个段
S->>C: collect_segment
C->>SC: for_segment
C->>W: for_each 或 for_each_pruning
W->>SC: collect doc score
SC-->>C: harvest 得到 fruit
end
S->>C: merge_fruits
src/collector/ 在 0.27.0 有 7755 行,其中 top_score_collector.rs 1838 行(大半是测试)、facet_collector.rs 924 行、sort_key/ 下 2715 行。Searcher 怎么把段分给线程见 IndexReader 与 Searcher。实验在 exp-tantivy/srchb-lab:wiki.json 1000 篇、一个段,body 是 TEXT | STORED,blen(正文字节数)是 INDEXED | FAST 的 u64;对照组链接 0.26.1。平台 M2 Max、macOS 27、rustc 1.99.0,release。
两个 trait
Collector 是整个查询的收集器,要 Sync + Send,因为多线程 Executor 下各段会在不同线程上同时调用它;SegmentCollector 是某一个段上的那一份,只在一个线程上用,所以可以随便改内部状态、不用加锁。
| 方法 | 属于 | 做什么 |
|---|---|---|
for_segment(segment_ord, reader) | Collector | 为一个段建 SegmentCollector;要读快速字段的收集器在这里打开列 |
requires_scoring() | Collector | 要不要算分;Searcher::search 据此决定 EnableScoring |
collect(doc, score)、collect_block(docs) | SegmentCollector | 收一篇;不要分数时一次收一批 |
harvest() | SegmentCollector | 交出这个段的果实 |
merge_fruits(fruits) | Collector | 合并所有段的果实 |
collect_segment(weight, ord, reader) | Collector | 默认实现把上面几步串起来,收集器可以整个覆盖 |
默认的 collect_segment 调 default_collect_segment_impl,按「有没有删除」和「要不要分数」分四种:
match (reader.alive_bitset(), with_scoring) {
(Some(alive_bitset), true) => {
weight.for_each(reader, &mut |doc, score| {
if alive_bitset.is_alive(doc) {
segment_collector.collect(doc, score);
}
})?;
}
// ...
(None, false) => {
weight.for_each_no_score(reader, &mut |docs| {
segment_collector.collect_block(docs);
})?;
}
}
删除的文档是在这里、在收集器门口被挡掉的,scorer 并不知道(删除位图见 fieldnorm 与删除位图)。不要分数又没有删除时,for_each_no_score 每次给一批最多 64 个文档号(COLLECT_BLOCK_BUFFER_LEN),收集器可以按块处理。
Count 连这些都不用:它覆盖了 collect_segment,直接调 weight.count(reader)。TermWeight 的 count 在段没有删除时返回词典里的 doc_freq,一条倒排表都不读。
TopDocs:先选排序键,才是收集器
0.27.0 里 TopDocs 只是两个数(limit、offset),本身不实现 Collector。直接 searcher.search(&q, &TopDocs::with_limit(10)) 编译不过:
error[E0277]: the trait bound `TopDocs: Collector` is not satisfied
--> src/bin/topdocs_err.rs:11:33
|
11 | let top = s.search(&AllQuery, &TopDocs::with_limit(10))?;
| ------ ^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Collector` is not implemented for `TopDocs`
| |
| required by a bound introduced by this call
要再选一个排序键:order_by_score、order_by_fast_field、order_by_string_fast_field、tweak_score,或者通用的 order_by。它们都返回同一个 TopBySortKeyCollector,区别只在 SortKeyComputer:这个 trait 说明排序键是什么、要不要分数、每个段上怎么算。这套接口是 0.26.0 的「Lazy scorers」(#2726)引入的,TopDocs 从那时起不再是收集器。with_limit(0) 直接 panic:「Limit must be greater than 0」。
and_offset(n) 做分页的方式很直接:每个段都收 offset + limit 个,合并后再跳过前 offset 个。翻到第 100 页时每个段要维护 1000 个候选。
按分数:堆和剪枝
SortBySimilarityScore::collect_segment_top_k 覆盖了默认的收集流程,改调 weight.for_each_pruning:
let mut top_n = TopNHeap::new(k);
if let Some(alive_bitset) = reader.alive_bitset() {
let mut threshold = Score::MIN;
weight.for_each_pruning(Score::MIN, reader, &mut |doc, score| {
if alive_bitset.is_deleted(doc) {
return threshold;
}
top_n.push(score, doc);
threshold = top_n.threshold.unwrap_or(Score::MIN);
threshold
})?;
} else {
// ...
回调的返回值就是新的门槛:分数不超过它的文档,scorer 可以不交上来。Weight::for_each_pruning 的默认实现只是在逐篇算分时比一下门槛;布尔查询会覆盖它,用倒排表每块的分数上界整块跳过(Block-Max WAND,见布尔查询)。TopNHeap 是一个大小为 K 的最小堆,堆满之后堆顶就是第 K 名,门槛随每次替换立即更新。分数相同的文档不替换(严格大于),文档按升序到达,所以同分时文档号小的赢。
剪枝能省多少,看进到收集器回调的文档数。实验给查询包一层 Weight,数 for_each_pruning 和 for_each 的回调次数:
the matches 849 | TopDocs(10) pruning-callbacks 55 for_each 0 | (TopDocs, Count) pruning 0 for_each 849 count=849 top1=0.3468
the of and in matches 907 | TopDocs(10) pruning-callbacks 46 for_each 0 | (TopDocs, Count) pruning 0 for_each 907 count=907 top1=2.1508
old man sea matches 68 | TopDocs(10) pruning-callbacks 28 for_each 0 | (TopDocs, Count) pruning 0 for_each 68 count=68 top1=11.4531
+the +of matches 753 | TopDocs(10) pruning-callbacks 43 for_each 0 | (TopDocs, Count) pruning 0 for_each 753 count=753 top1=0.8836
the 匹配 849 篇,只有 55 篇走到了收集器。
searcher.search(&q, &(TopDocs::with_limit(10).order_by_score(), Count)) 一次拿前 10 和总数,走到收集器的文档有多少?上表最右两列就是答案的一部分;原因在动脑筋答案。
按其他键:2K 缓冲和 select_nth
其他排序键走 SortKeyComputer 的默认 collect_segment_top_k:不剪枝,逐篇算键,推进 TopNComputer。它不是堆,是一个容量 2K 的 Vec:
push:有门槛时,键不严格大于门槛就丢掉;否则append_doc追加到末尾。- 满了调
truncate_top_n:select_nth_unstable_by(K)把前 K 名换到前面(线性时间,不排序),第 K+1 名的键作为新门槛,truncate(K)扔掉后一半。 - 最后
into_sorted_vec再截一次、排一次序。
每篇摊下来是常数时间,比堆的 log K 便宜,代价是门槛滞后。排序键不是分数时本来就没有剪枝可做,滞后无所谓;按分数时剪枝靠门槛,所以单独用了堆。
0.26.x 里按分数收集也用 TopNComputer(上面讲的缓冲区):门槛只在缓冲区满、截断一次时才更新,两次截断之间 scorer 拿到的一直是旧门槛。#2881(a65107135,「Use BinaryHeap for score-based top-K collection」,首次发布于 0.27.0)给按分数排序单独换成了 BinaryHeap,代码里留了理由:「TopNComputer only updates its threshold every K docs (at truncation), giving Block-WAND a stale bound」。同一段程序链接 0.26.1,回调次数:
the matches 849 | TopDocs(10) pruning-callbacks 83 for_each 0 | (TopDocs, Count) pruning 0 for_each 849 count=849 top1=0.3468
the of and in matches 907 | TopDocs(10) pruning-callbacks 66 for_each 0 | (TopDocs, Count) pruning 0 for_each 907 count=907 top1=2.1508
old man sea matches 68 | TopDocs(10) pruning-callbacks 38 for_each 0 | (TopDocs, Count) pruning 0 for_each 68 count=68 top1=11.4531
+the +of matches 753 | TopDocs(10) pruning-callbacks 71 for_each 0 | (TopDocs, Count) pruning 0 for_each 753 count=753 top1=0.8836
在 0.27.0 上用公开的 TopNComputer 照 0.26.1 的写法重做一个按分数的收集器(覆盖 collect_segment、调同一个 for_each_pruning),回调次数和 0.26.1 一模一样,前 10 名也相同:
the OldTop(TopNComputer) on this version: pruning-callbacks 83 same_top10=true
the of and in OldTop(TopNComputer) on this version: pruning-callbacks 66 same_top10=true
old man sea OldTop(TopNComputer) on this version: pruning-callbacks 38 same_top10=true
+the +of OldTop(TopNComputer) on this version: pruning-callbacks 71 same_top10=true
快速字段的键在段内是列里的 u64 表示:SortByStaticFastValue 取 column.first(doc),多值字段只看第一个值,harvest 时才转回 i64、f64、日期。字符串更省:SortByString 在段内比的是字典序号(序号和字符串同序,见快速字段),只给最后留下的 K 篇查字典取回字符串(convert_segment_sort_key);不同段的序号不能比,跨段合并比的是字符串。两个段、有的文档缺值:
rank Desc: [(Some(30), 1, 1), (Some(20), 0, 0), (Some(10), 1, 0), (None, 0, 1)]
name Desc: [(Some("pear"), 0, 0), (Some("fig"), 1, 0), (Some("apple"), 0, 1), (None, 1, 1)]
rank Asc: [(Some(10), 1, 0), (Some(20), 0, 0), (Some(30), 1, 1), (None, 0, 1)]
name Asc: [(Some("apple"), 0, 1), (Some("fig"), 1, 0), (Some("pear"), 0, 0), (None, 1, 1)]
缺值(None)升序、降序都排在最后:Order::Desc 用 NaturalComparator(None 最小),Order::Asc 用 ReverseNoneLower(反转,但 None 仍算最小)。
几种排序键在 wiki 上查 old man 的前三名:
score 8.3020 DocAddress { segment_ord: 0, doc_id: 225 } Kripapureeswarar Temple
score 7.5601 DocAddress { segment_ord: 0, doc_id: 948 } I-70 Killer
score 6.3906 DocAddress { segment_ord: 0, doc_id: 427 } Ride the Man Down
blen Some(18363) DocAddress { segment_ord: 0, doc_id: 844 } Vladimir Tsyganko
blen Some(13833) DocAddress { segment_ord: 0, doc_id: 725 } Speaker of the United States House of Representatives election, October 2015
blen Some(10496) DocAddress { segment_ord: 0, doc_id: 682 } Helene Bresslau Schweitzer
...
tweaked 102.9538 DocAddress { segment_ord: 0, doc_id: 225 } Kripapureeswarar Temple
tweaked 94.5820 DocAddress { segment_ord: 0, doc_id: 948 } I-70 Killer
tweaked 70.4421 DocAddress { segment_ord: 0, doc_id: 54 } Town hall of Bordighera
tweak_score 用的是 score × log2(2 + blen):闭包外层在每个段上执行一次、打开列,内层逐篇调用。它把分数变成了另一个键,所以同样不剪枝,52 篇匹配文档全部走一遍。
元组与 MultiCollector
两个到四个收集器可以组成元组,(Count, MaxLen) 一次拿到两个结果;个数不定时用 MultiCollector:每加一个收集器得到一个 FruitHandle,果实以 Box<dyn Fruit> 存着,extract 时向下转型。两者都只把 collect、collect_block 转发给每个成员,requires_scoring 取或:
multi: top=2 count=52 maxlen=18363
tuple: count=52 maxlen=18363
它们没有覆盖 collect_segment,于是走默认实现:成员各自覆盖的 collect_segment(Count 的 weight.count,按分数 TopDocs 的剪枝)全部用不上。
FacetCollector
FacetCollector 统计层级分类(facet,如 /lang/rust)每个子节点的文档数。add_facet("/lang") 表示「数 /lang 下一层的每个孩子」,祖先和后代不能同时加。
每个段上,compute_collapse_mapping 顺着这个段的 facet 字典走一遍,把每个序号映射到它所属的那个孩子(/lang/rust/async 归到 /lang/rust);collect 读文档的 facet 序号,查表加一,同一篇文档在同一个孩子下只算一次。harvest 才把序号换回 Facet,merge_fruits 按 Facet 相加。和字符串排序一样,段内用序号,跨段用值。
/lang 本身,结果里会冒出 /zoo没有被任何登记的 facet 覆盖的序号,映射表里填的是哨兵 (u64::MAX, 0)。文档的 facet 正好等于登记的 /lang(不是它的孩子)时,它的序号也是哨兵。0.26.x 的 harvest 拿 u64::MAX 去查字典,ord_to_term 落到字典里最后一个词上,凭空多出一个不相关的分支。#2867(54cd5bba9,「fix: skip sentinel facet ords in harvest to prevent wrong root」,首次发布于 0.27.0)在 harvest 里跳过哨兵,说明写的是「Passing it to ord_to_term would resolve to the last dictionary entry and produce a spurious facet from an unrelated branch」。五篇文档 /lang、/lang/rust、/lang/rust/async、/lang/go、/zoo/cat,登记 /lang。0.27.0:
/lang/go 1
/lang/rust 2
/lang/go 1
/lang/rust 2
/zoo 1
写一个收集器
一个收集器就是上面那几个方法。下面这个求匹配文档里 blen 的最大值,for_segment 打开列,collect 逐篇读,merge_fruits 取最大:
struct MaxLen;
struct MaxLenSeg(Column<u64>, u64);
impl Collector for MaxLen {
type Fruit = u64;
type Child = MaxLenSeg;
fn for_segment(&self, _: u32, r: &SegmentReader) -> tantivy::Result<MaxLenSeg> {
Ok(MaxLenSeg(r.fast_fields().u64("blen")?, 0))
}
fn requires_scoring(&self) -> bool {
false
}
fn merge_fruits(&self, f: Vec<u64>) -> tantivy::Result<u64> {
Ok(f.into_iter().max().unwrap_or(0))
}
}
impl SegmentCollector for MaxLenSeg {
type Fruit = u64;
fn collect(&mut self, doc: DocId, _: Score) {
for v in self.0.values_for_doc(doc) {
self.1 = self.1.max(v);
}
}
fn harvest(self) -> u64 {
self.1
}
}
requires_scoring 返回 false,查询就不算 BM25,collect 收到的分数是 0。仓库里的 examples/custom_collector.rs 是同样结构的均值和标准差。按值统计更复杂的需求(分桶、嵌套、跨索引合并)不必自己写,见聚合,它也是一个收集器。
动脑筋答案
849 篇,一篇不少:
the matches 849 | TopDocs(10) pruning-callbacks 55 for_each 0 | (TopDocs, Count) pruning 0 for_each 849 count=849 top1=0.3468
元组的 collect_segment 是 Collector 的默认实现,它为整个元组调一次 weight.for_each,每篇匹配的文档都算分、都交给两个成员。TopDocs 自己的 collect_segment_top_k 和 Count 自己的 weight.count 都被绕过了:剪枝没了,计数也从「读词典里的 doc_freq」变成了逐篇数。前 10 名不变,只是更慢。要两样都快,分开调两次 search:Count 那次只读词典,TopDocs 那次剪枝。
小结
Collector每段建一个SegmentCollector,各段的果实由merge_fruits合并;删除的文档在默认的collect_segment里被挡掉,不要分数时按 64 篇一块收。Count覆盖collect_segment直接问weight.count,TermQuery在无删除的段上只读doc_freq。TopDocs要先选排序键才是收集器(0.26.0 起)。按分数用大小为 K 的堆,门槛实时交还给for_each_pruning;0.27.0 之前用缓冲区、门槛滞后(#2881)。- 其他排序键用 2K 缓冲加
select_nth_unstable,不剪枝;段内比快速字段的u64或字典序号,跨段比真实值;缺值无论升降都在最后。分页让每段都收offset + limit个。 - 元组和
MultiCollector走默认收集流程,成员的捷径(剪枝、count)全部失效。 FacetCollector段内按序号计数、跨段按Facet合并;0.27.0 修了恰好等于登记 facet 时冒出无关分支的问题(#2867)。