* seaweed-vector: dev plan for ANN search over S3 Vectors indexes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: dev plan: one probe count per query, a budget that can shrink Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: dev plan: smaller deltas are searched in full only up to the probe count Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: dev plan: drop the private issue reference Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: dev plan: phase 1 probes use the Design count Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: dev plan: status after phases 0 to 3, and what the measurements changed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: dev plan: name the Enterprise repository, not the private one Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: dev plan: the Enterprise repository by that name Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: dev plan: a filtered query widens to four times the probes, not without bound Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: dev plan: throughput through the index at 50k and 200k Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: dev plan: union coverage over shard names, and an explicit merge at the delta limit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: dev plan: status table names branches, not pull requests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: dev plan: maximum_nprobes is always four times the count Lance reads maximum_nprobes only under a prefilter, and the phase 1 policy sets it the same way for every query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: dev plan: the retrain follows the partition count, not a recorded row count Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: dev plan: phase 3 is open like the other phases Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: dev plan: the retrain stops past 2,048 partitions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: dev plan: the merge at the delta limit takes the name's delta count Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
19 KiB
S3 Vectors ANN — Dev Plan
Goal
Approximate nearest-neighbour search for S3 Vectors indexes, built on Lance vector indexes, so that query latency stops growing linearly with the index. Target: 90%+ recall@10 (what AWS states for S3 Vectors), exact results below a size threshold and for rows written since the last index build, and one code path that serves both the open-source single engine and the Enterprise distributed query layer. Writes stay unchanged.
Status (2026-11-04)
| Phase | Branch | State |
|---|---|---|
| 0 Build and measure | vector-ann-phase0 |
open: vector_index builder, weed-vector-cli index, PERF_INDEX, PERF_CLUSTERS |
| 1 Probe policy | vector-ann-phase1 |
open: policy in query::scanner, --nprobes, one Lance session per engine |
| 2 Build job | vector-ann-phase2 |
open: lance_vector_index in weed-worker, admin-driven end-to-end test, worker CI legs |
| 3 Keep it covering | vector-ann-phase3 |
open: relative budget, retrain rule, end-to-end steps |
| 4 Docs and gates | — | after the above merge |
| 5 Enterprise sharded builds | — | the Enterprise repository |
What the measurements changed:
- IVF needs structure in the data. On independent Gaussian vectors, the
perf suite's default, recall through an IVF index tracks the fraction of
partitions probed: at 50k × 384 with 224 partitions, 8 probes gave 0.13 and
64 probes 0.56, and latency did not fall with fewer probes. On vectors drawn
around 100 shared centres, 8 probes gave recall 1.0; with eight clients
the index served 145 queries/s against 52 exact at 50k and 70 against 11
at 200k, and the 10% filtered query at 200k fell from 445 to 265 ms,
while one client's latency stayed at the request path's floor on the
shared host. Embeddings cluster; the perf suite grows
PERF_CLUSTERSand the end-to-end test draws its rows around centres. The probe rule stands as written. - The engine opened every table in a fresh Lance session, so the index
cache died with each request. Phase 1 gives the engine one session, sized
by
--index-cache-mb. - Compaction keeps the index covering (Lance 10 remaps it), so the compaction risk below did not materialise.
Where things stand (2026-11-04)
What already exists, with the files that hold it:
- The engine is index-aware; nothing builds an index.
QueryVectorsincrates/server/src/service.rsalways callsweed_vector_core::query::ann, which runs a Lance scan withuse_index(true): ANN on fragments an index covers, a flat pass on the rest, decided per fragment by Lance (crates/core/src/query.rs).nprobesisNone.CreateIndexwrites an empty Lance dataset and no vector index, so today every fragment is "tail" and every query is an exact scan. - The maintenance worker only extends indexes that exist. The
lance_optimize_indicesjob inseaweed-worker/crates/lance/src/jobs/indices.rsskips a table whoseload_indices()is empty and otherwise callsoptimize_indices(&OptimizeOptions::default())oncenum_unindexed_rowsexceeds itsmax_unindexed_rowsbudget (default 100,000). - Coverage and segments are modelled.
crates/core/src/coverage.rsreports, per index name, the fragments covered and the uncovered rows; it requires the fragment bitmap each LanceIndexMetadatadelta carries.crates/core/src/segments.rslists physical segments (one per delta) with their names and bitmaps, and partitions them across workers.weed-vector-cli coverageandweed-vector-cli query --flatexpose both. - Filters are applied before ranking.
query.rssetsprefilter(true)unless the request asks for post-filtering, which S3 Vectors never does. Lance's IVF search honours the prefilter, so this is already the behaviour AWS calls anENHANCEDindex. - Enterprise consumes exactly this model. In the Enterprise repository,
enterprise/weed/pb/vector_coord.protodefinesSegmentInfo{id, resident_bytes, rows, fragment_ids}andSegmentList{tail_fragment_ids, tail_bytes};VectorQueryalready carriesnprobes; aTaskis a set of segment ids plus fragment ids.sw-vector-distributed/src/plan.rsassigns a fragment covered by several segments to the lowest segment and never mixes index names in one scan;resident_bytesis the index files' size.datasets.rswarms a slot withprewarm_index_segmentsper index name and warms the tail by scanning it.sw-vector-distributed/src/index.rsholdsplan_index(shard the uncovered fragments across workers),build_index_task(one delta per shard, named<column>-w<worker>-v<version>, built withcreate_index_builder(..) .fragments(..)) andoptimize_policy(append deltas untilMAX_DELTAS = 8, then Lance's default options, which promise no merge; see phase 3). Nothing calls these three yet;tests/index.rs,tests/plan_search.rsandtests/engine_query.rsexercise them withivf_flat(4)indexes. - Lance 10.0.0 offers
VectorIndexParams::ivf_flat,ivf_pq,with_ivf_sq_params,with_ivf_rq_params,ivf_hnswand HNSW variants; scanner knobsnprobes,minimum_nprobes,maximum_nprobes,ef,refine;OptimizeOptions{num_indices_to_merge, index_names, retrain}. - Measured today (384-d, cosine, top-10, one 16-core host, exact scans): one engine answers in 42 ms at 50k vectors and 82 ms at 200k; filtered queries take 81 and 259–336 ms. Four Enterprise engines split four ways bring 200k to 58 ms and the filtered query to 73 ms. Recall is 1.000.
- Tests assume exactness only on tiny tables.
test/s3vectors/s3vectors_test.pyasserts exact result order on indexes of a handful of vectors; the perf suite asserts recall@10 ≥ 0.9 against a client-side exact search. - AWS: "90%+ average recall for most datasets";
CLASSICandENHANCEDindex modes, the latter pre-filtering; no algorithm named.
Design
The contract every consumer relies on
An S3 Vectors index gains a Lance vector index on its vector column, and
everything downstream keeps working because it only ever sees:
- Segments: Lance index deltas with fragment bitmaps. Every build and
every merge must leave
coverage_known == true; a delta without a bitmap turns the whole index into "rebuild" for the Enterprise planner. - Names: one logical index name per shard, never two shards under one name within a build. Scans and warm-ups group by name.
- Tail: fragments no segment covers, searched exactly. Rows written after a build land here, so recall never drops to zero between builds.
- Bytes:
resident_bytesof a segment is its index files' size. The controller's W sizing (W_mem,W_scan) and the engine's--capacity-bytesbudget use it unchanged. - Versions: a build or merge commits a new dataset version. Routers cache
segment lists per version, plans pin versions with the lease tags in
crates/core/src/maintenance.rs, and cleanup respects those leases. No new mechanism is needed, but the build job must go through the same commit path as any Lance write.
Index type and parameters
- IVF_FLAT first. Distances inside probed partitions are exact, so recall
is governed by
nprobesalone; the only training is k-means over the centroids; resident bytes stay close to the raw vectors, which keeps the Enterprise W sizing in the same range as today.IVF_PQbecomes an operator option later for indexes whose raw size is the constraint. HNSW variants are out of scope: their build cost and memory do not fit the maintenance-worker model. - Metric from the table. The vector field's
distance_metricmetadata (COSINE,DOT_PRODUCT, else L2) is the metric the index is built with, exactly asservice.rsresolves it for queries. - Partitions: start at
clamp(round(sqrt(rows)), 1, 4096)for the whole table, and for a shard,sqrtof the shard's rows. Phase 0 measures recall and latency against this and adjusts before it becomes a default. - Probes: one count per query,
max(8, ceil(partitions / 10))clamped to the partition count, taken from the largest delta of the index. A delta with no more partitions than that count is searched in full; a larger one at that count. Lance searches at least that many partitions. Under a filter it may widen to four times the count until it has k rows, which a selective filter needs; an open upper bound made Lance prepare every partition for that and cost as much as the exact scan (201 ms against 104 ms for the 10% filter at 50k × 384). Without a filter it stops at the count.
Who builds, and when
The Lance maintenance worker (weed-worker) gets a new job type,
lance_vector_index, beside lance_optimize_indices:
- Detection: tables in a vector bucket's
vectornamespace (ids of the form<bucket>$vector$<index>) whose vector column has no index and at leastmin_rowslive rows (default 50,000). Below the threshold the index stays exact, which also keeps the existing small-table tests exact. - Execution: one
create_indexcall with IVF_FLAT, the table's metric, the partition rule above, and a fixed name (vector_idx). Progress and counters follow the pattern ofindices.rs; a newindexes_built_totalmetric and an admin observation of index coverage sit beside the row and fragment counts the worker already reports. - Why the worker and not the engine or the gateway.
CreateIndexhas no rows to index. The engine serves queries and should not run minute-long k-means builds. The worker already has the catalog client, credentials, the admin UI settings form, metrics, retries and dedupe, and plugin jobs start disabled until an operator turns them on, which is the rollout switch we want. - Enterprise sharded builds (phase 5): the same job, when Enterprise
engines are registered, splits the uncovered fragments with
plan_indexand has each engine runbuild_index_taskfor its shard, producing one named delta per shard. This is whatindex.rswas written for; the worker drives it, the engines build, and the sharedoptimize_policykeeps each name's delta count under eight.
Keeping it covering
lance_optimize_indices already does the work once an index exists. Two
changes:
- The budget becomes relative for vector tables: reindex when uncovered rows
exceed
min(absolute budget, 10% of indexed rows), so a 60k-row index reindexes after 6k new rows instead of waiting for 100k. - A retrain once the table has grown to about 4× the rows the centroids were trained on, so partitions do not go stale. No row count is recorded: the retrain is due when the partition rule for the current rows gives twice the partitions the index has, and it rebuilds a single IVF_FLAT name through the builder at that count. Past 2,048 partitions (about 4.2M rows) the rule cannot double under the 4,096 cap, so those centroids are not retrained again: that would need the trained row count recorded and a full rebuild of a table that size, which is left for later.
Deletes and upserts
A delete marks rows in Lance deletion files; an upsert is a delete plus an append. Deleted rows are excluded through the prefilter on indexed and unindexed fragments alike, and appended rows are tail until the next optimize. Phase 1 adds a test that proves both, since this is where ANN systems silently return stale hits.
API surface
No new S3 Vectors API parameter is needed to reach AWS parity. Two optional
items for later: accept and echo AWS's indexMode/queryMode (both behave
as ENHANCED here, because filters are always applied first), and a
non-AWS per-request nprobes for the perf suite and the CLI, carried in the
engine's QueryVectorsRequest (the Enterprise VectorQuery already has the
field).
Enterprise compatibility requirements
These hold for every phase, and the mono s3vectors legs (go/rust × single/distributed) are the gate:
- Builds produce deltas with fragment bitmaps; merges keep them. A test in
tests/index.rsassertscoverage_knownafter build, append and merge. - Index names never mix within a shard set; the router and
plan.rsgroup by name, andwith_index_segmentsrejects mixed names. - Segment count per index name stays under
MAX_DELTAS: at the limitoptimize_policy(deltas)passesOptimizeOptions::merge(deltas), wheredeltasis the name's delta count, so every delta of that name folds into one with the new rows; Lance's default options may append another delta instead of merging. A test runs repeated append-and-optimize cycles and checks the actual segment counts. - Coverage is measured as the union over an index's names. After a sharded build each name misses the other shards' fragments, so a per-name count would extend every shard over the whole table and duplicate the index. An optimize extends one name only, the one with the fewest deltas; a test checks that two disjoint shards stay disjoint through it.
resident_bytesis the index files' size andtail_bytesthe uncovered fragments' size; the controller'smax_scan_mband memory sizing need no change for IVF_FLAT, and PQ only makes slots smaller.- A merge replaces segment UUIDs, which is a new placement epoch. The
existing make-before-break (target groups warm with
prewarm_index_segmentswhile serving groups keep answering) covers it; phase 5 measures how long a merged segment takes to warm and sets expectations forwarmup_timeout. - Workers read index files the way they read data files, directly from the filers and volume servers, and the files are write-once, so the read cache and the warm path apply unchanged.
- Query semantics are identical in both modes: each engine applies the same
probe policy to the segments it owns, or the count the router passes in
VectorQuery.nprobes, plus a flat pass over its tail fragments; the gateway merges the partial top-k lists. Each delta is probed on its own centroids, so the merged answer is never worse than one engine's. - The open-source
weed-vectorstays the writer and the fallback engine, and it serves ANN through the samequery::annthe workers call.
Phases
Phase 0 — Measure before deciding (small)
weed-vector-cli index build --partitions N --name ..on a local table, reusing the code paths the tests already use, so builds can be tried without the worker.- Perf suite:
PERF_INDEX=1builds the index after ingest and reports exact versus ANN latency and recall@10 at 50k, 200k and 1M × 384, unfiltered and with the 10% filter, for a grid of partitions andnprobes. - Verify Lance behaviour we depend on: deletions excluded under ANN;
compaction (
lance_compact) keeps bitmaps and coverage;optimize_indiceswithappendversus merge versusretrain. - Exit: chosen defaults for
min_rows, partitions and probes with numbers behind them, and a written answer to each verification item.
Phase 1 — Engine query path (small)
- Probe policy when an index exists:
minimum_nprobesis the per-query count from the Probes rule above, taken from the largest delta;maximum_nprobesis four times it, which Lance uses only under a prefilter. nprobesinQueryVectorsRequestfor tests and the CLI; zero means the policy.- Per-query log line and counters: segments searched, tail rows scanned, index hit or not. Without these, recall regressions are invisible.
- Tests in
crates/core: ANN versus flat recall on a 50k synthetic table; delete and upsert correctness under ANN; filtered query with a selective filter still returns k results.
Phase 2 — Build job in the maintenance worker (medium)
lance_vector_indexjob: detection, execution, settings form (min_rows, index type, partition rule), metrics, admin observation of coverage.- Unit tests after the pattern of the other jobs; an integration test in
test/s3vectorsthat ingests above the threshold, runs the worker, and asserts coverage through the CLI and recall ≥ 0.9 through the API. - Exit: a fresh cluster with the job enabled indexes a 100k-vector table on its own, and the small-table tests stay exact.
Phase 3 — Keep it covering (small)
- Relative budget and
retrainpolicy inlance_optimize_indicesfor vector tables. The retrain rebuilds through the builder with partitions for the current row count; Lance'sOptimizeOptions::retrain()does not size them. - Union-based coverage over an index's names, extension scoped to one name,
and
optimize_policywith an explicit merge of all deltas atMAX_DELTAS, shared with the Enterprise builds in the worker crate. - Tests: append and optimize nine times and check the delta counts climb to the limit and fold; two names on disjoint fragments stay disjoint through an optimize; the end-to-end test runs the job after a tenth of the index is appended and again after the table has grown fivefold.
Phase 4 — Gateway, docs and gates (small)
- Optional
indexMode/queryModeacceptance;GetIndexechoes the mode. - Wiki and site docs: "Queries are exact" becomes "exact below the threshold, ANN above it, with the tail exact", with the recall and latency measured in phase 0. The performance page gets an ANN column.
- CI: a
test/s3vectorsleg with the worker and a low threshold, gated on recall.
Phase 5 — Enterprise distributed builds and placement (medium)
- Drive
plan_index/build_index_taskfrom the worker job when engines are registered; replace the Enterprise copy ofoptimize_policywith the shared one, which merges at the limit. - Measure four engines on 1M × 384 with ANN: latency, warm time of a merged segment, placement epochs per merge.
- mono s3vectors legs with the index enabled, go and rust volume servers, single and distributed, plus the hardened TLS legs.
Risks
- Filtered recall. AWS's weak spot is selective filters. Pre-filtering
keeps recall but costs probes;
minimum_nprobes/maximum_nprobesis the lever, and phase 0 has to show the latency is acceptable at 1M rows. - Memory. IVF_FLAT resident bytes are about the raw vectors: 1M × 384 ×
4 bytes is 1.5 GB per copy. Enterprise spreads it by
capacity_bytes; the single open-source engine needs that much RAM or PQ. - Compaction and coverage. Settled in phase 0: Lance 10 keeps the
bitmaps through
compact_files, and coverage stays complete. - Build cost on the worker. k-means over 1M × 384 is CPU-bound; execution concurrency stays at one and the job reports progress so the admin UI shows it working rather than hung.
- Placement churn after merges. Every merge is a new epoch; too frequent
merging makes engines re-warm constantly.
MAX_DELTAS = 8bounds it; the phase 5 measurement decides whether to raise it.
Out of scope
HNSW indexes, GPU builds, per-request recall parameters in the AWS API
surface, and building an index at CreateIndex time.