feat(index)!: quantized doc-length scoring and slimmer posting blocks for FTS v3#7626
Closed
BubbleCal wants to merge 6 commits into
Closed
feat(index)!: quantized doc-length scoring and slimmer posting blocks for FTS v3#7626BubbleCal wants to merge 6 commits into
BubbleCal wants to merge 6 commits into
Conversation
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.
Port of Lucene's MaxScoreBulkScorer, opt-in via LANCE_FTS_MAXSCORE=1: 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 (must beat the running threshold), so results are score-identical. Measured on a 200M-doc warm benchmark at 24 partitions: 3-word OR match k10 0.137s -> 0.035s, k100 0.250s -> 0.064s; hot single-term 250ms -> 3ms.
With right-sized partitions the bulk path reaches Lucene-parity latency (k10 0.034-0.037s/213-235qps vs Lucene 10.4's 0.037s/216qps on the same 200M-doc corpus, queries, and warm protocol) and its results are score-identical to the classic WAND loop. LANCE_FTS_MAXSCORE=0 opts back into the classic loop.
AND and phrase queries previously leapfrogged doc-at-a-time through boxed PostingIterator::next calls (~61% of the AND profile) and phrase checks decoded a whole 256-doc position block per candidate (~39% of the phrase profile). - and_bulk_search: block-max window pruning plus a k-pointer merge over decompressed block slices; per-candidate advance cost drops to a few loads. Results are identical to the classic loop (LANCE_FTS_BULK_AND=0 opts out). Phrase queries ride the same path. - seek_packed_doc_positions: PackedDelta full groups are self-describing ([num_bits u8][16*num_bits bytes]), so group offsets are recovered by hopping headers; decode only the 1-2 groups overlapping the candidate doc's delta range, with a lazily-built group index, memoized unpacked group, and a decoded-tail cache per block. - check_exact_positions_bulk: allocation-free slop=0 alignment check on the decoded scratch slices for parked lead clauses. Warm mmlb benchmarks, 8 concurrent queries: AND\@200M k10 0.114->0.060s, k100 0.240->0.118s; phrase\@50m 3-word k10 0.335->0.210s, 2-word k10 0.098->0.042s. All steps verified score-identical to the classic path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r bulk fts conjunctions Three changes to the bulk AND/phrase candidate pipeline, all score-identical to the classic loop: - Clause-count-specialized merge kernels (2 and 3 clauses) keep cursors and bounds in registers, with an AVX2 find_next_geq catch-up scan (the analogue of Lucene's VectorUtil.findNextGEQ) replacing the mispredict- heavy scalar walk. Generic kernel covers other widths. - Two-pass batched scoring: kernels only record (doc, offsets) matches; doc lengths are then gathered back-to-back so their cache misses overlap, and the prune/score/insert pass runs in doc order with the classic semantics. - A per-clause frequency-bucketed score-bound LUT lets kernels skip lead docs before any follower advance when even the doc-length-free bound cannot beat the threshold (score-first ordering, Lucene-style); the bound is monotone so the skipped set is a subset of the exact prune. Warm mmlb AND @200M, 8 concurrent: k10 0.060->0.049s (161qps), k100 0.118->0.098s (81qps); phrase\@50m 3w k10 0.210->0.204s, 2w 0.042->0.040s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… for fts v3 Two v3-only (256-doc block) changes; 128-block indexes are untouched on disk and keep exact scoring bit-for-bit. - BM25 doc lengths for v3 partitions are quantized to a Lucene SmallFloat-style byte code (4 mantissa bits, exact below 8, <= 6.25% relative error). The byte-norm slab bakes lazily per loaded DocSet and quarters the bytes the scoring gather pulls through the cache (200M docs: 800MB -> 200MB working set). Impact bounds bake against the same quantized lengths; quantization is monotone, so the stored (freq, doc_len) pareto frontier still dominates and bounds stay valid. - 256-doc posting blocks drop the leading block-max-score f32: v3 always carries impact skip data, which supplies the tight per-block bound (the stored one was already unused on that path). Block layout becomes [first_doc u32][doc num_bits u8][docs][pfor freqs]; readers key the prefix off the block size, and CompressedPostingList:: block_max_score falls back to the list-level max for prefix-less blocks. BREAKING: v3 (block_size=256) indexes must be rebuilt; v3 is unreleased so no migration is provided. BM25 scores on v3 differ from exact by the norm quantization, matching Lucene's norm semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2f9de34 to
3c60d9a
Compare
Contributor
Author
|
Closing: per review direction, all breaking changes are consolidated into #7466. The quantized doc-length scoring + block-max-score-prefix removal moved there (commit c4b12f1 on that branch); the impact-bound quantization folded into #7602 where impact skip data lives. The rest of this stack (#7624/#7625/#7627) is rebased on the updated train and carries no breaking changes. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #7625. Format/scoring change for V3 only — covered by the format discussion #7606 (spec updated there).
What
Two changes scoped to
block_size=256(V3); 128-block indexes are untouched on disk and keep exact scoring bit-for-bit:DocSet(gated at partition load by block size, threaded throughLazyDocSet), quartering the doc-length working set the scoring gather pulls through the cache (200M docs: 800MB u32 -> 200MB u8). Impact bounds bake against the same quantized lengths; quantization is monotone, so the stored (freq, doc_len) pareto frontier still dominates every doc in range and WAND pruning stays lossless relative to quantized scores.[first_doc u32][doc num_bits u8][docs][pfor freqs];posting_block_score_prefix_len(block_size)keys every reader/writer, andblock_max_scorefalls back to the list-level max for prefix-less blocks.Breaking / behavior notes
DocSetconstructor inlazy_docset.rs; whichever lands second must applyset_quantized_scoringinside that cached constructor too (the integration branchyang/fts-lucene-parity-integrationshows the resolution).Results (mmlb warm, 8 concurrent)
Verification
cargo test -p lance-index,clippy -D warnings,fmt --checkclean.🤖 Generated with Claude Code