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 | 列存 |
|---|---|---|---|---|---|
Str | add_text_field | TextOptions | 有 indexing 时 | stored | fast |
U64 I64 F64 Bool | add_u64_field 等 | NumericOptions | indexed | stored | fast |
Date | add_date_field | DateOptions | indexed(截到秒) | stored | fast |
Facet | add_facet_field | FacetOptions | 总是 | stored | 总是 |
Bytes | add_bytes_field | BytesOptions | indexed | stored | fast |
JsonObject | add_json_field | JsonObjectOptions | 有 indexing 时 | stored | fast |
IpAddr | add_ip_addr_field | IpAddrOptions | indexed | stored | fast |
TieBreaker | add_tie_breaker_field | 无 | 否 | 否 | 总是 |
Custom | add_custom_field | CustomOptions | 否 | 否 | 否 |
表里后三列是 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:
| 常量 | 是什么 | 用在 |
|---|---|---|
TEXT | TextOptions:分词器 default、记录到位置、有 fieldnorm | 文本 |
STRING | TextOptions:分词器 raw(整串一个词)、只记 doc id、有 fieldnorm | 文本 |
STORED | SchemaFlagList<StoredFlag, ()> | 所有有选项的类型 |
FAST | SchemaFlagList<FastFlag, ()> | 除 facet 外(facet 总是快速字段);文本用 raw 分词器 |
INDEXED | SchemaFlagList<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。这个限制在编译期就生效。
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)里找,和倒排用的那个不是同一个。
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 不匹配,清空重建(见本站的搜索模块)。
问: 线上索引想加一个字段,能不能在 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。