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.
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:
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;
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.