Rust in Everything
基于 tantivy 0.27.0

日期 term 查不到:倒排里只有秒

主线位置:写入主线里日期值变成倒排索引的词,查询主线里 TermQuery 和范围查询拿词去词典里找。日期怎么编码成 8 字节、截断发生在哪一行见 Term,范围查询走倒排还是走快速字段见短语与其他查询。

flowchart TB
  v["00.123 和 00.456"] --> idx["倒排 截到秒"]
  idx --> key["键 00.000"]
  q1["from_field_date 00.123"] --> m1["0 篇"]
  q2["for_search 截到秒"] --> m2["2 篇"]
  q3["范围下界 00.100"] --> m3["0 篇"]
  key --> m1
  key --> m2
  key --> m3

实验 crate:exp-tantivy/pita-lab,src/bin/p07_date.rs。两个日期字段:when 是 INDEXED | STORED;when_ms 是 INDEXED | STORED | FAST,并 set_precision(DateTimePrecision::Milliseconds)。两篇文档,日期分别是 2025-10-17T00:00:00.123Z 和 .456Z,同一秒。M2 Max、macOS 27、rustc 1.99.0,release。

现象

TermQuery from_field_date(when, .123)            -> Ok(0)
TermQuery from_field_date_for_search(when, .123) -> Ok(2)
QueryParser when:"2025-10-17T00:00:00.123Z"                                -> Ok(2)
QueryParser when:[2025-10-17T00:00:00.200Z TO 2025-10-17T00:00:00.500Z]    -> Ok(0)
QueryParser when:[2025-10-17T00:00:00.100Z TO 2025-10-17T00:00:01Z]        -> Ok(0)
QueryParser when:[2025-10-17T00:00:00Z TO 2025-10-17T00:00:01Z]            -> Ok(2)
...
  • 用写入时的同一个 DateTime 拼 TermQuery,0 篇。
  • 换成 from_field_date_for_search,或者交给 QueryParser,命中 2 篇:.123 和 .456 都算「等于 .123」。
  • 范围 [.100 TO 01] 明明包含两篇,0 篇。范围 [.200 TO .500] 包含 .456 那篇,也是 0 篇。
  • 全程没有错误。

原因

写入时截断。 日期进倒排索引前先 truncate(DATE_TIME_PRECISION_INDEXED),常量 DATE_TIME_PRECISION_INDEXED 写死为 DateTimePrecision::Seconds。DateOptions::set_precision 只管快速字段,注释写着「Indexed precision is always [DATE_TIME_PRECISION_INDEXED]」。所以 when 的词典里只有一个键:00:00:00.000。

TermQuery 精确匹配字节。 Term::from_field_date 不截断,键是 .123,词典里没有;Term::from_field_date_for_search 先截到秒再编码,命中这一秒里的所有文档。QueryParser 解析单个日期时用的是后者。

范围的边界不截断。 QueryParser 解析范围边界走的是另一个函数 compute_boundary_term,它对日期调的是 from_field_date:

            FieldType::Date(_) => {
                let dt = OffsetDateTime::parse(phrase, &Rfc3339)?;
                Ok(Term::from_field_date(field, DateTime::from_utc(dt)))
            }

when 不是快速字段,RangeQuery::weight 选择走倒排索引,在词典上取 [.100, 01.000] 之间的键。唯一的键 00.000 小于下界 .100,不在范围里,这一秒的文档就全漏了。同一个解析器,等值查询截断、范围查询不截断,结果互相矛盾,我们把它算作上游的不一致。0.27.0 之后 main 上 src/query/ 只有一个导出 PhraseScorer 的提交,没有动这里。

📝NOTE
冷知识:from_field_date 在 0.24 之前是截断的

PR #2456(3d1c4b313,「support ff range queries on json fields」,首次发布在 0.24)之前,from_field_date 的函数体就是今天 from_field_date_for_search 的那一行:Term::from_fast_value(field, &val.truncate(DATE_TIME_PRECISION_INDEXED));append_type_and_fast_value 遇到日期也先截断。PR 的主题是让 JSON 字段上的范围查询走快速字段,提交说明里夹着一条「fix term date truncation」。diff 里:from_field_date 改成不截断,截断的版本另起名字叫 from_field_date_for_search,QueryParser 解析单个日期的那一处改用新函数,范围边界那一处留在 from_field_date。所以 0.23 及以前写的「from_field_date 拼 TermQuery」代码,升到 0.24 以后对带毫秒的值静默变成 0 篇。

修法

等值查询: 自己拼 term 时用 Term::from_field_date_for_search,或者把查询交给 QueryParser。它命中的是「同一秒内的所有文档」,不是精确到毫秒的那一篇。

需要毫秒级的范围或等值: 字段加 FAST 并设精度。RangeQuery 发现字段是快速字段就改走 FastFieldRangeWeight,比较的是快速字段里按 precision 存的值:

let ms = DateOptions::from(INDEXED | STORED | FAST).set_precision(DateTimePrecision::Milliseconds);
let when_ms = sb.add_date_field("when_ms", ms);
QueryParser when_ms:[2025-10-17T00:00:00.200Z TO 2025-10-17T00:00:00.500Z] -> Ok(1)
QueryParser when_ms:[2025-10-17T00:00:00.100Z TO 2025-10-17T00:00:01Z]     -> Ok(2)
QueryParser when_ms:"2025-10-17T00:00:00.123Z"                             -> Ok(2)
QueryParser when_ms:[2025-10-17T00:00:00.123Z TO 2025-10-17T00:00:00.123Z] -> Ok(1)

范围查询对了:[.200 TO .500] 只命中 .456 那一篇。第三行说明等值查询仍然走倒排、仍是「整秒」;要毫秒级的等值,写成闭区间 [.123 TO .123],第四行只命中一篇。

只有倒排、不想加快速字段时: 自己把范围下界截到秒再交给解析器,代价是这一秒里下界之前的文档也会进来:

QueryParser when:[2025-10-17T00:00:00Z TO 2025-10-17T00:00:00.500Z]        -> Ok(2)

怎么检查

  • 代码里搜 from_field_date(:用在查询里的都应该换成 _for_search,或者确认字段是快速字段、走的是范围。
  • 时间戳带毫秒的字段,看 schema 里它是不是 FAST、precision 是什么。只有 INDEXED 的日期字段,任何带亚秒的边界都可能漏文档。
  • 写一个测试:同一秒内写两篇不同毫秒的文档,分别做等值和范围查询,断言命中数。

小结

  • 日期进倒排索引一律截到秒(DATE_TIME_PRECISION_INDEXED),set_precision 只管快速字段。
  • from_field_date 不截断,拼 TermQuery 查带毫秒的值是 0 篇;from_field_date_for_search 和 QueryParser 的等值查询会截断,命中整秒。
  • QueryParser 的范围边界不截断,下界带毫秒时同一秒的文档全部漏掉。毫秒级范围要用 FAST 加 precision。
  • 0.24(#2456)之前 from_field_date 自己会截断,老代码升级后会静默失效。

💬 关联讨论

发起讨论
加载中...