Rust in Everything
基于 tantivy 0.27.0

短语与其他查询

主线位置:查询主线第三、四步的其他形状。old man 不加引号是四个 TermQuery 的 OR;加上引号、星号、波浪号、方括号,QueryParser 给出的是本页这些查询。它们和 TermQuery 一样走 Query → Weight → Scorer,差别在 scorer 里读什么:短语读 .pos,模糊和正则在词典上跑自动机,范围查询可能完全不碰倒排表、只扫 .fast。

flowchart TB
  q["Query"] --> kind{"scorer 读什么"}
  kind -->|倒排加位置| ph["PhraseScorer"]
  kind -->|词典上跑自动机| aw["AutomatonWeight"]
  kind -->|快速字段| ff["RangeDocSet"]
  kind -->|只看列索引| ex["Exists"]
  ph --> bm["BM25 打分"]
  aw --> bs["BitSet 常数分"]
  ff --> cs["ConstScorer 常数分"]
  ex --> cs

src/query/ 在 0.27.0 有 22174 行,本页的几种查询分布在 phrase_query/(2393 行,含正则短语)、phrase_prefix_query/(694 行)、range_query/(2930 行)和 fuzzy_query.rs、regex_query.rs、exist_query.rs、set_query.rs 里。Query、Weight、Scorer 三层的分工和 DocSet 的 seek、seek_danger 见 Query、Weight、Scorer;.pos 的格式和短语查询最基本的「平移后求交」见位置;自动机怎么在 FST 上走见词典。本页接着往下讲。

实验在 exp-tantivy/srchb-lab:wiki.json 1000 篇,body 是 TEXT | STORED,另加两个 INDEXED | FAST 的 u64 字段 id、blen(正文字节数),一个线程写成一个段。平台 M2 Max、macOS 27、rustc 1.99.0,release。

两类查询:打分的和不打分的

本页的查询按打分方式分成两类:

查询Weight读什么分数
PhraseQueryPhraseWeight.idx + .posBM25,短语出现次数当词频
PhrasePrefixQueryPhrasePrefixWeight词典范围 + .idx + .pos两个词时恒为 1.0;三个词以上 BM25
FuzzyTermQuery、RegexQueryAutomatonWeight词典 + .idx常数(boost,默认 1.0)
TermSetQueryBooleanWeight<DoNothingCombiner> 套 AutomatonWeight词典 + .idx常数 1.0
RangeQueryFastFieldRangeWeight 或 InvertedIndexRangeWeight.fast 或 词典 + .idx常数
ExistsQueryFastFieldExistsWeight.fast 的列索引常数

在 wiki 上实测,graet~1、正则 gr[a-z]*t、{great, grant} 三个查询各自的前三名都是 1.0000。常数分意味着和别的子句放进同一个 BooleanQuery 时,它们只负责「过滤」,排序由其他子句决定。

短语:slop 是一笔预算

PhraseQuery 只有三个字段:字段、带偏移的词、slop。set_slop 的文档把 slop 说成「a budget between all terms」:"A B C"~1 允许 A X B C 或 A B X C,不允许 A X B X C;换位要花 2,因为两个词各挪了一格。

PhraseScorer 外层是文档交集,内层逐篇比位置。没有 slop 时是有序数组精确求交(位置讲过);有 slop 时换成「距离不超过 slop 就算对上」。两个词用 intersection_count_with_slop:

        let distance = left_val.abs_diff(right_val);
        if distance <= slop {
            while left_index + 1 < left_len {
                // there could be a better match
                let next_left_val = left_positions[left_index + 1];
                if next_left_val > right_val {
                    // the next value is outside the range, so current one is the best.
                    break;
                }
                // the next value is better.
                left_index += 1;
            }
            // ...

abs_diff 不分前后:平移之后左边的位置比右边大也算,所以 slop 够大时词序可以颠倒。三个词以上换成 intersection_count_with_carrying_slop,每个候选位置旁边多记一个 u8:到这一步已经花掉的 slop。下一个词的距离加上已花掉的,不超过 max_slop 才保留。这就是「预算」的实现。

在 6 篇只有几个字母的文档上,"a b c" 带不同的 slop(TopDocs 取回):

"a b c"~0: docs [3]
"a b c"~1: docs [0, 1, 3]
"a b c"~2: docs [0, 1, 2, 3]
"a b c"~4: docs [0, 1, 2, 3, 4, 5]

六篇依次是 a x b c、a b x c、a x b x c、a b c、c b a、b a c。slop 1 放进了只多一个词的 0 和 1;a x b x c 要花 2;两篇乱序的要到 4 才进来。

❗IMPORTANT
动脑筋:wiki 的 body 里,"old man" 只命中一篇(doc 225)。把词序倒过来写成 "man old",slop 取 0、1、2、3,各命中几篇?

答案在动脑筋答案。

打分走 BM25,但「词」换成了短语。Bm25Weight::for_terms 在多于一个词时把每个词的 idf 相加当作短语的 idf,平均字段长度照常;scorer 里拿 compute_phrase_count 数出的短语出现次数当词频。不需要分数时(比如 Count),phrase_match 只判断有没有,找到一处就停。公式本身见 BM25。

读位置很贵,所以 PhraseScorer 对外报的代价是文档交集的十倍:size_hint 除以 10 × 词数,cost 乘以 10 × 词数。布尔查询按这两个数决定谁来领跑交集,短语和普通词项做 AND 时会让词项先走、短语只在候选文档上验证(见布尔查询)。

短语前缀:只展开字典序的前 50 个词

PhrasePrefixQuery 把最后一个词当前缀。PhrasePrefixWeight::phrase_scorer 在当前段的词典上取 [前缀, 前缀的后继) 这个范围,按顺序读出词,读满 max_expansions 个就停:

        let mut suffixes = Vec::with_capacity(self.max_expansions as usize);
        let mut new_term = self.prefix.1.clone();
        while stream.advance() && (suffixes.len() as u32) < self.max_expansions {
            new_term.clear_with_type(new_term.typ());
            new_term.append_bytes(stream.key());
            // ...

默认值是 DEFAULT_MAX_EXPANSIONS = 50。词典按字节序排列,所以留下的是字典序最小的 50 个,跟词频、跟这个词是否和前面的词挨着都无关;而且每个段各自取 50 个,不同段留下的词可能不同。body 里以 ma 开头的词有 414 个,前三个是 ma、maa、maasi,第 50 个是 mag,man 排在第 150:

"old ma"* max_expansions=50: 0 docs, top []
"old ma"* max_expansions=200: 2 docs, top [(1.0, DocAddress { segment_ord: 0, doc_id: 137 }), (1.0, DocAddress { segment_ord: 0, doc_id: 225 })]
"old ma"* max_expansions=1000: 4 docs, top [(1.0, DocAddress { segment_ord: 0, doc_id: 137 }), (1.0, DocAddress { segment_ord: 0, doc_id: 225 }), (1.0, DocAddress { segment_ord: 0, doc_id: 319 })]
"the old ma"* max_expansions=1000: 1 docs, top [(3.9370472, DocAddress { segment_ord: 0, doc_id: 225 })]

两件事从输出里能看出来:

  • 默认的 50 个里没有 man,"old ma"* 一篇也找不到,尽管 "old man" 有一篇。"of s"* 同理:默认 6 篇,放到 1000 是 117 篇(s 开头的词有 1977 个)。
  • 两个词的短语前缀分数都是 1.0。scorer 里有两种形状(PhraseKind):前面只有一个完整的词时是 SinglePrefix,直接拿那个词的倒排表,score 有位置就是 1.0;两个完整的词以上才是包了一个 PhraseScorer 的 MultiPrefix,按 BM25 打分,而且 idf 只算前面那几个完整的词,前缀展开出来的词不参与。

只有一个词(全部是前缀)时,weight 直接退化成词典范围上的 InvertedIndexRangeWeight,同样带着 max_expansions 当上限。

📝NOTE
没有蠢问题

问: 搜索框里做「边输入边搜」,用户打到 "old ma"*,old man 明明在索引里,为什么没有结果?

答: 上限是按字典序截的,不是按「最可能」截的。ma 后面先排着 maa、mac…、mad…,50 个名额在 mag 就用完了。前缀越短、词典越大,截掉的越多。要么调大 set_max_expansions(代价是每个段多读这么多条倒排表),要么前缀至少留到两三个字符再发查询。QueryParser 生成的 PhrasePrefixQuery 用的是默认值,解析器上没有设置它的入口。

模糊与正则:自动机加位图

FuzzyTermQuery 和 RegexQuery 的 weight 都返回 AutomatonWeight,自动机在 FST 上怎么剪枝见词典。这里看 AutomatonWeight::scorer 之后的事:每个匹配的词,把整条倒排表按 IndexRecordOption::Basic 读出、塞进一个 max_doc 位的 BitSet,最后返回 ConstScorer。

几个和 TermQuery 不同的地方:

  • 没有展开上限。 匹配多少词就读多少条倒排表。the 距离 1 匹配 10 个词,距离 2 匹配 222 个词、917 篇(全部 1000 篇):
fuzzy graet~1: 2 terms, 38 docs, top ["1.0000@54", "1.0000@68", "1.0000@103"]
fuzzy the~1: 10 terms, 863 docs, top ["1.0000@1", "1.0000@2", "1.0000@3"]
fuzzy the~2: 222 terms, 917 docs, top ["1.0000@0", "1.0000@1", "1.0000@2"]
fuzzy graet~3: Err("An invalid argument was passed: 'Levenshtein distance of 3 is not allowed. Choose a value less than 3'")
  • 距离最多 2。 automaton 里的构建器缓存是一个 [[OnceCell; 2]; 3],下标就是距离和「换位算不算 1」,越界即报上面的错。这个方法在 0.27.0 才公开(CHANGELOG 记为 #3136),可以拿它自己去词典上展开,比如先看会匹配多少词。
  • 不报告查询词。 FuzzyTermQuery、RegexQuery 都没有实现 Query::query_terms,用的是 默认的空实现;高亮靠这个方法收集要标出来的词,所以模糊查询命中的文档生成不出片段,见高亮。

RegexPhraseQuery 把短语里的每个位置都换成正则,每个正则展开的上限是 1 << 14,其余同短语。

范围:快速字段还是倒排

RangeQuery::weight 只看 schema:字段是快速字段、类型在 is_type_valid_for_fastfield_range_query 的白名单里(除了 Facet 和 Custom 都在),就用 FastFieldRangeWeight;否则用倒排。JSON 字段没有第二条路,不是快速字段就报「RangeQuery on JSON is only supported for fast fields currently」。两种实现完全不同:

  • InvertedIndexRangeWeight:数值的 Term 是保序的 8 字节(见 Term),范围就是词典上的一段。scorer 把这段里每个词的倒排表读进 BitSet,和模糊查询一样是先全部算出来。
  • FastFieldRangeWeight::scorer:把边界换成列里的 u64 表示(字符串和字节列换成字典序号),先和列的最小、最大值比较:范围为空返回 EmptyScorer,整列都在范围里且每篇都有值就返回 AllScorer;否则交给 RangeDocSet。它是惰性的,fetch_block 每次扫一段文档号,窗口从 128 开始翻倍到 100000,遇到跨度大的 seek 再缩回 128;seek_danger 只查目标那一篇的值,不往后扫。

RangeQuery 默认走哪条由 schema 决定;另有 FastFieldRangeQuery、InvertedIndexRangeQuery 可以强制指定。100 万篇、一个段、v 是 INDEXED | FAST 的 u64,随机取 0 到 999999;tag:rare 每 1000 篇出现一次。三种范围各跑 7 次取最小(release,负载 6.0 左右):

[0,1000) RangeQuery        1036 docs 473.916µs | AND tag:rare    0 docs  11.458µs
[0,1000) FastField         1036 docs 472.125µs | AND tag:rare    0 docs  10.791µs
[0,1000) InvertedIndex     1036 docs 113.542µs | AND tag:rare    0 docs 114.500µs
[0,100000) RangeQuery      100035 docs 503.084µs | AND tag:rare   99 docs   9.917µs
[0,100000) FastField       100035 docs 502.708µs | AND tag:rare   99 docs   9.750µs
[0,100000) InvertedIndex   100035 docs   9.467ms | AND tag:rare   99 docs   8.138ms
[0,900000) RangeQuery      900016 docs 778.208µs | AND tag:rare  885 docs  10.833µs
[0,900000) FastField       900016 docs 777.709µs | AND tag:rare  885 docs  11.000µs
[0,900000) InvertedIndex   900016 docs  81.859ms | AND tag:rare  885 docs  72.141ms
  • 倒排的耗时跟范围里的词数成正比:窄范围(约 1000 个不同的值)最快,宽范围要读几十万条倒排表。
  • 快速字段的耗时跟文档数成正比,几乎不随范围变:扫一遍 100 万个值不到 1 ms。
  • 和一个稀有词做 AND 时差距最大:快速字段靠 seek_danger 只检查约 1000 个候选,10 µs 左右;倒排必须先把整个位图建好,和单独查差不多慢。0.27.0 的 CHANGELOG 把这一项记为「Up to 50x faster intersections with range queries via seek_danger on RangeDocSet」(#2963)。

InvertedIndexRangeWeight::new 的文档说 limit「is only enabled with the quickwit feature flag」,而 scorer 里数词的那个循环不分 feature,limit 总是生效;quickwit 只是额外让 sstable 词典少下载几块。RangeQuery 的文档也还写着只有 IP 字段走快速字段,现在所有类型都走。

📝NOTE
冷知识:>= 1.5 曾经会命中 1

JSON 字段里的整数存成 i64/u64 列,用户给的边界却可以是小数。transform_from_f64_bounds 把小数边界换成整数,0.26.x 用的是 trunc():下界 1.5 截成 1 再按「含」处理,1 就进来了;上界 -1.5 截成 -1,同理多一个。#3074(b5d8deb80,「Fix fractional JSON range bounds on integer columns」,首次发布于 0.27.0)把下界改成 ceil()、上界改成 floor(),并加了注释「No integer equals a fractional bound: both > and >= start at its ceiling.」CHANGELOG 写的是「>= 1.5 no longer matches 1」。同一段程序在 -4 到 4 九个整数上跑,0.27.0:

RangeQuery [1.5, 3.5] on values -4..=4: 2 docs
RangeQuery [-3.5, -1.5] on values -4..=4: 2 docs
json.value:>1.5            -> 3 docs
json.value:<-1.5           -> 3 docs
0.26.1:
RangeQuery [1.5, 3.5] on values -4..=4: 3 docs
RangeQuery [-3.5, -1.5] on values -4..=4: 3 docs
json.value:>1.5            -> 4 docs
json.value:<-1.5           -> 4 docs

错只出在「正数的下界、负数的上界」:trunc 朝 0 截,恰好在这两种情况下朝范围里面截。[-1.5, 1.5] 在两个版本都是 3 篇。

exists 与 TermSetQuery

ExistsQuery 只能用在快速字段上,weight 先查 schema,body 不是快速字段就报 Schema error: 'Field body is not a fast field.'。scorer 只打开列的索引部分、丢掉值:任何一列是 Full(每篇都有值)就直接 AllScorer;一到三列时在 optional/multivalued 索引上迭代;四列以上(JSON 字段的子路径多)先合成一个位图。列索引的几种形状见快速字段。QueryParser 里的 field:* 生成的是 ExistsQuery::new(field, true),对 JSON 字段会连子路径一起算。

TermSetQuery 是「这个字段等于集合里任何一个值」。specialized_weight 按字段把词排序去重,每个字段把这组词建成一个小 FST,当自动机交给 AutomatonWeight,在词典上和段的 FST 同步走;几个字段的结果再用 DoNothingCombiner 的 BooleanWeight 并起来,所以分数恒为 1.0。和写成一串 OR 的 TermQuery 相比,它不为每个词建 scorer、不算 BM25,集合大时省得多。QueryParser 的语法是 body:IN [great grant],wiki 上 38 篇,和 graet~1 正好是同一批。

其余几个

查询一句话
AllQuery、EmptyQuery全部文档、没有文档;* 解析成前者
BoostQuery子查询的分数乘一个系数;^2 和 set_field_boost 都生成它
ConstScoreQuery子查询的匹配集合加一个固定分数,0.27.0 起关掉子查询的打分(#3059)
DisjunctionMaxQuery子查询的最高分,加上其余分数乘 tie_breaker;0.27.0 修了它在 TopDocs 下被当成求和来剪枝的问题(#3022)
MoreLikeThisQuery从一篇文档(或一组字段值)里按 tf × idf 挑词,组成带 boost 的布尔查询
DocPredicateQuery0.27.0 新增,逐篇调用一个谓词;jitexpr feature 下谓词可以是 JIT 编译的表达式

动脑筋答案

"man old" 在 slop 0 和 1 时 0 篇,2 和 3 时 1 篇(还是 doc 225),分数和 "old man" 一样:

"old man"~0: 1 docs, top ["7.9689@225"]
"old man"~1: 1 docs, top ["7.9689@225"]
"old man"~2: 1 docs, top ["7.9689@225"]
"old man"~3: 1 docs, top ["7.9689@225"]
"man old"~0: 0 docs, top []
"man old"~1: 0 docs, top []
"man old"~2: 1 docs, top ["7.9689@225"]
"man old"~3: 1 docs, top ["7.9689@225"]
+old +man: 6 docs

"man old" 里 man 偏移 0、old 偏移 1,man 的位置要加 1 再和 old 比。doc 225 里 old man 连着出现时,old 在 p、man 在 p+1,平移后 man 是 p+2,和 old 的距离是 2,所以 slop 至少要 2,正是文档说的「换位花 2」。分数相同,因为两个词的 idf 之和、短语出现次数(5 次)、字段长度都没变。+old +man 有 6 篇:两个词都在、但从不相邻的另外 5 篇,slop 放到 3 也够不着。

小结

  • 打分的只有短语(BM25,idf 求和、短语次数当词频)和三个词以上的短语前缀;模糊、正则、词集合、范围、exists 都是常数分,放进布尔查询只起过滤作用。
  • slop 是全短语共用的预算:两个词按 abs_diff 比,三个词以上每个位置带一个「已花掉的 slop」;换位花 2。短语的 cost 报成交集的十倍,好让布尔查询先用便宜的子句缩小候选。
  • 短语前缀每段只展开字典序最小的 50 个词,"old ma"* 在 wiki 上找不到 old man;两个词时分数恒为 1.0。
  • 模糊和正则:自动机在 FST 上选词,所有匹配词的倒排表读进位图;没有展开上限,距离最多 2,query_terms 为空,高亮不了。
  • 范围查询按 schema 二选一:快速字段扫列(耗时随文档数,和稀有词做 AND 只要约 10 µs),倒排读词典的一段(耗时随词数)。0.27.0 修了 JSON 整数列上小数边界多算一个的问题(#3074)。
  • ExistsQuery 只看快速字段的列索引;TermSetQuery 把集合建成小 FST 交给 AutomatonWeight,分数恒为 1.0。

💬 关联讨论

发起讨论
加载中...