Rust in Everything
基于 tantivy 0.27.0

Term

主线位置:磁盘主线的起点,也是写入主线和查询主线的交汇点。写入时分词器吐出的每个词、每个数值都变成一串字节,作为内存哈希表的键;段定稿时这串字节去掉字段号,成为词典(term dictionary)里的键。查询时 old 被构造成一个 Term,用同样的规则编码,再拿去词典里查。两边的字节必须一模一样,查询才能命中。

flowchart TB
  tok["token 或数值"] --> it["IndexingTerm"]
  it --> hm["内存哈希表的键"]
  hm --> td["词典的键"]
  q["查询词 old"] --> term["Term"]
  term --> key["serialized_value_bytes"]
  key --> look["termdict.get"]
  td --> look

本页只讲字节长什么样、为什么这样排。词典本身是 FST 还是 sstable、怎么从键找到 TermInfo,见词典。

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

Term 的结构

0.27.0 的 Term 是两个字段:

pub struct Term {
    field: Field,
    serialized_value_bytes: Vec<u8>,
}

serialized_value_bytes 的第一个字节是类型码,就是 Type 的判别值('s'、'u'、'i'……,列表见 Schema 与字段),后面是值本身。同一个 Term 有三种字节视图,名字容易混:

方法内容用在
value()[类型码][值],包成 ValueBytes解码、Debug
serialized_value_bytes()[值],跳过类型码查词典
serialized_term()(已废弃)[字段号 4 字节大端][类型码][值]旧代码兼容

查询时真正用的是第二种,InvertedIndexReader::get_term_info 就一行:

    pub fn get_term_info(&self, term: &Term) -> io::Result<Option<TermInfo>> {
        self.termdict.get(term.serialized_value_bytes())
    }

字段号不在键里,因为每个字段有自己的一部词典:segment_reader.inverted_index(field) 先按字段挑出这部词典,再用值去查。类型码也不在键里,因为一个字段的类型由 schema 固定,同一部词典里不会出现两种类型。只有 JSON 字段例外,下面再讲。

📝NOTE
冷知识:字段号在 0.26.0 才从 Term 的字节里搬出去

0.25 及以前,Term 是 pub struct Term<B = Vec<u8>>(B),注释写着「The first 5 bytes are metadata. 4 bytes are the field id, and the last byte is the type.」写入时它当内存哈希表的键,查询时再切掉前 5 个字节去查词典,一个类型两种用法。

提交 842fe9295(「split Term in Term and IndexingTerm」,#2744)先把写入侧拆出去,新建 src/indexer/indexing_term.rs;九天后 618e3bd11(「Term and IndexingTerm cleanup」,#2750)把 Term 改成现在的 field 加 serialized_value_bytes 两个字段,元数据长度常量从 TERM_METADATA_LENGTH = 5 变成 TERM_TYPE_TAG_LEN = 1,同时加了两个 #[deprecated] 的 wrap 和 serialized_term,废弃说明是「we want to avoid working on the serialized representation directly, replace with typed API calls (add more if needed) or use serde to serialize/deserialize」。两个提交都首次发布在 0.26.0,本站用的 0.26.1 已经是新结构。

实测:五种类型的字节

给 title: TEXT、count: INDEXED(u64)、delta(i64)、score(f64)、when(日期)、attrs(JSON,STRING)各构造 Term,打印三种视图(每个 show 打印四行:Debug、value()、serialized_term()、serialized_value_bytes()):

  show("text \"man\"", &Term::from_field_text(title, "man"));
  show("u64 983", &Term::from_field_u64(count, 983));
  show("i64 -1", &Term::from_field_i64(delta, -1));
  show("i64 1", &Term::from_field_i64(delta, 1));
  show("f64 -1.5", &Term::from_field_f64(score, -1.5));
  show("f64 1.5", &Term::from_field_f64(score, 1.5));
text "man"
  debug           Term(field=0, type=Str, "man")
  value()         73 6d 61 6e
  serialized_term 00 00 00 00 73 6d 61 6e
  termdict key    6d 61 6e
u64 983
  debug           Term(field=1, type=U64, 983)
  value()         75 00 00 00 00 00 00 03 d7
  serialized_term 00 00 00 01 75 00 00 00 00 00 00 03 d7
  termdict key    00 00 00 00 00 00 03 d7
i64 -1
  debug           Term(field=2, type=I64, -1)
  value()         69 7f ff ff ff ff ff ff ff
  serialized_term 00 00 00 02 69 7f ff ff ff ff ff ff ff
  termdict key    7f ff ff ff ff ff ff ff
i64 1
  debug           Term(field=2, type=I64, 1)
  value()         69 80 00 00 00 00 00 00 01
  serialized_term 00 00 00 02 69 80 00 00 00 00 00 00 01
  termdict key    80 00 00 00 00 00 00 01
f64 -1.5
  debug           Term(field=3, type=F64, -1.5)
  value()         66 40 07 ff ff ff ff ff ff
  serialized_term 00 00 00 03 66 40 07 ff ff ff ff ff ff
  termdict key    40 07 ff ff ff ff ff ff
f64 1.5
  debug           Term(field=3, type=F64, 1.5)
  value()         66 bf f8 00 00 00 00 00 00
  serialized_term 00 00 00 03 66 bf f8 00 00 00 00 00 00
  termdict key    bf f8 00 00 00 00 00 00

然后把同样的值写进一个文档、提交,遍历每个字段的词典打印键:

termdict of "title":
  6d 61 6e
  6f 6c 64
  74 68 65
termdict of "count":
  00 00 00 00 00 00 03 d7
termdict of "delta":
  7f ff ff ff ff ff ff ff
  80 00 00 00 00 00 00 01
termdict of "score":
  40 07 ff ff ff ff ff ff
  bf f8 00 00 00 00 00 00
...

(title 的值是 The Old Man,分词后三个词 man、old、the 按字节序排好。)词典里的键和 serialized_value_bytes() 逐字节相同,Term 构造出来就能直接查。

  • 文本就是 UTF-8 原文,没有长度前缀:键的长度由词典自己记。
  • u64、i64、f64 和日期都是 8 字节大端。大端的好处是字节序等于数值序,00 .. 03 d7(983)排在 00 .. 03 d8(984)前面,前缀相同的键在 FST 里共享前缀。
  • i64 的 -1 是 7f ff ..,1 是 80 00 .. 01,词典里 -1 排在 1 前面。f64 的 -1.5 是 40 07 ..,1.5 是 bf f8 ..,同样有序。直接写补码或 IEEE 754 位模式都做不到这一点。

数字怎么变成可排序的 u64

Term::from_field_i64、from_field_f64、from_field_date 都走 from_fast_value:先 val.to_u64(),再 to_be_bytes()。to_u64 的映射在 common crate 里(HIGHEST_BIT = 1 << 63):

pub fn i64_to_u64(val: i64) -> u64 {
    (val as u64) ^ HIGHEST_BIT
}
pub fn f64_to_u64(val: f64) -> u64 {
    let bits = val.to_bits();
    if val.is_sign_positive() {
        bits ^ HIGHEST_BIT
    } else {
        !bits
    }
}
  • i64_to_u64:只翻最高位。补码里负数最高位是 1,翻过来变成 0,就排到了非负数前面;负数之间、非负数之间原有的大小顺序不变。-1 的补码是全 ff,翻完是 7f ff ..,与上面的输出一致。注释给的另一个理由是位压缩:-10 到 10 直接 as u64 会把负数投到 2^63 以上,每个值都要 64 位;翻转后它们挤在 2^63 附近,差值小,快速字段的列存能压得很紧。
  • f64_to_u64:正数翻符号位,排到所有负数后面;负数整体取反,因为 IEEE 754 负数的位模式越大值越小,取反正好倒过来。1.5 的位模式是 3f f8 00 ..,翻符号位得 bf f8 ..;-1.5 是 bf f8 00 ..,取反得 40 07 ff ..。
  • 日期:MonotonicallyMappableToU64 for DateTime 取纳秒时间戳(i64)再走 i64_to_u64。
  • u64 原样,bool 是 0 或 1,IP 地址统一成 IPv6 的 u128,16 字节大端。

快速字段的列存用的是同一套映射,所以倒排和列存对同一个数值的「u64 形式」是一致的;列存怎么再压缩见快速字段。

日期在倒排里只到秒

同一个时间点 2025-10-17T00:00:00.123Z,两种构造方式:

date (from_field_date)
  debug           Term(field=4, type=Date, 2025-10-17T00:00:00.123Z)
  value()         64 98 6f 1e 36 d1 da d4 c0
  serialized_term 00 00 00 04 64 98 6f 1e 36 d1 da d4 c0
  termdict key    98 6f 1e 36 d1 da d4 c0
date (from_field_date_for_search)
  debug           Term(field=4, type=Date, 2025-10-17T00:00:00Z)
  value()         64 98 6f 1e 36 ca 86 00 00
  serialized_term 00 00 00 04 64 98 6f 1e 36 ca 86 00 00
  termdict key    98 6f 1e 36 ca 86 00 00

而词典里 when 字段只有一个键:

termdict of "when":
  98 6f 1e 36 ca 86 00 00

写入时 InvertedIndexPluginWriter::index_document 对日期先截断再编码:

                FieldType::Date(_) => {
                    let mut num_vals = 0;
                    for value in values {
                        let value = value.as_value();

                        num_vals += 1;
                        let date_val = value.as_datetime().ok_or_else(make_schema_error)?;
                        term_buffer
                            .set_u64(date_val.truncate(DATE_TIME_PRECISION_INDEXED).to_u64());
                        postings_writer.subscribe(doc_id, 0u32, term_buffer, ctx);
                    }

DATE_TIME_PRECISION_INDEXED 是常量 DateTimePrecision::Seconds,DateOptions 里能配的 precision 只管快速字段,管不到这里。源码没有写为什么固定为秒;效果是同一秒内的文档共享一个键和一条倒排表。代价是查询侧也必须截断:Term::from_field_date 不截,from_field_date_for_search 才截。QueryParser 在 compute_logical_ast_for_leaf 里用的是后者,所以 when:"2025-10-17T00:00:00.123Z" 能命中;自己拼 TermQuery 时就要自己选对。

❗IMPORTANT
动脑筋:上面那个 .123Z 的日期写进 when: INDEXED | STORED、提交,然后分别用 Term::from_field_date(when, dt) 和 Term::from_field_date_for_search(when, dt) 构造 TermQuery,用 Count 收集。两次各命中几篇?答案在文末。

JSON 字段:路径也编进字节

JSON 字段的值是一棵树,同一部词典里会有很多路径、多种类型,所以它的键要多带两样东西:路径和内层类型码。Term::from_field_json_path 加上 append_type_and_str / append_type_and_fast_value 构造查询用的 Term:

json attrs.color.name = "red"
  debug           Term(field=5, type=Json, path=color.name, type=Str, "red")
  value()         6a 63 6f 6c 6f 72 01 6e 61 6d 65 00 73 72 65 64
  serialized_term 00 00 00 05 6a 63 6f 6c 6f 72 01 6e 61 6d 65 00 73 72 65 64
  termdict key    63 6f 6c 6f 72 01 6e 61 6d 65 00 73 72 65 64
json attrs.n = 5 (i64)
  debug           Term(field=5, type=Json, path=n, type=I64, 5)
  value()         6a 6e 00 69 80 00 00 00 00 00 00 05
  serialized_term 00 00 00 05 6a 6e 00 69 80 00 00 00 00 00 00 05
  termdict key    6e 00 69 80 00 00 00 00 00 00 05

键的格式是 [路径][00][类型码][值],ValueBytes 的注释写作 [type code=JSON][JSON path][JSON_END_OF_PATH][ValueBytes],并说明内层不会再嵌 JSON:

  • 路径段之间用 JSON_PATH_SEGMENT_SEP(0x01)分隔,不用 .:键名里本来就可能有点号。expand_dots 打开时,JsonPathWriter::push 会把键名里的 . 也换成 0x01,{"k8s.node": 1} 和 {"k8s": {"node": 1}} 就落到同一个路径。
  • 路径以 JSON_END_OF_PATH(0x00)结束。0x00 比任何可见字符都小,a 的所有词排在 a.b 的所有词前面,前缀查询和范围查询可以按路径圈出一段连续的键。键名里真有 \0 怎么办:写词典时 JsonTermSerializer::append_json_path 把它换成 ASCII '0'。
  • 内层类型码是必须的:同一个路径下,这篇文档是字符串,那篇可能是数字。

真正写一篇文档 {"color": {"name": "red"}, "n": 5, "big": 9223372036854775808}(big 是 2^63),词典里的键:

termdict of "attrs":
  62 69 67 00 75 80 00 00 00 00 00 00 00
  63 6f 6c 6f 72 01 6e 61 6d 65 00 73 72 65 64
  6e 00 69 80 00 00 00 00 00 00 05

n = 5 的类型码是 69('i'),big 的是 75('u')。JSON 数字先试 i64,放不下才用 u64,再不行用 f64,见 Value for &serde_json::Value 里 as_value 对 Number 的分支。仓库里的 doc/src/json.md 写的是「Numbers will be interpreted as u64, i64 and f64 in that order」,和代码不一致;按代码,同一个路径上的 5 和 2^63 落在不同类型码下,分属两段键。

同一篇文档里的字符串值也照常分词。仿照 json.md 的例子(人名换成了 Blue Whale),把 {"name": "Blue Whale", "address": {...}, "created_at": "2018-11-12T23:20:50.52Z"} 写进一个 TEXT 的 JSON 字段,按路径解开词典:

("address.city", s, tokyo)
("address.country", s, japan)
("created_at", d, [95, 66, 83, cf, 94, dd, b4, 00])
("name", s, blue)
("name", s, whale)

字符串被 default 分词器切开并转小写;能解析成 RFC 3339 的字符串当日期存(类型码 'd',同样截到秒)。json.md 给的排序例子把 created_at 放在 name 之后,实际按字节序它排在前面。

写入侧:IndexingTerm

写入时不用 Term,用 IndexingTerm。它的注释说明了为什么字段号要放回字节里:

/// IndexingTerm is used to represent a term during indexing.
/// It's a serialized representation over field and value.
///
/// It actually wraps a `Vec<u8>`. The first 4 bytes are the field.
///
/// We serialize the field, because we index everything in a single
/// global term dictionary during indexing.

一个段在内存里只有一张 ArenaHashMap,所有字段的词都进这张表,键要能区分字段。SpecializedPostingsWriter::subscribe 把 term.serialized_term()(4 字节字段号加值)交给 term_index.mutate_or_create(哈希表本身见 SegmentWriter 与内存池)。几处和 Term 不同的细节:

  • 非 JSON 字段的 IndexingTerm 没有类型码:set_u64 等走 set_bytes,直接截到 4 字节再追加值。wrap 的注释说后面是「type tag + payload」,与代码不符。
  • JSON 字段的路径在内存里不存原文,存一个 4 字节的路径编号(path_to_unordered_id),同一路径的词只存一次路径。
  • 段定稿时 serialize_postings 把键切掉前 4 字节,按(字段,路径序号,值字节)排序,再逐字段写词典;JSON 字段在这一步把路径编号换回 [路径][00]。

写入侧和查询侧用的是两个类型,只靠「编码规则相同」对齐,没有一个函数同时被两边调用。上面的实验把两边的字节摆在一起,正是为了验证这一点。

📝NOTE
没有蠢问题

问: 词典的键里既没有字段号也没有类型码。u64 字段里的 9223372036854775809(2^63 + 1)和 i64 字段里的 1,编码后都是 80 00 00 00 00 00 00 01,会不会查混?

答: 不会,因为它们根本不在同一部词典里。词典按字段分开存(.term 是一个组合文件,每个字段一段),查之前已经按 Field 选好了那一段;同一个字段的类型由 schema 定死,键不需要再自证类型。需要内层类型码的只有 JSON 字段,因为它是唯一一种「一个字段多种类型」的情况。

动脑筋答案

from_field_date: 0 hit(s)
from_field_date_for_search: 1 hit(s)
query parser: 1 hit(s)

from_field_date 构造的键是 98 6f 1e 36 d1 da d4 c0(带 123 毫秒),词典里只有截到秒的 98 6f 1e 36 ca 86 00 00,TermQuery 精确匹配字节,一篇也找不到,也不报错。from_field_date_for_search 先 truncate(DATE_TIME_PRECISION_INDEXED),命中 1 篇。第三行是同一个日期交给 QueryParser,它内部用的就是 from_field_date_for_search。文档本身用 STORED 存的仍是完整的纳秒值,取回来能看到 .123,倒排里却只有秒。

小结

  • Term = Field + [类型码][值];查词典用 serialized_value_bytes(),只有值。每个字段一部词典,字段号和类型码都不必进键;0.26.0 起字段号才从字节里搬出去(#2744、#2750)。
  • 数值统一成 8 字节大端的 u64:i64 翻最高位,f64 正数翻符号位、负数整体取反,日期取纳秒再按 i64 处理。字节序因此等于数值序,范围查询可以直接在词典上做。
  • 日期在倒排里固定截到秒;查询要用 from_field_date_for_search,from_field_date 遇到带毫秒的值静默查不到。
  • JSON 字段的键是 [路径][00][类型码][值],路径段用 0x01 分隔;数字先试 i64,与 doc/src/json.md 的说法相反。
  • 写入时的 IndexingTerm 是 4 字节字段号加值(非 JSON 字段没有类型码),因为一个段在内存里只有一张哈希表;定稿时去掉字段号写进各字段的词典。

💬 关联讨论

发起讨论
加载中...