Skip to content

feat(fts): impact skip data for posting lists#7602

Merged
BubbleCal merged 4 commits into
mainfrom
yang/lan2-88-impact-skip-data
Jul 13, 2026
Merged

feat(fts): impact skip data for posting lists#7602
BubbleCal merged 4 commits into
mainfrom
yang/lan2-88-impact-skip-data

Conversation

@BubbleCal

@BubbleCal BubbleCal commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Builds on the merged configurable posting block size work in #7466. Format discussion: #7606.

Store per-block (freq, doc_len) impact frontiers alongside compressed posting blocks, plus one level-1 entry per 32 blocks, and drive block-max WAND pruning from them instead of build-time scores that can become stale as index statistics drift.

  • Both 128- and 256-doc blocks use the same compact impact codec: quantized u8 document-length norms, delta-encoded frequencies, and an omitted norm byte for the common +1 delta.
  • Scorer-specific bounds bake once into cache-shared state; query-local caches reuse the slab without sharing stale bounds across different corpus statistics.
  • Impact data survives packed prewarm and persistent-cache round trips. Posting-list cache versions are bumped while older versions remain readable.
  • Packed posting views share both impact-derived state and the block-head cache introduced by feat(fts)!: add configurable posting block size #7466.
  • Malformed or missing impact entries fall back to conservative infinite bounds instead of enabling unsafe WAND skips.
  • Public posting cache-key struct literals remain source-compatible; impact-bearing entries use an internal namespaced key.
  • V3 indexes without impacts retain the finite BM25 ceiling, while custom scorers without a declared safe bound fall back to INFINITY.

The query benchmark below predates this codec follow-up; the V3 scoring bounds are unchanged, but impact size and decode cost need to be remeasured.

Benchmark

Measured before this restack against #7466 using per-branch-tip wheels: 200M-doc V3/256 index, 24 partitions, 1000 warm queries at 8 concurrent requests.

query #7466 list-max fallback this PR
OR 3w k10 0.363s / 22 qps 0.103s / 76 qps (3.5x)
OR 3w k100 0.392s / 20 qps 0.198s / 40 qps (2.0x)
AND 3w k10 0.547s / 15 qps 0.115s / 69 qps (4.8x)
AND 3w k100 0.558s / 14 qps 0.245s / 33 qps (2.3x)

Validation

  • cargo test -p lance-index: 773 passed, 2 ignored; doctest passed
  • After the final rebase to current main, cargo test -p lance-index --lib scalar::inverted: 249 passed
  • cargo check --workspace --tests --benches
  • cargo clippy --all --tests --benches -- -D warnings
  • cargo fmt --all -- --check

Summary by CodeRabbit

  • New Features

    • Full-text search now optionally uses impact skip data to improve block-max scoring and pruning when index partitions provide it.
    • Impacts are carried through compressed and packed posting data, with impacts-aware WAND routing and scorer-weighted bound caching.
  • Bug Fixes

    • Improved decoding/validation of impacts envelopes, including safe behavior for malformed, null, or truncated data.
    • More robust posting component extraction and cache-key isolation to prevent mixing impact vs non-impact partitions.
  • Tests

    • Expanded roundtrip, cache isolation, and backward/forward compatibility tests for impact-enabled and legacy postings.

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Important

This PR touches the Lance format specification.

Substantive changes to the format specification — the .proto definitions
and the spec docs under docs/src/format/ — require a PMC vote before merge.
Minor edits such as typo fixes, wording, or formatting are excluded; use your
judgment.

If this is a meaningful format change:

  • Start a vote following the Lance community voting process.
    Format specification modifications need 3 binding +1 votes (excluding the
    proposer), held on GitHub Discussions, with a minimum voting period of 1 week.
  • Once the vote passes, link the completed vote in this PR. It should not be
    merged until the vote is linked.

@BubbleCal

Copy link
Copy Markdown
Contributor Author

Rebased on the updated #7466 (which now owns all V3 breaking changes, including quantized doc-length scoring). This PR's impact bounds are baked against the same quantized lengths for the Varint (256-doc) format — quantization is monotone, so the stored (freq, doc_len) pareto frontier still dominates and the bounds stay valid upper bounds of quantized scores. No breaking change here.

@BubbleCal
BubbleCal marked this pull request as draft July 5, 2026 17:51
BubbleCal added a commit that referenced this pull request Jul 13, 2026
## Feature

Linear:
[OSS-1344](https://linear.app/lancedb/issue/OSS-1344/make-fts-index-block-size-configurable)

### What is the new feature?

FTS inverted index creation now accepts a `block_size` parameter for
compressed posting blocks. Supported values are `128` and `256`.

### Why do we need this feature?

The posting block size was previously fixed at `128`, which made the
block-max granularity impossible to tune for different datasets and
query profiles.

### How does it work?

- Adds `block_size` to `InvertedIndexParams`, protobuf details,
posting-list schema metadata, and cache headers.
- Uses `128` as the default for newly created indexes.
- Treats older serialized params, schema metadata, and cache entries
that omit `block_size` as legacy `128`.
- Rejects unsupported values, including `512`, with a clear validation
error.
- Uses Lance-owned `BitPacker4x` for physical 128-value posting blocks
and `BitPacker8x` for physical 256-value posting blocks.
- Marks `block_size=256` as experimental in public API docs because it
may introduce breaking changes.
- Keeps position-stream packing on the legacy 128-value block format.
- Keeps downgrade compatibility tests on explicit legacy
`block_size=128`, since older wheels cannot read current-created
physical 256 FTS posting blocks.
- Threads the configured block size through FTS build, read, iterator,
WAND, cache, and MemWAL flush paths.
- Exposes the parameter in Python and Java FTS index creation APIs, with
docs and focused tests.

## Validation

- `cargo fmt --all`
- `cargo fmt --all --check`
- `git diff --check`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-no512 cargo test -p
lance-index block_size -- --nocapture`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-no512 cargo clippy -p
lance-index --tests -- -D warnings`
- `uv run make build` from `python/`
- `uv run pytest
python/tests/test_scalar_index.py::test_create_scalar_index_fts_block_size`
from `python/`
- `uv run ruff format --check python/tests/test_scalar_index.py
python/lance/dataset.py` from `python/`
- `uv run ruff check python/tests/test_scalar_index.py
python/lance/dataset.py` from `python/`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo test -p
lance-index block_size -- --nocapture`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo test -p
lance-index test_256_posting_block_uses_single_physical_bitpack_chunk --
--nocapture`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo test -p
lance-bitpacking`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo clippy -p
lance-bitpacking -p lance-index --tests -- -D warnings`
- `uv run ruff format --check
python/tests/compat/test_scalar_indices.py` from `python/`
- `uv run ruff check python/tests/compat/test_scalar_indices.py` from
`python/`
- `uv run pytest --run-compat -vvv -s
python/tests/compat/test_scalar_indices.py::test_FtsIndex_downgrade
--durations=30` from `python/`
- `CARGO_TARGET_DIR=/tmp/lance-a479-target cargo test -p lance-index
test_new_training_request_defaults_missing_block_size_to_128`
- `CARGO_TARGET_DIR=/tmp/lance-a479-target cargo test -p lance-index
block_size`
- `uv run ruff format --check python/lance/dataset.py` from `python/`
- `uv run ruff check python/lance/dataset.py` from `python/`

Not run locally: Java focused test / spotless check, because this
machine has no Java Runtime installed (`Unable to locate a Java
Runtime`).

---

## Update: all V3 breaking changes consolidated here

Per review direction, every breaking change for the 256-doc block format
now lands in this single PR (the follow-up stack
#7602/#7603/#7604/#7624/#7625/#7629 carries none). On top of the
configurable block size and PFOR frequency encoding, this PR now also
includes:

- **Quantized doc-length scoring (Lucene norm semantics), 256-doc blocks
only.** BM25 doc lengths are quantized to a SmallFloat-style byte code
(4 mantissa bits: 0-7 exact, <= 6.25% relative error, decode = bucket
floor). The byte-norm slab bakes lazily per loaded DocSet and quarters
the doc-length bytes scoring pulls through the cache (200M docs: 800MB
-> 200MB). 128-block indexes keep exact-length scoring bit-for-bit.
Measured top-k overlap vs exact scoring on the (score-clustered,
synthetic) mmlb corpus: 98.1% mean for phrase, 89.7% for 3-word AND;
corpora with more score spread shift less.
- **256-doc posting blocks drop the leading block-max-score f32** (~1.5G
on a 200M-doc index; 131G -> 130G). Block layout: `[first_doc u32][doc
num_bits u8][docs][pfor freqs]`;
`posting_block_score_prefix_len(block_size)` keys every reader/writer.
The impact skip data from the stacked #7602 supplies a tighter per-block
bound; until it lands, 256-block block-max pruning falls back to the
(valid, looser) list-level max score.

**BREAKING:** 256-doc-block (v3) indexes must be rebuilt; v3 is
unreleased so no migration is provided. BM25 scores on v3 differ from
exact-length BM25 by the norm quantization, matching Lucene's norm
semantics. The format discussion #7606 documents the final layout and
scoring semantics.

Additional validation for this update: bulk-vs-classic A/B under
quantized scoring is score-identical (both paths quantize identically);
the full stack's warm benchmarks vs Lucene 10.4 on mmlb-200m: OR k10
0.0249s/318qps and OR k100 0.0467s/170qps (both ahead of Lucene sliced),
AND k10 0.0443s (1.29x), AND k100 0.0883s (1.94x).

---

## Standalone results vs main (per-branch-tip wheels)

**Legacy (128) read-path parity.** Threading a runtime `block_size`
through
`PostingIterator` initially replaced the compile-time `BLOCK_SIZE`
division
(a shift) with real `div` instructions in the `doc()`/`next()` hot
loops,
measured as +11-14% on 3-word OR against the 200M legacy index
(`PostingIterator::next` grew from 16.5% to 23.6% of the profile). Block
sizes are validated powers of two, so the iterator now derives block
indices
with `trailing_zeros` shifts and masks; after that fix the legacy path
is at
parity with main: 3-word OR k10 0.131s (main 0.132-0.134s), k100
0.255-0.256s (main 0.256-0.257s), single-term 0.025s (main 0.027s)
across 3 warm passes on the 200M legacy index, 400G cache.

**block_size=256 index size** (5M-doc controlled build, same wheel,
`with_position=false`): postings shrink **3.33 GiB → 2.62 GiB (−21%)**
from
PFOR frequencies + no per-block max-score prefix + half the block
headers.
Index build 192s → 210s (+10%, PFOR encode cost). Top-10 overlap vs the
128 exact-length scoring on 10 3-word OR queries: 95% mean (5/10
identical
sets; the rest differ by 1-2 near-tie docs, from the quantized-norm
scoring).

**Query wins for 256 land in the stacked PRs.** A 256 index without
impact
skip data prunes on the (valid, looser) list-level max and is *slower*
than
128 — e.g. classic AND k10 0.547s until #7602's impacts restore
block-granular bounds (0.115s), and #7603/#7604/#7624/#7625/#7629 take
the
same index to 0.025s OR k10 / 0.045s AND k10.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added configurable FTS posting `block_size` (128/256) to scalar index
creation, including updated examples and APIs.
* Enabled FTS format version 3 (`v3`) for `block_size=256`, with
quantized doc-length scoring for v3.
* **Bug Fixes**
* Enforced `block_size`/`format_version` compatibility (invalid
combinations now error).
* Persisted and restored FTS metadata for format version and posting
block size, with legacy indexes defaulting to `128`.
* **Documentation**
* Updated full-text-search and quickstart guides and parameter docs for
`block_size`, defaults, accepted values, and the experimental `256`/`v3`
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Yang Cen <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Base automatically changed from yang/oss-1344-make-fts-index-block-size-configurable to main July 13, 2026 09:21
Store per-block (freq, doc_len) impact frontiers alongside 256-doc posting
blocks (varint-encoded: 2-3 bytes per pair) plus one level1 entry per 32
blocks, and drive block-max WAND pruning from them instead of build-time
scores that go stale as index stats drift:

- Bounds bake once per cached list into an Arc-shared slab (max doc weight
  per entry plus the list-wide max); per-query clones reuse it, so query
  time pays one multiply per bound instead of frontier rescans.
- Entry doc_up_tos decode once at construction.
- Lagging iterators park in the WAND tail under the data-driven global
  bound (query_weight x baked list max) instead of INFINITY.

128-doc-block indexes keep fixed-width u32 impact entries.
@BubbleCal
BubbleCal force-pushed the yang/lan2-88-impact-skip-data branch from 4db6b8c to 4d334d8 Compare July 13, 2026 10:42
@coderabbitai

coderabbitai Bot commented Jul 13, 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: ASSERTIVE

Plan: Pro Plus

Run ID: 5fa20e57-3201-4d6c-ab8e-dacb89f19eb6

📥 Commits

Reviewing files that changed from the base of the PR and between fe1b30e and 452685c.

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

📝 Walkthrough

Walkthrough

The inverted index adds impact skip data for compressed postings, including serialization, version compatibility, cache propagation, BM25 scorer bounds, and WAND pruning. Posting schemas, cache keys, search integration, and tests are updated for impact-bearing and legacy postings.

Changes

Impact-aware inverted search

Layer / File(s) Summary
Impact model and scorer contracts
rust/lance-index/src/scalar/inverted/impact.rs, rust/lance-index/src/scalar/inverted/scorer.rs
Adds impact encoding, skip-data builders and decoders, score-bound caching, and optional scorer upper-bound/cache-key methods.
Impact schema and cache framing
rust/lance-index/protos-cache/cache.proto, rust/lance-index/src/scalar/inverted/builder.rs, rust/lance-index/src/scalar/inverted/cache_codec.rs
Adds impact metadata and IPC sections, cache version updates, named-column decoding, compatibility handling, and roundtrip tests.
Posting construction and index integration
rust/lance-index/src/scalar/inverted/index.rs
Generates, loads, caches, and preserves impact data, then selects impact-aware BM25 scoring for eligible partitions.
Scorer-aware WAND pruning
rust/lance-index/src/scalar/inverted/wand.rs
Adds impact and scorer-aware bounds, memoized iterator state, conservative V3 bounds, threshold handling, and related tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Query
  participant InvertedIndex
  participant PostingListReader
  participant WAND
  Query->>InvertedIndex: BM25 search with impact scorer
  InvertedIndex->>PostingListReader: load impact-aware postings
  PostingListReader-->>InvertedIndex: postings and impact skip data
  InvertedIndex->>WAND: apply scorer-aware bounds
  WAND-->>Query: return ranked matches
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding impact skip data support for posting lists.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch yang/lan2-88-impact-skip-data

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

@BubbleCal
BubbleCal marked this pull request as ready for review July 13, 2026 11:02
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

@Xuanwo Xuanwo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  1. The central correctness invariant is not covered by the current regression test. test_mixed_impact_and_legacy_partitions_use_global_final_scores uses separate impact and legacy thresholds, so it cannot prove that two impact-enabled partitions sharing a threshold preserve the global BM25 winner. Without a deterministic limit=1 cross-partition regression, this path can silently return the wrong top-k.

  2. This makes fixed-width impacts unconditional for the default 128-doc/V2 writer—about 3.8 bits per posting, or roughly 47 GB at 99.7B postings—while the performance evidence only covers opt-in 256-doc/V3 indexes. This materially changes the default index and cache footprint without evidence that the 128-doc path justifies the cost.

@BubbleCal

Copy link
Copy Markdown
Contributor Author
  1. The central correctness invariant is not covered by the current regression test. test_mixed_impact_and_legacy_partitions_use_global_final_scores uses separate impact and legacy thresholds, so it cannot prove that two impact-enabled partitions sharing a threshold preserve the global BM25 winner. Without a deterministic limit=1 cross-partition regression, this path can silently return the wrong top-k.
  2. This makes fixed-width impacts unconditional for the default 128-doc/V2 writer—about 3.8 bits per posting, or roughly 47 GB at 99.7B postings—while the performance evidence only covers opt-in 256-doc/V3 indexes. This materially changes the default index and cache footprint without evidence that the 128-doc path justifies the cost.

Nice catch, added that regression tests and make V2 be with the same impact encoding as V3

@BubbleCal
BubbleCal requested a review from Xuanwo July 13, 2026 15:02

@Xuanwo Xuanwo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's go!

@BubbleCal
BubbleCal merged commit 5dc074f into main Jul 13, 2026
31 checks passed
@BubbleCal
BubbleCal deleted the yang/lan2-88-impact-skip-data branch July 13, 2026 15:32
BubbleCal added a commit that referenced this pull request Jul 14, 2026
Port of Lucene's MaxScoreBulkScorer, enabled by default for compatible
top-k OR queries. Set `LANCE_FTS_MAXSCORE=0` to fall back to the classic
WAND loop:
per outer window (bounded by the essential clauses' blocks with adaptive
growth), clauses split into a non-essential prefix and essential rest by
window max score vs the running threshold. Essential clauses bulk-stream
decompressed blocks (single-essential windows stream with no
accumulator);
non-essential clauses are only probed for candidates that can still beat
the threshold. Dead ranges with one live clause skip by scanning the
baked
per-block bound slab. Candidate emission matches the classic path, so
results are score-identical (verified over a 40-case A/B snapshot).

## Measured vs #7602 (its base)

Per-branch-tip wheels, 200M-doc v3-256 index, 1000 3-word OR queries × 8
concurrent, warm, default settings:

| query | #7602 (classic WAND) | this PR (default MAXSCORE) |
|---|---|---|
| OR 3w k10 | 0.103s / 76 qps | **0.034s / 231 qps (3.0×)** |
| OR 3w k100 | 0.198s / 40 qps | **0.063s / 127 qps (3.1×)** |

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved full-text search performance via impact-aware scoring and
smarter pruning for compressed posting lists.
* Added an optional bulk MAXSCORE execution path for OR searches,
controlled by `LANCE_FTS_MAXSCORE` (with the prior approach as
fallback).
  * Enhanced OR skip behavior using improved group/block upper bounds.
* **Bug Fixes**
* More robust handling of document-boundary/score limit values, treating
malformed or missing entries safely.
* **Tests**
* Expanded coverage for impact caching, group skipping, and the
optimized search execution path.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Yang Cen <[email protected]>
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 enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants