Rust in Everything
基于 tantivy 0.27.0

默认 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。

原因

三件事叠在一起:

  1. 默认是 Should。 QueryParser 的 conjunction_by_default 初始为 false,没写运算符的子句一律补 Should,minimum_number_should_match 是 1:任何一个词出现就算命中。
  2. AND 是全有或全无。 set_conjunction_by_default 的文档写得很清楚:happy tax payer 变成 happy AND tax AND payer。用户的输入越长、停用词越多,越容易是 0。
  3. 字段名只管紧跟的一个词。 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 组合。

💬 关联讨论

发起讨论
加载中...