Rust in Everything
基于 tantivy 0.27.0

grea* 什么都没查到

主线位置:查询主线第二、三步。QueryParser 只在引号后面认 *,生成 PhrasePrefixQuery;它在每个段的词典上取前缀范围,读满 max_expansions 个词就停。展开怎么做、分数怎么算见短语与其他查询。

flowchart TB
  a["grea*"] --> w["word 连星号一起读"]
  w --> t["分词器剥掉星号"]
  t --> tq["TermQuery grea 0 篇"]
  b["grea 加引号加星号"] --> one["只有一个词"]
  one --> e["报错 至少两个词"]
  c["old ma 加引号加星号"] --> pp["PhrasePrefixQuery"]
  pp --> cap["字典序前 50 个"]
  cap --> miss["没有 man 0 篇"]

实验在 exp-tantivy/pitb-lab,bin p18_prefix(release):wiki.json 1000 篇,body 是 TEXT,一个段。M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0。

现象

grea*        =>   0 docs  TermQuery(Term(field=2, type=Str, "grea"))
great        =>  35 docs  TermQuery(Term(field=2, type=Str, "great"))
"grea"*      => Err: The phrase '"grea"' does not produce at least two terms using the tokenizer '"default"'
"old ma"*    =>   0 docs  PhrasePrefixQuery { field: Field(2), phrase_terms: [(0, Term(field=2, type=Str, "old"))], prefix: (1, Term(field=2, type=Str, "ma")), max_expansions: 50 }
"old man"    =>   1 docs  PhraseQuery { field: Field(2), phrase_terms: [(0, Term(field=2, type=Str, "old")), (1, Term(field=2, type=Str, "man"))], slop: 0 }
  • 按 Lucene 的习惯写 grea*,不报错,0 篇。
  • 加上引号是解析器文档里写的前缀语法,单个词却报错。
  • 两个词的 "old ma"* 不报错,0 篇,可是 "old man" 明明有 1 篇。

原因

单个词没有前缀语法。 语法层的 word 不把 * 当特殊字符,grea* 整个是一个词;default 分词器按非字母数字切,* 被丢掉,剩下 grea,成了一个普通的 TermQuery。词典里没有 grea 这个词,所以 0 篇,而且没有任何提示。前缀标记只在 slop_or_prefix_val 里解析,它接在引号后面。

引号里只有一个词时拒绝。 generate_literals_for_str 在词数不超过 1 且带前缀标记时返回 PhrasePrefixRequiresAtLeastTwoTerms,也就是上面那句报错。所以通过解析器,任何写法都得不到单词前缀查询。

展开按字典序截断。 "old ma"* 生成的 PhrasePrefixQuery 带着默认的 DEFAULT_MAX_EXPANSIONS = 50。每个段从词典里 ma 开始往后读 50 个以 ma 开头的词,跟词频、跟它是否紧跟在 old 后面都无关。数一下这个段的词典:

terms starting with "grea": 4, "great" is #1
terms starting with "ma": 414, "man" is #151

man 排在第 151,前 50 个里没有它。解析器上没有设置 max_expansions 的方法。

修法

自己建查询,不经过解析器。单个词的前缀直接用 PhrasePrefixQuery,只放一个词;它会退化成词典上的范围查询,同样受 max_expansions 限制。或者用正则:

let one = PhrasePrefixQuery::new(vec![Term::from_field_text(body, "grea")]);
let rq = RegexQuery::from_pattern("grea.*", body)?;
PhrasePrefixQuery[grea] => 51 docs
RegexQuery grea.* => 51 docs

多个词的前缀用 set_max_expansions 放大上限:

PhrasePrefixQuery[old, ma] max_expansions=50 => 0 docs
PhrasePrefixQuery[old, ma] max_expansions=200 => 2 docs
PhrasePrefixQuery[old, ma] max_expansions=1000 => 4 docs

上限每放大一倍,每个段就多读一倍的倒排表。做「边输入边搜」时,更省的办法是前缀不到两三个字符就不发查询;或者干脆在写入时多建一个 n-gram 分词的字段,把前缀匹配变成普通的词项查询。

前缀词要和索引里的词同样处理过:default 分词器会小写,Grea 要先变成 grea 再交给 Term::from_field_text,RegexQuery 也一样(见 Regex queries are not allowed.)。

怎么检查

  • 把解析结果打出来。用户输入里有 *,解析结果里却只有 TermQuery,就是前缀被吞了。
  • 用 inverted_index(field)?.terms().range().ge(prefix) 数一下以某个前缀开头的词有多少,和 max_expansions 比一比;数量远超 50 的短前缀,结果基本是随机的一小部分。
  • 搜索框的前缀补全,拿高频词(man、the)的前两个字母测一次,看能不能补出来。

小结

  • QueryParser 没有单词前缀:grea* 被分词器剥掉星号成了 grea,"grea"* 报「至少两个词」。
  • "old ma"* 每个段只展开字典序最小的 50 个词;ma 开头的词有 414 个,man 排第 151,所以 0 篇。解析器不能改这个上限。
  • 修法:自己建 PhrasePrefixQuery(单词也行)或 RegexQuery,按需 set_max_expansions;输入要先按索引的方式小写。

💬 关联讨论

发起讨论
加载中...