位置
主线位置:磁盘主线的第五步,
TermInfo.positions_range指向.pos里的一段字节。查询主线里只有需要位置的查询(短语、带 slop 的短语、短语前缀)才会读它;old man不加引号是两个词的 OR,不碰.pos。
flowchart TB rec["Recorder 记位置加一"] --> delta["文档内位置差分"] delta --> ser["PositionSerializer"] ser --> blk["128 个一块 bitpacking"] ser --> vt["不足一块用 vint"] ser --> hdr["VInt 块数和宽度表"] ti["TermInfo positions_range"] --> rd["PositionReader"] sp["SegmentPostings"] --> off["跳表 tf_sum 加块内词频"] off --> rd rd --> pos["一个文档的位置"] pos --> phrase["PhraseScorer 平移求交"]
src/positions/ 只有 478 行:PositionSerializer 和 PositionReader。位置是词在字段里的序号(第几个词,从 0 开始),不是字节偏移。实验同前两页:exp-tantivy/stora-disk,wiki.json 1000 篇、一个段,M2 Max、macOS 27、rustc 1.99.0,release。
写:每个词一段
索引时 TfAndPositionRecorder 把每个位置加一后写进内存池,一个文档结束写一个 0(POSITION_END)当分隔,所以要加一。定稿时它的 serialize 把位置还原、在每个文档内部做差分:文档的第一个位置差分基准是 0,下一个文档重新从 0 开始。差分交给 FieldSerializer::write_doc,再到 PositionSerializer::write_positions_delta。
PositionSerializer 不分文档,把一个词在所有文档里的差分接成一个长序列,每满 128 个就 flush_block:
if self.block.len() == COMPRESSION_BLOCK_SIZE {
let (bit_width, block_encoded): (u8, &[u8]) = self
.block_encoder
.compress_block_unsorted(&self.block[..], false);
self.bit_widths.push(bit_width);
self.positions_buffer.extend(block_encoded);
} else {
debug_assert!(self.block.len() < COMPRESSION_BLOCK_SIZE);
let block_vint_encoded = self.block_encoder.compress_vint_unsorted(&self.block[..]);
self.positions_buffer.extend_from_slice(block_vint_encoded);
}
用的是和倒排表同一个 BlockEncoder(BitPacker4x,见倒排表),但 minus_one_encoded 是 false:同一文档里两个位置的差分至少是 1,可文档的第一个位置可以是 0,减一会下溢。词结束时 close_term 才真正写出:
pub fn close_term(&mut self) -> io::Result<()> {
self.flush_block();
VInt(self.bit_widths.len() as u64).serialize(&mut self.positions_wrt)?;
self.positions_wrt.write_all(&self.bit_widths[..])?;
self.positions_wrt.write_all(&self.positions_buffer)?;
self.bit_widths.clear();
self.positions_buffer.clear();
Ok(())
}
于是一个词在 .pos 里是:VInt 满块数 n,n 个字节的宽度表,n 个打包块,最后是 vint 尾块。尾块有多少个值没有记录,读者读到这段字节的末尾为止(uncompress_vint_unsorted_until_end)。positions_range 是字节范围,这段字节不依赖任何别的词。
实测:the 的位置
term "the": doc_freq 849, postings 166390..167521 (1131 B), positions 199598..209685 (10087 B)
...
positions: vint 1 B, 92 bitpacked blocks, widths [8, 7, 8, 6, 6, 6, 8, 7, 7, 6, 6, 7, 7, 6, 6, 7, 7, 6, 6, 7, 7, 7, 7, 8, 7, 6, 7, 7, 7, 7, 6, 7, 8, 6, 7, 7, 7, 8, 7, 7, 7, 7, 6, 6, 6, 6, 7, 6, 7, 7, 7, 7, 6, 7, 6, 7, 7, 7, 7, 7, 7, 6, 6, 6, 7, 6, 7, 7, 7, 7, 7, 7, 7, 8, 8, 7, 6, 7, 8, 6, 7, 7, 6, 6, 7, 7, 6, 6, 6, 6, 6, 7], packed 9936 B, vint tail 58 B (total 10087 B)
doc 1 tf 5 positions [11, 23, 33, 39, 47]
doc 2 tf 8 positions [6, 9, 33, 59, 66, 78, 100, 128]
sum tf = 11834 = number of positions; 128-blocks = 92
849 篇文档里 the 一共出现 11834 次,11834 = 92 × 128 + 58:92 个满块,加 58 个位置的尾块。1 + 92 + 9936 + 58 = 10087 字节,和 positions_range 一致。尾块 58 个值正好 58 字节,每个差分都小于 128。宽度 6 到 8 位:英文里两个 the 之间通常隔几个到一百多个词。
同一个词,倒排表 1131 字节,位置 10087 字节,差了将近 9 倍:倒排表一篇文档一项,位置一次出现一项。整个段也一样,.pos 245863 字节,比 .idx 的 200383 还大。把 body 改成只记词频(IndexRecordOption::WithFreqs),同样 1000 篇:
no-positions: .pos 5.21 KB B, .idx 199.84 KB B
(输出里的单位是 tantivy ByteCount 的 Display,后面那个 B 是实验程序多打的。).pos 只剩 title 字段的位置,body 的那一段为空。
读:从文档找到它的位置
read_postings_from_terminfo 只有在请求的选项和字段的选项都带位置时,才 read_bytes_slice(term_info.positions_range) 并 PositionReader::open:读出 VInt n,把后面 n 字节切成宽度表,其余是数据。此时没有解码任何块。
难点是:游标停在某个文档上时,它的第一个位置是这个词的第几个位置?append_positions_with_offset 是这样算的:
let read_offset = self.block_cursor.position_offset()
+ (self.block_cursor.freqs()[..self.cur]
.iter()
.cloned()
.sum::<u32>() as u64);
前半部分是之前所有满块的词频之和。倒排表的跳表条目在 WithFreqsAndPositions 时多存 4 字节的 tf_sum(块内词频和),SkipReader::advance 每跨过一块就把它加进 position_offset,跳过的块不用解码也能知道跳过了多少个位置。后半部分是当前块里排在前面的文档的词频。这就是为什么只记词频的字段跳表条目是 8 字节、带位置的是 12 字节。
拿到 read_offset 后,PositionReader::read 用 offset / 128 算出要跳过几块,advance_num_blocks 把宽度表里这几块的宽度加起来、乘以 16,跳过这么多字节,然后解码一块(最后一块按 vint 解到底),复制出 term_freq 个差分;跨块就接着解下一块。读者记着一个「锚点」,偏移只增不减时可以从锚点继续往后跳;偏移变小就 reset 回开头。最后 append_positions_with_offset 把差分累加成绝对位置,再加上调用方给的 offset。
SegmentPostings 的文档注释说「Positions on the other hand, are optionally entirely decoded upfront」,0.27.0 的代码并不是这样:打开时只切出字节,块是在 read 里按需解码的。
0.15 之前的 positions/mod.rs 开头写着「Positions are stored in three parts and over two files」:.pos 里是所有词的位置块首尾相接,最后一块补零;每块的宽度放在另一个文件 .posidx(SegmentComponent::PositionsSkip)里,每隔若干块再存一个累计宽度,方便长距离跳转。TermInfo 里也不是字节范围,而是 positions_idx: u64,一个以「第几个位置」计的全局偏移,读者要靠 .posidx 把它换算成字节。issue #1022 提出简化,PR #1023(提交 2dc5403e7「Closes #1022」,首次进入 0.15)改成今天的格式:每个词自带块数和宽度表,TermInfo 改成 positions_range 字节范围,.posidx 文件消失。0.15 的 CHANGELOG 记的是「Simplified positions index format #1022」。代价是每个词多了一个 VInt 和 n 字节的宽度表;好处是一个词的位置只要一次 read_bytes_slice 就齐了,quickwit 那种按范围远程取数据的目录因此只需取一段。
短语查询怎么读位置
"old man"(带引号)在 PhraseQuery::phrase_weight 里先查 schema:字段没有位置就直接报错,不会退化成别的查询。然后 PhraseWeight::phrase_scorer_danger 给每个词读一个 WithFreqsAndPositions 的 SegmentPostings,任何一个词在这个段里不存在,整个段返回空。
PhraseScorer 的外层是一个普通的文档交集(Intersection):先找到同时含 old 和 man 的文档,只有这些文档才去读位置。读位置时每个词带一个平移量,在 new_danger 里算好:
let postings_with_offsets = term_postings_with_offset
.into_iter()
.map(|(offset, postings)| {
PostingsWithOffset::new(postings, (max_offset - offset) as u32)
})
.collect::<Vec<_>>();
old 在短语里的偏移是 0,man 是 1,max_offset 是 1,所以 old 的位置都加 1,man 不变。平移之后,「old 后面紧跟 man」就变成「两个列表里有同一个数」。compute_phrase_match 从第一个词的位置开始,依次和后面每个词的位置做有序数组求交(intersection),中途为空就提前结束。打分时数交集的大小(phrase_count,当作词频进 BM25),不打分时只看有没有。
实测 body 里的 "old man",唯一命中的文档:
doc 225 old: positions [331, 347, 373, 378, 397, 633], with offset 1 -> [332, 348, 374, 379, 398, 634]
doc 225 man: positions [332, 348, 374, 398, 634], with offset 0 -> [332, 348, 374, 398, 634]
平移后的两个列表有 5 个公共值,这篇文档里 old man 连着出现 5 次;位置 378 的 old 后面不是 man。同一个索引上三种写法:
query "old man" -> 1 docs
query old man -> 52 docs
query +old +man -> 6 docs
52 是 OR(默认),6 是两个词都有,1 是两个词相邻。从 6 到 1,靠的就是位置;这 6 篇里只有它们需要读 .pos。slop、多词短语的「携带 slop」求交、短语前缀,见短语与其他查询。
问: 字段没存位置,短语查询会怎样?会悄悄退化成 AND 吗?
答: 不会,两条路都直接报错。把 body 设成 WithFreqs、同样 1000 篇:
parse error: The field 'body' does not have positions indexed
PhraseQuery error: Schema error: 'Applied phrase query on field "body", which does not have positions indexed'
第一行是 QueryParser::parse_query("\"old man\"") 返回的 QueryParserError::FieldDoesNotHavePositionsIndexed,在解析阶段就拦下;第二行是绕过解析器、直接 searcher.search(&PhraseQuery::new(...)),在建 Weight 时报 SchemaError。TEXT 默认带位置,STRING 不分词、记录选项是 Basic。自己拼 TextOptions 时要当心:TextFieldIndexing::default() 的记录选项也是 Basic,不调 set_index_option(IndexRecordOption::WithFreqsAndPositions) 就没有位置(见常见坑)。
小结
.pos按字段分区;每个词一段:VInt 满块数、每块一字节宽度、128 个一块的 bitpacked 位置差分、vint 尾块(读到段末为止)。差分在每个文档内部从 0 开始,不做减一编码。- 位置远比倒排表大:
the倒排表 1131 字节、位置 10087 字节;整个段.pos比.idx大。 - 一个文档的位置起点 = 跳过的满块的
tf_sum之和(存在 12 字节的跳表条目里)+ 当前块前面文档的词频和;PositionReader靠宽度表跳块,按需解码。 - PR #1023(0.15)把两个文件(
.pos+.posidx)、以位置计的全局偏移,改成每个词自带头部、TermInfo存字节范围。 - 短语查询先做文档交集,命中的文档才读位置;每个词的位置平移
max_offset - offset后做有序数组求交,交集大小是短语词频。字段没有位置时解析器和Weight都直接报错。