Rust in Everything
基于 tantivy 0.27.0

不带空格的中文查询 0 篇

主线位置:查询主线第二步。QueryParser 先在语法层把输入切成字面量,再把每个字面量交给字段的分词器;切出两个以上的词就是短语查询(查询解析器)。英文用户习惯打空格,中文用户不打,问题就出在这两层对「词」的理解不一样。

flowchart TB
  in["合并段"] --> g["语法层按 ASCII 空白切"]
  g --> lit["一个字面量"]
  lit --> jb["jieba 切词"]
  jb --> two["合并 和 段"]
  two --> ph["PhraseQuery"]
  ph --> need["原文必须相邻"]
  need --> zero["0 篇"]

实验在 exp-tantivy/pitb-lab,bin p17_zh、p17_ws(release)。三篇自己写的短文档,body 用 jieba(JiebaTokenizer::new())加 LowerCaser,记录选项 WithFreqsAndPositions,和分词里的注册方式一样。M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0。

现象

三篇文档:段合并在后台线程里进行,合并完成后旧段才会被删除。、倒排索引按词组织,每个词对应一条倒排表。、索引写入以后要提交才能被搜到。

"段合并"            => 1 docs  Phrase["段", "合并"]
"合并段"            => 0 docs  Phrase["合并", "段"]
"合并 段"           => 1 docs  BooleanQuery
"倒排索引"           => 1 docs  Phrase["倒排", "索引"]
"索引倒排"           => 0 docs  Phrase["索引", "倒排"]
"倒排,合并"          => 0 docs  Phrase["倒排", ",", "合并"]
"倒排 合并"          => 2 docs  BooleanQuery
"倒排\u{3000}合并"   => Err: SyntaxError("倒排\u{3000}合并")
"倒排、合并"          => 0 docs  Phrase["倒排", "、", "合并"]
lenient "倒排\u{3000}合并" => 1 docs errors=[SyntaxError("unparsed end of query at position 6")]
  • 不带空格的输入都成了短语:合并段 要求原文里「合并」后面紧跟「段」,第一篇里两个词都在,但不相邻,0 篇。
  • 用全角逗号、顿号隔开两个词,标点本身成了短语里的一个词,原文必须连着出现「倒排,合并」。
  • 用全角空格(U+3000)隔开,严格解析直接 SyntaxError;容错解析只留下第一个词,第二个词连同错误一起被扔掉。

原因

语法层只认 ASCII 空白做分隔。 词由 word 读出:一直读到 char::is_whitespace() 为真的字符为止,全角逗号、顿号都不是空白,所以 倒排,合并 整个是一个词。两个词之间要靠 ast 里的 nom multispace1 分开,它只认空格、\t、\r、\n。

一个字面量切出几个词就是几个词的短语。 generate_literals_for_str 不管用户有没有加引号,词数大于 1、字段有位置,就生成 PhraseQuery,位置用分词器给的 position。jieba 把标点也当词输出(分词里量过占比),于是标点进了短语。

📝NOTE
没有蠢问题

问: 全角空格不也是空白吗?为什么 倒排 合并 报语法错误,而不是当成两个词?

答: 两处用的「空白」不是同一个定义。word 用 Unicode 的 is_whitespace,读到 U+3000 就停;接下来该由 multispace1 吃掉分隔符,它不认 U+3000,解析停在半路,严格模式整串报错。复制网页文字时常带进来的不换行空格(U+00A0)也一样。p17_ws 在 0.27.0 和 0.26.1 上结果相同:

"a\u{3000}b" => Err(SyntaxError("a\u{3000}b"))
"a\u{a0}b" => Err(SyntaxError("a\u{a0}b"))
"a\tb" => Ok("BooleanQuery { subqueries: [(Should, TermQuery(Term(field=0, type=Str, \"a\"))), (Should, TermQuery(Term(field=0, type=Str, \"b\")))], minimum_number_should_match: 1 }")

0.27.0 的提交 54b82e582(「Report unsupported whitespace as a syntax error」,CHANGELOG 记在 #3099 下)让 * 后面和容错解析里的非 ASCII 空白也统一报错,方向是「不支持就明说」,并没有让这类空白变成分隔符。

修法

不让解析器替中文用户决定什么是短语:先用字段自己的分析器把输入切开,丢掉不含字母数字的词元(标点、空白),再组成 TermQuery 的布尔查询:

let mut analyzer = index.tokenizer_for_field(body)?;
let mut subs: Vec<(Occur, Box<dyn Query>)> = Vec::new();
let mut words = Vec::new();
let mut ts = analyzer.token_stream(input);
while let Some(t) = ts.next() {
  if t.text.chars().any(char::is_alphanumeric) && !words.contains(&t.text) {
    words.push(t.text.clone());
    let term = Term::from_field_text(body, &t.text);
    subs.push((Occur::Should, Box::new(TermQuery::new(term, IndexRecordOption::WithFreqs))));
  }
}
let q = BooleanQuery::new(subs);
fix "合并段"          words=["合并", "段"] => 1 docs
fix "索引倒排"         words=["索引", "倒排"] => 2 docs
fix "倒排,合并"        words=["倒排", "合并"] => 2 docs
fix "倒排\u{3000}合并" words=["倒排", "合并"] => 2 docs

全角标点、全角空格都不再起作用,词序也无所谓了。用的是 index.tokenizer_for_field,和写入时同一个分析器,切法一致。这里用 OR;要更紧,把 Occur::Should 换成 Must,或者用「至少 k 个词」(见默认 OR 结果太多,AND 又 0 篇)。想保留短语的精确度,可以再加一个 PhraseQuery 作为 Should 子句,只用来给相邻的文档加分。

只想沿用 QueryParser 的语法(字段名、引号、+/-),退一步的办法是在交给它之前,把 U+3000、U+00A0 这类空白换成 ASCII 空格,把全角标点换成空格;不带空格的中文仍然会是短语。

本站的搜索模块也碰到了全角标点粘成短语这件事,见本站的搜索模块。

怎么检查

  • 把解析结果打出来,用户没加引号却出现 PhraseQuery,就是这个坑;短语里有 ,、、、空格这样的词,更是。
  • 测试用例里放几条中文输入:不带空格的词组、全角逗号隔开的两个词、全角空格隔开的两个词,各断言一下命中数和是否报错。
  • 用了 parse_query_lenient 的,记录错误列表;unparsed end of query 说明后半截输入被丢了。

小结

  • 语法层只按 ASCII 空白切词;全角标点算词的一部分,全角空格和不换行空格让严格解析报 SyntaxError、容错解析丢掉后半截。0.26.1 和 0.27.0 都是这样。
  • 一个字面量被 jieba 切成几个词,就成了要求严格相邻的短语,标点也在其中。
  • 修法:用字段的分析器自己切词,去掉标点和空白词元,组成 TermQuery 的布尔查询;或者至少先把非 ASCII 空白和全角标点换成空格。

💬 关联讨论

发起讨论
加载中...