How it works
Six decisions carry the whole engine. Each one bought something and cost something, and both halves are written down here.
No page cache
Classic databases cut the disk into pages and copy those pages into a cache in user space. The eviction policy, dirty-page tracking, checkpoints and most of the locking cost all follow from that one structure.
fenecdb skips the layer:
- Segments are immutable. A write only appends to the active one, which is sealed at 8 MiB.
- The byte sequence on disk and the byte sequence in memory are the same format, so there is no caching stage — a read decodes directly over the arena slice.
- The only helper structure is the
HashMap<DocId, Loc>offset index. - Projection and filters decode a single field; the fields in front of it are skipped without allocating.
No eviction policy, no dirty pages, no warm-up. Dead bytes are reclaimed by
compact.
What it costs. The whole file is resident — not just the vector
arena. Open peak is about 2× the file, and compact or
checkpoint peaks at about 3×. Scale is bounded by memory, and the
numbers are in Limits.
Vectors are first-class
vector<N> is a type, @hnsw is an index,
near is a clause. Not a plugin — the language itself.
get articles where year >= 2024 near embed $1 ef 200 limit 10
get articles near embed $1 exact limit 10 -- exact scan, for verification- HNSW is written in-house. Every vector lives in one contiguous arena.
- Under the cosine metric vectors are normalised inside the index, so the query reduces to a dot product.
- Neighbour selection uses the diversity heuristic (Malkov & Yashunin,
Algorithm 4). Taking only the
mnearest candidates trapped the graph in local clusters and lowered recall. - The read-only part of the build — descent, beam search, neighbour selection — runs in parallel over a batch; only writing the links is serial. The result is independent of thread count and deterministic.
- The graph is written to the file, but only the links, not the vectors: the vectors are already in the document records, and the expensive part is the build.
A filter and a vector together
The filter set is extracted first. Then one of two paths runs:
- if the set is smaller than the candidate count an ANN walk would measure
anyway (
ef × m0), that set is scanned directly — cheaper, and exact; - otherwise the ANN runs and candidates go through a membership test. If the result lands under the limit, the filter set is scanned in full.
The fallback is not an optimisation, it is required. The membership
test is applied after the candidates are gathered. When the filter
field correlates with the vector, every one of the ef nearest
neighbours can be eliminated and the query would return empty while thousands
of documents match.
Half precision
Precision is part of the type, not of the index — the same choice pgvector
makes with halfvec. That shrinks the record and the arena
together; as an index option only the arena would shrink, and in the browser
the file image is in memory too.
create collection docs (embed vector<768, f16> @hnsw(cosine))The runtime representation does not change: values are f32
everywhere and conversion happens only at the storage boundary. Same data,
100 000 × 128:
| vector<128> | vector<128, f16> | |
|---|---|---|
| vector arena | 51.2 MB | 25.6 MB |
| file image | 57.9 MB | 32.3 MB |
| index build | 10.4 s | 12.5 s |
| ANN p50 | 0.127 ms | 0.147 ms |
| recall@10 | 100% | 99.6% |
The cost is arithmetic: distance over an f16 arena unpacks inside the loop.
Two things were measured and fixed. The expansion has to be branchless — the
first version called the exact conversion in codec, whose
subnormal branch killed auto-vectorisation and made the build five times slower
(52 s); the branchless version is one multiply and is bit-identical over all
65 536 values. And the fixed side of the diversity loop is unpacked once
rather than per comparison: 22.6 s down to 12.4 s.
The win is at the memory boundary. One million 768-dimension vectors take
about 3 GB as f32 and about 1.5 GB as f16. In
WASM32's 4 GB address space that is the difference between fitting and not.
Embedding model output sits comfortably inside the f16 range (±65 504, roughly
three decimal digits) and the measured recall loss is 0.3 points.
Bulk load first, index after
Writing the index during the load makes every insert a graph mutation. Building it afterwards turns the write path into a pure append and builds the graph in one parallel pass.
create collection docs (title text, embed vector<768>)
put docs [ ... 100 000 documents ... ]
create index on docs (embed) @hnsw(cosine)This is already the recommended order for SQLite and PostgreSQL. In fenecdb
create index exists for exactly this reason, and the comparison in
Benchmarks uses that order for all three engines.
Zero dependencies
The core uses std and nothing else: its own binary codec, its
own JSON, its own HNSW, its own lexer and parser, its own SCRAM. The reason is
the WebAssembly output — it has to stay small and auditable.
The rule covers fenec-core, fenec-ql,
fenec-wasm, fenec-http, fenec-pg and
fenec-import. fenec-core does dev-depend on
fenec-ql, which Cargo permits through a dev dependency, so tests
can be written as real queries.
What it costs. Every bug is ours, and so is every protocol detail. The server and client halves of SCRAM are both hand-written, which at least means they test each other.
Straight into the browser
fenec-wasm exposes a C ABI and there is no
wasm-bindgen. The glue is about 165 lines of JavaScript inside
web/fenec.js, alongside the query builder, the HTTP client and the
sync layer — one dependency-free ES module.
persist(db) and restore(db) move the snapshot in
and out of IndexedDB.
Threads are compiled out of wasm32. The parallel HNSW build path
must not enter that target. now() errors there too —
wasm32-unknown-unknown has no clock, so time is passed in as a parameter.
Single writer
Reads take a shared lock (Database::query), writes take the
exclusive one (execute_with). There are no transactions;
BEGIN and COMMIT are accepted and do nothing.
That is what removes MVCC row headers, the WAL and the visibility map, and it is why the file is 2.5× smaller than the same data under pgvector.
Two processes opening the same file corrupts it. This is why the
HTTP endpoint is a second listener inside fenec-pg and never its
own binary — one process, one file, one sync policy, one shutdown signal.
Concurrency still exists on the read side. HNSW search buffers are kept thread-locally so the same index can be searched in parallel; eight concurrent full scans on eight cores measured a 4.3× speed-up.
The replica is an endpoint, not a library
Because the same engine runs in the browser and on the server, the
local-copy architecture needed no new layer — only two things: the server being
able to say "these changed", and the client being able to choose where to bind
a query. bind() already existed, and the first was sitting in the
file format, because the file is already a change log.
const db = await sync({ url, shapes: [{ collection: 'tasks', key: 'key' }] });
await db.from('tasks').where('priority', '>=', 3).rows(); // local, no networkIt is TanStack DB's model with two differences. There is no incremental
dataflow: there a collection is a Map, and re-running a filter
on every keystroke over a large set is unacceptable — here the local side is
indexed and the query is already under a millisecond. And a shape is
mandatory: the whole database is in memory, so the client cannot pull all
of it. Details in Sync.