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 解析 | 一个 | 词项、子查询、选项;不碰索引 |
Weight | Searcher::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,是的话走特化的路径(见布尔查询)。
问: 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! 会拦下。
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) | 交集建子 scorer | scorer 加一次 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 里,段里一有删除就退回逐篇数。
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。