Rust in Everything
基于 tantivy 0.27.0

Query、Weight、Scorer

主线位置:查询主线的 searcher.search(&query, &collector) 内部。old man 解析出的 BooleanQuery 先变成一个 Weight(全局只建一次),再在每个段上变成一个 Scorer;收集器驱动 Scorer 逐篇前进、取分。这一页讲三层各自的职责、DocSet 的游标约定,并用 TermQuery 把三层走一遍。

sequenceDiagram
  participant S as Searcher
  participant Q as Query
  participant W as Weight
  participant R as Scorer
  participant C as Collector
  S->>Q: weight EnableScoring
  Q-->>S: Box dyn Weight
  S->>C: collect_segment 每个段
  C->>W: for_each_pruning 或 count
  W->>R: scorer reader boost
  loop 直到 TERMINATED
    R-->>W: doc 和 score
    W-->>C: callback
  end
  S->>C: merge_fruits

三层的 trait 都很短:Query 在 query.rs(207 行),Weight 在 weight.rs(165 行),Scorer 在 scorer.rs(25 行),DocSet 在 src/docset.rs(342 行)。Query 的上游文档用一句话概括了分工:「a Query is a recipe…, a Weight is this recipe tied to a specific Searcher…, a Scorer is a cursor over the set of matching documents, for a specific SegmentReader」。

实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,release 构建。实验程序在 srcha-lab/src/bin/(qws.rs、staleweight.rs)。

三层与生命周期

什么时候建建几个持有什么
Query用户构造或 QueryParser 解析一个词项、子查询、选项;不碰索引
WeightSearcher::search 开头调一次 query.weight(...)每次查询一个从整个 Searcher 取来的统计(BM25 的 idf、avgdl)
Scorer收集器处理某个段时 weight.scorer(reader, boost)每段一个这个段的倒排表游标、fieldnorm 读取器

三个 trait 的约束都没有生命周期参数:Query: QueryClone + Send + Sync + Downcast + Debug,Weight: Send + Sync + 'static,Scorer: Downcast + DocSet + 'static。Weight 不借用 Searcher,统计算完就复制进自己;Scorer 不借用 SegmentReader,它读的倒排表是 OwnedBytes,自己拴着底层的 mmap。所以 Weight 可以在多个段、多个线程间共享(Executor 并行时就是这样用的),Scorer 也可以比创建它的 SegmentReader 活得久。

Query 要 Downcast 是为了让 QueryParser 和使用者能把 Box<dyn Query> 还原成具体类型;Scorer 要 Downcast,是因为布尔查询要判断子 scorer 是不是 TermScorer,是的话走特化的路径(见布尔查询)。

📝NOTE
没有蠢问题

问: Weight 是 'static 的,同一个查询反复执行时,能不能把 Weight 缓存起来,省掉每次算统计?

答: 类型上可以,结果会悄悄变。Weight 里的 idf 和 avgdl 是建它那一刻的 Searcher 的。staleweight.rs 在 1000 篇维基上建好 football 的 Weight,再提交 1000 篇只有标题的文档、reload,用旧 Weight 和新 Weight 在同一个段上给同一篇文档打分:

segments now 2
doc 16 old weight 5.092805  new weight 5.501494

旧 Weight 不报错,只是分数不再是当前索引的 BM25。新文档也没有算进 N。要省开销,该缓存的是 Query。

EnableScoring:要不要打分

query.weight(...) 的参数 EnableScoring 有两个变体:

pub enum EnableScoring<'a> {
    Enabled {
        searcher: &'a Searcher,
        statistics_provider: &'a dyn Bm25StatisticsProvider,
    },
    Disabled {
        schema: &'a Schema,
        searcher_opt: Option<&'a Searcher>,
    },
}

Searcher::search 按 collector.requires_scoring() 选:TopDocs 按分数排序要分数,Count 不要。不打分时各个查询可以走捷径:

  • TermQuery 把记录选项降成 Basic,不解码词频;
  • 词出现在段里的每一篇文档(doc_freq == max_doc)时,直接返回 AllScorer,连倒排表都不读;
  • 字段没建索引、但是快速字段时,TermQuery::weight 改用快速字段上的 RangeQuery。这条退路只在不打分时生效。

最后一条在实验里是一个坑:n 是 FAST 但不是 INDEXED 的 u64 字段,

fast-only field, Count: Ok(3)
fast-only field, TopDocs: Err(SchemaError("Field \"n\" is not indexed."))

同一个 TermQuery,计数能用,取前 K 报错。

DocSet:游标的约定

Scorer 就是「会打分的 DocSet」。DocSet 是一个按 doc id 递增的游标:

  • 刚建好时已经停在第一篇文档上,doc() 直接可读;空集合的 doc() 是 TERMINATED。
  • advance() 前进一篇并返回新的 doc id;到头返回 TERMINATED,到头以后再调仍然返回 TERMINATED。
  • seek(target) 停在第一个 >= target 的文档上。要求 target >= doc();默认实现是一路 advance,倒排表的实现用跳表跳块,见倒排表。seek(TERMINATED) 是合法的,就是「耗尽」。
  • size_hint() 是估计的文档数,cost() 默认等于它;交集按 cost() 排序,最便宜的打头。

TERMINATED 是 i32::MAX as u32,注释的解释是「This is not u32::MAX as one would have expected, due to the lack of SSE2 instructions to compare [u32; 4]」:SSE2 只有有符号的 32 位比较,哨兵留在有符号范围内,块内用 SIMD 比较找位置时不会出错。

qws.rs 在 1000 篇维基上手动取 football 的 scorer:

TERMINATED = 2147483647
fresh scorer doc() = 16 score 5.0928 size_hint 51
advance -> 29
seek(500) -> 503
seek(500) again -> 503
seek(TERMINATED) -> 2147483647
advance after end -> 2147483647
all 51 docs: first [16, 29, 42, 109, 141, 172, 220, 238]

第二次 seek(500) 违反了 target >= doc() 的约定(当前已在 503)。release 构建里它原样返回 503;debug 构建里 TermScorer::seek 开头的 debug_assert! 会拦下。

📝NOTE
冷知识:0.13 之前,DocSet 要先 advance 一次才能用

PR #824(「Major change in the DocSet/Scorer API」,首次发布在 0.13)之前,advance() 返回 bool,新建的 DocSet 停在第一篇之前,文档写着「.advance(...) needs to be called a first time to point to the correct element」;跳转是 skip_next(target),返回一个三值枚举 SkipResult { Reached, OverStep, End },而且「Calling skip always advances the docset」:已经停在目标上再跳,会跳过去。这个 PR 改成现在的样子:新建即停在第一篇,advance 返回新的 doc id,用哨兵 TERMINATED 表示结束,skip_next 换成 seek。提交说明的原话是「The change made it possible to greatly simplify a lot of the docset's code」,Scorer::for_each_pruning 也是这个 PR 引入的。

seek_danger:可以不精确的 seek

求交集时,大部分 seek 的目标其实不在集合里,调用方只想知道「在不在」和「下一个可能的位置」。seek_danger 就是为此放宽了约定的 seek,返回 SeekDangerResult:

  • Found:目标在集合里,游标停在目标上,状态正常;
  • SeekLowerBound(d):目标不在,d 是一个下界,落在 (target, 真正的下一篇] 里,或者是 TERMINATED。这时游标可以处于无效状态,之后只许再调 seek_danger,直到某次返回 Found。

这样,短语查询这种「确认一篇文档要读位置」的 scorer 可以只回答下界、不把自己真正对齐。默认实现就是 seek 加一次比较,不进入无效状态。0.27.0 又给 Weight 加了 scorer_danger(#3070):交集建子 scorer 时直接传入当前候选文档,昂贵的 scorer 不必先找自己的第一篇。seek_danger 怎么在交集里用,见布尔查询。

另外两个批量接口:fill_buffer 一次填 64 个 doc id(COLLECT_BLOCK_BUFFER_LEN),不打分的收集器走它;fill_bitset_block 把 [min_doc, min_doc + 1024) 里的文档填进 16 个 64 位的位图,交集计数用。

Weight 的入口

Weight 必须实现的只有 scorer 和 explain,其余都有默认实现,查询类型按需覆盖:

方法谁调用默认实现
scorer(reader, boost)所有路径必须实现
scorer_danger(reader, target, boost)交集建子 scorerscorer 加一次 seek_danger
explain(reader, doc)Query::explain必须实现
count(reader)Count、Query::count建 scorer,逐篇数,有删除时查存活位图
for_each(reader, cb)要分数、不剪枝的收集器建 scorer,逐篇回调
for_each_no_score(reader, cb)不要分数的收集器建 scorer,每次回调 64 个 doc id
for_each_pruning(threshold, reader, cb)TopDocs 按分数取前 K建 scorer,分数 > threshold 才回调,回调返回新门槛

收集器一侧的分派在 default_collect_segment_impl:按「段里有没有删除」和「要不要分数」四种组合,分别调 for_each 或 for_each_no_score,删除由收集器用存活位图过滤。TopDocs 按分数排序时覆盖成 for_each_pruning,Count 的 collect_segment 覆盖成 weight.count(reader)。

for_each_pruning 的回调返回值是剪枝的关键:收集器每收下一篇,就把当前第 K 名的分数告诉 Weight,Weight 可以据此跳过整块不可能超过它的文档。默认实现只是「分数不够就不回调」,真正跳块的是 TermWeight 和 BooleanWeight 的覆盖(Block-Max WAND)。

TermQuery 走一遍

Query → Weight。 TermQuery 只有一个 Term 和一个 IndexRecordOption。specialized_weight 先检查字段建了索引(否则 SchemaError),打分时用 Bm25Weight::for_terms 从统计提供者算出 idf 和 avgdl,不打分时放一个常数 1 的 Bm25Weight,最后装进 TermWeight。

Weight → Scorer。 每个段上,TermWeight::specialized_scorer:

        let field = self.term.field();
        let inverted_index = reader.inverted_index(field)?;
        let Some(term_info) = inverted_index.get_term_info(&self.term)? else {
            // The term was not found.
            return Ok(TermOrEmptyOrAllScorer::Empty);
        };

        // If we don't care about scores, and our posting lists matches all doc, we can return the
        // AllMatch scorer.
        if !self.scoring_enabled && term_info.doc_freq == reader.max_doc() {
            return Ok(TermOrEmptyOrAllScorer::AllMatch(AllScorer::new(
                reader.max_doc(),
            )));
        }

        let segment_postings: SegmentPostings =
            inverted_index.read_postings_from_terminfo(&term_info, self.index_record_option)?;

        let fieldnorm_reader = self.fieldnorm_reader(reader)?;
        let similarity_weight = self.similarity_weight.boost_by(boost);
        Ok(TermOrEmptyOrAllScorer::TermScorer(Box::new(
            TermScorer::new(segment_postings, fieldnorm_reader, similarity_weight),
        )))

查词典(词典)得到 TermInfo;没有这个词就是空 scorer;否则按 TermInfo 里的字节范围打开倒排表(倒排表),配上这个字段的 fieldnorm 读取器(不打分或字段没有 fieldnorm 时是常数 1,见 fieldnorm 与删除位图),把 boost 乘进 BM25 的权重。

Scorer。 TermScorer 的 DocSet 方法全部转给 SegmentPostings,打分只有三行:

    fn score(&mut self) -> Score {
        let fieldnorm_id = self.fieldnorm_id();
        let term_freq = self.term_freq();
        self.similarity_weight.score(fieldnorm_id, term_freq)
    }

从 fieldnorm 里取一个字节、从当前块取词频,交给 Bm25Weight 查表算分(BM25)。

收集。 TopDocs 调 TermWeight::for_each_pruning,它把 TermScorer 交给 block_wand_single_scorer:块上界不超过门槛的块整块跳过,不解码。块上界从哪来、什么时候不可靠,见布尔查询。

explain

Query::explain 的默认实现重新建一次 Weight(总是打分),取文档所在的段,调 weight.explain(reader, doc)。TermWeight::explain 建一个 boost 为 1 的 scorer,seek 到这篇文档;不在倒排表里就返回 does_not_match;在的话由 Bm25Weight::explain 逐项写出 (K1+1)、idf、词频部分,再附上 Term=... 作为上下文。对一篇不含 football 的文档:

explain doc 0: An invalid argument was passed: 'Document #(0) does not match'

explain 是给人看的:每次都重新建 Weight、重新 seek,代价和一次单文档查询差不多。逐项的含义和手算见 BM25;BooleanQuery 的 explain 把每个匹配的子句挂成一个子节点,见一次查询。

count 的捷径

Query::count 用不打分的 Weight,逐段调 weight.count(reader)。默认的 Weight::count 建 scorer、逐篇数,有删除时每篇查一次存活位图。几种 scorer 覆盖了这个过程:

  • TermWeight::count:段里没有删除时,答案就是词典里的 doc_freq,一次查词典,不读倒排表。
  • 并集(BufferedUnionScorer)不逐篇前进,而是把 4096 篇一窗的位图 popcount。
  • 交集在打头的子集合足够密(覆盖超过约 1/32 的文档)时,每 1024 篇填一次位图、按位与、popcount(0.27.0 的 #2861)。

后两种都只在 count_including_deleted 里,段里一有删除就退回逐篇数。

❗IMPORTANT
动脑筋:10 万篇的 hdfs 日志,receiving 出现在 31,561 篇里。删掉其中一篇、提交、reload 之后,再对 TermQuery(receiving) 计数,耗时会变成原来的几倍?

qws.rs:一个段,id 字段是 STRING,用 delete_term 删 id = 7;q.count(&searcher) 各跑 50 次取最小值。答案在文末。

动脑筋答案

约 150 倍,从 0.2 µs 变成 31 µs:

[no deletes] TermQuery(receiving).count =  31561      0.208 us   search(Count)     0.250 us   segments 1
...
[1 doc deleted] TermQuery(receiving).count =  31560     31.000 us   search(Count)    31.041 us   segments 1

没有删除时 TermWeight::count 只查一次词典,返回 doc_freq,和命中多少篇无关。删除一篇以后 reader.alive_bitset() 不再是 None,doc_freq 里包含了被删的那篇、不能直接用,于是建 scorer、解码 31,561 个 doc id、逐个查存活位图。删除只要还没被合并清掉,这个段上的每次计数都付这个代价;被删的文档什么时候真正消失,见合并。

小结

  • Query 描述查什么,Weight 每次查询建一次、带着整个 Searcher 的统计,Scorer 每段一个、是会打分的 DocSet;三者都不借用 Searcher,Weight 可以跨线程共享,但缓存它会用上过期的统计。
  • EnableScoring::Disabled 让 TermQuery 不读词频、整段命中时换成 AllScorer;快速字段上的 TermQuery 只在不打分时可用,TopDocs 会报 Field "n" is not indexed.。
  • DocSet 建好即停在第一篇,advance/seek 返回新位置,TERMINATED 是 i32::MAX;seek_danger 允许只返回下界、暂时处于无效状态,交集靠它少做对齐。
  • Weight 的 for_each、for_each_no_score、for_each_pruning、count 是收集器的四个入口;TopDocs 用剪枝入口,Count 用计数入口。
  • TermQuery:查词典 → 打开倒排表 → TermScorer 用 fieldnorm 字节和词频查表打分;explain 重建 Weight 再 seek,不匹配时报 Document #(n) does not match。
  • 无删除时 TermWeight::count 就是 doc_freq;段里有一篇删除,计数退回逐篇检查,实测 0.2 µs 变 31 µs。

💬 关联讨论

发起讨论
加载中...