Rust in Everything
基于 tantivy 0.27.0

查询解析器

主线位置:查询主线第二步。QueryParser::parse_query("old man") 把用户输入变成一个 Box<dyn Query>:先由子 crate query-grammar 解析出一棵只认语法、不认 schema 的 UserInputAst,再由 QueryParser 按默认字段、各字段的分词器和类型展开成 LogicalAst,最后换成具体的 TermQuery、PhraseQuery、BooleanQuery。一次查询里那四个 Should 子句就是这样来的。

flowchart TB
  s["查询字符串"] --> g["query-grammar nom"]
  g --> u["UserInputAst"]
  u --> l["compute_logical_ast"]
  sch["schema 和分词器"] --> l
  l --> la["LogicalAst"]
  la --> simp["simplify 拉平"]
  simp --> cq["convert_to_query"]
  cq --> q["Box dyn Query"]
  g -. 容错版 .-> e["错误列表"]

两层各在一处:语法在工作区的 query-grammar/(tantivy-query-grammar 0.27.0,2957 行,主体是 1966 行的 query_grammar.rs),展开在 src/query/query_parser/(query_parser.rs 2304 行,其中一半以上是测试;logical_ast.rs 168 行)。实验在 exp-tantivy/srchb-lab:wiki.json 1000 篇,title、body 都是 TEXT,一个段;平台 M2 Max、macOS 27、rustc 1.99.0,release。

第一层:语法不认识 schema

query_grammar::parse_query 只拿一个 &str,不知道有哪些字段、字段是什么类型。它返回的 UserInputAst 只有三种节点:

pub enum UserInputAst {
    Clause(Vec<(Option<Occur>, UserInputAst)>),
    Boost(Box<UserInputAst>, ordered_float::OrderedFloat<f64>),
    Leaf(Box<UserInputLeaf>),
}

叶子 UserInputLeaf 有六种:Literal(词或短语,带可选的字段名、slop、前缀标记)、All、Range、Set、Exists、Regex。Option<Occur> 是 None 时表示「用户没写」,留给下一层按默认连接方式决定。tantivy 重新导出了这个 crate(tantivy::query_grammar),可以直接打印这一层的结果。Debug 输出里 * 是没写,+ 是 Must,- 是 MustNot,? 是 Should:

old man            => (*old *man)
title:old man      => (*"title":old *man)
a AND b OR c       => (?(+a +b) ?c)
+old -man sea      => (+old -man *sea)
"old man"~2^3      => ("old man"~2)^3
title:(old man)    => (*"title":old *"title":man)
graet~1            => graet~1
*                  => *
title:*            => $exists("title")
blen:[10 TO *}     => "blen":["10" TO "*"}
-man               => (-man)

几条规则从这里能读出来:

  • 字段名只管紧跟着的一个叶子。 title:old man 里 man 没有字段;要一起限定得写成 title:(old man),term_group 把组里所有没有字段的叶子设成 title。
  • AND 比 OR 绑得紧。 aggregate_binary_expressions 把被 AND 连起来的叶子收进同一组、都标 Must,组和组之间是 Should。用了 AND/OR 的地方,Occur 已经写死,不再是 None。
  • ~ 只跟在引号后面才是 slop。 slop_or_prefix_val 接在一个词或短语之后,但裸词的 word 本身就会把 ~、* 吃进去:graet~1 整个是一个叫 graet~1 的词。下一层把它交给分词器,变成什么见下文。
  • 单独一个 * 是全部文档,field:* 是 exists。 语法层很早就认识后者,但 0.26.1 的 QueryParser 遇到它报 UnsupportedQuery("Range query need to target a specific field."),0.27.0 才把它换成 ExistsQuery(CHANGELOG 记为 #2798)。

语法用 nom 7 的组合子写成,自上而下:ast 是一串由空白隔开的 operand_leaf(可选的 AND/OR、可选的 +/-、一个带 boost 的叶子);leaf 依次试括号组、*、NOT、带字段名的字面量;字面量再在范围、集合、exists、正则、词或短语之间选。括号组里又是一个 ast,所以嵌套靠递归。parse_to_ast 最后调 rewrite_ast:去掉重复的子句,拆掉只有一个孩子的多余括号。

第二层:按 schema 展开

QueryParser::parse_query 只有三行:语法层解析失败直接返回 SyntaxError(只带原字符串,不带位置);成功就交给 compute_logical_ast_lenient,有错取第一个返回;最后 simplify 再 convert_to_query。展开是一次递归下降(compute_logical_ast_with_occur_lenient),三种节点各做一件事:

  • Clause:没写的 Occur 换成 default_occur,默认 Should,调过 set_conjunction_by_default 是 Must。
  • Boost:乘到子树上。
  • Leaf:compute_logical_ast_from_leaf_lenient 按叶子种类处理。字面量先由 compute_path_triplets_for_literal 找目标字段:写了字段名就用它(找不到时再试默认字段里的 JSON 字段,把名字当路径);没写就展开到所有默认字段,一个字段一个 Should;默认字段为空时报 NoDefaultFieldDeclared。

到具体字段上,compute_logical_ast_for_leaf 按字段类型解析文本:u64、i64、f64、bool 用 FromStr,日期要 RFC 3339,字节要 base64,IP 用 IpAddr;文本字段取出字段的分词器(名字找不到报 UnknownTokenizer,见分词),交给 generate_literals_for_str。这里有一条容易被忽略的规则:

    if terms.len() <= 1 {
        if prefix {
            return Err(QueryParserError::PhrasePrefixRequiresAtLeastTwoTerms {
                // ...
            });
        }
        let term_literal_opt = terms
            .into_iter()
            .next()
            .map(|(_, term)| LogicalLiteral::Term(term));
        return Ok(term_literal_opt);
    }
    if !indexing_options.index_option().has_positions() {
        return Err(QueryParserError::FieldDoesNotHavePositionsIndexed(
            field_name.to_string(),
        ));
    }
    Ok(Some(LogicalLiteral::Phrase {

一个字面量切出两个以上的词,就成了短语,不管用户有没有加引号。决定的是分词器,不是语法:graet~1 被 default 分词器切成 graet 和 1,于是成了 PhraseQuery;中文没有空格,一个词组在语法层是一个字面量,jieba 切成几个词就是几个词的短语。在一个用 jieba 的字段上:

倒排索引 => PhraseQuery { field: Field(0), phrase_terms: [(0, Term(field=0, type=Str, "倒排")), (2, Term(field=0, type=Str, "索引"))], slop: 0 }
倒排 索引 => BooleanQuery { subqueries: [(Should, TermQuery(Term(field=0, type=Str, "倒排"))), (Should, TermQuery(Term(field=0, type=Str, "索引")))], minimum_number_should_match: 1 }
倒排索引怎么合并 => PhraseQuery { field: Field(0), phrase_terms: [(0, Term(field=0, type=Str, "倒排")), (2, Term(field=0, type=Str, "索引")), (4, Term(field=0, type=Str, "怎么")), (6, Term(field=0, type=Str, "合并"))], slop: 0 }

偏移 0、2、4、6 是 jieba 给的 position(它按字符计,见分词),写入时也是同一个分词器,两边一致,所以短语能对上;但「倒排索引怎么合并」要求四个词严格相邻,原文换一种说法就查不到。字段没有位置时,这条路直接报 FieldDoesNotHavePositionsIndexed(位置有实测)。

展开的结果是 LogicalAst,结构和 UserInputAst 一样是子句、boost、叶子三种,但叶子已经是带字段的 Term、带位置的短语、Bound<Term> 范围。simplify 把 Occur 相同的嵌套子句拉平;全是 MustNot 的查询在 compute_logical_ast_lenient 里报 AllButQueryForbidden(单独一个 -man 就是这样)。convert_to_query 先删掉空子句,再逐个换成查询:

LogicalLiteral换成
TermTermQuery(WithFreqs);字段设了 fuzzy 时换成 FuzzyTermQuery
PhrasePhraseQuery,带 * 时 PhrasePrefixQuery
RangeRangeQuery
SetTermSetQuery
RegexRegexQuery,要先 allow_regexes(),否则报「Regex queries are not allowed.」
ExistsExistsQuery::new(field, true)
AllAllQuery

各查询怎么执行见短语与其他查询。

默认 OR 与 set_conjunction_by_default

同样两个默认字段,默认 OR 和调了 set_conjunction_by_default 的对比:

[or] old man        => 53 docs  BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "old"))), (Should, TermQuery(Term(field=2, type=Str, "old"))), (Should, TermQuery(Term(field=1, type=Str, "man"))), (Should, TermQuery(Term(field=2, type=Str, "man")))], minimum_number_should_match: 1 }
[and] old man        => 6 docs  BooleanQuery { subqueries: [(Must, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "old"))), (Should, TermQuery(Term(field=2, type=Str, "old")))], minimum_number_should_match: 1 }), (Must, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "man"))), (Should, TermQuery(Term(field=2, type=Str, "man")))], minimum_number_should_match: 1 })], minimum_number_should_match: 0 }
[or] title:old man  => 26 docs  BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "old"))), (Should, TermQuery(Term(field=1, type=Str, "man"))), (Should, TermQuery(Term(field=2, type=Str, "man")))], minimum_number_should_match: 1 }
[and] title:old man  => 0 docs  BooleanQuery { subqueries: [(Must, TermQuery(Term(field=1, type=Str, "old"))), (Must, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "man"))), (Should, TermQuery(Term(field=2, type=Str, "man")))], minimum_number_should_match: 1 })], minimum_number_should_match: 0 }

Must 加在「一个字面量」这一层,字面量展开到多个默认字段的那一层仍是 Should:old man 在 AND 模式下是「每个词至少在一个字段里出现」,不是「每个词在每个字段里都出现」。OR 模式下两个默认字段上的四个词都是同一层的 Should,因为 simplify 把嵌套的 Should 子句拉平了。

📝NOTE
没有蠢问题

问: AND 模式下 title:old man 为什么一篇都没有?QueryParser 的文档说它会被解释成 OR。

答: 文档那句写的是「body:Barack Obama is not interpreted as body:Barack AND body:Obama but as body:Barack OR (body:Barack OR text:Obama)」,和代码不一致。实际是 +title:old +(title:man body:man):title: 只管 old,man 回到默认字段,两个字面量之间按 AND 连接。wiki 里标题含 old 的文档没有一篇同时含 man,所以是 0。要两个词都限定在标题里,写 title:(old man)。

显式写出的运算符不受默认值影响。

❗IMPORTANT
动脑筋:a AND b OR c 在默认 OR 和 AND 两种模式下,各命中几篇?

答案在动脑筋答案。

boost 与 fuzzy

boost 有两个来源:查询里的 ^2(语法层的 boost,只接受非负的十进制数,等于 1 时直接丢掉),和 set_field_boost。字段 boost 在展开每个字面量时套在它外面,查询里的 boost 套在整个子树外面,两者都生成 BoostQuery,乘起来。title 设为 3:

old          => BooleanQuery { subqueries: [(Should, Boost(query=TermQuery(Term(field=1, type=Str, "old")), boost=3)), (Should, TermQuery(Term(field=2, type=Str, "old")))], minimum_number_should_match: 1 }  top [(20.615593, DocAddress { segment_ord: 0, doc_id: 219 })]
old^2        => Boost(query=BooleanQuery { subqueries: [(Should, Boost(query=TermQuery(Term(field=1, type=Str, "old")), boost=3)), (Should, TermQuery(Term(field=2, type=Str, "old")))], minimum_number_should_match: 1 }, boost=2)  top [(41.231186, DocAddress { segment_ord: 0, doc_id: 219 })]
title:old^2  => Boost(query=Boost(query=TermQuery(Term(field=1, type=Str, "old")), boost=3), boost=2)  top [(32.23046, DocAddress { segment_ord: 0, doc_id: 219 })]

old^2 的分数正好是 old 的两倍。

模糊不是查询语法的一部分。set_field_fuzzy 按字段登记距离、是否前缀、换位算不算 1;convert_literal_to_query 遇到这个字段上的 Term 就换成 FuzzyTermQuery,短语不受影响:

graet              => 38 docs  FuzzyTermQuery { term: Term(field=2, type=Str, "graet"), distance: 1, transposition_cost_one: true, prefix: false }
"graet britain"    => 0 docs  PhraseQuery { field: Field(2), phrase_terms: [(0, Term(field=2, type=Str, "graet")), (1, Term(field=2, type=Str, "britain"))], slop: 0 }
graet~1            => 0 docs  PhraseQuery { field: Field(2), phrase_terms: [(0, Term(field=2, type=Str, "graet")), (1, Term(field=2, type=Str, "1"))], slop: 0 }

字段一旦设了 fuzzy,这个字段上的每个词都是模糊查询,打常数分(见短语与其他查询);Lucene 那种写在词后面的 graet~1 在这里是一个两个词的短语。

容错解析

parse_query_lenient 总是返回一个查询,外加一个错误列表。它用的是语法层的另一套组合子:parse_to_ast_lenient 从 ast_infallible 开始,每个 *_infallible 函数遇到问题记一条带位置的错误、跳过去接着解析,所以 query_grammar.rs 里差不多每个规则都写了两遍。语义层的错误(字段不存在、数字解析失败)则让对应的叶子变成空子句,最后被删掉:

title:           strict=Err(SyntaxError("title:"))
                 lenient=EmptyQuery errors=[SyntaxError("expected word at position 6")]
old AND          strict=Err(SyntaxError("old AND"))
                 lenient=BooleanQuery { subqueries: [(Should, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "old"))), (Should, TermQuery(Term(field=2, type=Str, "old")))], minimum_number_should_match: 1 }), (Should, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "and"))), (Should, TermQuery(Term(field=2, type=Str, "and")))], minimum_number_should_match: 1 })], minimum_number_should_match: 1 } errors=[]
nosuch:old man   strict=Err(FieldDoesNotExist("nosuch"))
                 lenient=BooleanQuery { subqueries: [(Should, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "man"))), (Should, TermQuery(Term(field=2, type=Str, "man")))], minimum_number_should_match: 1 })], minimum_number_should_match: 1 } errors=[FieldDoesNotExist("nosuch")]
id:abc           strict=Err(ExpectedInt(ParseIntError { kind: InvalidDigit }))
                 lenient=EmptyQuery errors=[ExpectedInt(ParseIntError { kind: InvalidDigit })]
(old man         strict=Err(SyntaxError("(old man"))
                 lenient=BooleanQuery { subqueries: [(Should, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "old"))), (Should, TermQuery(Term(field=2, type=Str, "old")))], minimum_number_should_match: 1 }), (Should, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "man"))), (Should, TermQuery(Term(field=2, type=Str, "man")))], minimum_number_should_match: 1 })], minimum_number_should_match: 1 } errors=[SyntaxError("expected ')' at position 8")]

old AND 是个例外:容错版把末尾的 AND 当成了一个普通的词(被 default 分词器小写成 and),而且不报错。严格版的 SyntaxError 只能告诉调用方「整串不合法」,要给用户提示错在哪,只能用容错版的位置信息。另一个差别:容错版不调 simplify,生成的布尔查询会多几层嵌套,匹配结果相同。

0.27.0 修掉的三类输入

0.27.0 的 CHANGELOG 在 Query Parser 下列了三条修复:#2905(深层嵌套的指数级解析时间)、#2960(*^2 和正则后面的 boost)、#3099(畸形输入上的 panic 和死循环)。同一段程序分别链接 0.27.0 和 0.26.1,用两个默认字段的解析器、打开 allow_regexes。

嵌套深度。 输入是 n 层括号包着 title:a,每个深度解析 3 次取最小:

深度0.27.00.26.1
80.000002 s0.000211 s
120.000002 s0.002608 s
160.000002 s0.038811 s
180.000002 s0.156110 s
200.000002 s0.627831 s
220.000002 s2.513106 s
2000.000011 s未测完(120 s 超时)

0.26.1 每多一层慢一倍,深度 22 的输入只有 51 个字符,要 2.5 秒。0.27.0 在 200 层时仍是微秒级。

*^2 和正则后的 boost。 0.26.1 上严格解析 *^2 直接 panic;title:/ol./^2 不报错,但正则被悄悄解析成了词 ol:

  panicked at .../tantivy-query-grammar-0.26.0/src/user_input_ast.rs:51:30: Exist query without a field isn't allowed
strict  "*^2" -> Err("PANIC")
strict  "title:/ol./^2" -> Ok(Ok("Boost(query=TermQuery(Term(field=0, type=Str, \"ol\")), boost=2)"))

0.27.0 上前者是 Boost(query=AllQuery, boost=2),后者是 boost 为 2 的 RegexQuery。修法(7fd1dbe9f,经 #2960 合入)是在 * 和正则结束处允许下一个字符是 ^。

畸形输入。 * 后面紧跟一个不常见的空白字符(\u{b} 垂直制表、\u{85} 下一行)在 0.26.1 上同样走到那句 expect 里 panic;容错解析 title:IN [\u{b} 在 0.26.1 上死循环(20 s 被杀掉),0.27.0 立刻返回三条错误:

lenient "title:IN [\u{b}" ...
  returned in 14.917µs: [SyntaxError("expected word at position 10"), SyntaxError("missing ] at position 10"), SyntaxError("unparsed end of query at position 10")]

对应的两个提交是 34edbf782(「Fix query parser panics on malformed input」:没有字段的 exists 叶子当作全部文档,集合解析在不再消耗输入时停下)和 54b82e582(「Report unsupported whitespace as a syntax error」),CHANGELOG 把它们记在 #3099 下。

📝NOTE
冷知识:一个 alt 让解析时间每层翻倍

0.26.x 的 ast 写成 alt((boolean_expr, single_leaf)):先试「叶子后面跟着运算符」,失败再试「单个叶子」。#2905(89f0cef80,「Fix O(2^n) query parser regression for deeply-nested queries」,首次发布于 0.27.0)的说明讲了问题在哪:「When the group contained a single leaf with no trailing operand, boolean_expr would parse occur_leaf (recursing into the inner group), fail at multispace1, backtrack, and then single_leaf would re-parse occur_leaf from scratch. Every nesting level doubled the work」。括号里只有一个叶子时,第一条分支把整个内层解析完才发现后面没有运算符,nom 的 alt 回溯后第二条分支从头再解析一遍;内层又是同样的情况,于是 n 层就是 2^n 次。现在的 ast 只解析一次 occur_leaf,然后用 preceded(multispace1, many1(operand_leaf)) 往后看有没有运算符,没有就直接用已经解析好的叶子。提交说明里的测量是深度 25 从 28.23 s 降到 1 µs 以内;上表是本机的数。容错解析用的是另一套不回溯的 ast_infallible,两个版本都没有这个问题(本机 2000 层约 1.6 ms)。

动脑筋答案

两种模式都是 75 篇,生成的查询逐字相同:

[or] a AND b OR c   => 75 docs  BooleanQuery { subqueries: [(Should, BooleanQuery { subqueries: [(Must, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "a"))), (Should, TermQuery(Term(field=2, type=Str, "a")))], minimum_number_should_match: 1 }), (Must, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "b"))), (Should, TermQuery(Term(field=2, type=Str, "b")))], minimum_number_should_match: 1 })], minimum_number_should_match: 0 }), (Should, TermQuery(Term(field=1, type=Str, "c"))), (Should, TermQuery(Term(field=2, type=Str, "c")))], minimum_number_should_match: 1 }
[and] a AND b OR c   => 75 docs  BooleanQuery { subqueries: [(Should, BooleanQuery { subqueries: [(Must, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "a"))), (Should, TermQuery(Term(field=2, type=Str, "a")))], minimum_number_should_match: 1 }), (Must, BooleanQuery { subqueries: [(Should, TermQuery(Term(field=1, type=Str, "b"))), (Should, TermQuery(Term(field=2, type=Str, "b")))], minimum_number_should_match: 1 })], minimum_number_should_match: 0 }), (Should, TermQuery(Term(field=1, type=Str, "c"))), (Should, TermQuery(Term(field=2, type=Str, "c")))], minimum_number_should_match: 1 }

默认连接方式只替换 UserInputAst 里值为 None 的 Occur。写了 AND、OR 的地方,aggregate_binary_expressions 已经填好了 Must 和 Should,default_occur 没有机会介入。set_conjunction_by_default 只影响用空格隔开、没写运算符的词。

小结

  • 两层:query-grammar 用 nom 把字符串解析成不认 schema 的 UserInputAst(None 表示没写连接方式);QueryParser 按 schema 展开成 LogicalAst,simplify 拉平后换成具体的查询。
  • 字段名只管紧跟的一个叶子;AND 比 OR 紧;显式运算符不受 set_conjunction_by_default 影响;AND 模式的 Must 在字面量这一层,字面量在多个默认字段之间仍是 Should。
  • 一个字面量被分词器切出两个以上的词就是短语:graet~1 是 graet、1 的短语,不加空格的中文词组是 jieba 切出的几个词的短语。
  • boost 来自 ^n 和 set_field_boost,相乘;fuzzy 只能按字段整体打开,查询里的 ~ 不是模糊。
  • parse_query 只给一个不带位置的 SyntaxError;parse_query_lenient 用另一套组合子,总返回查询和带位置的错误列表。
  • 0.27.0 修了三类输入:深层嵌套从每层翻倍变成线性(#2905),*^2 的 panic 和正则后 boost 的误解析(#2960),垂直制表符等引起的 panic 与集合语法的死循环(#3099)。

💬 关联讨论

发起讨论
加载中...