Skip to content

feat(fts)!: add configurable posting block size#7466

Draft
BubbleCal wants to merge 22 commits into
mainfrom
yang/oss-1344-make-fts-index-block-size-configurable
Draft

feat(fts)!: add configurable posting block size#7466
BubbleCal wants to merge 22 commits into
mainfrom
yang/oss-1344-make-fts-index-block-size-configurable

Conversation

@BubbleCal

@BubbleCal BubbleCal commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Feature

Linear: OSS-1344

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 feat(fts): impact skip data for posting lists #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.

@github-actions

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.

@github-actions github-actions Bot added A-python Python bindings A-index Vector index, linalg, tokenizer A-java Java bindings + JNI A-format On-disk format: protos and format spec docs enhancement New feature or request labels Jun 25, 2026
@BubbleCal BubbleCal force-pushed the yang/oss-1344-make-fts-index-block-size-configurable branch from dd4ac88 to d9f0acb Compare June 25, 2026 07:09
@BubbleCal BubbleCal force-pushed the yang/oss-1344-make-fts-index-block-size-configurable branch from d9f0acb to 23e4810 Compare June 25, 2026 09:13
@BubbleCal BubbleCal force-pushed the yang/oss-1344-make-fts-index-block-size-configurable branch from 23e4810 to 059ae90 Compare June 25, 2026 09:23
@BubbleCal BubbleCal marked this pull request as ready for review June 29, 2026 10:50
@BubbleCal

Copy link
Copy Markdown
Contributor Author

@claude reivew

@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.

I think this needs a compatibility boundary before merge.

  1. block_size=256 changes the persisted posting-block layout, but the stored index version still looks like the existing FTS format. Older readers will ignore the new metadata/details field and try to decode the blocks as legacy 128-doc BitPacker4x blocks. That can fail open as wrong FTS results or decode panics instead of cleanly ignoring the index. We should either bump the FTS/index version for non-legacy block sizes or reject writing 256 until older readers can be gated out.

  2. Legacy segments with no block_size and newly written default-128 segments with block_size=128 are semantically identical, but the multi-segment details check compares the raw protobuf values. Mixed old/new default-128 segments can be rejected as inconsistent. The comparison should canonicalize missing block_size to 128 before comparing.

@BubbleCal

Copy link
Copy Markdown
Contributor Author

I think this needs a compatibility boundary before merge.

  1. block_size=256 changes the persisted posting-block layout, but the stored index version still looks like the existing FTS format. Older readers will ignore the new metadata/details field and try to decode the blocks as legacy 128-doc BitPacker4x blocks. That can fail open as wrong FTS results or decode panics instead of cleanly ignoring the index. We should either bump the FTS/index version for non-legacy block sizes or reject writing 256 until older readers can be gated out.
  2. Legacy segments with no block_size and newly written default-128 segments with block_size=128 are semantically identical, but the multi-segment details check compares the raw protobuf values. Mixed old/new default-128 segments can be rejected as inconsistent. The comparison should canonicalize missing block_size to 128 before comparing.

bumped block_size=256 to version=3
keep 128 the default

@BubbleCal BubbleCal requested a review from Xuanwo June 30, 2026 15:15

Xuanwo commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Thanks for pushing the format-version boundary for block_size=256; that addresses the old-reader compatibility concern.

I think there is still one blocking invariant gap in the MemWAL FTS path.

MemIndexConfig::fts_from_metadata can construct InvertedIndexParams with format_version=V3 and the legacy/default block_size=128 when IndexMetadata.index_version == 3 but index_details are missing, invalid, or carry default params. FtsMemIndex::with_params / FtsIndexConfig::with_params also accept params without calling validate_format_version().

Later, MemWAL flush calls InnerBuilder::new_with_format_version_and_block_size(format_version, block_size), whose validation is currently an expect(...), so this invalid public/metadata state becomes a panic instead of a Result error.

Could we validate the format/block-size invariant at the MemWAL config/metadata boundary and add a regression test for index_version=3 + missing/default block_size? The expected behavior should be a clear error, not a panic. A positive MemWAL roundtrip for block_size=256 would also lock in the intended path.

BubbleCal and others added 4 commits July 1, 2026 16:25
256-doc blocks pack frequencies with Lucene PForUtil-style patched FOR: the
body uses the bit width that minimizes total bytes and up to 31 outliers are
appended as (index u8, high-bits varint) exceptions. Plain FOR let a single
large tf widen the whole block; measured on a 200M-doc corpus (avg tf 4)
this was 4.7 bits/posting for frequencies, patched FOR brings it to ~2.5.
128-doc blocks are unchanged.
…gList

Block boundary lookups re-read block headers and re-decoded the tail block
on every call; bake first doc ids once per cached list and share the slab
across per-query clones.
@BubbleCal

Copy link
Copy Markdown
Contributor Author

Format-change discussion / PMC vote thread for the V3 (256-doc block + patched-FOR frequency) format introduced here: #7606

… for 256-doc blocks

Folds the v3 breaking changes into this PR so the format/scoring break
lands in one place; 128-block indexes are untouched on disk and keep
exact scoring bit-for-bit.

- BM25 doc lengths for 256-doc-block partitions are quantized to a
  Lucene SmallFloat-style byte code (4 mantissa bits, exact below 8,
  <= 6.25% relative error, decode = bucket floor). The byte-norm slab
  bakes lazily per loaded DocSet, quartering the doc-length bytes the
  scoring path pulls through the cache (200M docs: 800MB -> 200MB).
- 256-doc posting blocks drop the leading block-max-score f32. The
  impact skip data introduced by the stacked follow-up supplies a
  tighter per-block bound; until it lands, block-level pruning for 256
  falls back to the list-level max score, which is a valid (looser)
  bound. Block layout: [first_doc u32][doc num_bits u8][docs][pfor
  freqs]; posting_block_score_prefix_len(block_size) keys every
  reader/writer.

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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Yang Cen added 2 commits July 5, 2026 03:19
…igurable

Resolves the ngram.rs conflict by taking main's byte-bounded
stream_spill_reader wholesale and dropping this branch's boxing
refactor (NGramSpillStream alias), which main's rewrite superseded.
Threading a runtime block_size through PostingIterator replaced the old
compile-time BLOCK_SIZE division (a shift) with real div instructions in
doc()/next(), costing 11-14% on 3-word OR queries against legacy
128-block indexes. Block sizes are validated powers of two, so derive
block indices with trailing_zeros shifts and masks instead.
@BubbleCal BubbleCal marked this pull request as draft July 5, 2026 17:51
BubbleCal added a commit that referenced this pull request Jul 6, 2026
`ensure_num_tokens_loaded` rebuilt the num_tokens-only `DocSet` from the
cached arrow column on every call, copying the whole column (tens of MB
per
partition) once per query per partition. Materialize it once in a
`OnceCell`
like the full DocSet, and account for it in `deep_size_of`.

## Measured vs its merge-base

Per-branch-tip wheels on the 200M-doc legacy index (338 partitions),
1000
queries × 8 concurrent, 400G index cache. The rebuild only happens while
a
partition's DocSet is deferred (not yet fully materialized), so the win
shows
on the not-yet-prewarmed path, and the fix is exactly neutral once the
index
is warm:

| protocol | query | base | this PR |
|---|---|---|---|
| no prewarm, steady-state passes | single-term k10 | 0.304s / 27 qps |
**0.076s / 105 qps (4.0×)** |
| no prewarm, steady-state passes | 3-word OR k10 | 0.359s / 22 qps |
**0.179s / 45 qps (2.0×)** |
| prewarmed | single-term k10 | 0.027s | 0.027s (neutral) |
| prewarmed | 3-word OR k10 / k100 | 0.133s / 0.258s | 0.133s / 0.257s
(neutral) |

In the stacked V3 setup (#7466 and its followers) partitions serve
queries
from the tokens-only DocSet indefinitely, where this same rebuild
dominated
hot single-term queries (250ms → 3ms when the cache landed there).

Independent of the block-size/impact PR stack; applies to any FTS query.

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

Co-authored-by: Yang Cen <yang@lancedb.com>
…igurable

Reconciles #7600's tokens_only DocSet cache with this branch's
quantized_scoring flag: the cached num_tokens-only DocSet is built with
quantized scoring applied inside the OnceCell init, matching how
ensure_loaded stamps the full DocSet.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-format On-disk format: protos and format spec docs A-index Vector index, linalg, tokenizer A-java Java bindings + JNI A-python Python bindings breaking-change enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants