Rust in Everything
基于 tantivy 0.27.0

文档

主线位置:写入主线的入口和查询主线的出口。add_document(doc) 收进来的是一个实现了 Document 的值,三条写入路径(倒排、doc store、快速字段)都从它身上按字段取值;查询主线最后一步 searcher.doc(addr) 从 doc store 解压出字节,再反序列化成一个实现了 DocumentDeserialize 的类型。

flowchart TB
  user["用户的文档类型"] --> it["iter_fields_and_values"]
  it --> rv["Value::as_value"]
  rv --> inv["倒排写入器"]
  rv --> ff["快速字段写入器"]
  rv --> se["BinaryDocumentSerializer"]
  se --> store["doc store 块"]
  store --> de["BinaryDocumentDeserializer"]
  de --> dd["DocumentDeserialize"]
  dd --> td["TantivyDocument 等"]

本页讲 src/schema/document/(4502 行):文档在内存里是什么、写进 doc store 是什么字节、取回来是什么。doc store 的分块、压缩和缓存在文档存储。

实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,release 构建。

Document:一个只读的迭代器

Document 只有一个必须实现的方法:

pub trait Document: Send + Sync + 'static {
    /// The value of the field.
    type Value<'a>: Value<'a> + Clone
    where Self: 'a;

    /// The iterator over all of the fields and values within the doc.
    type FieldsValuesIter<'a>: Iterator<Item = (Field, Self::Value<'a>)>
    where Self: 'a;

    /// Get an iterator iterating over all fields and values in a document.
    fn iter_fields_and_values(&self) -> Self::FieldsValuesIter<'_>;
    // ...
}

文档就是一串 (Field, 值),同一个字段可以出现多次(多值字段),顺序随意。两个关联类型都带生命周期参数(GAT),值可以借用文档自己的数据:tantivy 拿到的是 &str,不需要先拷一份 String。Send + Sync + 'static 是因为文档要经 channel 送到索引线程(见 IndexWriter)。IndexWriter 本身对文档类型泛型:IndexWriter<D: Document = TantivyDocument>。

写入时三条路径各自调用一次 iter_fields_and_values:倒排写入器先把字段排序再按字段分组,doc store 的序列化器按原顺序过滤出 STORED 的字段,快速字段写入器取 FAST 的字段。模块文档因此提醒实现者:这个方法「will be called multiple times」,不要在里面做重活。0.27.0 新加的默认方法 into_tantivy_document,注释说「The indexer calls this once per document so it can hand a single concrete type to every PluginWriter」,但在 tag 上整个 src/ 没有一处调用它:SegmentWriter::add_document 交给段插件的是类型擦除的 &dyn ErasedDocument。

这套 trait 是 0.22.0 引入的(#2071,CHANGELOG 原话「Tantivy documents as a trait: Index data directly without converting to tantivy types first」)。在那之前,所有文档都得先转成 tantivy 自己的 Document 结构体。

Value 和 ReferenceValue

每个值要实现 Value<'a>,核心是 fn as_value(&self) -> ReferenceValue<'a, Self>。ReferenceValue 是三选一:

pub enum ReferenceValue<'a, V>
where V: Value<'a> + ?Sized
{
    /// A null value.
    Leaf(ReferenceValueLeaf<'a>),
    /// A an array containing multiple values.
    Array(V::ArrayIter),
    /// A nested / dynamic object.
    Object(V::ObjectIter),
}

数组和对象不是 Vec,是用户类型自己给的迭代器,嵌套结构同样不必先物化。叶子 ReferenceValueLeaf 有 12 种:Null、Str(&str)、U64、I64、F64、Date、Facet(&str)、Bytes(&[u8])、IpAddr、Bool、PreTokStr(已分好词的文本)、0.27.0 新增的 Custom(&[u8])。字符串和字节都是借用。

tantivy 自带了几种 Value 实现:&serde_json::Value、&OwnedValue、CompactDocValue。OwnedValue 是 ReferenceValue 的拥有所有权版本,Array(Vec<Self>)、Object(Vec<(String, Self)>),适合在 API 边界上传来传去。serde_json::Value 转过来时,数字先试 i64、再试 u64、最后 f64,能解析成 RFC 3339 的字符串变成日期,这些规则决定了 JSON 字段里的词用哪个类型码(见 Term)。

Document 也给 BTreeMap<Field, OwnedValue> 和 HashMap<Field, OwnedValue> 实现了。它们写入没问题,取回来有一个坑,在文末的动脑筋里。

TantivyDocument 就是 CompactDoc

最常用的文档类型 TantivyDocument 在 default_document.rs 里只是一个别名:pub use CompactDoc as TantivyDocument;。CompactDoc 把整篇文档压进一个字节数组:

#[repr(C, packed)]
#[derive(Debug, Clone)]
/// A field value pair in the compact tantivy document
struct FieldValueAddr {
    pub field: u16,
    pub value_addr: ValueAddr,
}

#[derive(Debug, Clone)]
/// The default document in tantivy. It encodes data in a compact form.
pub struct CompactDoc {
    /// `node_data` is a vec of bytes, where each value is serialized into bytes and stored. It
    /// includes all the data of the document and also metadata like where the nodes are located
    /// in an object or array.
    ///
    /// Invariant: string values are only written from `&str`, so their bytes are valid UTF-8.
    /// `extract_str` relies on this. The field must not be exposed outside of the crate.
    pub(crate) node_data: Vec<u8>,
    /// The root (Field, Value) pairs
    field_values: Vec<FieldValueAddr>,
}
  • 每个顶层值占一个 FieldValueAddr:2 字节字段号,加一个 ValueAddr(1 字节 ValueType 加 4 字节偏移),packed 之后共 7 字节。
  • 值本身写进 node_data:字符串、字节是「VInt 长度 + 内容」,数值是 8 字节,Null 和 Bool 直接塞在偏移那 4 字节里,不占 node_data。数组和对象先递归写子值,再把子值的 ValueAddr 列表作为一段字节写进去。
  • 读的时候 CompactDocValue 是「容器引用 + 地址」,as_value 当场从 node_data 里切出 &str。注释里的不变量是 extract_str 用 from_utf8_unchecked 的前提,所以 node_data 在 0.27.0 改回了 pub(crate)(CHANGELOG:「CompactDoc::node_data is no longer public」,#3175)。

字段号只给了 u16。add_field_value 用 try_into().expect(...) 转换,schema 有 65537 个字段时往最后一个字段写值:

thread 'main' (6177819) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tantivy-0.27.0/src/schema/document/default_document.rs:164:18:
support only up to u16::MAX field ids: TryFromIntError(PosOverflow)

(第 164 行是 add_leaf_field_value 里同样的 expect。)SchemaBuilder 不限制字段数,限制藏在默认文档类型里。

另外两个行为值得知道:CompactDoc::new() 预分配 1024 字节的 node_data;from_json_object(parse_json 走它)遇到 schema 里没有的键直接跳过,不报错,遇到 TieBreaker 字段也跳过。

📝NOTE
冷知识:TantivyDocument 曾经是一个 Vec<FieldValue>

0.23 及以前,TantivyDocument 是一个真正的结构体:pub struct TantivyDocument { field_values: Vec<FieldValue> },每个 FieldValue 是字段加一个 OwnedValue,Value<'a> 类型是 &'a OwnedValue。每个字符串值都是一个独立的 String 堆分配,JSON 对象里每一层又是一个 Vec。

提交 e1679f3fb(「compact doc」,#2402)把它换成 CompactDoc,TantivyDocument 降为别名。提交说明里的步骤能看出设计的演变:「store positions instead of pos+len」「remove nodes array」「remove 24MB limitation in document / use u32 for Addr / Remove the 3 byte addressing limitation and use VInt instead」。CHANGELOG 0.24 的说法是「Replace TantivyDocument with CompactDoc. CompactDoc is much smaller and provides similar performance.」首次发布在 0.24。代价是上面那个 u16 字段号上限,以及读回来时 CompactDoc 的 DocumentDeserialize 要先把每个值反序列化成 OwnedValue 再写进 node_data,源码里留着一句「TODO: Deserializing into OwnedValue is wasteful」。

doc store 里的字节

写 doc store 时,StoreWriter::store 记下当前块里的偏移,然后交给 BinaryDocumentSerializer::serialize_doc:

    pub(crate) fn serialize_doc<D>(&mut self, doc: &D) -> io::Result<()>
    where D: Document {
        let stored_field_values = || {
            doc.iter_fields_and_values()
                .filter(|(field, _)| self.schema.get_field_entry(*field).is_stored())
        };
        let num_field_values = stored_field_values().count();
        let mut actual_length = 0;

        VInt(num_field_values as u64).serialize(self.writer)?;
        for (field, value_access) in stored_field_values() {
            field.serialize(self.writer)?;

            let mut serializer = BinaryValueSerializer::new(self.writer);
            match value_access.as_value() {
                ReferenceValue::Leaf(ReferenceValueLeaf::PreTokStr(pre_tokenized_text)) => {
                    serializer.serialize_value(ReferenceValue::Leaf::<&'_ OwnedValue>(
                        ReferenceValueLeaf::Str(&pre_tokenized_text.text),
                    ))?;
                }
                _ => {
                    serializer.serialize_value(value_access.as_value())?;
                }
            }

            actual_length += 1;
        }
        // ...

迭代器跑两遍:第一遍数个数,因为个数要写在最前面;第二遍逐个写。没有 STORED 的字段在 filter 这里就被丢掉了,doc store 里连字段号都不会有。预分词文本只存原文,不存那些 token。

每个值是「1 字节类型码 + 负载」,类型码是 type_codes 里的小整数:

码类型负载
0文本VInt 长度 + UTF-8
1 / 2u64 / i648 字节小端
3facetVInt 长度 + 编码后的路径
4字节VInt 长度 + 内容
5日期i64 纳秒,8 字节小端
6f64f64_to_u64 后 8 字节小端
7扩展再跟一个扩展码,目前只有预分词文本
8(已废弃的 JSON 对象)
9 / 10bool / IP1 字节 / u128 16 字节小端
11 / 12 / 13null / 数组 / 对象无 / VInt 个数 + 各值 / VInt(2 × 键数)+ 键值交替

字段号是 u32 小端。整数在这里一律小端:common 的 BinarySerializable 用 byteorder::LittleEndian,doc store 只需要按顺序读回来,不需要字节序等于数值序,和 Term 的大端是两回事。

实测一篇文档。schema 七个字段:title: TEXT | STORED(0)、body: TEXT(1)、views: INDEXED | FAST(2)、score: STORED f64(3)、tag: STRING | STORED(4)、when: STORED 日期(5)、meta: STORED JSON(6):

  w.add_document(doc!(
    tag => "rust",
    title => "Old Man",
    body => "he was an old man",
    views => 7u64,
    score => 1.5f64,
    tag => "search",
    when => DateTime::from_timestamp_secs(1),
    meta => serde_json::json!({"a": 1})
  ))?;

提交后用 StoreReader::get_document_bytes(0) 取出第 0 篇的原始字节(已经解压过):

doc 0 in store: 80 bytes
86 04 00 00 00 00 84 72 75 73 74 00 00 00 00 00 87 4f 6c 64 20 4d 61 6e 03 00 00 00 06 00 00 00 00 00 00 f8 bf 04 00 00 00 00 86 73 65 61 72 63 68 05 00 00 00 05 00 ca 9a 3b 00 00 00 00 06 00 00 00 0d 82 00 81 61 02 01 00 00 00 00 00 00 00

按上面的格式拆开:

86                                    VInt 6:6 个值
04 00 00 00 | 00 | 84 72 75 73 74     tag   文本 长度 4  "rust"
00 00 00 00 | 00 | 87 4f 6c 64 20 4d 61 6e
                                      title 文本 长度 7  "Old Man"
03 00 00 00 | 06 | 00 00 00 00 00 00 f8 bf
                                      score f64 0xbff8000000000000(1.5 映射后)
04 00 00 00 | 00 | 86 73 65 61 72 63 68
                                      tag   文本 长度 6  "search"
05 00 00 00 | 05 | 00 ca 9a 3b 00 00 00 00
                                      when  日期 1_000_000_000 纳秒
06 00 00 00 | 0d | 82 | 00 81 61 | 02 01 00 00 00 00 00 00 00
                                      meta  对象 VInt 2(一对键值)
                                            键:文本 "a";值:i64 1
  • tantivy 的 VInt 每字节 7 位,最后一个字节最高位置 1 作为结束标记,所以 6 写成 86、长度 4 写成 84。
  • 值按文档里的原顺序写:tag 的两个值一个在最前、一个在中间,没有按字段排序,也没有把同一字段的值归到一起。
  • body 和 views 不在里面:一个只进倒排,一个进倒排和快速字段,都没有 STORED。
  • 日期写纳秒。0.24 之前 doc store 里的日期只有微秒(CHANGELOG:「Store DateTime as nanoseconds in doc store」,#2486),读的时候按 doc store 的版本号分开处理,见 deserialize_datetime。

80 字节里有 24 字节是字段号。每个值都带 4 字节字段号,是这种格式最大的固定开销;这些字节随后整块压缩(默认 LZ4,见文档存储)。

📝NOTE
没有蠢问题

问: 类型码已经出现三套了:Term 里的 's'、'u',doc store 里的 0、1、2,还有 CompactDoc 的 ValueType。为什么不统一成一套?

答: 三者的约束不一样。Term 的类型码是 Type 的判别值,用于 JSON 键内部,它参与词典排序。doc store 的 type_codes 是磁盘格式,老索引要能读:8 号已经废弃(JSON_OBJ_CODE,注释「Replaced by the OBJECT_CODE」),反序列化器至今仍把它读成 ValueType::JSONObject,这个号就不能挪作他用。ValueType 只活在内存里,注释说明了为什么不复用另两套:「We can't use [schema::Type] or [columnar::ColumnType] here, because they are missing some items like Array and PreTokStr.」磁盘格式要稳定,内存格式要能随意改,绑在一起反而两头受限。

取回来:DocumentDeserialize

Searcher::doc 对返回类型泛型:

    pub fn doc<D: DocumentDeserialize>(&self, doc_address: DocAddress) -> crate::Result<D> {
        let store_reader = &self.inner.store_readers[doc_address.segment_ord as usize];
        store_reader.get(doc_address.doc_id)
    }

DocumentDeserialize 拿到一个 DocumentDeserializer,反复调用 next_field 取出 (Field, V),V 是任何实现了 ValueDeserialize 的类型。这套 API 照着 serde 的 Deserializer / Visitor 设计(模块注释:「strongly inspired by serde's API but with some tweaks」),多了 IP、日期这些具体类型的 visit_*;serde 那边的结构见 serde 卷的 Deserializer 与 Visitor。

同一篇文档读回来,不同的视角:

to_json: {"meta":[{"a":1}],"score":[1.5],"tag":["rust","search"],"title":["Old Man"],"when":["1970-01-01T00:00:01Z"]}
get_first(body)  = None
get_first(views) = None
get_all(tag)     = [Some("rust"), Some("search")]
fast field views[0] = Some(7)

没设 STORED 的字段,取回来就是没有:不是空字符串,不是 null,get_first 返回 None,to_json 里没有这个键。doc store 里本来就没写,searcher.doc 无从还原。views 虽然取不回来,快速字段还在,fast_fields().u64("views") 读出 7;body 只进了倒排,原文彻底找不回来,只能从倒排表里反推出有哪些词。to_json 是 Document 的默认方法,用 TantivyDocument 调它要先 use tantivy::Document,否则编译器报 no method named to_json found。

to_json 输出里每个字段都是数组,因为 schema 层面每个字段都可以多值;键按字段名排序,因为中间经过了 NamedFieldDocument 的 BTreeMap。

❗IMPORTANT
动脑筋:同一篇文档用 searcher.doc::<BTreeMap<Field, OwnedValue>>(addr) 取回来,tag 字段对应的是什么?

BTreeMap<Field, OwnedValue> 实现了 DocumentDeserialize,编译能过。答案在文末。

动脑筋答案

as BTreeMap<Field, OwnedValue>: {Field(0): Str("Old Man"), Field(3): F64(1.5), Field(4): Str("search"), Field(5): Date(1970-01-01T00:00:01Z), Field(6): Object([("a", I64(1))])}

Field(4) 只剩 "search","rust" 不见了,没有任何错误。DocumentDeserialize for BTreeMap<Field, OwnedValue> 是这样写的:

        while let Some((field, value)) = deserializer.next_field()? {
            document.insert(field, value);
        }

doc store 里 tag 有两个值,按写入顺序先 rust 后 search,insert 第二次覆盖了第一次。一个键只能放一个值的映射,装不下多值字段;HashMap 版本的实现完全一样。多值字段要用 TantivyDocument(或者自己实现 DocumentDeserialize,把值收进 Vec)。

小结

  • Document 只要求一个 iter_fields_and_values,值通过 Value::as_value 变成借用数据的 ReferenceValue;任何类型都能直接被索引,不必先转成 tantivy 的结构。
  • TantivyDocument 是 CompactDoc 的别名:一个 Vec<u8> 装所有值,加每值 7 字节的索引;字段号是 u16,超过 65535 个字段会 panic。0.24 起如此(#2402)。
  • doc store 里一篇文档是「VInt 个数 +(u32 字段号,类型码,负载)× n」,整数小端,按文档原顺序写,只写 STORED 的字段。
  • 三套类型码各管一层:Term 用 ASCII 字母,doc store 用 0 到 13 的磁盘码,CompactDoc 用内存里的 ValueType。
  • 没设 STORED 的字段取回来不存在:get_first 为 None,to_json 里没有键。
  • 用 BTreeMap / HashMap 取回多值字段,只保留最后一个值,不报错。

💬 关联讨论

发起讨论
加载中...