graet~1 变成了短语
主线位置:查询主线第二步。Lucene 和 Elasticsearch 的 query string 里,
graet~1是编辑距离 1 的模糊查询;tantivy 的QueryParser不认这个写法。模糊查询本身怎么在词典上跑自动机见短语与其他查询,~在语法层的位置见查询解析器。
flowchart TB in["graet~1"] --> w["word 读成一个词"] w --> tok["default 分词器"] tok --> two["graet 和 1"] two --> ph["PhraseQuery"] ph --> zero["0 篇 不报错"] ff["set_field_fuzzy"] --> all["字段上每个词都模糊"]
实验在 exp-tantivy/pitb-lab,bin p19_fuzzy(release):wiki.json 1000 篇,默认字段 body(TEXT),一个段。M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0。
现象
graet~1 => PhraseQuery { field: Field(2), phrase_terms: [(0, Term(field=2, type=Str, "graet")), (1, Term(field=2, type=Str, "1"))], slop: 0 }
graet~1 0 docs top []
graet~1 britain => BooleanQuery { subqueries: [(Should, PhraseQuery { field: Field(2), phrase_terms: [(0, Term(field=2, type=Str, "graet")), (1, Term(field=2, type=Str, "1"))], slop: 0 }), (Should, TermQuery(Term(field=2, type=Str, "britain")))], minimum_number_should_match: 1 }
graet~1 britain 9 docs top ["6.515@562", "5.090@505", "4.448@174"]
"graet britain"~1 => PhraseQuery { field: Field(2), phrase_terms: [(0, Term(field=2, type=Str, "graet")), (1, Term(field=2, type=Str, "britain"))], slop: 1 }
"graet britain"~1 0 docs top []
great britain 40 docs top ["11.187@562", "8.739@505", "5.089@999"]
单独查 graet~1 还能发现是 0 篇;和别的词一起查时,graet~1 那一半悄悄变成了一个永远对不上的短语,结果全靠 britain 撑着:9 篇,而拼对了的 great britain 有 40 篇。没有报错,也没有警告。
原因
- 语法层的
word把~当成普通字符,graet~1整个是一个词。~只在slop_or_prefix_val里有意义,它接在引号后面,表示短语的 slop:"graet britain"~1是允许挪一格的短语,不是模糊。 default分词器按非字母数字切,graet~1变成graet、1两个词,generate_literals_for_str于是生成一个两个词的短语(字段要是没有位置,这里就会报 The field 'body' does not have positions indexed)。- 模糊查询在解析器里只有一个入口:
set_field_fuzzy,按字段登记距离;convert_literal_to_query把这个字段上的每个TermQuery都换成FuzzyTermQuery。没有「只对这一个词模糊」的写法。
修法
整个字段都模糊:set_field_fuzzy。 用户输入里不需要任何标记:
qp.set_field_fuzzy(body, false, 1, true); // prefix, distance, transposition_cost_one
set_field_fuzzy: graet britain 43 docs top ["2.000@505", "2.000@562", "2.000@563"]
找回来了,但 britain 也成了模糊查询,所有文档的分数都变成同一个常数。
问: 为什么前三名都是 2.000,原来 great britain 那种 11.187、8.739 的区分没了?
答: FuzzyTermQuery 走 AutomatonWeight,把所有匹配词的倒排表读进一个位图,返回常数分 1.0,不算 BM25。两个词都是模糊查询,同时命中两个词的文档就是 1.0 + 1.0 = 2.0,只命中一个的是 1.0,同分的按文档号排。set_field_fuzzy 换来了容错,丢掉了这个字段上的相关性排序。
只对用户标了 ~N 的词模糊。 在交给解析器之前,把 word~N 自己换成 FuzzyTermQuery,其余部分照常解析:
/// `word~N` becomes a FuzzyTermQuery, everything else goes through the parser.
fn parse_with_tilde(qp: &QueryParser, field: Field, input: &str) -> tantivy::Result<Box<dyn Query>> {
let mut subs: Vec<(Occur, Box<dyn Query>)> = Vec::new();
for part in input.split_whitespace() {
let fuzzy = part.rsplit_once('~').and_then(|(w, d)| {
let d: u8 = d.parse().ok()?;
(d <= 2 && !w.is_empty() && w.chars().all(char::is_alphanumeric)).then(|| (w.to_lowercase(), d))
});
let q: Box<dyn Query> = match fuzzy {
Some((w, d)) => Box::new(FuzzyTermQuery::new(Term::from_field_text(field, &w), d, true)),
None => qp.parse_query(part)?,
};
subs.push((Occur::Should, q));
}
Ok(Box::new(BooleanQuery::new(subs)))
}
parse_with_tilde: graet~1 britain 43 docs top ["7.515@562", "6.090@505", "4.448@174"]
命中数和 set_field_fuzzy 一样,britain 保留了 BM25,模糊的那个词贡献常数 1.0(6.515 + 1.0 = 7.515)。距离限制在 2 以内,因为 FuzzyTermQuery 不接受 3(短语与其他查询有报错原文);词要先小写,和索引里的词一致。这个辅助函数按空白切,所以不支持引号、括号里的 ~N,用户输入复杂时要么只在没有引号时走这条路,要么在 query_grammar::parse_query 的语法树上做同样的替换。
怎么检查
- 解析结果里出现
PhraseQuery,短语的最后一个词是"1"或"2",就是用户写了~N。 - 从 Lucene、Elasticsearch 迁过来的系统,在查询日志里搜一下带
~的输入,看它们的命中数。
小结
~在 tantivy 的查询语法里只表示短语的 slop,且只接在引号后面;graet~1是graet、1两个词的短语,混在别的词里时悄悄失效。- 解析器的模糊只能按字段整体打开(
set_field_fuzzy),这个字段上每个词都变成常数分的FuzzyTermQuery。 - 要按词控制,自己把
word~N换成FuzzyTermQuery、其余交给解析器,再用BooleanQuery合起来。