You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
lance-spark: feat: distributed vector index creation #605 — the distributed CREATE INDEX baseline this work extends. The mode option introduced here lives on top of that PR's AddIndexExec / VectorIndexJob skeleton.
The R1 (driver-only) implementation in this PR is independent of #7319 and lands first; once #7319 ships, runIncremental(...) switches to the distributed path with no SQL surface change.
Problem
ALTER TABLE … CREATE INDEX … USING IVF_* currently always does a full rebuild: the driver re-trains IVF centroids (and the PQ codebook for PQ variants), every fragment is re-indexed in parallel by executors, and the resulting segments atomically replace the
previous index via commitExistingIndexSegments (see AddIndexExec.scala and VectorIndexJob.scala from #605).
Once a table has data appended after its index was built, the only way to make the index cover the new fragments is to re-run the same CREATE INDEX. Even when only a small number of fragments were added, this:
Re-trains centroids / PQ codebook (k-means runs up to max_iters iterations).
Rebuilds segments for every fragment, including ones that haven't changed.
Replaces all old segments (every Index.uuid rotates), which is wasteful for downstream consumers that cache or materialize per-segment artifacts.
SHOW INDEXES already exposes num_unindexed_fragments / num_unindexed_rows, so users can observe what's missing — but there's no SQL surface to act on it incrementally.
Motivation
Cost. Re-training + full rebuild is paid on every ingest cycle. For typical production tables (tens of millions of vectors, training cost in the tens of minutes), repeated full rebuilds for small appends are clearly disproportionate.
Consistency. Incremental builds must reuse the same centroids / codebook; otherwise queries see inconsistent semantics across segments of one logical index. lance-core's Dataset.optimizeIndices(OptimizeOptions.retrain=false) already provides this contract — it
just isn't exposed via Spark SQL today.
Segment stability. Incremental builds produce add-only segments (old segments untouched), which is friendlier to downstream consumers that key off Index.uuid.
Proposed change
Add a mode named argument to the WITH clause; values replace (default, preserves current behavior) or incremental:
ALTERTABLElance.db.items CREATE INDEX vec_idx USING ivf_pq (embedding) WITH (
mode ='incremental'
);
Contract
mode='incremental' requires the named index to already exist; otherwise an IllegalArgumentException("Index '<name>' does not exist; cannot run in incremental mode") is raised. We do not silently fall back to full build — that would defeat the purpose.
All other WITH(...) arguments are ignored with a warning. Centroids / codebook / num_partitions / etc. are inherited from the existing index.
When num_unindexed_fragments == 0, returns (0, indexName) and commits no transaction.
The fragments_indexed output column means "fragments newly covered by this run" in incremental mode.
Implementation (R1, driver-only)
AddIndexExec.run() parses mode first and short-circuits to runIncremental(...), which calls Dataset.optimizeIndices(OptimizeOptions.builder().indexNames([name]).retrain(false).build()) on the driver. Build runs single-threaded on the driver in this stage — not
distributed yet — but the SQL surface, parameter whitelist, observability, and regression tests are all complete.
R2 (truly distributed incremental build) depends on lance-format/lance#7319 to expose centroids / codebook readers in Rust / Java / Python. Design docs:
Once #7319 lands, runIncremental(...) swaps to the distributed path; no SQL surface changes.
Structural parameter changes (e.g. changing num_partitions) — not supported in incremental mode; users must use mode='replace'. Documented.
Segment compaction — repeated incremental runs grow segment count over time. A follow-up OPTIMIZE INDEX … COMPACT subcommand should be considered as a separate PR.
Dependencies
This proposal depends on:
CREATE INDEXbaseline this work extends. Themodeoption introduced here lives on top of that PR'sAddIndexExec/VectorIndexJobskeleton.The R1 (driver-only) implementation in this PR is independent of #7319 and lands first; once #7319 ships,
runIncremental(...)switches to the distributed path with no SQL surface change.Problem
ALTER TABLE … CREATE INDEX … USING IVF_*currently always does a full rebuild: the driver re-trains IVF centroids (and the PQ codebook for PQ variants), every fragment is re-indexed in parallel by executors, and the resulting segments atomically replace theprevious index via
commitExistingIndexSegments(seeAddIndexExec.scalaandVectorIndexJob.scalafrom #605).Once a table has data appended after its index was built, the only way to make the index cover the new fragments is to re-run the same
CREATE INDEX. Even when only a small number of fragments were added, this:max_itersiterations).Index.uuidrotates), which is wasteful for downstream consumers that cache or materialize per-segment artifacts.SHOW INDEXESalready exposesnum_unindexed_fragments/num_unindexed_rows, so users can observe what's missing — but there's no SQL surface to act on it incrementally.Motivation
Dataset.optimizeIndices(OptimizeOptions.retrain=false)already provides this contract — itjust isn't exposed via Spark SQL today.
Index.uuid.Proposed change
Add a
modenamed argument to theWITHclause; valuesreplace(default, preserves current behavior) orincremental:Contract
mode='incremental'requires the named index to already exist; otherwise anIllegalArgumentException("Index '<name>' does not exist; cannot run in incremental mode")is raised. We do not silently fall back to full build — that would defeat the purpose.WITH(...)arguments are ignored with a warning. Centroids / codebook /num_partitions/ etc. are inherited from the existing index.num_unindexed_fragments == 0, returns(0, indexName)and commits no transaction.fragments_indexedoutput column means "fragments newly covered by this run" in incremental mode.Implementation (R1, driver-only)
AddIndexExec.run()parsesmodefirst and short-circuits torunIncremental(...), which callsDataset.optimizeIndices(OptimizeOptions.builder().indexNames([name]).retrain(false).build())on the driver. Build runs single-threaded on the driver in this stage — notdistributed yet — but the SQL surface, parameter whitelist, observability, and regression tests are all complete.
R2 (truly distributed incremental build) depends on lance-format/lance#7319 to expose centroids / codebook readers in Rust / Java / Python. Design docs:
Once #7319 lands,
runIncremental(...)swaps to the distributed path; no SQL surface changes.Scope (this PR)
AddIndexExec.scala: addextractMode/runIncremental, plusAddIndexExeccompanion object (MODE_REPLACE/MODE_INCREMENTAL).VectorIndexParamsResolver.scala: includemodeinCommonKeyswhitelist so it doesn't fail validation as an unknown parameter.BaseIncrementalAddVectorIndexTest.java: 7 JUnit 5 cases covering happy path, no-op, missing-index rejection, empty table, IVF_PQ, explicitmode='replace', unrecognized mode.lance-spark-3.4_2.12andlance-spark-3.5_2.12.docs/src/operations/ddl/create-index.md: new "Incremental Mode" section +moderow in the options table.docs/design/: three design documents (incremental plan / lance-core API design / RFC draft) plus a pyo3 investigation report.Out of scope / follow-ups
num_partitions) — not supported in incremental mode; users must usemode='replace'. Documented.OPTIMIZE INDEX … COMPACTsubcommand should be considered as a separate PR.