不带空格的中文查询 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 把标点也当词输出(分词里量过占比),于是标点进了短语。
问: 全角空格不也是空白吗?为什么 倒排 合并 报语法错误,而不是当成两个词?
答: 两处用的「空白」不是同一个定义。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 空白和全角标点换成空格。