Rust in Everything
基于 tantivy 0.27.0

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。

💬 关联讨论

发起讨论
加载中...