Skip to content

fix(fts): deterministic top-k tiebreak (score DESC, row_id ASC)#7846

Open
sbrunk wants to merge 1 commit into
lance-format:mainfrom
sbrunk:fts-deterministic-topk-tiebreak
Open

fix(fts): deterministic top-k tiebreak (score DESC, row_id ASC)#7846
sbrunk wants to merge 1 commit into
lance-format:mainfrom
sbrunk:fts-deterministic-topk-tiebreak

Conversation

@sbrunk

@sbrunk sbrunk commented Jul 19, 2026

Copy link
Copy Markdown

Problem

Lance's BM25 top-k resolved equal scores by encounter and pruning order, which varies with k and with the order partitions finish. So top-k1 was not an ordered prefix of top-k2, and paginating a broad tied-score term (many docs sharing tf and doc length, so identical BM25) duplicated and skipped rows across from/size pages.

Lucene avoids this with a deterministic secondary sort (score DESC, docId ASC). This PR brings the same guarantee to Lance, keyed on row_id.

Change

Give the FTS top-k a total order (score DESC, row_id ASC):

  • ScoredDoc::cmp breaks score ties by ascending row_id (the final sort).
  • The per-partition collector and the cross-partition merge evict on the full (score, row_id) key, so ties resolve by row_id regardless of visit order or which partition finishes first.
  • WAND pruning lowers the threshold one ULP below the k-th score (admit_ties_floor), so the score-only prune tests keep, rather than drop, docs tied at the k-th score, including a slower partition's ties behind the shared cross-partition floor. This needs no change to the pruning kernels.
  • On the no-filter path, row_ids are resolved only after the WAND walk, so the collector tiebreaks by a local doc_id proxy. That proxy diverges from row_id order after a stable-row-id compaction remap, so the collector retains the k-th-score tie band and the merge re-selects by row_id once resolved.

Row id stability

Correctness within a dataset version does not depend on stable row ids. The tiebreak only needs row_id to be unique and comparable, which holds for both legacy row addresses and stable row ids, so the dup/skip fix works in either mode. What stable row ids add is stability across compaction: legacy row addresses change when fragments are compacted, so the tiebreak key moves and the order of tied docs can shift between versions, whereas stable row ids persist so the order survives compaction. This mirrors Lucene, whose docId tiebreak is deterministic within a searcher version but not stable across segment merges.

Tests

  • Stable prefix (top-k1 is an ordered prefix of top-k2) for single-partition (sorted and post-compaction unsorted), multi-partition, and filtered (non-deferred) paths.
  • Collector unit tests for full-key eviction and the deferred tie-band overflow, including the reset when the k-th score rises.
  • Full inverted suite passes.

Performance

The change is a no-op on the non-tied path by construction: admit_ties_floor only alters pruning when a block max bit-equals the k-th score (which does not happen for distinct float scores), the tie overflow stays empty, and the row_id tiebreak never engages. A large exactly-tied band is scanned instead of pruned and, on the no-filter path, buffered in memory for row_id resolution. That is the inherent cost of an exact row_id tiebreak on that path.

Local benchmarking on a laptop was too thermally noisy to confirm this (identical-code runs swung by 3 to 15%). Please run @bench-bot benchmark for an authoritative measurement on the CI ARM64 runner, since this touches the WAND pruning inequality.

BM25 top-k resolved equal scores by encounter and pruning order, which
varies with k and with partition completion order. top-k1 was not a prefix
of top-k2, so paginating a tied-score query duplicated and skipped rows.

Give the top-k a total order (score DESC, row_id ASC), matching Lucene:

- ScoredDoc::cmp breaks score ties by ascending row_id (the final sort).
- The per-partition collector and the cross-partition merge evict on the
  full (score, row_id) key, so ties resolve by row_id regardless of visit
  order or which partition finishes first.
- WAND pruning lowers the threshold one ULP below the k-th score, so the
  score-only prune tests keep, rather than drop, docs tied at the k-th
  score, including a slower partition's ties behind the shared floor. This
  needs no change to the pruning kernels.
- On the no-filter path, where row_ids are resolved after the WAND walk,
  the collector retains the k-th-score tie band and the merge re-selects by
  row_id. This keeps the result correct when doc_id order diverges from
  row_id order after a compaction remap.

Non-tied queries keep full pruning power. A large exactly-tied band is
scanned instead of pruned and, on the no-filter path, buffered in memory
for row_id resolution.
@github-actions github-actions Bot added A-index Vector index, linalg, tokenizer bug Something isn't working labels Jul 19, 2026
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 46bda4b1-30e4-4fe1-8e39-0158634d990d

📥 Commits

Reviewing files that changed from the base of the PR and between 252d81a and 6badd5f.

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

📝 Walkthrough

Walkthrough

Changes

Deterministic top-k ranking

Layer / File(s) Summary
Full scored-document ordering and BM25 eviction
rust/lance-index/src/scalar/inverted/builder.rs, rust/lance-index/src/scalar/inverted/index.rs
Equal scores now use deterministic row_id ordering, with regression tests covering stable prefixes, filtered searches, and partition merges.
Deferred boundary-tie collection
rust/lance-index/src/scalar/inverted/wand.rs
TopKCollector retains k-th-score ties when row IDs are deferred and includes them in final candidates.
WAND threshold admission and collector wiring
rust/lance-index/src/scalar/inverted/wand.rs
WAND thresholds are lowered by one ULP to admit ties, and search modes configure deferred tie-breaking based on row ID availability.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Query as WAND search
  participant Collector as TopKCollector
  participant Results as Final candidates
  Query->>Collector: insert scored documents
  Collector->>Collector: retain k-th-score boundary ties
  Collector->>Results: materialize heap and deferred ties
  Results->>Query: return deterministic top-k candidates
Loading

Suggested reviewers: xuanwo, bubblecal, westonpace

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title precisely matches the core change: making top-k tiebreaking deterministic by score DESC then row_id ASC.
Description check ✅ Passed The description accurately describes the BM25 top-k determinism fix, tie handling, and added tests in the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

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.

1 participant