feat(fts)!: add configurable posting block size#7466
Conversation
|
Important This PR touches the Lance format specification. Substantive changes to the format specification — the If this is a meaningful format change:
|
dd4ac88 to
d9f0acb
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
d9f0acb to
23e4810
Compare
23e4810 to
059ae90
Compare
…s-index-block-size-configurable
|
@claude reivew |
Xuanwo
left a comment
There was a problem hiding this comment.
I think this needs a compatibility boundary before merge.
-
block_size=256changes 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-docBitPacker4xblocks. 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. -
Legacy segments with no
block_sizeand newly written default-128 segments withblock_size=128are 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 missingblock_sizeto 128 before comparing.
bumped block_size=256 to version=3 |
|
Thanks for pushing the format-version boundary for I think there is still one blocking invariant gap in the MemWAL FTS path.
Later, MemWAL flush calls Could we validate the format/block-size invariant at the MemWAL config/metadata boundary and add a regression test for |
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.
|
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>
…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.
`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.
Feature
Linear: OSS-1344
What is the new feature?
FTS inverted index creation now accepts a
block_sizeparameter for compressed posting blocks. Supported values are128and256.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?
block_sizetoInvertedIndexParams, protobuf details, posting-list schema metadata, and cache headers.128as the default for newly created indexes.block_sizeas legacy128.512, with a clear validation error.BitPacker4xfor physical 128-value posting blocks andBitPacker8xfor physical 256-value posting blocks.block_size=256as experimental in public API docs because it may introduce breaking changes.block_size=128, since older wheels cannot read current-created physical 256 FTS posting blocks.Validation
cargo fmt --allcargo fmt --all --checkgit diff --checkCARGO_TARGET_DIR=/tmp/lance-target-a479-no512 cargo test -p lance-index block_size -- --nocaptureCARGO_TARGET_DIR=/tmp/lance-target-a479-no512 cargo clippy -p lance-index --tests -- -D warningsuv run make buildfrompython/uv run pytest python/tests/test_scalar_index.py::test_create_scalar_index_fts_block_sizefrompython/uv run ruff format --check python/tests/test_scalar_index.py python/lance/dataset.pyfrompython/uv run ruff check python/tests/test_scalar_index.py python/lance/dataset.pyfrompython/CARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo test -p lance-index block_size -- --nocaptureCARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo test -p lance-index test_256_posting_block_uses_single_physical_bitpack_chunk -- --nocaptureCARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo test -p lance-bitpackingCARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo clippy -p lance-bitpacking -p lance-index --tests -- -D warningsuv run ruff format --check python/tests/compat/test_scalar_indices.pyfrompython/uv run ruff check python/tests/compat/test_scalar_indices.pyfrompython/uv run pytest --run-compat -vvv -s python/tests/compat/test_scalar_indices.py::test_FtsIndex_downgrade --durations=30frompython/CARGO_TARGET_DIR=/tmp/lance-a479-target cargo test -p lance-index test_new_training_request_defaults_missing_block_size_to_128CARGO_TARGET_DIR=/tmp/lance-a479-target cargo test -p lance-index block_sizeuv run ruff format --check python/lance/dataset.pyfrompython/uv run ruff check python/lance/dataset.pyfrompython/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:
[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_sizethroughPostingIteratorinitially replaced the compile-timeBLOCK_SIZEdivision(a shift) with real
divinstructions in thedoc()/next()hot loops,measured as +11-14% on 3-word OR against the 200M legacy index
(
PostingIterator::nextgrew from 16.5% to 23.6% of the profile). Blocksizes are validated powers of two, so the iterator now derives block indices
with
trailing_zerosshifts and masks; after that fix the legacy path is atparity 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%) fromPFOR 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.