Skip to content

feat(index): covering columns for IVF vector indexes - #8811

Draft
vivek-bharathan wants to merge 3 commits into
lance-format:mainfrom
vivek-bharathan:vb/covering-fields-subset
Draft

vivek-bharathan wants to merge 3 commits into
lance-format:mainfrom
vivek-bharathan:vb/covering-fields-subset

Conversation

@vivek-bharathan

@vivek-bharathan vivek-bharathan commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Lets an IVF vector index store the values of chosen extra columns alongside its quantization
codes, so a search returns them directly. A query whose projection those columns satisfy skips
the base-table take.

Covers every IVF index type (PQ, SQ, RQ, FLAT and the HNSW variants) and the index lifecycle:
schema evolution, overlays, remap, compaction, optimize_indices, merge_insert and concurrent
commits. Builds on #8535 and #8856.

@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 breaking-change labels Aug 26, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 26, 2026
@vivek-bharathan
vivek-bharathan force-pushed the vb/covering-fields-subset branch from bedba12 to bfac834 Compare August 26, 2026 20:20
@lance-gatekeeper lance-gatekeeper Bot removed K-risk Latest Gatekeeper recommendation includes a non-blocking risk. K-approved Latest Gatekeeper recommendation permits acceptance. labels Aug 26, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 26, 2026
@vivek-bharathan
vivek-bharathan force-pushed the vb/covering-fields-subset branch from bfac834 to f64966f Compare August 27, 2026 04:51
@lance-gatekeeper lance-gatekeeper Bot removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 27, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 27, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-changes Latest Gatekeeper recommendation requests changes. and removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 27, 2026
@vivek-bharathan
vivek-bharathan force-pushed the vb/covering-fields-subset branch from f64966f to 82c2044 Compare August 27, 2026 15:13
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 27, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 27, 2026
@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 27, 2026
@vivek-bharathan
vivek-bharathan force-pushed the vb/covering-fields-subset branch from de5fbec to 67fcc1c Compare August 27, 2026 18:30
@lance-gatekeeper lance-gatekeeper Bot removed K-risk Latest Gatekeeper recommendation includes a non-blocking risk. K-approved Latest Gatekeeper recommendation permits acceptance. labels Aug 27, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 27, 2026
@vivek-bharathan
vivek-bharathan force-pushed the vb/covering-fields-subset branch from 67fcc1c to 4add6f5 Compare August 27, 2026 19:39
@lance-gatekeeper lance-gatekeeper Bot removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 27, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Gate recommendation: approve with a non-blocking risk.

The revised test now exercises covered take elision without conflating that contract with HNSW’s default search beam. Production behavior is unchanged, and the covering correctness and lifecycle assessment remains supported.

Covered searches can still be materially slower than a plain index when results stream per partition—HNSW, query_parallelism > 1, or a pending deferred-remap/fragment-reuse state—because payload reads scale with probed or contributing partitions instead of final k. For latency-sensitive deployments, prefer a global-top-k IVF path and avoid or finish deferred remaps, or let planning fall back to the base-table take when survivor positions are unavailable.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 27, 2026
@vivek-bharathan
vivek-bharathan force-pushed the vb/covering-fields-subset branch from 4add6f5 to 4e867ff Compare August 30, 2026 19:53
@vivek-bharathan vivek-bharathan changed the title feat(index)!: covering columns for vector indexes feat(index)!: covering columns for IVF vector indexes Aug 30, 2026
@lance-gatekeeper lance-gatekeeper Bot removed K-risk Latest Gatekeeper recommendation includes a non-blocking risk. K-approved Latest Gatekeeper recommendation permits acceptance. labels Aug 30, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate recommendation: request changes.

This revision makes covering_fields a serving guarantee even though the accepted format contract defines it only as a declaration. The safe dependency order is to land #8856 first, then validate each selected segment’s physical field-ID bindings and retain the base-table take for anything not proven present.

let mut fields = KNN_INDEX_SCHEMA.fields().to_vec();
let included_ids = indices
.first()
.map(|idx| idx.covering_fields.clone())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covering_fields cannot define the emitted schema until the selected segment’s physical capability is verified. The current format contract explicitly makes declaration-without-payload legal and requires a base-table fallback. Here the declaration widens ANNIvfSubIndexExec, so Scanner::take skips that fallback before storage is checked.

Reproducer

I added this focused regression in an isolated worktree:

#[tokio::test]
async fn test_declared_covering_without_storage_falls_back_to_take() {
    let uri = TempStrDir::default();
    let mut dataset = covering::write_vector_payload_dataset(&uri).await;
    covering::create_ivf_pq_index(&mut dataset, "vec").await;
    covering::declare_covering(&mut dataset, "vec", "payload").await;

    let query = generate_random_array(covering::DIMENSION as usize);
    let mut scan = dataset.scan();
    scan.nearest("vec", &query, 10).unwrap();
    scan.project(&["payload"]).unwrap();
    assert!(scan.try_into_batch().await.is_ok());
}

Run with:

CARGO_TARGET_DIR=/home/agent/tmp/pr8811-target-impl cargo test -p lance --lib dataset::tests::dataset_index::test_declared_covering_without_storage_falls_back_to_take --locked -- --exact --nocapture

It fails in 0.37s with PhysicalExpr Column references column 'payload' at index 2 ... input schema only has 2 columns: ["_distance", "_rowid"].

#8856, this PR’s stated prerequisite, defines the missing field-ID binding but is still open and absent from this head. Land/rebase it first, persist and validate those bindings for every selected segment, derive effective coverage only from columns every segment proves, and leave unverifiable fields to the base-table take.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 30, 2026
@vivek-bharathan
vivek-bharathan marked this pull request as draft August 30, 2026 20:36
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 30, 2026
@vivek-bharathan vivek-bharathan changed the title feat(index)!: covering columns for IVF vector indexes feat(index): covering columns for IVF vector indexes Aug 30, 2026
@vivek-bharathan
vivek-bharathan force-pushed the vb/covering-fields-subset branch 2 times, most recently from 8e5b530 to b125075 Compare September 3, 2026 14:25
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.
@vivek-bharathan
vivek-bharathan force-pushed the vb/covering-fields-subset branch from b125075 to 7d0ac19 Compare September 3, 2026 21:24
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.

1 participant