查询解析器
主线位置:查询主线第二步。
QueryParser::parse_query("old man")把用户输入变成一个Box<dyn Query>:先由子 cratequery-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 | 换成 |
|---|---|
Term | TermQuery(WithFreqs);字段设了 fuzzy 时换成 FuzzyTermQuery |
Phrase | PhraseQuery,带 * 时 PhrasePrefixQuery |
Range | RangeQuery |
Set | TermSetQuery |
Regex | RegexQuery,要先 allow_regexes(),否则报「Regex queries are not allowed.」 |
Exists | ExistsQuery::new(field, true) |
All | AllQuery |
各查询怎么执行见短语与其他查询。
默认 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 子句拉平了。
问: 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)。
显式写出的运算符不受默认值影响。
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.0 | 0.26.1 |
|---|---|---|
| 8 | 0.000002 s | 0.000211 s |
| 12 | 0.000002 s | 0.002608 s |
| 16 | 0.000002 s | 0.038811 s |
| 18 | 0.000002 s | 0.156110 s |
| 20 | 0.000002 s | 0.627831 s |
| 22 | 0.000002 s | 2.513106 s |
| 200 | 0.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 下。
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)。