Proposal: Block-Row Posting Layout and Block Executor for FTS Index V3 #7668
Xuanwo
started this conversation in
Lance Table Format
Replies: 1 comment
-
|
This looks very good, and has quite promising benchmarks. I have a few key questions about how this will interact with the index cache.
question: how big is each block on disk in terms of bytes? Does it turn reading a few blocks into many small IOs? question: what's the unique of caching in the index cache? Recall that we found V2 had an issue where some entries were too small, so we grouped them into chunks. If the unit is one block, the size of the block is important. It sounds like this comment is getting at this issue: "Full-prewarm cache-entry aggregation: shrink the 240k-entry overhead". |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Summary
V3 is a new format version for the FTS inverted index. On the storage side, it splits the posting list from "one row per token" into a block-row layout: each 128-doc posting block becomes its own row, with the skip metadata pulled out into narrow columns. On the query side, the executor plans at block granularity: it reads skip metadata first and skips a payload block only when the block's score upper bound proves it cannot enter the top-k; whenever completeness cannot be proven, it falls back to the full posting-list path. On the cache side, prewarm gains a selective mode that populates the cache by workload scope and block score instead of loading everything. Every new V3 path returns the same top-k set and scores as the full path.
Measured on a 120k-doc S3 dataset (160 queries, limit=10): selective cache reaches 1376–1408 QPS with 4.09 MiB resident — about 95% of full-prewarm QPS at about 6% of its resident cache.
Problem
V1/V2 store a token's entire posting list as one row of the postings file, with the row number acting as the token id:
_max_scoreand_lengthare separate columns, so term-level metadata can be projected on its own. But the block-level decision inputs — block max score and doc id bounds — are embedded in each block's header and only become visible after the payload has been read. Querying a token therefore always reads the whole row back and materializes the full posting list; WAND's block skipping can only happen at the decode layer, after every byte has already crossed the network into memory. Cold reads on S3, high-frequency terms, small top-k, and OR/WAND queries all amplify range bytes and peak memory.Splitting the storage alone is not enough: if the reader still fetches all block rows back and rebuilds the full posting list, the layout has only changed the file shape. So V3 defines the layout, the query execution, and the cache contract together.
Proposed Design
Block-row layout
A V3 index consists of four kinds of files:
A term's block rows must be contiguous with monotonic
block_start;block_start + num_blocksdefines its row range. The three skip columns are narrow and can be read via column projection alone;posting_payloadandcompressed_positionare fetched only for selected rows.posting_coarse_skipis always written by the builder; it aggregates doc id ranges and group max scores at a group granularity fixed by the format, and serves long-distanceadvance(target)and segmented metadata reads for high-frequency terms. A reader that ignores it must produce identical results — it can only change the IO plan.Ownership boundaries: the builder cuts posting lists into fixed-size blocks, writes the block-row tables, and maintains the term → block-range prefix mapping; the reader interprets V3 metadata as block row ranges; the executor decides which blocks to read. The payload codec stays FTS-private binary — Lance provides only row-level random access, range reads, and column projection.
Block executor
Queries move from term-level materialization to block-level planning:
termsyields each term's block row range.first_doc_id,last_doc_id,block_max_score.posting_payloadonly for blocks that can contribute, generating payload ranges from the skip columns; already-cached block rows are served from the block cache.posting_positions.Pruning is not approximate search: every skipped block must be proven unable to enter the top-k by its upper bound. When the proof cannot be completed, the executor falls back to full posting-list materialization.
Selective prewarm and cache
Selective prewarm reads the skip metadata for the workload scope, picks each query term's most valuable block rows by
block_max_score, then warms the payload, optionally positions, and fallback posting lists for the workload terms. In the current setup the workload scope comes from a query list;percent=10keeps the top 10% of block rows by score for each query term.Cache keys:
V3BlockMetadataKey(token_id)— a term's block skip metadata.V3TopBlockRowsKey(token_id)— the block rows selective prewarm picked for that term.V3PostingBlockKey(block_row, with_position)— a single posting block payload, optionally with positions.When every query term's selected rows and metadata are cached, the executor first runs a speculative block search over those rows. That pass uses an isolated threshold, result heap, and score state, and returns only when the result is provably exact; otherwise it falls back. The fallback posting-list cache exists so that a fallback does not push query-time IO back to S3; it is also workload-scoped, not a full prewarm.
Observability
The executor and cache must expose: reads for block metadata, payload blocks, payload ranges, and position blocks; the number of pruned blocks; how often the full posting-list path triggers; speculative pass completion and fallback rates; and cache composition (entries and bytes for skip metadata, payload, positions, and fallback posting lists). Without these counters it is impossible to tell whether a win comes from block pruning, block cache hits, or the workload-scoped fallback cache.
Contract and Invariants
posting_positionsrow ids must equalposting_blocksrow ids; when an index has no positions, phrase/proximity returns an explicit error.with_positiononly decides whether query time needs lazy positions IO —with_position=falsedoes not promise zero-IO phrase/proximity.first_doc_id,last_doc_id, andblock_max_scoremust be sufficient for block-level upper-bound decisions without reading payload.percentor a cache budget only sets the cutoff on the score-ranked population.Compatibility and Migration
V3 is a new index format version. The V2 reader, the existing posting-list materialization path, and the public APIs are all preserved; the compatibility goal is that any mix of V2 and V3 cache states, query types, and fallback returns the same semantic results.
prewarm_indexkeeps meaning full prewarm; selective prewarm is a new entry point that only changes the cache population policy. The full-prewarm path stays long-term as the correctness boundary for uncovered query types, failed speculative proofs, and the rollout window.Measured Boundaries
One S3 dataset, 120k docs, 160 queries, limit=10. Full-prewarm reads about 2.73 MB from storage; resident cache is much larger than the bytes read (decompressed postings, positions, and cache entry overhead), so the MiB ratios below do not extrapolate directly to GB-scale indexes.
Prewarm IO shape: top-10% with positions issues ~409 ranges / 3.06 MB, while full-prewarm issues ~35 ranges / 2.73 MB — the selective mode saves resident memory but does not yet save prewarm bytes/ranges.
Future Work
Beta Was this translation helpful? Give feedback.
All reactions