Rust in Everything
基于 tantivy 0.27.0

delete_term 什么都没删,或者删多了

主线位置:写入主线的提交阶段,删除队列在段定稿和提交时落到各段,写出新的存活位图。删除队列、opstamp 的规则见提交,tantivy 为什么没有主键见设计取舍。

flowchart TB
  dt["delete_term"] --> tq["包成 TermQuery"]
  tq --> fld{"字段怎么分词"}
  fld -->|TEXT| tok["存的是小写词元"]
  fld -->|STRING| raw["存的是原值"]
  tok --> a["原文 删 0 篇"]
  tok --> b["一个词 删 N 篇"]
  raw --> c["按 id 删 1 篇"]

实验 crate:exp-tantivy/pita-lab,src/bin/p06_delete.rs。schema:uid 是 STRING | STORED,title 是 TEXT | STORED。每种删法用一个新索引,先写三篇(a「The Old Man and the Sea, v3」、b「Frankenstein, v3」、c「Of Mice and Men, v2」)并提交,再 delete_term 一次、提交。M2 Max、macOS 27、rustc 1.99.0,release。

现象

delete_term(title = "The Old Man and the Sea, v3") -> opstamp  6; doc_freq before 0; num_docs 3 -> 3
delete_term(title = "v3"                        ) -> opstamp  6; doc_freq before 2; num_docs 3 -> 1
delete_term(title = "the"                       ) -> opstamp  6; doc_freq before 1; num_docs 3 -> 2
delete_term(uid = 7 (u64 term on a str field)   ) -> opstamp  6; doc_freq before 0; num_docs 3 -> 3
delete_term(uid = "a"                           ) -> opstamp  6; doc_freq before 1; num_docs 3 -> 2
  • 用 title 的完整原文删:什么都没删。
  • 用 v3 删:想删一篇,删掉了两篇。
  • 给字符串字段一个 u64 的 term:什么都没删。
  • 五次调用的返回值都是一个普通的 opstamp,没有一次报错,也不告诉你删了几篇。
  • 只有按 STRING 字段 uid 删,结果和预期一致。

原因

delete_term 的全部实现:

    pub fn delete_term(&self, term: Term) -> Opstamp {
        let query = TermQuery::new(term, IndexRecordOption::Basic);
        // For backward compatibility, if Term is invalid for the index, do nothing but return an
        // Opstamp
        self.delete_query(Box::new(query))
            .unwrap_or_else(|_| self.stamper.stamp())
    }

它删的是「倒排表里含这个词的所有文档」,和查询里的 TermQuery 是同一回事,只是把搜到的文档标成删除。

  • TEXT 字段写进倒排表的是分词、小写之后的词:the、old、man、and、sea、v3。整句原文 "The Old Man and the Sea, v3" 从来不是一个词,词典里没有它,命中 0 篇。Term::from_field_text 不会帮你分词。
  • v3 是一个词,两篇都含它,两篇都删。the 只出现在第一篇,删一篇,但那是巧合。
  • 给字符串字段一个 u64 的 term,TermQuery 照样建得起来,只是词典里没有这个字节串,0 篇。

真正出错的情况也看不到。src/bin/p06_swallow.rs 对一个只有 STORED、没有索引的字段 note 分别调 delete_query 和 delete_term:

delete_query(u64 term on str field): Ok(0)
delete_query(term on a STORED-only field): Err(SchemaError("Field \"note\" is not indexed."))
delete_term(term on a STORED-only field): 1

delete_query 返回了错误,delete_term 把同一个错误吞掉,换一个新 opstamp 返回。

返回值 opstamp 只表示「这个删除排在第几号」,和删了多少篇无关;真正去找文档要等段定稿、提交时才做(提交)。

📝NOTE
冷知识:delete_term 不返回 Result,是 0.19 留下的兼容

0.19 之前,delete_term 直接把 term 放进删除队列:DeleteOperation { opstamp, term },这一步不可能失败,签名就是 -> Opstamp。PR #1535(fa3d786a2,「Add support for deleting all documents matching query」,首次发布在 0.19)把删除队列里的 term 换成一个 Weight,新增 delete_query;建 Weight 可能出错,delete_query 返回 crate::Result<Opstamp>。为了不改 delete_term 的签名,它改成调用 delete_query,出错时「do nothing but return an Opstamp」,就是上面那段注释。delete_query 至今带着 #[doc(hidden)]。

修法

按一个不分词的 id 字段删。 主键字段用 STRING(raw 分词器,整个值一个词)或数值字段,写入和删除都用它:

let uid = sb.add_text_field("uid", STRING | STORED);
// 更新 = 删旧的 + 加新的,同一次提交
writer.delete_term(Term::from_field_text(uid, "a"));
writer.add_document(doc!(uid => "a", title => "The Old Man and the Sea, v4"))?;
writer.commit()?;

上面最后一行就是这种删法:doc_freq 是 1,删掉 1 篇。同一次提交里先删后加,新文档的 opstamp 更大,不受这次删除影响(设计取舍里的 nopk 实验)。

如果 schema 里只有 TEXT 字段可用,没有可靠的按值删除办法:短语查询、布尔查询都只能表达「含有这些词」,不能表达「整个字段等于这个值」。加一个 STRING 的 id 字段,重建索引。

怎么检查

  • 看字段: 用来删除的字段,get_indexing_options().tokenizer() 应该是 raw(STRING 就是),或者它是数值字段。
  • 删之前数一下: delete_term 不告诉你删了几篇,自己在提交过的 searcher 上先问一次:
let doc_freq = searcher.doc_freq(&term)?;

上面每一行的 doc_freq before 就是这样来的。按 id 删时它应该恰好是 1(或者 0,表示这个 id 本来就不存在);大于 1 说明 id 不唯一或者字段被分词了。注意 doc_freq 只看已提交的段,还计入已删除但没被合并掉的文档(fieldnorm 与删除位图),当作检查够用,不当作精确计数。

小结

  • delete_term 是「删掉含这个词的所有文档」。TEXT 字段里存的是分词后的小写词元:用原文删 0 篇,用其中一个词删掉所有含它的文档。
  • 返回的 opstamp 不表示删了几篇;字段没有索引这类错误被吞掉,这是 0.19 为兼容保留的行为。
  • 按 STRING 或数值的 id 字段删;删之前用 searcher.doc_freq(&term) 核对数量。

💬 关联讨论

发起讨论
加载中...