feat(index)!: serve refine from the index instead of the base table - #8960
Draft
vivek-bharathan wants to merge 8 commits into
Draft
feat(index)!: serve refine from the index instead of the base table#8960vivek-bharathan wants to merge 8 commits into
vivek-bharathan wants to merge 8 commits into
Conversation
Contributor
|
Important Format specification voteThis PR modifies the Lance format specification, so it requires 3 binding +1 votes from PMC members (excluding the proposer) and a minimum 72-hour voting period, weekends excluded, before it can merge. Vote by approving this PR (+1) or requesting changes (−1, a veto). See the voting process. Status: ❌ Blocked — 0 of 3 required approvals
Updated automatically by the format-spec vote gate, which re-checks every 15 minutes — just voted? Re-check now (press Run workflow; leave the input blank to re-check every open format PR). A PMC member may apply the |
vivek-bharathan
force-pushed
the
vb/covering-refine
branch
from
September 3, 2026 00:47
bcdbde5 to
eeff50c
Compare
vivek-bharathan
marked this pull request as draft
September 3, 2026 00:53
vivek-bharathan
force-pushed
the
vb/covering-refine
branch
2 times, most recently
from
September 3, 2026 14:25
ce55b65 to
2aa6360
Compare
An IVF_PQ index can store the values of chosen extra columns next to its compressed vectors, and a search returns them directly from the index. A query whose projection those columns satisfy no longer reads the base table at all. Each storage format names its own internal columns, so covering detection is a per-storage filter rather than a per-type special case. Nested fields, blob columns, duplicates, reserved storage names and non-IVF_PQ index types are rejected at creation. `fields` is built as `[keyed_id] ++ covering_fields` wherever an index is created, so every covered index satisfies the suffix rule `IndexMetadata::validate_covering_fields` enforces at commit, and segments of one logical index are rejected if they disagree on `covering_fields` -- the read path derives its output schema from the first segment, so a disagreement yields a plan no segment can satisfy. BREAKING CHANGE: `VectorIndexParams` gains a public `covering_columns: Vec<String>` field. The struct is not `#[non_exhaustive]`, so code constructing it with an exhaustive struct literal needs one added line.
Covering columns worked only on IVF_PQ. Each storage format now declares which of its own columns are internal, so anything else is treated as covered payload, and the creation-time restriction to IVF_PQ is gone. IVF_SQ, RQ, FLAT and the HNSW variants each get end-to-end coverage.
A covered index now survives the operations that change its data: schema evolution, overlays, remap, compaction, and concurrent commits. Guards reject the alterations that would desync covering data -- casting an indexed key column, and any rename, cast or nullability change of a covering column -- because the read path resolves covering columns from the live schema while index storage still emits the old name and type. A partial `merge_insert` that updates a covered column is rewritten as a row-move on the indexed-scan path, so only the rows it touched leave the covering index instead of the whole fragment. The move is an optimisation and never fails the operation: stable row ids, sources carrying inserts, legacy v1 blob columns and partial struct subschemas each fall back to the in-place path, which is correct for all of them. Four public surfaces reported a covered index wrongly -- one answering "no index on this column" for an indexed column, another returning an unrelated column's centroids. All are the same mistake: `fields` answers "what invalidates this index", not "what it can serve". The keyed prefix answers the second.
…ilds
A vector index built as separate shards and merged afterwards now carries its
covering columns through instead of rejecting them. Each shard stores its
covered values and the merge step classifies them from the shard schema, using
each storage format's own list of internal column names so a new internal
column is excluded everywhere at once. Both distributed commit styles are
covered by tests that check query results against the base table.
BREAKING CHANGE: the four public `init_writer_for_{flat,pq,sq,rq}` functions in
lance-index each take a new `covering_fields` argument. Pass an empty slice for
the previous behaviour. `VectorStore` also gains a required `INTERNAL_COLUMNS`
associated const naming the storage's own non-covering columns. No compatibility
overloads were added: an empty slice is trivially expressible at the call site,
and carrying a second name for each function would outlive the reason for it.
…ed column lance-format#8535 declared `IndexMetadata.covering_fields` but left the physical side unspecified; this specifies it -- carried values are extra columns in `auxiliary.idx`, discovered by exclusion against the quantizer's internal columns and bound to their source fields by a new `covering_field_ids` metadata key, with no `index_version` bump. It also permits an index to carry a column it is also keyed on, the only case where an id repeats in `fields`, so readers must take the carried set from `covering_fields` rather than subtract the keyed prefix. The "Current state" note becomes a rule about verifying each segment rather than a snapshot of which writers exist, so it stays accurate as implementations land instead of needing an edit to this spec each time one does. Adds `VectorQueryProto.covering_projection` (field 15) to reserve the tag, with the one initializer the new field forces on `query_to_proto`; no writer emits carried values yet, so the implementation follows separately.
A covered index materialised every column it carries on every query and loaded those columns with every partition it probed, caching them beside the quantization codes -- so a query touching none of them still paid for all of them twice, and a wide covering column evicted the codes it shared a cache entry with. This narrows both reads: the scanner declares which covering columns a query actually reads, and partition loads now fetch only the storage's own columns, with covering values read by position for the rows that survive scoring and not cached. BREAKING CHANGE: `lance_index::vector::Query` gains a `covering_projection` field. Construct it with `..Default::default()`, or pass `None` for the previous behaviour. BREAKING CHANGE: `IvfPq::load_partition_storage` gains a required `columns: PartitionColumns` parameter, between `partition_id` and `io_stats`. Partition loads now read only the storage's own internal columns by default, so a caller must say which set it wants: `PartitionColumns::Internal` reproduces the new default and `PartitionColumns::All` reproduces the previous behaviour of loading the covering columns too.
Covering was reachable only from Rust: both bindings could read an index's `covering_fields` back, but neither could create a covered index, because both create paths passed an empty list. Java gains `VectorIndexParams.coveringColumns`, read through JNI into the Rust params. Python gains an `covering_columns` argument on `create_index`, `create_index_uncommitted` and the shared implementation they both funnel through -- all three, since threading only some of them leaves a path that silently builds an uncovered index. Passing it where it cannot be honoured now raises instead of being dropped. Python also surfaces covering when reading an index back: the ids an index carries, their names resolved against the current schema, and both in the description's repr, so a covered and an uncovered index no longer print identically.
vivek-bharathan
force-pushed
the
vb/covering-refine
branch
2 times, most recently
from
September 4, 2026 03:44
a8d9c4f to
7c028a1
Compare
PQ, SQ and RQ store only lossy codes, so a query with `refine_factor` re-ranks by taking full-precision vectors from the base table -- the widest column most tables have, and exactly the take covering exists to eliminate. `store_vectors_for_refine` carries those vectors in the index so the re-rank reads them from storage and the take disappears. BREAKING CHANGE: `VectorIndexParams` gains a public `store_vectors_for_refine: bool` field. The struct is not `#[non_exhaustive]`, so code constructing it with an exhaustive struct literal needs one added line.
vivek-bharathan
force-pushed
the
vb/covering-refine
branch
from
September 4, 2026 04:11
7c028a1 to
6eae3de
Compare
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 #8959 — only the last commit is new here.
PQ, SQ and RQ store lossy codes, so a query with
refine_factorre-ranks by taking full-precisionvectors from the base table — the widest column most tables have, and exactly the take covering
exists to eliminate.
store_vectors_for_refinecarries those vectors in the index so the re-rankreads them from storage.
Carries the spec commit from #8856 (under PMC vote) via the stack, hence the
format-changelabel;it drops out on rebase once #8856 merges.
BREAKING CHANGE:
VectorIndexParamsgains a publicstore_vectors_for_refine: boolfield. Thestruct is not
#[non_exhaustive], so code constructing it with an exhaustive struct literal needsone added line.