Vector search

Semantic search lives in the same file as everything else. A vector is stored as plain data in the versioned tree, searched with scalar functions and ordinary SQL, and — at scale — accelerated by a versioned IVF / IVF-PQ index. No second service, no data leaving the file, and the same AS OF and verify() that cover the rest of your data cover your embeddings too.

On SIFT 1M (128-dim, recall ≈ 0.99) a single query sustains ~356 qps, and ~700 qps under concurrent load on 8 cores. The index is 14–21× smaller than the HNSW indexes it is measured against (pgvector, Qdrant) — roughly 39 MB per million vectors — so it fits in a fraction of the RAM. The build is parallel and streaming (≈186 s → 64 s), and never materialises the dataset, so it scales to tens of millions of vectors on a modest box.

Arkeion stores vectors, it doesn’t make them

Arkeion ships no models and no ML dependencies — that is a hard rule, and it is what keeps the engine deterministic and auditable. Embeddings are produced by your own model, on your side, and handed to Arkeion as data. The engine stores opaque floats and searches them; it never interprets them. What you get in return is that your vectors inherit everything the file already guarantees: versioning, the hash-chained history, AS OF, and verify().

A vector is just data

A vector is a BLOB of little-endian f32 — no new column type, no format change. You build and compare vectors with a handful of pure scalar functions:

-- vector(x, y, …)          pack floats into a BLOB
-- cosine_distance(a, b)    1 − cosine similarity; lower = more similar
-- l2_distance(a, b)        Euclidean distance;    lower = more similar
-- dot(a, b)                dot product;           higher = more similar

CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT, emb BLOB);
INSERT INTO docs (body, emb) VALUES ('…', vector(0.1, 0.2, 0.3));  -- or a BLOB from your client

SELECT id, body
FROM docs
ORDER BY cosine_distance(emb, vector(0.11, 0.19, 0.31))
LIMIT 10;

Distances accumulate in f64, and a dimension mismatch is an error, not a silent wrong answer. That ORDER BY … LIMIT is already exact K-nearest-neighbour: it walks the rows, computes the distance to each, and keeps the K best. Because it is just data in the versioned tree, adding AS OF VERSION n searches the past semantically — the same query, against the database as it stood at any commit.

Exact KNN is the foundation

Exact KNN — brute force over every candidate — is deterministic, complete and reproducible. Those are not accidental virtues: they are what an ANN index is measured against (it supplies the ground truth for recall) and what a re-rank step builds on. For collections up to ~1M vectors it is often enough on its own, especially with int8 vectors (below). Everything faster is built on top of it, not instead of it.

int8 quantization

vector_i8() stores a vector as a scale factor plus one signed byte per dimension (symmetric per-vector quantization, max|v| / 127). A one-byte tag distinguishes the formats (0x00 = f32, 0x01 = int8), and the distance functions unpack both transparently — an f32 query against int8-stored vectors just works. The result is ~4× less storage with a small, bounded loss of precision.

IVF and IVF-PQ: search that skips most of the data

Brute force reads everything. At scale you want to read almost nothing. An IVF (inverted file) index clusters the vectors with k-means and stores, per cluster, a posting list of its members. A query only has to scan the handful of clusters nearest to it — nprobe of them — instead of the whole collection.

k-means partitions the vectors into cells; the query scans only the nearest nprobe query nprobe = 3 cells scanned · the rest of the collection is never touched
The centroids are chosen once by k-means; a query ranks the centroids, opens the nearest nprobe cells, and ranks only the vectors inside them.

You create one with SQL, and the planner routes matching queries to it automatically:

CREATE VECTOR INDEX vi ON docs(emb) USING cosine LISTS 1024 PROBES 32;

-- an ORDER BY distance … LIMIT k (no WHERE) is now served by the index
SELECT id, body FROM docs
ORDER BY cosine_distance(emb, vector(0.11, 0.19, 0.31))
LIMIT 10;

LISTS is the number of clusters, PROBES (nprobe) is how many a query scans — the single dial between recall and speed (default ceil(lists / 10)). The index lives in the catalogue at schema v9, with the re-rank mode added at v10.

Why IVF, and not HNSW

The graph indexes that dominate benchmarks (HNSW) mutate on every insert. That is incompatible with a copy-on-write, versioned file: you cannot have an audit trail over a structure that rewrites itself constantly. IVF centroids, by contrast, are versioned data — chosen at a discrete CREATE or REBUILD event and then read, not mutated per insert. New rows are assigned to the nearest existing centroid, so the clustering drifts slowly; REBUILD VECTOR INDEX retrains it over the current data when you want it fresh. You trade a little peak recall for a vector index that time-travels and verifies like the rest of your data.

PQ codes vs inline int8 re-rank

To make the postings small, the index stores a product-quantization (PQ) code per vector — roughly dim / 8 bytes, about 8× smaller than the same vector in f32. That is what makes the whole index 14–21× smaller than pgvector’s or Qdrant’s HNSW. PQ narrows the field to a shortlist; the exact ORDER BY … LIMIT then re-ranks that shortlist for accuracy.

Where the re-rank reads its vectors from is a real tradeoff:

Default — PQ codes posting: PQ code (~dim/8 B) smallest index (~8× vs f32) re-rank fetches f32 per row — scattered shortlist → point-lookup each candidate random access — grows costly at scale RERANK int8 posting: int8 vector (~1 B/dim) ~2× larger index (still ≪ pgvector) re-ranks inline — no row fetch score during the cluster scan sequential scan ANN over SQL viable at 1M
PQ keeps the index smallest but re-ranks with scattered row fetches; RERANK int8 stores a little more per posting and re-ranks inline, so no per-candidate lookup is needed.

Both modes share one code path — build, incremental insert/update/delete, REBUILD and search all key off whether PQ codebooks are present — so switching mode does not fork the engine. Changing it does change the posting format, so an existing index must be rebuilt.

Hybrid search: lexical + semantic, fused by rank

Full-text (BM25) finds the exact words; vectors find similar meaning. You usually want both, and because BM25 and cosine both already exist as SQL, hybrid search needs no new engine feature — just a query. The robust way to combine them is Reciprocal Rank Fusion (RRF), which fuses by rank rather than raw score, so the unbounded BM25 scale and the [0, 2] cosine scale never have to be reconciled:

WITH ranked AS (
  SELECT id,
    ROW_NUMBER() OVER (ORDER BY bm25(body, 'rust') DESC)                       AS lex,
    ROW_NUMBER() OVER (ORDER BY cosine_distance(emb, vector(1.0, 0.0, 0.0)) ASC) AS sem
  FROM docs
)
SELECT id FROM ranked
ORDER BY 1.0 / (60 + lex) + 1.0 / (60 + sem) DESC
LIMIT 10;
Lexical (BM25) Semantic (vector) Fused (RRF) 1 · doc B 2 · doc D 3 · doc A 1 · doc D 2 · doc C 3 · doc B 1 · doc D 2 · doc B 3 · doc C score(doc) = Σ 1 / (60 + rank in each list) doc D ranks well in both → it wins
RRF rewards documents that rank well across signals. A result strong in both lexical and semantic search beats the best of either alone.

One file, three ways to ask a question — exact SQL, full-text and vectors — and one way to combine them, all versioned and provable together. See the benchmarks for the full vector numbers.