Error getting tokenizer for field
主线位置:写入主线第四步(索引线程新建段、按字段取分析器)和查询主线第二步(
QueryParser切词)。分词器为什么按名字存、默认注册了哪几个、两处各在哪查,见分词。本页讲最常见的触发方式:进程重启,Index::open_in_dir之后忘了register。
flowchart TB
open["open_in_dir"] --> reg{"注册 jieba 了吗"}
reg -->|是| ok["正常"]
reg -->|否| qp["parse_query"]
reg -->|否| add["add_document Ok"]
qp --> e1["UnknownTokenizer"]
add --> cm["commit"]
cm --> e2["Error getting tokenizer"]
e2 --> dead["writer 再无索引线程"]
实验 crate:exp-tantivy/pita-lab,src/bin/p03_tok.rs。title 字段的分词器名是 "jieba"(tantivy-jieba 0.19)。M2 Max、macOS 27、rustc 1.99.0,release。
现象
第一次运行:建索引、注册 jieba、写两篇、提交,一切正常。然后模拟重启:用 Index::open_in_dir 重新打开同一个目录,不注册,照常用:
first run: num_docs = 2
== restart, forget to register "jieba"
startup check, missing tokenizers: ["jieba"]
parse_query("老人"): Some(UnknownTokenizer { tokenizer: "jieba", field: "title" })
add_document: Ok(3)
commit: Err(SchemaError("Error getting tokenizer for field: title"))
== register "jieba" now, keep using the same writer
startup check, missing tokenizers: []
add_document: Ok(4)
commit: Ok(5)
num_docs = 2
== drop that writer, open a new one
add_document: Ok(5)
commit: Ok(7)
num_docs = 3
search 老人: 1
- 查询侧立刻报
UnknownTokenizer,错误里有分词器名和字段名。 - 写入侧
add_document返回Ok,commit才返回SchemaError,错误里只有字段名title,没有jieba。 - 补注册之后继续用同一个 writer:
add_document和commit都返回Ok,num_docs仍是 2,第三篇不见了,没有任何报错。 - 丢掉这个 writer、新开一个,同样的操作才真正写进去。
原因
名字在 schema 里,分析器在内存里。 meta.json 只记 "tokenizer": "jieba";TokenizerManager 是 Index 上的一张内存表,每次打开索引都只带默认的几个名字。QueryParser 在 compute_logical_ast_for_leaf 里按名字查表,查不到就返回 UnknownTokenizer。
写入侧为什么拖到 commit。 分析器是索引线程新建段时取的:0.27.0 里在 InvertedIndexPluginWriter::new,对每个字段按名字取,取不到就是 "Error getting tokenizer for field: {}"。这个错误让索引线程退出,要等 prepare_commit join 线程时才带回调用方;add_document 只是把文档送进 channel,不知道线程已经没了。
同一个 writer 为什么再也写不进去。 prepare_commit 先 mem::take 拿走全部线程句柄,逐个 join,遇到第一个错误就用 ? 返回,后面补线程的 add_indexing_worker() 没有执行。writer 手里从此没有索引线程,之后的文档进的是一个没人读的 channel,commit 却照样成功。代码和「写满一万篇后 add_document 永远阻塞」的实验见分词。这是上游的问题:错误返回了,writer 的状态却没有恢复,也没有被标记为不可用。0.27.0 之后 main 上没有改 index_writer.rs。
修法
打开索引之后、建 reader、writer、QueryParser 之前注册。 把它写成打开索引的唯一入口,不要让两处代码各自 open_in_dir:
fn open_index(dir: &Path) -> tantivy::Result<Index> {
let index = Index::open_in_dir(dir)?;
index.tokenizers().register("jieba", JiebaTokenizer::new());
Ok(index)
}
上面输出的最后一段就是注册之后新开 writer 的结果:num_docs 变成 3,查 老人 命中 1 篇。注册要用 index.tokenizers().register(...),不要用 set_tokenizers 换掉整张表,原因见分词。
commit 返回错误之后,丢掉这个 writer。 不管什么原因失败,都不要继续用它;drop 之后再 index.writer(...)。
怎么检查
启动时把 schema 需要的名字逐个核对,缺了就拒绝启动:
fn missing_tokenizers(index: &Index) -> Vec<String> {
let mut names = vec!["default".to_string()]; // looked up for every field when a segment starts
for (_, entry) in index.schema().fields() {
let opts = match entry.field_type() {
FieldType::Str(o) => o.get_indexing_options().cloned(),
FieldType::JsonObject(o) => o.get_text_indexing_options().cloned(),
_ => None,
};
if let Some(o) = opts {
names.push(o.tokenizer().to_string());
}
}
names.into_iter().filter(|n| index.tokenizers().get(n).is_none()).collect()
}
上面输出里的两行 startup check 就是它在注册前后的结果。
- 列表里放
"default",是因为InvertedIndexPluginWriter::new对非文本字段也会unwrap_or("default")去取,管理器里没有default时任何 schema 都写不进去(分词的实验 D)。 - 只核对
Str和JsonObject:Index::tokenizer_for_field对数值字段返回"... is not a text field.",不能拿它对所有字段挨个调。 - 快速字段的
set_fast("名字")查的是另一张表index.fast_field_tokenizer(),用到时同样要核对。
小结
- schema 只存分词器的名字,重新打开索引必须再注册;忘了注册,查询报
UnknownTokenizer,写入要到commit才报Error getting tokenizer for field: title。 commit失败后同一个 writer 没有索引线程了,之后的提交都返回Ok却什么也不写,这是上游的问题;失败后要丢掉 writer 重开。- 用一个函数统一「打开 + 注册」,启动时核对 schema 里的每个分词器名和
default。