默认 OR 结果太多,AND 又 0 篇
主线位置:查询主线第二步。
QueryParser给用户没写连接方式的子句补一个Occur:默认Should,调过set_conjunction_by_default是Must。一次查询里old man的四个Should就是这么来的;整条展开过程见查询解析器。
flowchart TB
q["title:old man"] --> g["语法层"]
g --> a["title:old"]
g --> b["man 没有字段"]
b --> df["展开到默认字段"]
df --> m1["title:man"]
df --> m2["body:man"]
a --> occ{"默认连接方式"}
m1 --> occ
m2 --> occ
occ -->|OR| many["26 篇"]
occ -->|AND| zero["0 篇"]
实验在 exp-tantivy/pitb-lab,bin p16_conj(release):wiki.json 1000 篇,title、body 都是 TEXT,一个段,两个字段都是默认字段。M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0。
现象
同一组输入,左边是默认的 OR,右边调了 set_conjunction_by_default:
[or ] old man sea 69 docs top1=Some("11.46 Ride the Man Down")
[and] old man sea 0 docs top1=None
[or ] the old man and the sea 891 docs top1=Some("14.91 Ride the Man Down")
[and] the old man and the sea 0 docs top1=None
[or ] title:old man 26 docs top1=Some("11.46 Ride the Man Down")
[and] title:old man 0 docs top1=None
[or ] title:(old man) 5 docs top1=Some("5.37 Old Islam in Detroit")
[and] title:(old man) 0 docs top1=None
[or ] +old +man +sea 0 docs top1=None
[and] +old +man +sea 0 docs top1=None
- 用户把一句话原样输进来,OR 命中 891 篇,占全部的 89%。排序靠 BM25 还说得过去,但「共找到 891 条」和翻页都没有意义。
- 换成 AND,同一句话一篇也没有:
the、and也必须出现,而且六个词要同时出现在某篇文档里。 title:old man在 AND 模式下是 0 篇,OR 模式下的第一名标题里根本没有old。
原因
三件事叠在一起:
- 默认是
Should。QueryParser的conjunction_by_default初始为false,没写运算符的子句一律补Should,minimum_number_should_match是 1:任何一个词出现就算命中。 - AND 是全有或全无。
set_conjunction_by_default的文档写得很清楚:happy tax payer变成happy AND tax AND payer。用户的输入越长、停用词越多,越容易是 0。 - 字段名只管紧跟的一个词。
title:old man解析成:
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 }
man 回到了两个默认字段上(field=1 是 title,field=2 是 body)。OR 模式下这等于「标题有 old,或者任何地方有 man」,所以排第一的 Ride the Man Down 标题里没有 old。QueryParser 的文档注释说这种写法会被解释成 OR,和 AND 模式下的实际行为不一致,查询解析器有详细对照。
修法
字段限定写全。 要两个词都在标题里,写 title:(old man),语法层会把组里每个没写字段的叶子都设成 title。上表 OR 模式下是 5 篇、第一名标题含 Old。
在 OR 和 AND 之间取一个「至少 k 个词」。 解析器没有这个设置,但 BooleanQuery 有。把输入按空白切开,每个词单独交给解析器(这样每个词仍会展开到所有默认字段),再用 union_with_minimum_required_clauses 组起来:
let mut words: Vec<&str> = text.split_whitespace().collect();
words.sort();
words.dedup();
let subs = words.iter().map(|w| qp.parse_query(w)).collect::<Result<Vec<_>, _>>()?;
let q = BooleanQuery::union_with_minimum_required_clauses(subs, k);
the old man and the sea 去重后 5 个词,k 从 1 到 5:
min 1 of 5 words ["and", "man", "old", "sea", "the"]: 891 docs
min 2 of 5 words ["and", "man", "old", "sea", "the"]: 652 docs
min 3 of 5 words ["and", "man", "old", "sea", "the"]: 64 docs
min 4 of 5 words ["and", "man", "old", "sea", "the"]: 9 docs
min 5 of 5 words ["and", "man", "old", "sea", "the"]: 0 docs
k 是一个旋钮:k=1 就是 OR,k 等于词数就是 AND,中间的值比 OR 收得紧,又不会因为一个词不中就一篇不剩。代价是自己切词:引号、括号、+/- 这些语法要么先交给解析器整体处理,要么只在输入里没有运算符时才走这条路。
用户自己写的 + 和 - 一直有效。 显式运算符不受默认连接方式影响,+old +man +sea 在两种模式下都是同一个查询。
怎么检查
- 把
parse_query的结果用{:?}打出来。一长串同一层的Should、minimum_number_should_match: 1,就是 OR;看到Must套着Should,说明有字段名只管了一个词。 - 统计线上查询的命中数:OR 模式下命中数接近文档总数的查询,多半是整句输入。
- AND 模式下记录 0 结果的查询,看是不是被
the、and这类词或字段前缀拖成了 0。
小结
QueryParser默认 OR,一句话能命中绝大多数文档;set_conjunction_by_default是全有或全无,长输入容易 0 篇。title:old man里title:只管old,man回到默认字段;要都限定在标题里写title:(old man)。- 解析器没有「至少命中 k 个词」,自己按词解析后用
BooleanQuery::union_with_minimum_required_clauses组合。