Rust in Everything
基于 tantivy 0.27.0

收集器

主线位置:查询主线第四、五步。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 篇走到了收集器。

❗IMPORTANT
动脑筋:同一个查询,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 便宜,代价是门槛滞后。排序键不是分数时本来就没有剪枝可做,滞后无所谓;按分数时剪枝靠门槛,所以单独用了堆。

📝NOTE
冷知识:前 K 名曾经每 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
所以 83 变 55、71 变 43 全是门槛松紧的差别,与两版之间 scorer 一侧的改动无关。

快速字段的键在段内是列里的 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 相加。和字符串排序一样,段内用序号,跨段用值。

📝NOTE
冷知识:文档恰好是 /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
0.26.1:
  /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)。

💬 关联讨论

发起讨论
加载中...