Rust in Everything
基于 tantivy 0.27.0

Schema 与字段

主线位置:写入主线开始之前。Index::create_in_dir(path, schema) 把 schema 写进 meta.json;之后建段、分词、写 doc store、写快速字段,每一步都先查 schema 决定这个字段要不要处理、怎么处理。查询主线里 QueryParser 也靠它知道 old 该去哪个字段、用哪个分词器。

flowchart TB
  sb["SchemaBuilder"] --> add["add_text_field 等"]
  add --> fe["FieldEntry"]
  fe --> ft["FieldType"]
  sb --> build["build"]
  build --> sch["Schema"]
  sch --> meta["meta.json"]
  ft --> idx["is_indexed"]
  ft --> st["is_stored"]
  ft --> fa["is_fast"]
  idx --> inv["倒排"]
  st --> ds["doc store"]
  fa --> col["列存"]

tantivy 的 schema 是严格的:所有字段在建索引前声明,类型和处理方式一旦写进 meta.json 就不能改,也没有删除字段的 API(Schema 的文档注释原话是「It is for the moment impossible to remove fields」)。本页讲 src/schema/ 里除了 term.rs 和 document/ 之外的部分:字段怎么声明、怎么组合标志、怎么落盘。

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

SchemaBuilder 和 Field

SchemaBuilder 只有两个字段:一个 Vec<FieldEntry>,一个名字到 Field 的 HashMap。每个 add_*_field 最后都走到 add_field:

    pub fn add_field(&mut self, field_entry: FieldEntry) -> Field {
        let field = Field::from_field_id(self.fields.len() as u32);
        let field_name = field_entry.name().to_string();
        if let Some(_previous_value) = self.fields_map.insert(field_name, field) {
            panic!("Field already exists in schema {}", field_entry.name());
        };
        self.fields.push(field_entry);
        field
    }
  • Field 就是一个 u32,值等于添加顺序。它只在同一个 schema 里有意义,拿 A 索引的 Field 去查 B 索引,编译器不会拦你。
  • 同名字段直接 panic,不返回错误。名字的唯一要求是 is_valid_field_name:非空、不以 - 开头(- 在查询语法里是排除)。FieldEntry::new 里用 assert! 检查,不合法同样 panic。
  • build 把两样东西装进 Arc<InnerSchema>。Schema 克隆一次只是加一次引用计数,所以 Index、每个 SegmentReader、每个写入线程都各拿一份。
  • InnerSchema 的 PartialEq 只比较 fields,fields_map 的注释是 // transient:它可以从 fields 推出来,不参与相等性,也不序列化。

Schema::get_field 按名字查,找不到返回 TantivyError::FieldNotFound。find_field 多做一步:整串查不到时,从右往左在未转义的 . 处切开,看前缀是不是字段名,后缀就当成 JSON 路径。QueryParser 解析 attrs.color:red 靠的就是它,JSON 路径怎么编码见 Term。

FieldType:12 个变体

FieldEntry 是名字加一个 FieldType。FieldType 每个变体带自己的选项结构:

变体add_* 方法选项倒排doc store列存
Stradd_text_fieldTextOptions有 indexing 时storedfast
U64 I64 F64 Booladd_u64_field 等NumericOptionsindexedstoredfast
Dateadd_date_fieldDateOptionsindexed(截到秒)storedfast
Facetadd_facet_fieldFacetOptions总是stored总是
Bytesadd_bytes_fieldBytesOptionsindexedstoredfast
JsonObjectadd_json_fieldJsonObjectOptions有 indexing 时storedfast
IpAddradd_ip_addr_fieldIpAddrOptionsindexedstoredfast
TieBreakeradd_tie_breaker_field无否否总是
Customadd_custom_fieldCustomOptions否否否

表里后三列是 FieldType::is_indexed、FieldEntry::is_stored、FieldType::is_fast 三个 match 的结果。写入时三条路各自调用这三个方法:倒排的写入器跳过 !is_indexed() 的字段,doc store 的序列化器跳过 !is_stored() 的字段(见文档),快速字段写入器跳过 !is_fast() 的字段。所以「只存不索引」「只做快速字段」都是合法组合。

变体和 Type 一一对应(TieBreaker 对外是 I64),Type 的判别值是一个 ASCII 字节:Str = b's'、U64 = b'u'、I64 = b'i'、F64 = b'f'、Bool = b'o'、Date = b'd'、Facet = b'h'、Bytes = b'b'、Json = b'j'、IpAddr = b'p'、Custom = b'c'。这个字节会出现在 Term 的字节里。

三个去向,五个标志

用户代码很少直接构造 TextOptions,通常写 TEXT | STORED。这五个常量定义在 flags.rs 和 text_options.rs:

常量是什么用在
TEXTTextOptions:分词器 default、记录到位置、有 fieldnorm文本
STRINGTextOptions:分词器 raw(整串一个词)、只记 doc id、有 fieldnorm文本
STOREDSchemaFlagList<StoredFlag, ()>所有有选项的类型
FASTSchemaFlagList<FastFlag, ()>除 facet 外(facet 总是快速字段);文本用 raw 分词器
INDEXEDSchemaFlagList<IndexedFlag, ()>数值、日期、字节、IP、facet

TEXT 和 STRING 是现成的 TextOptions 常量;另外三个是零大小的标志类型,包在 SchemaFlagList 里。STORED | FAST 得到的是 SchemaFlagList<FastFlag, SchemaFlagList<StoredFlag, ()>>,一个类型层面的链表;传给 add_u64_field 时,From<SchemaFlagList<Head, Tail>> 递归地把头和尾各自转成 NumericOptions 再 | 起来。哪种字段接受哪个标志,由「有没有 From<XxxFlag> for XxxOptions」决定。TextOptions 没有 From<IndexedFlag>,于是:

  sb.add_text_field("title", INDEXED | STORED);
error[E0277]: the trait bound `TextOptions: From<schema::flags::IndexedFlag>` is not satisfied
   --> src/bin/indexed_text.rs:4:30
    |
  4 |   sb.add_text_field("title", INDEXED | STORED);
    |      --------------          ^^^^^^^^^^^^^^^^ the trait `From<schema::flags::IndexedFlag>` is not implemented for `TextOptions`
    |      |
    |      required by a bound introduced by this call
    |
    = help: `TextOptions` implements trait `From<T>`:
              From<()>
              From<schema::flags::CoerceFlag>
              From<schema::flags::FastFlag>
              From<schema::flags::SchemaFlagList<Head, Tail>>
              From<schema::flags::StoredFlag>
...

文本的「索引」不是一个布尔值:要选分词器、要选记多少信息,一个 IndexedFlag 装不下,所以文本只能用 TEXT、STRING 或者自己构造 TextFieldIndexing。这个限制在编译期就生效。

❗IMPORTANT
动脑筋:TEXT | STRING 和 STRING | TEXT 都能编译。两者得到的字段各用哪个分词器、记录到哪一级?

两个都是完整的 TextOptions,各有一份 indexing。答案在文末。

TextOptions、TextFieldIndexing、IndexRecordOption

TextOptions 有四个字段:indexing: Option<TextFieldIndexing>、stored、fast: Option<FastFieldTextOptions>、coerce。indexing 为 None 就是不进倒排。TextFieldIndexing 又是三项:

pub struct TextFieldIndexing {
    #[serde(default)]
    record: IndexRecordOption,
    #[serde(default = "default_fieldnorms")]
    fieldnorms: bool,
    #[serde(default = "default_tokenizer")]
    tokenizer: Cow<'static, str>,
}
  • tokenizer 只是一个名字。Cow<'static, str> 让 TEXT、STRING 这样的 const 可以直接借用字面量 "default"、"raw"。名字背后的分词器存在 Index 的 TokenizerManager 里,不在 schema 里,后果见分词。
  • IndexRecordOption 三级:Basic 只记 doc id,WithFreqs 加词频,WithFreqsAndPositions 再加位置。没有词频,BM25 只能把每个词频当成 1;没有位置,短语查询会报错。三级分别决定倒排表里有没有词频块和 .pos 文件里有没有这个字段的数据(见倒排表和位置)。数值、日期等非文本字段建索引时一律是 Basic。
  • fieldnorms 默认 true,记录每个文档这个字段有多少个词,BM25 用它做长度归一(见 fieldnorm)。

数值类字段用 NumericOptions,五个布尔值:indexed、fieldnorms、fast、stored、coerce。反序列化时经过一个中间结构 NumericOptionsDeser:老的 meta.json 没有 fieldnorms 这一项,读进来时按 indexed 补上。fieldnorms() 本身也是 self.fieldnorms && self.indexed,不索引就没有 fieldnorm。

文本的 fast 是 Option<FastFieldTextOptions>,里面是快速字段用的分词器名字。FAST 标志给的是 raw;set_fast 可以换成别的,比如 set_fast("default") 让快速字段里存小写后的词。两个都设了快速字段的 TextOptions 相 |,由 merge_fast_field_options 决定:显式配置的分词器压过隐含的 raw。快速字段的分词器从另一个 TokenizerManager(Index::fast_field_tokenizer)里找,和倒排用的那个不是同一个。

📝NOTE
冷知识:set_fast 在 0.27.0 换了参数类型,meta.json 里一个字节没变

0.26 的 set_fast 签名是 set_fast(mut self, tokenizer_name: Option<&str>),None 表示用 raw;内部的 FastFieldTextOptions 是一个 #[serde(untagged)] 枚举 IsEnabled(bool) / EnabledWithTokenizer { with_tokenizer },「没开」和「开了」都是这个枚举的值。提交 3bb9a430d(「Refactoring TextFastFieldOptions」,#3020)把它改成 Option<FastFieldTextOptions>,结构体里只剩一个 tokenizer: String,set_fast 改为必须给名字。

为了不动磁盘格式,同一个提交加了 fast_field_text_options_serde 模块,里面重新声明了一个私有的 WireFormat 枚举,形状和旧枚举一模一样:None 写成 false,分词器是 raw 时写成 true,其他写成 {"with_tokenizer": ...}。下面实验里的 meta.json 正是这三种写法。CHANGELOG 把它列在 0.27.0 的 Breaking change 第二条:「.set_fast(..) now takes a &str. The same behavior as .set_fast(None) can be obtained with .set_fast(tantivy::tokenizer::RAW_TOKENIZER_NAME).」首次发布在 0.27.0。

schema 怎么存进 meta.json

Schema 手写了 Serialize:把 fields 按顺序写成一个 JSON 数组。数组下标就是 Field 的值,所以顺序不能乱。FieldEntry 用 #[serde(flatten)] 把 FieldType 摊平,FieldType 用 #[serde(tag = "type", content = "options")],就是 serde 的相邻标签枚举(serde 卷讲了这种写法生成的代码)。

建一个有七个字段的 schema,提交一次,meta.json 的 schema 部分(中间删掉了 JSON 字段一项):

  let title = sb.add_text_field("title", TEXT | STORED);
  sb.add_text_field("slug", STRING | FAST);
  sb.add_text_field("tag", TextOptions::default().set_fast("default"));
  sb.add_u64_field("views", INDEXED | FAST);
  sb.add_date_field("published", INDEXED | STORED);
  sb.add_json_field("meta", STORED | TEXT);
  sb.add_tie_breaker_field("tb");
  "schema": [
    {
      "name": "title",
      "type": "text",
      "options": {
        "indexing": {
          "record": "position",
          "fieldnorms": true,
          "tokenizer": "default"
        },
        "stored": true,
        "fast": false
      }
    },
    {
      "name": "slug",
      "type": "text",
      "options": {
        "indexing": {
          "record": "basic",
          "fieldnorms": true,
          "tokenizer": "raw"
        },
        "stored": false,
        "fast": true
      }
    },
    {
      "name": "tag",
      "type": "text",
      "options": {
        "stored": false,
        "fast": {
          "with_tokenizer": "default"
        }
      }
    },
    {
      "name": "views",
      "type": "u64",
      "options": {
        "indexed": true,
        "fieldnorms": true,
        "fast": true,
        "stored": false
      }
    },
    {
      "name": "published",
      "type": "date",
      "options": {
        "indexed": true,
        "fieldnorms": true,
        "fast": false,
        "stored": true,
        "precision": "seconds"
      }
    },
    ...
    {
      "name": "tb",
      "type": "tie_breaker"
    }
  ],

整个 meta.json 1717 字节,顶层四个键:index_settings、segments、schema、opstamp。其他三个键见提交。

  • tag 没有 indexing 键:skip_serializing_if = "Option::is_none",不索引就不写。
  • views 只写了 INDEXED | FAST,fieldnorms 却是 true:From<IndexedFlag> 一并打开了它。
  • published 的 precision 是快速字段里日期的精度,默认秒;倒排里的精度固定是秒,不可配置(见 Term)。
  • 分词器只写了名字 "default"。

打开索引时,Deserialize 逐项调用 add_field 重建 Schema,顺序和编号原样恢复。IndexBuilder::open_or_create 拿磁盘上的 schema 和传进来的比,只要 fields 有一处不同(包括分词器名字)就返回 SchemaError("An index exists but the schema does not match."),上面的实验用一个只有 title: TEXT 的 schema 打开同一个目录,得到的就是这个错误。本站的搜索模块正是利用这一点:改了分词器就换一个名字,旧索引打开时 schema 不匹配,清空重建(见本站的搜索模块)。

📝NOTE
没有蠢问题

问: 线上索引想加一个字段,能不能在 meta.json 里手工追加一项?

答: Schema 没有任何修改方法,Index 也没有「换 schema」的入口,schema 唯一的来源就是 meta.json。试了一下:一个只有 title 的索引写一篇、提交,关掉,往 meta.json 的数组末尾手工加一个 body: TEXT | STORED,重新打开。add_field 照单全收,body 拿到编号 1;再写一篇带 body 的文档,body:hello 和 title:old 都查得到。可一旦把新旧两个段合并:

Error: SchemaError("Field norm not found for field \"body\". Was the field set to record norm during indexing?")

老段没有 body 的 fieldnorm 数据,合并器按 schema 去取就失败。查询能用、合并不能用,这条路在 0.27.0 走不通;正路是新建索引、重新写入。

TieBreaker:0.27.0 新增的生成字段

SchemaBuilder::add_tie_breaker_field 加的是一个 FieldType::TieBreaker,没有选项。文档注释说明了它的语义:对外是一个 i64 快速字段,取值在 i32 范围内;同一个段里连续递增,起点随机;不同段的区间可能重叠,所以「几乎总是不同,但不保证唯一」;文档里给这个字段的值会被忽略。CHANGELOG 0.27.0 补充了一点:合并时保留原值(#3104)。

从上面的表能看出它的形状:不进倒排、不进 doc store,只在列存里。值不由用户给,TantivyDocument::from_json_object 碰到这个字段直接 continue;列存写入器给它单独调 record_tie_breaker_column。上面实验里取回的文档是 {"title":["hello"]},没有 tb。它不能做索引排序字段(sort_by_field 指向它时 IndexBuilder 返回 InvalidArgument)。tantivy 内部没有代码读它:TopDocs 遇到同分时按 DocAddress 定序(见收集器)。这个列是给调用方自己按快速字段排序时做最后一级比较用的。

同一版还加了 FieldType::Custom,给 SegmentPlugin 用:tantivy 自己的倒排、doc store、列存都不处理它,值是一段不透明的字节,由插件自己写自己的段文件。插件机制本卷只提这一句。

动脑筋答案

TEXT | STRING -> Some(("default", WithFreqsAndPositions))
STRING | TEXT -> Some(("raw", Basic))

谁在左边听谁的。BitOr for TextOptions 对 indexing 用的是 Option::or:

    fn bitor(self, other: T) -> TextOptions {
        let other = other.into();
        TextOptions {
            indexing: self.indexing.or(other.indexing),
            stored: self.stored | other.stored,
            fast: merge_fast_field_options(self.fast, other.fast),
            coerce: self.coerce | other.coerce,
        }
    }

stored 和 coerce 是布尔或,可以叠加;indexing 是一个整体,两份配置没法「或」,只能挑一个,挑的是左边那个不为 None 的。STORED、FAST 转成 TextOptions 时 indexing 是 None,所以 STORED | TEXT 和 TEXT | STORED 一样;只有两边都带 indexing 时顺序才有影响,而且不报错。

小结

  • SchemaBuilder 按添加顺序给字段编号,Field 就是这个 u32;重名、名字以 - 开头都直接 panic。
  • FieldType 有 12 个变体;每个字段是否进倒排、doc store、列存,由 is_indexed、is_stored、is_fast 三个 match 决定,三条写入路径各查各的。
  • TEXT、STRING 是 TextOptions 常量,STORED、FAST、INDEXED 是类型层面的标志链表;INDEXED 不能用在文本上,编译期报错;两份 indexing 相 | 时左边的胜出。
  • 分词器在 schema 里只是名字。IndexRecordOption 决定倒排里有没有词频和位置。
  • Schema 序列化成 meta.json 里的 JSON 数组,打开时逐项 add_field 重建;open_or_create 发现不一致就报 SchemaError。
  • 0.27.0:set_fast 改为必须给分词器名字(磁盘格式不变);新增不可索引、不可存储的 TieBreaker 快速字段和给插件用的 Custom。

💬 关联讨论

发起讨论
加载中...