Rust in Everything
基于 tantivy 0.27.0

中文字段用 default 分词:搜不到词,长句整句消失

主线位置:写入主线里 SegmentWriter 把文本交给字段的分析器,查询主线里 QueryParser 用同一个分析器切查询串。default 这条链的每一层、RemoveLongFilter 为什么按字节算,见分词。本页讲中文字段直接用 TEXT 时,从搜索结果上看到的是什么。

flowchart TB
  txt["连续汉字"] --> st["SimpleTokenizer"]
  st --> one["整段一个词"]
  one --> len{"40 字节以上"}
  len -->|否| keep["整段进索引"]
  len -->|是| drop["丢掉 不报错"]
  keep --> q1["搜两字词 0 篇"]
  drop --> q2["这句永远搜不到"]

实验 crate:exp-tantivy/pita-lab,src/bin/p04_zh.rs。同一个 schema 里两个字段:title_default 是 TEXT | STORED,title_jieba 用 tantivy-jieba 0.19 的 JiebaTokenizer::new()。M2 Max、macOS 27、rustc 1.99.0,release。

现象

写入两篇,标题是「老人与海」和「他是一个独自在湾流中一条小船上钓鱼的老人」,两个字段内容相同。查三个词:

title_default 老人   -> 0 hit(s)
title_default 老人与海 -> 1 hit(s)
title_default 钓鱼   -> 0 hit(s)
title_jieba   老人   -> 2 hit(s)
title_jieba   老人与海 -> 1 hit(s)
title_jieba   钓鱼   -> 1 hit(s)

default 字段里「老人」一篇也搜不到,「钓鱼」也是。只有和整段原文一字不差的「老人与海」能命中。写入、提交、查询都没有任何错误或警告。

把两个字段的词典列出来,原因一目了然:

title_default terms: ["老人与海"]
title_jieba terms: ["一个", "一条", "上", "中", "他", "在", "小船", "是", "湾流", "独自", "的", "老人", "老人与海", "钓鱼"]

title_default 的词典里只有一个词。第二篇的标题一个词都没留下。

原因

TEXT 的分词器名是 "default",即 SimpleTokenizer → RemoveLongFilter::limit(40) → LowerCaser(TokenizerManager::default)。两层各做了一件对英文合理、对中文致命的事:

  • SimpleTokenStream::advance 按 char::is_alphanumeric 切:连续的字母数字是一个词,其他字符是分隔符。汉字是字母类,所以两个标点之间的整段中文是一个词。
  • RemoveLongFilter 只留 len() < 40 的词,len() 是字节数。一个汉字 3 字节,13 个字以内保留,14 个字以上整段丢掉。

直接看分析器的输出:

default  12 bytes "老人与海" -> ["老人与海"]
default  33 bytes "一个老人独自在海上捕鱼" -> ["一个老人独自在海上捕鱼"]
default  60 bytes "他是一个独自在湾流中一条小船上钓鱼的老人" -> []
default  90 bytes "他是一个独自在湾流中一条小船上钓鱼的老人,至今已去了八十四天" -> ["至今已去了八十四天"]
default  34 bytes "Rust 写的全文检索库 tantivy" -> ["rust", "写的全文检索库", "tantivy"]
  • 11 个字的短句是一个词:只有整句原文能搜到它。
  • 20 个字的句子 60 字节,被丢掉,输出是空的。逗号把第四句切成两段,前段被丢,后段 9 个字留下。
  • 中英混排时,英文按空格切开了,中文仍是整段。

查询侧用的是同一个分析器:「老人」切出来是词 老人,而索引里只有 老人与海,两者不等,所以 0 篇。这不是 bug,default 本来就是为空格分词的语言准备的。本站语料上整体有多少汉字因此消失(约 9%),见分词的统计。

修法

中文字段换一个中文分词器,写入和查询用同一个名字注册(tantivy-jieba 怎么接见分词):

let zh = TextFieldIndexing::default()
  .set_tokenizer("jieba")
  .set_index_option(IndexRecordOption::WithFreqsAndPositions);
let title_jieba = sb.add_text_field("title_jieba", TextOptions::default().set_indexing_options(zh));
// ...
index.tokenizers().register("jieba", JiebaTokenizer::new());

同一句话交给 jieba:

jieba   "他是一个独自在湾流中一条小船上钓鱼的老人" -> ["他", "是", "一个", "独自", "在", "湾流", "中", "一条", "小船", "上", "钓鱼", "的", "老人"]

上面「现象」里 title_jieba 的三行就是换了之后的结果:「老人」命中两篇,「钓鱼」命中一篇。

已经用 default 写好的字段改不了分词器(schema 建好就不能改),只能新建一个用 jieba 的字段或新索引,把文档重新写一遍;原文没有 STORED 时,得从你自己的数据源重灌。

换了 jieba 以后还有两件事要知道:它把空白和标点也当词输出,position 是字符偏移而不是词序号,这些都影响 fieldnorm 和短语查询,见分词和在本站中。

怎么检查

  • 看 schema: 每个可能有中文的 Str 字段,get_indexing_options().tokenizer() 不应该是 default、en_stem、whitespace。TEXT 这个常量就是 default。
  • 看分析器的输出: index.tokenizer_for_field(field)?.token_stream(样例),拿一句 14 个字以上、不带标点的中文试一下,输出为空就是这个坑。
  • 看词典: 对已有的索引,segment_reader.inverted_index(field)?.terms().stream()? 列出词,一眼能看出词是不是整句。上面的 title_default terms 就是这样打出来的。

小结

  • TEXT 的 default 分析器按 is_alphanumeric 切词,汉字是字母类,两个标点之间的整段中文是一个词;搜其中的词查不到。
  • RemoveLongFilter(40) 按字节算,14 个汉字以上的整段直接丢掉,这段文字在索引里不存在,也不报错。
  • 中文字段注册 jieba 一类的分词器;已有字段只能重建。检查 schema 里的分词器名,或者把一个长句子喂给分析器看输出。

💬 关联讨论

发起讨论
加载中...