ADR 0002 — Optional IVF index as an attachable per-collection object¶
Status: accepted · Date: 2026-08-22
Context¶
ADR 0001 rejected IVF coarse partitioning as a default at the 553k benchmark scale: the best recall-preserving cell was 1.63x, and ~0.4 ms fixed cost per probed shard ate the savings. That rejection does not extend to larger collections — the flat scan is linear in N while IVF scans ~nprobe/nlist of it — and HNSW-class alternatives remain out of scope (no graph hooks in turbovec's public API; 1.5–2x raw-vector RAM is hostile to the small-container goal). Requirements set by the maintainer: collections must still be creatable without an index; the index is an additional object clients can add to an existing collection or remove; when an index makes sense must be clearly documented (README + docs).
Evidence (all committed under bench/)¶
| Measurement | Source | Result |
|---|---|---|
| 553k real corpus, IVF sweep | bench/ivf_probe.py, ivf-probe.log |
best ≥0.95-recall cell 1.63x → not worth it (ADR 0001) |
| 2.2M scaled corpus (4x, jittered near-dups, DGX) | ivf-probe-s4.log |
flat 18.1 ms; nlist=256 nprobe=16 → 5.1 ms @ 0.980, nprobe=8 → 2.7 ms @ 0.974 |
| Per-shard fixed RAM | bench/shard_mem_probe.py |
~0.5–1 MB/shard (rotation is NOT a dense 9.4 MB d×d per shard); nlist=256 ≈ 1.14x flat at 2.2M |
| In-process RSS deltas | ivf_probe.py rss logging |
unreliable across builds (allocator reuse) — hence the isolated probe above |
Scaled-corpus recall is flattered by near-duplicates (flat itself: 0.985 vs 0.965 on real data); the honest recall anchor is the real 553k corpus: nprobe=16 costs ~1 pt.
Decisions¶
| Decision | Rationale |
|---|---|
Opt-in via POST/DELETE /collections/{name}/index; default stays flat |
Rejected-as-default ≠ rejected-as-option; below ~1M the flat scan wins (and micro-batches concurrent queries, which shard probing breaks) |
Retain fp16 vector copies in meta.db (always on) |
turbovec cannot enumerate/reconstruct vectors (ADR 0001), so attach-to-existing and detach are impossible without originals. fp16 halves the cost (dim × 2 B/record, disk-only); re-quantization error vs f32 is negligible for 4-bit codes |
Blobs in a separate vecs table, not a records column |
3 KB/row inline blobs drag GBs through every records full-table scan: measured cold start 2.2 s → 25.8 s at 552k with an inline column; separate table restores lean scans (reconcile, indexed_counts, allowlists) |
One live representation (flat or ivf/ shards), catalog index_config decides |
Keeping both would double RAM and complicate writes; a stale sibling dir after a crash is ignored and cleaned by the replayed job |
| Attach/detach are jobs on the existing ingest queue | Serializes them against ingest (worker is the only adder), makes them durable/replayable/idempotent, and gives free progress reporting |
| Build outside the write lock; ghost-diff concurrent deletes at swap time | Searches keep serving during the tens-of-seconds rebuild; deletes are the only concurrent mutation and are removed from the new shards under the lock |
| Shards calibrated from a fresh sample before rows are added | Calibrate-early-or-never (ADR 0001) applies cleanly because rebuilds re-encode from retained originals — no double quantization |
| Per-shard allowlist intersection via lazy per-shard id caches | turbovec raises on allowlist ids an index doesn't hold; caches (~8 B/row, built on demand, dropped per touched shard) also give small filters exact owner-shard probing — no recall loss for ≤128-id allowlists |
remove(id) = contains-scan over shards |
O(nlist) µs-scale calls beat maintaining a persistent id→shard map |
| nlist auto ≈ rows/8192 (power of two, 16–1024); nprobe default 16, per-query override, nprobe-only retune without rebuild | Matches the measured sweet spot; recall/speed stays a runtime knob |
| Centroids fixed after attach | Milestone-refit equivalents lose recall (cal_probe); heavy drift is handled by re-POSTing the index (full retrain) |
Legacy rows without vec: backfill by re-embedding text, else fail with a count |
Only correct when the configured endpoint produced the originals — documented; vector-only legacy rows require re-ingest |
_retry_fs around dir/file swaps |
Windows AV/indexer holds transient handles on fresh files; same class of retry turbovec's _persist does |
| cgroup headroom check before rebuilds | Attaching at 552k inside the 1 GiB bench container was SIGKILLed (2nd codes copy + 0.4 GB train sample); since the job replays on boot, an OOM becomes a crash loop — refuse with a job error instead |
Rejected¶
- HNSW / graph index on top — no API surface for neighbor graphs; belongs in the turbovec kernel if ever (upstream feature, not ours).
- Always-on IVF — measurably worse at ≤553k (ADR 0001).
- Retain f32 instead of fp16 — 2x disk for no measurable recall benefit at 4-bit.
- Retention opt-in per collection — would make "add an index to an existing collection" silently impossible for collections created without the flag.
Verification (552k served, DGX Spark, bench/results-*.md)¶
- No regression unindexed (
results-vec-regression.mdvs the committed run): search p50 9.3 vs 9.0 ms, recall 0.976 (=), hybrid 15.3 vs 14.5 ms, ingest 1,060 vs 1,105 vec/s (−4%, the fp16 blob writes), cold start 4.6 vs 2.2 s (larger meta.db; an earlier inline-blob-column draft measured 25.8 s — hence the separatevecstable). - Indexed at 552k, nlist=256 nprobe=16, 3 GiB container (
results-ivf-553k.md): p50 9.3 → 6.8 ms, serial QPS 101 → 144, concurrent QPS ~flat, filtered p50 8.0 → 15.8 ms, recall 0.976 → 0.960. Attach 40 s, detach 24 s, cold restart with 256 shards 1.9 s, all online through the job queue. - Headroom guard: replaying the attach job in the 1 GiB container produced the clean job error ("needs ~1172 MB free, container has ~484 MB") instead of the SIGKILL loop it produced before the guard.
Consequences¶
meta.dbgrows bydim × 2bytes/record for every collection (1536-d ≈ +3 KB/rec).- Ingest writes one more blob per record; measured impact within noise at bench scale.
- Batched concurrent scans degrade to per-query probing on indexed collections — documented; high-QPS small collections should stay unindexed.
- Docs:
docs/indexing.md(when/how), README section, API reference, storage sizing.