fenecdb

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 m nearest 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 arena51.2 MB25.6 MB
file image57.9 MB32.3 MB
index build10.4 s12.5 s
ANN p500.127 ms0.147 ms
recall@10100%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 network

It 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.