Phase 5 — Capabilities: Vector Search
Phase 5 adds in-core vector search — a vector(N) type, an HNSW access method, the <->/<=>/<#> distance operators, and top-k KNN — that branches and scales-to-zero with the database, additive (ENGINE_ABI_VERSION 3, STORAGE_TRAIT_VERSION stays 2), plus composition (better-auth / PostgREST / DuckDB) around the core.
Overview
Phase 5 grows the platform by the Capabilities deciding rule (spec 12, roadmap §Phase 5): storage/execution capabilities go INTO the engine; interface/service capabilities are COMPOSED AROUND it. The one capability that is built in is vector search; the three that are composed around (better-auth, PostgREST, DuckDB) are adopted unmodified and demonstrated as glue, never welded into the core.
Everything is additive and the storage seam never moves: ENGINE_ABI_VERSION goes to 3 (the vector type, HNSW, distance operators, and the v… bind encoding — no C symbols added or removed), while STORAGE_TRAIT_VERSION stays 2. The vector index needs no new storage capability: it rides the existing append_wal/replay path exactly like the rows do.
Built IN — vector search (the headline)
| Deliverable (spec 12) | Where |
|---|---|
vector(N) type — fixed-length f32, dimension declared at column time, validated on insert | crates/engine/src/value.rs (Value::Vector, ColumnType::Vector(u32), parse_vector/format_vector), enforced in exec.rs::check_vector_dims |
HNSW access method behind the same Storage trait the rows use | crates/engine/src/vector.rs (VectorIndex — multi-layer navigable small world, hnswlib/usearch lineage, dependency-free) |
| Distance operators the planner pushes into the index scan | <-> L2, <=> cosine, <#> inner product (sql.rs lexer/parser + exec.rs::vec_distance) |
| Top-k nearest-neighbour answered by the access method, not a full scan + sort | exec.rs::knn_select (+ knn_plan): detects ORDER BY <col> <dist-op> <q> ASC LIMIT k, searches the index, MVCC-filters the candidates |
Why it branches / scales-to-zero / S3-backs for free
The engine is WAL-centric: rows are not stored as pages, they are replayed from the durable WAL into the in-memory MVCC store on open (db.rs::replay). The vector index is built the same way — it is a derived structure over the column's vectors, not a side file. The only durable artifacts are the CreateIndex/DropIndex WAL ops (wal.rs) and the vector values themselves; the graph is rebuilt by Store::rebuild_indexes after replay (the index's cold-start "warm"). Three properties fall straight out, with no special-casing:
- Branching branches the index (spec 12's differentiator, gated by
tests/vector.rs::branch_branches_the_vector_indexand the Bunbranching forks the vector indextest). A branch is aBranchStoragewhose replay includes the parent'sCreateIndex+ the in-window inserts, so the branch rebuilds its own graph; its diverged writes never touch the base. - Scale-to-zero: idle compute drops, the next open replays the WAL and rebuilds the graph — the warm is the replay.
- S3-backing / PITR: the vectors and the index definition are LSN-versioned WAL records like everything else, so the object backend and point-in-time window cover them unchanged (
s3://works with no recompile, same as rows).
MVCC + index maintenance
The index maps vid -> vector for every row version that has one; visibility is resolved at query time against the row version the vid identifies, so the index stays MVCC-agnostic. Inserts/updates add the new vid (Store::index_row_inserted); deletes need no index change (the row version's delete_lsn filters it out); a rolled-back pending insert is tombstoned out (Store::rollback_pending). The KNN scan over-fetches (KNN_OVERFETCH) to absorb invisible/filtered hits, then takes the first k that pass WHERE and snapshot visibility.
SQL surface added
embedding VECTOR(768)column type (dimension required, validated).- Vector literals
[1, 2, 3]and'[1,2,3]'text coercion (pgvector-style). CREATE INDEX name ON table USING hnsw (col [vector_cosine_ops]) [WITH (m=…, ef_construction=…, ef_search=…, metric='cosine'|'l2'|'inner_product')]andDROP INDEX [IF EXISTS] name(autocommit DDL, likeCREATE TABLE).- Distance operators usable in projection,
WHERE, andORDER BY. Without a matching index the query still works as a brute-force scan + sort (the operator evaluates per row); with one it is answered by the access method — same results, proven bytests/vector.rs::hnsw_index_answers_top_k_and_matches_brute_force.
Composed AROUND — placement, not core code
Per the rule, none of these enter the engine binary; the core stays "rows + vectors + the storage trait". They are demonstrated as the thin glue we own:
- better-auth (service, in-process). The real library runs in-process against a Twill database adapter (
clients/bun/src/better-auth.ts) that stores users/sessions/accounts as ordinary rows — no external auth service, and because it is rows it branches and recovers with the database. The adapter maps better-auth's CRUD contract onto the SQL subset (OR-chain forIN, client-sideOFFSET, literalLIMIT). Exercised end-to-end (sign-up/sign-in/session + branch isolation) inclients/bun/test/better-auth.test.ts; runnable democlients/bun/examples/auth-app.ts. The conceptual shape also appears incompose.ts. - PostgREST (interface, in front). Attaches over server mode (pgwire, Phase 3) with zero engine changes — wire-compatibility is the contract. Absent in embedded deployments; nothing to build here.
- DuckDB (OLAP, over shared storage). A second engine over the same floor. The only code we own is the materialization job (
clients/bun/src/olap.ts): snapshot a table to an open columnar file (Parquet) on the shared store, atomically (temp + rename), on a configurable cadence (Materializer); DuckDB reads it directly. DuckDB is also the off-the-shelf Parquet writer the job shells out to, falling back to CSV (read_csv_auto) when it is absent. Verified end-to-end inclients/bun/test/olap.test.ts(CSV export + atomic publish always; the Parquet→DuckDB aggregation round-trip where the DuckDB CLI is installed, reported skipped otherwise); runnable democlients/bun/examples/duckdb-olap.ts. The conceptual shape also appears incompose.ts.
Tests
crates/engine/tests/vector.rs— type round-trip + dimension validation, the three distance operators (brute force), HNSW top-k vs brute-force parity,WHERE-filtered + MVCC-correct KNN, branch isolation of the index, rebuild from WAL on restart, rollback tombstoning, exact nearest over a larger set, and theCREATE INDEXguards.crates/engine/tests/ffi.rs::vector_search_via_c_abi— the vector type, an HNSW index, and av…-bound query through the same C ABIbun:ffibinds.clients/bun/test/vector.test.ts— the embedded path end-to-end, including the branch-the-memory payoff. Examples:vector-memory.ts,compose.ts.
Deliberate scope boundaries (documented, not accidental)
- The index is derived, not a side file. Spec 12 says "store the graph as pages, not a side file"; the engine's rows are themselves WAL-derived in-memory state, so the consistent realization is a WAL-derived index — same durability path, same branch/scale-to-zero semantics, never a side file. The literal page layout is a cold-read optimization, now realized additively (see Vector hardening below), not a separate durable graph.
- Composed engines are adopted, not vendored. better-auth/PostgREST/DuckDB are external; Phase 5 builds only the glue (the vector capability and the materialization job), per spec 12's "build only thin glue".
- SQL subset unchanged otherwise. Joins, GROUP BY, subqueries, and
DISTINCTremain out of scope;ORDER BYranks by expressions, not output aliases.
Vector hardening (#89)
The three "MAY"/open-question items above are now implemented — additively, with the storage seam untouched (STORAGE_TRAIT_VERSION stays 3, ENGINE_ABI_VERSION stays 3). Branching and scale-to-zero still rebuild each branch's graph from its own WAL (the warm is the replay), gated by tests/vector.rs::branch_branches_the_vector_index.
- VH-3 — recall tuning surface (#99).
ef_searchis now a per-session knob:SET twill.vector_ef_search = N(andTO N/TO DEFAULT),RESET twill.vector_ef_search, andSHOW twill.vector_ef_search. The value threads throughrun_select→knn_select→VectorIndex::searchas a pure parameter binding — no shared or durable state, so branching/scale-to-zero are unaffected. The recall/latency trade-off is documented with a measured curve in spec 12. - VH-2 — incremental maintenance under delete churn (#98). A committed delete/supersede marks its index node head-deleted (still navigable, still returned so older snapshots stay correct; the executor re-checks MVCC visibility). Two levers then keep recall up: KNN over-fetch widens by the dead/live ratio, and once head-deleted density crosses ~20% of a graph of ≥64 nodes a compacting rebuild fires automatically (off the read path, on the single-writer commit path) dropping the dead nodes. The compaction records a floor LSN; a snapshot below it falls back to a brute-force scan, so snapshot isolation is preserved across compaction (
tests/vector.rs::old_snapshot_survives_compaction). - VH-1 — page-laid-out index (#97). A freshly built graph is checkpointed as fixed-size, LSN-versioned page images through the existing
put_page/get_pagecontract (the trait never learns "index page vs row page"). A cold open adopts the checkpoint when it exactly reflects the replayed head, loading the graph in bounded page reads instead of replaying every vector through HNSW insertion; a missing, stale, or branch-private checkpoint simply takes the rebuild-from-rows path, so correctness never depends on the pages.
Related
append_wal/replay path, no new storage capability.
Lifecycle & ControllerScale-to-zero and branching the index follow for free — the warm is the WAL replay that rebuilds the graph.
Roadmap & Build SequenceThe phased plan whose Phase 5 capabilities gate this map implements.