Skip to content

Commit 327c969

Browse files
stumpylogclaude
andcommitted
Fix MoreLikeThis panic on indexes with deleted documents
create_score_term used the alive-only document count (num_docs) as the idf denominator, while the term doc_freq it reads from the term dictionary still counts soft-deleted documents until a merge expunges them. After deletions this could make doc_freq > num_docs, tripping the `doc_count >= doc_freq` assertion in idf() and panicking the search thread. Use max_doc (which includes deleted documents) instead, matching how the standard BM25 scorer computes total_num_docs. Add a regression test that deletes documents and runs a MoreLikeThis query. Discovered downstream in paperless-ngx: paperless-ngx/paperless-ngx#13024 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 70f0b03 commit 327c969

3 files changed

Lines changed: 63 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Tantivy 0.26 (Unreleased)
88
================================
99

1010
## Bugfixes
11+
- Fix `MoreLikeThis` panic on indexes with deleted documents [#2964](https://github.com/quickwit-oss/tantivy/pull/2964)(@stumpylog)
1112
- Align float query coercion during search with the columnar coercion rules [#2692](https://github.com/quickwit-oss/tantivy/pull/2692)(@fulmicoton)
1213
- Fix lenient elastic range queries with trailing closing parentheses [#2816](https://github.com/quickwit-oss/tantivy/pull/2816)(@evance-br)
1314
- Fix intersection `seek()` advancing below current doc id [#2812](https://github.com/quickwit-oss/tantivy/pull/2812)(@fulmicoton)

src/query/more_like_this/more_like_this.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,10 +302,13 @@ impl MoreLikeThis {
302302
per_field_term_frequencies: HashMap<Term, usize>,
303303
) -> Result<Vec<ScoreTerm>> {
304304
let mut score_terms: BinaryHeap<Reverse<ScoreTerm>> = BinaryHeap::new();
305+
// Use `max_doc` (includes soft-deleted docs) to match `doc_freq`, which
306+
// also counts deletes until a merge. Using the alive-only `num_docs`
307+
// can yield `doc_freq > num_docs` and trip the assertion in `idf`.
305308
let num_docs = searcher
306309
.segment_readers()
307310
.iter()
308-
.map(|segment_reader| segment_reader.num_docs() as u64)
311+
.map(|segment_reader| segment_reader.max_doc() as u64)
309312
.sum::<u64>();
310313

311314
for (term, term_frequency) in per_field_term_frequencies.iter() {

src/query/more_like_this/query.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,9 @@ impl MoreLikeThisQueryBuilder {
190190
mod tests {
191191
use super::{MoreLikeThisQuery, TargetDocument};
192192
use crate::collector::TopDocs;
193+
use crate::indexer::NoMergePolicy;
193194
use crate::schema::{Schema, STORED, TEXT};
194-
use crate::{DocAddress, Index, IndexWriter};
195+
use crate::{DocAddress, Index, IndexWriter, Term};
195196

196197
fn create_test_index() -> crate::Result<Index> {
197198
let mut schema_builder = Schema::builder();
@@ -291,4 +292,60 @@ mod tests {
291292
assert_eq!(doc_ids, vec![3, 4]);
292293
Ok(())
293294
}
295+
296+
#[test]
297+
fn test_more_like_this_query_with_deleted_documents() -> crate::Result<()> {
298+
// Regression test: a More Like This query must not panic when the
299+
// index contains soft-deleted documents.
300+
//
301+
// Tantivy deletes are soft: a term's `doc_freq` (read from the term
302+
// dictionary) keeps counting deleted documents until a merge expunges
303+
// them, but `SegmentReader::num_docs()` only counts alive documents.
304+
// `create_score_term` previously used the alive-only count as the idf
305+
// denominator while `doc_freq` still included deleted docs, so
306+
// `doc_freq > num_docs` could hold and trip the `doc_count >= doc_freq`
307+
// assertion inside `idf()`.
308+
let mut schema_builder = Schema::builder();
309+
let title = schema_builder.add_text_field("title", TEXT | STORED);
310+
let body = schema_builder.add_text_field("body", TEXT | STORED);
311+
let schema = schema_builder.build();
312+
let index = Index::create_in_ram(schema);
313+
let mut index_writer: IndexWriter = index.writer_for_tests()?;
314+
// Keep every document in a single, never-merged segment so the deleted
315+
// documents' postings (and thus their contribution to doc_freq) stick
316+
// around.
317+
index_writer.set_merge_policy(Box::new(NoMergePolicy));
318+
319+
// Six documents share the same body terms, so each body term has a
320+
// doc_freq of 6. Each has a unique title term so they can be deleted
321+
// individually.
322+
for i in 0..6 {
323+
index_writer
324+
.add_document(doc!(title => format!("doc{i}"), body => "shared body content"))?;
325+
}
326+
index_writer.commit()?;
327+
328+
// Delete two documents. Alive count drops to 4, but the body terms'
329+
// doc_freq stays 6 until a merge.
330+
index_writer.delete_term(Term::from_field_text(title, "doc0"));
331+
index_writer.delete_term(Term::from_field_text(title, "doc1"));
332+
index_writer.commit()?;
333+
334+
let reader = index.reader()?;
335+
let searcher = reader.searcher();
336+
assert_eq!(searcher.num_docs(), 4);
337+
338+
// Run More Like This against a surviving document. This used to panic.
339+
let query = MoreLikeThisQuery::builder()
340+
.with_min_doc_frequency(1)
341+
.with_min_term_frequency(1)
342+
.with_document(DocAddress::new(0, 2));
343+
let top_docs = searcher.search(&query, &TopDocs::with_limit(10).order_by_score())?;
344+
let mut doc_ids: Vec<_> = top_docs.iter().map(|item| item.1.doc_id).collect();
345+
doc_ids.sort_unstable();
346+
347+
// Only the four surviving documents should be returned.
348+
assert_eq!(doc_ids, vec![2, 3, 4, 5]);
349+
Ok(())
350+
}
294351
}

0 commit comments

Comments
 (0)