Rust in Everything
基于 tantivy 0.27.0

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 也成了模糊查询,所有文档的分数都变成同一个常数。

📝NOTE
没有蠢问题

问: 为什么前三名都是 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 合起来。

💬 关联讨论

发起讨论
加载中...