Rust in Everything
基于 tantivy 0.27.0

The field 'body' does not have positions indexed

主线位置:查询主线第二步。QueryParser 把一个字面量交给字段的分词器,切出两个以上的词就要做短语查询,这时才去看字段有没有索引位置。位置是写入主线建 schema 时定下的,存在 .pos 里,格式见位置。

flowchart TB
  lit["一个字面量"] --> tok["字段的分词器"]
  tok --> n{"切出几个词"}
  n -->|"0 或 1"| term["TermQuery"]
  n -->|"2 个以上"| pos{"字段有位置"}
  pos -->|有| phrase["PhraseQuery"]
  pos -->|没有| err["FieldDoesNotHave PositionsIndexed"]

实验在 exp-tantivy/pitb-lab,bin p15_positions、p15_reopen(release);M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0。

现象

想给 body 换一个分词器,常见的写法是从 TextFieldIndexing::default() 开始,只设分词器:

let custom = TextOptions::default()
  .set_indexing_options(TextFieldIndexing::default().set_tokenizer("default"))
  | STORED;

两篇文档 send an e-mail to the old man、the man is old,QueryParser 的默认字段是 body:

[TextFieldIndexing::default()] old man      => 2 docs
[TextFieldIndexing::default()] "old man"    => Err: The field 'body' does not have positions indexed
[TextFieldIndexing::default()] e-mail       => Err: The field 'body' does not have positions indexed
[TextFieldIndexing::default()] "old"        => 2 docs
[TextFieldIndexing::default()] lenient "old e-mail" => 2 docs, errors=[FieldDoesNotHavePositionsIndexed("body")]
  • 加引号的 "old man" 报错,这在意料之中。
  • 没加引号的 e-mail 也报错。用户根本没想做短语查询。
  • 加了引号、只有一个词的 "old" 反而能查。
  • parse_query_lenient 不报错,把 e-mail 那个叶子整个丢掉,只剩 old,错误放在返回的列表里。调用方不看这个列表,结果就悄悄少了一个词。

原因

报错的地方在 generate_literals_for_str:

    if terms.len() <= 1 {
        // ...
        return Ok(term_literal_opt);
    }
    if !indexing_options.index_option().has_positions() {
        return Err(QueryParserError::FieldDoesNotHavePositionsIndexed(
            field_name.to_string(),
        ));
    }

判断看的是分词器切出了几个词,不看用户有没有加引号。default 分词器按非字母数字切,e-mail 切成 e、mail 两个词,就走到了短语这条路;"old" 只有一个词,退化成 TermQuery。规则的来龙去脉见查询解析器。

位置为什么没有?TextFieldIndexing 的 Default 用的是 IndexRecordOption::default(),而 IndexRecordOption 的 #[default] 标在 Basic 上:只记文档号,不记词频,也不记位置。常量 TEXT 自己写明了 WithFreqsAndPositions,所以只用 TEXT 的人碰不到这个坑;一旦为了 jieba、n-gram 之类自己拼 TextOptions,又没调 set_index_option,字段就悄悄降成了 Basic。三级记录选项各存什么见字段与选项。

绕过解析器、自己建 PhraseQuery 也一样,只是错误换成 phrase_weight 在建 Weight 时给出的 SchemaError,位置一页有实测。

修法

建 schema 时把记录选项写全:

let fixed = TextOptions::default()
  .set_indexing_options(
    TextFieldIndexing::default()
      .set_tokenizer("default")
      .set_index_option(IndexRecordOption::WithFreqsAndPositions),
  )
  | STORED;
[WithFreqsAndPositions] old man      => 2 docs
[WithFreqsAndPositions] "old man"    => 1 docs
[WithFreqsAndPositions] e-mail       => 1 docs
[WithFreqsAndPositions] "old"        => 2 docs
[WithFreqsAndPositions] lenient "old e-mail" => 2 docs, errors=[]

已经建好的索引改不了。schema 存在 meta.json 里,打开索引时以它为准。p15_reopen 先用 Basic 建一个磁盘索引,再用改好的 schema 去开:

open_or_create: Err: Schema error: 'An index exists but the schema does not match.'
open_in_dir keeps: Some(Basic)

IndexBuilder::open_or_create 发现 schema 不同就拒绝;open_in_dir 不比较,用的还是旧的 Basic。只能换一个目录、用新 schema 把文档重新写一遍。位置会让索引变大,.pos 有多大见位置。

如果确实不想要位置,就别让解析器生成短语:把用户输入先按字段的分词器切好、拼成 TermQuery 的布尔查询,做法见不带空格的中文查询 0 篇。

怎么检查

  • 打开索引后遍历 schema,打印每个文本字段的记录选项:
for (_, entry) in index.schema().fields() {
  if let FieldType::Str(o) = entry.field_type() {
    println!("check: {} -> {:?}", entry.name(), o.get_indexing_options().map(|i| i.index_option()));
  }
}
check: body -> Some(Basic)
  • 在代码里搜 TextFieldIndexing::default(),看后面有没有跟 set_index_option。
  • 用了 parse_query_lenient 的地方,把返回的错误列表记进日志;FieldDoesNotHavePositionsIndexed 出现在那里时,查询照样返回结果,只是少了词。
  • 测试里放一个带连字符或撇号的词(e-mail、don't),不加引号查一次。

小结

  • 一个字面量被分词器切出两个以上的词就是短语,和引号无关;字段没有位置时整条查询报 FieldDoesNotHavePositionsIndexed,容错解析则悄悄丢掉那个词。
  • TextFieldIndexing::default() 的记录选项是 Basic。自己拼 TextOptions 时要显式 set_index_option(WithFreqsAndPositions);TEXT 常量本身带位置。
  • schema 在 meta.json 里,改代码不改已有索引:open_or_create 报 schema 不匹配,open_in_dir 沿用旧 schema。只能重建。

💬 关联讨论

发起讨论
加载中...