Skip to content

fix: skip empty string docs in FTS stats#7699

Open
Ecthlion wants to merge 5 commits into
lance-format:mainfrom
Ecthlion:agent/skip-empty-fts-docs
Open

fix: skip empty string docs in FTS stats#7699
Ecthlion wants to merge 5 commits into
lance-format:mainfrom
Ecthlion:agent/skip-empty-fts-docs

Conversation

@Ecthlion

@Ecthlion Ecthlion commented Jul 9, 2026

Copy link
Copy Markdown

Summary

Fixes #7660.

This PR makes FTS indexing skip plain string documents that produce zero tokens, matching the behavior recently added for list-string documents. Empty strings and whitespace-only strings no longer get appended to the FTS DocSet, so they no longer inflate BM25 corpus statistics like num_docs and average document length.

Why

I followed the suggested fix from the issue and audited IndexWorker::process_document.

The root cause was that the empty-document behavior already existed, but only for list-string inputs. process_batch passed skip_empty_document = true for List<Utf8> / LargeList<Utf8>, while plain Utf8 / LargeUtf8 passed false. As a result, a plain string document such as "" or " " could be stored in the document metadata with num_tokens = 0.

Since such a document has no posting-list entries, keeping it only affects corpus-level statistics used by BM25 scoring. It does not add searchable content.

Implementation

I removed the special-case flag and made process_document return early whenever tokenization yields token_num == 0, regardless of whether the source is a plain string or a list-string document.

This keeps the change localized to the FTS builder and mirrors the issue's suggested direction: zero-token string documents now follow the same skip behavior as empty list-string documents.

Tests

Added regression coverage for:

  • mixed input containing empty string, whitespace-only string, null, and a real document
  • all-empty string input still building and loading as an empty index
  • worker-level behavior confirming all-empty input does not create a tail partition

Validation:

  • cargo fmt --all
  • cargo test -p lance-index empty_string_documents
  • git diff --check

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The inverted index and in-memory FTS paths now exclude documents that produce zero tokens from BM25 statistics, visibility counts, scoring, tail publication, freezing, and flushed indexes. Tests cover mixed and entirely empty corpora.

Changes

Zero-token FTS handling

Layer / File(s) Summary
Inverted-index zero-token ingestion and coverage
rust/lance-index/src/scalar/inverted/builder.rs
String and list-string inputs use a shared process_document path that returns early for zero-token documents; tests cover mixed and all-empty corpora.
BM25 corpus statistics and scoring
rust/lance-index/src/scalar/inverted/index.rs
Tokenization excludes null and zero-token rows from counted input, and BM25 statistics use only surviving documents.
Mem-WAL visibility, flush, and freeze paths
rust/lance/src/dataset/mem_wal/index/fts.rs
Visibility counts, tail publication, builder export, frozen partitions, and search consistency omit zero-token rows.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FtsMemIndex
  participant Tokenizer
  participant TailIndex
  participant Partition
  FtsMemIndex->>Tokenizer: tokenize batch
  Tokenizer-->>FtsMemIndex: return token counts
  FtsMemIndex->>TailIndex: append non-zero-token rows
  TailIndex->>Partition: freeze non-zero-token rows
  Partition-->>FtsMemIndex: provide searchable frozen index
Loading

Possibly related PRs

Suggested reviewers: bubblecal, xuanwo, jackye1995

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: skipping empty string docs from FTS stats.
Description check ✅ Passed The description is directly related to the implemented zero-token document skip behavior and tests.
Linked Issues check ✅ Passed The PR satisfies #7660 by excluding zero-token string documents from FTS metadata and corpus stats while preserving empty-index behavior.
Out of Scope Changes check ✅ Passed The additional changes in scorer and mem-wal paths support the same zero-token document handling and are in scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added A-index Vector index, linalg, tokenizer bug Something isn't working labels Jul 9, 2026
@Ecthlion
Ecthlion marked this pull request as ready for review July 11, 2026 17:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/lance-index/src/scalar/inverted/builder.rs (1)

1433-1441: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant token_num > 0 guard downstream is now always true.

With this unconditional early return whenever token_num == 0, execution past this point guarantees token_num > 0. The } else if token_num > 0 { at Line 1483 is therefore a silent guard against an impossible condition and should be simplified to a plain else. As per coding guidelines, "Do not silently guard against impossible conditions; use debug_assert!, return an explicit error, or remove the check."

♻️ Proposed simplification (Line 1483)
-        } else if token_num > 0 {
+        } else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/inverted/builder.rs` around lines 1433 - 1441, In
the control flow following the token_num == 0 early return, simplify the
downstream `else if token_num > 0` branch to a plain `else`, since reaching it
guarantees a positive token count. Preserve the existing branch body and
behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rust/lance-index/src/scalar/inverted/builder.rs`:
- Around line 1433-1441: In the control flow following the token_num == 0 early
return, simplify the downstream `else if token_num > 0` branch to a plain
`else`, since reaching it guarantees a positive token count. Preserve the
existing branch body and behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1a248101-40ed-4aab-8dcf-7ee0edffef09

📥 Commits

Reviewing files that changed from the base of the PR and between f698426 and 8a86ade.

📒 Files selected for processing (1)
  • rust/lance-index/src/scalar/inverted/builder.rs

@Ecthlion

Copy link
Copy Markdown
Author

Hi @BubbleCal and @Xuanwo, this PR addresses the empty-document follow-up identified during the review of #7656.

It makes plain string documents that produce zero tokens follow the same behavior as list-string documents, and adds regression coverage for mixed empty/non-empty input, all-empty indexes, and tail partition handling.

Since you’re familiar with the original change and discussion, could you please take a look when you have time? I’d especially appreciate a check on whether the zero-token and empty-index edge cases are covered appropriately. Thanks!

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.63636% with 10 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-index/src/scalar/inverted/builder.rs 88.63% 0 Missing and 10 partials ⚠️

📢 Thoughts on this report? Let us know!

);

if skip_empty_document && token_num == 0 {
if token_num == 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we apply this zero-token policy consistently across the other FTS paths? The flat/unindexed Utf8 path still appends every row and adds counted_input.num_rows() to num_docs, while MemWAL keeps zero-length documents and flushes them into the DocSet. A whitespace-only, stop-word-only, or overlength document is therefore excluded after a normal index build but counted while it is unindexed or flushed through MemWAL, so avgdl/IDF and potentially top-k ordering can change as the same data moves between these paths. Please update those paths as part of this change and add cross-path coverage.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. I understood the consistency issue at a high level, but I wasn’t sure about the best way to implement the policy across the flat/unindexed and MemWAL paths.

This follow-up commit was implemented mostly with assistance from GPT-5.6-sol. It now excludes zero-token documents from the flat counted input, MemWAL corpus statistics, frozen DocSets, and the flush builder. The added coverage uses whitespace-only, stop-word-only, overlength, null, and normal documents, and checks that num_docs, avgdl, and the IDF/query weight match the normal-build baseline through the flat path and the MemWAL tail-to-frozen/flush transitions. It also checks that the MemWAL score remains unchanged across freeze.

Thanks again for the detailed review—it clarified the invariant that needs to hold across the full FTS lifecycle.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance-index/src/scalar/inverted/index.rs`:
- Around line 9469-9487: Replace the manual Schema and RecordBatch::try_new
fixture setup with arrow_array::record_batch!(), preserving the ROW_ID_FIELD
values and nullable text values in the test data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 72d74616-a25c-4352-b70e-d8cdd6003152

📥 Commits

Reviewing files that changed from the base of the PR and between 8a86ade and acbe395.

📒 Files selected for processing (3)
  • rust/lance-index/src/scalar/inverted/builder.rs
  • rust/lance-index/src/scalar/inverted/index.rs
  • rust/lance/src/dataset/mem_wal/index/fts.rs

Comment on lines +9469 to +9487
let schema = Arc::new(Schema::new(vec![
ROW_ID_FIELD.clone(),
Field::new("text", DataType::Utf8, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(UInt64Array::from(vec![0_u64, 1, 2, 3, 4, 5])) as ArrayRef,
Arc::new(StringArray::from(vec![
Some(""),
Some(" "),
Some("the"),
Some("overlength"),
None,
Some("hello"),
])) as ArrayRef,
],
)
.unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target area and nearby test context.
sed -n '9440,9525p' rust/lance-index/src/scalar/inverted/index.rs

# Find existing uses of record_batch!() in Rust tests for repo-local style.
rg -n --glob '*.rs' 'record_batch!\(' rust | head -n 80

# Check the Arrow crate version in the workspace lockfile / manifests.
rg -n --glob 'Cargo.lock' 'name = "(arrow-array|arrow-ar|arrow)"|version = ' Cargo.lock
rg -n --glob 'Cargo.toml' 'arrow-array|arrow' Cargo.toml rust/**/Cargo.toml

Repository: lance-format/lance

Length of output: 39859


Use record_batch!() for this test fixture.

Replace the manual Schema / RecordBatch::try_new setup with arrow_array::record_batch!() and keep the nullable string values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/inverted/index.rs` around lines 9469 - 9487,
Replace the manual Schema and RecordBatch::try_new fixture setup with
arrow_array::record_batch!(), preserving the ROW_ID_FIELD values and nullable
text values in the test data.

Source: Coding guidelines

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/lance-index/src/scalar/inverted/builder.rs (1)

3593-3621: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Missing block_size field breaks compilation.

IndexWorkerConfig requires a block_size: usize field (see the struct definition and every other literal in this file, e.g. lines 3284, 3308, 3910). This new test's literal omits it, so the file will fail to compile.

🐛 Proposed fix
             IndexWorkerConfig {
                 with_position: false,
                 format_version: InvertedListFormatVersion::V1,
                 fragment_mask: None,
                 token_set_format: TokenSetFormat::default(),
                 worker_memory_limit_bytes: u64::MAX,
+                block_size: InvertedIndexParams::default().block_size,
             },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/inverted/builder.rs` around lines 3593 - 3621,
Update the IndexWorkerConfig literal in
test_all_empty_string_documents_do_not_create_tail_partition to include the
required block_size field, matching the value or established pattern used by
other IndexWorkerConfig literals in this file.
🧹 Nitpick comments (1)
rust/lance-index/src/scalar/inverted/index.rs (1)

10955-10974: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use record_batch!() here

Replace the manual Schema/Arc/RecordBatch::try_new setup with record_batch!() to keep this test fixture concise.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/inverted/index.rs` around lines 10955 - 10974,
Simplify the batch fixture in
flat_bm25_skips_zero_token_documents_from_corpus_stats by replacing the manual
Schema, Arc, and RecordBatch::try_new construction with the existing
record_batch!() helper, while preserving the same row IDs and text values.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rust/lance-index/src/scalar/inverted/builder.rs`:
- Around line 3593-3621: Update the IndexWorkerConfig literal in
test_all_empty_string_documents_do_not_create_tail_partition to include the
required block_size field, matching the value or established pattern used by
other IndexWorkerConfig literals in this file.

---

Nitpick comments:
In `@rust/lance-index/src/scalar/inverted/index.rs`:
- Around line 10955-10974: Simplify the batch fixture in
flat_bm25_skips_zero_token_documents_from_corpus_stats by replacing the manual
Schema, Arc, and RecordBatch::try_new construction with the existing
record_batch!() helper, while preserving the same row IDs and text values.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: b8c8f9d9-5dbb-4743-af54-44dccd70d1c0

📥 Commits

Reviewing files that changed from the base of the PR and between acbe395 and 58120c1.

📒 Files selected for processing (3)
  • rust/lance-index/src/scalar/inverted/builder.rs
  • rust/lance-index/src/scalar/inverted/index.rs
  • rust/lance/src/dataset/mem_wal/index/fts.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
rust/lance-index/src/scalar/inverted/index.rs (1)

6181-6185: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preallocate before materializing shared token counts.

to_vec() has no spare slot, so the following push can reallocate and copy the entire buffer again.

Proposed fix
             Self::Shared(values) => {
-                let mut owned = values.to_vec();
+                let mut owned = Vec::with_capacity(values.len() + 1);
+                owned.extend_from_slice(values.as_ref());
                 owned.push(value);
                 *self = Self::Owned(owned);
             }

As per coding guidelines, “Use Vec::with_capacity() when size is known or estimable.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/inverted/index.rs` around lines 6181 - 6185,
Update the Self::Shared branch to allocate the owned vector with capacity for
values.len() + 1 before copying the shared values, then append the new value
without triggering an avoidable reallocation; preserve the existing transition
to Self::Owned.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rust/lance-index/src/scalar/inverted/index.rs`:
- Around line 6181-6185: Update the Self::Shared branch to allocate the owned
vector with capacity for values.len() + 1 before copying the shared values, then
append the new value without triggering an avoidable reallocation; preserve the
existing transition to Self::Owned.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: f03ddb5b-6ffa-42a3-ab85-2b64f94a1030

📥 Commits

Reviewing files that changed from the base of the PR and between 58120c1 and 5fbdb7f.

📒 Files selected for processing (1)
  • rust/lance-index/src/scalar/inverted/index.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-index Vector index, linalg, tokenizer bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FTS: skip empty string documents when counting num_docs

2 participants