How it works

A handful of design decisions carry the whole engine, and this page gives what each one bought and what it cost.

No buffer pool of its own

The operating system keeps the pages of the file in memory, and fenecdb reads a row straight from them: it copies nothing in between, so there is no buffer pool of its own to warm, evict or keep in step with the file. A page read often stays in memory with nothing to configure; one that is not there is read from the disk by the system, as for any file.

The page cache on these pages is the operating system's, which every file has. What fenecdb leaves out is a buffer pool: the copy of the file's pages that a database keeps in memory of its own, with an eviction policy to run it.

  • Writes only append. Old data is never changed in place.
  • Reads decode in place. A read takes just the field it needs.
  • compact reclaims space from deleted and overwritten rows. A server and an app run it on their own, beside the queries, once half the file is dead.
Why there is no buffer pool

Classic databases cut the disk into pages and copy those pages into a buffer pool 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 no stage copies pages into a cache of its own — a read decodes directly over the mapped bytes.
  • 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 buffer pool to warm. The system's page cache is cold after a restart, as for any file, and a page's first read comes from the disk. Dead bytes are reclaimed by compact, which fenec-server and the native library run on their own (Compaction): left to grow, a file of updated rows spreads its live records thin over pages of dead ones, and reads wait for the disk. What this means for a cache in front of the server, with the measurements and their conditions, is on Instead of Redis.

The file is mapped

The operating system maps the file into memory, so the documents stay in the file and the process holds only the indexes built from them. A file larger than the machine's memory can be opened this way.

  • Replace a file by rename, never by copying over it. A copy over an open file, or a disk read that fails, ends the process.
  • Opening still takes time in proportion to the file.
  • fenec-server --no-mmap reads the file into memory instead, for network file systems.

What that buys, for one file of 1 GB, 2.3 million rows with a hash and an ordered index (make open-bench):

 read into memorymapped
open2.26 s, 1 095 MB held2.13 s, 188 MB
compact1.52 s, peak 2 012 MB2.08 s, peak 236 MB
held afterwards969 MB117 MB
How a 10 GB file opens on an 8 GB machine, and what mapping costs

Because the format on disk is the format in memory, the file can be the memory: fs::open maps it where the target maps files, and a document is decoded from the pages the operating system brings in — and can drop again. What the process holds is what it derived from the documents: the offset index, the hash, ordered and text indexes, the graph, and the documents written since the open until they amount to 16 MB.

A 10 GB file of 23 million rows opens on an 8 GB machine this way, which reading it cannot: that would want about 20 GB. The trade is the one every mapped file makes: a page the disk cannot read ends the process rather than the query, and a file copied over in place while it is open does too — replace one by rename, as fenecdb's own rewrites do. What it does not change is the open's cost — the records are still walked to build the offset index and the indexes are still built from the documents, so opening is proportional to the file either way.

A rewrite writes the new file and points the stores at it, so the file just written is the one being read and the old one is let go of. The rewrite knows where it put each record, so the stores work their new places out without reading the new file back: that read cost 1.8 to 2.3 s of a 1 GB checkpoint, under the write lock, where working the places out takes 19 ms. A compact over a mapped file therefore copies no record into memory, and rebuilds no index but a graph holding tombstones: the documents are the same ones, and every index is keyed by id. On a server it runs beside the database: the new file is written with no lock held, and the writers wait only while the documents written meanwhile are added to it — 24 ms at most over a 1.1 GB file, where writing it under the lock held them 1.22 s.

What is written after the open reaches the file as each block lands — a /batch, a statement — and the stores hold those documents in memory only until they amount to 16 MB: then they read them from the file, where the records put them, and let the memory go. A block that has not landed yet spills into the file at 16 MB the same way, in a record a load takes only once the block's land names it, so a load of any size holds no more; one of 250 000 768-dim rows peaked at 904 MB where it had at 2 389, of which 820 are its graph. Held until a restart before, 250 000 768-dim rows loaded into a server were held twice, in the file and in memory: the engine counted 1 592 MB after the load, 820 now and 818 for the same file started again. Nothing of the file is read or written for it — where each record went is noted as it lands, and the file is mapped with room past its end, which the appends grow into — and the pause is 1 ms for 16 MB of 768-dim documents, 2 ms of 128-dim ones. Whether the memory goes back to the system is the allocator's call: Linux's give a large block back when it is freed, so the process holds what the engine counts, while macOS's keeps hundreds of megabytes of them in a cache of its own.

fenec-server --no-mmap reads the file into memory instead — a replicated one, and every tenant of a --dir node, as well — over a network file system, where mapping is a bad bet, or to have --max-memory cover the data as well as the indexes.

Vectors are first-class

Vectors are part of the language, not a plugin: vector<N> is a type, @hnsw an index and near a clause.

get articles where year >= 2024 near embed $1 ef 200 limit 10
get articles near embed $1 exact limit 10        -- exact scan, for verification
How the HNSW graph is built
  • 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

A near with a where always fills its page with rows that pass the filter, however the filter and the vectors relate. An index on the filtered field makes a selective filter fast.

get articles where year >= 2020 near embed $1 limit 10
  • A filter matching many rows is cheap: 1.1 ms for 23% of 200 000 rows.
  • A filter matching few rows is read in full — 17.9 ms for 1% — unless an index narrows it: 0.16 ms over @sorted.
  • explain says which path ran.
How a filter and a vector are planned together

The filter's rows are found first, but only as far as the plan needs them. An index hands them over directly — a @hash equality, a @sorted range. Otherwise they are probed: rows are tested in blocks spread across the collection until more of them match than an ANN walk would measure anyway (ef × m0, 3 200 at the defaults), or until none are left. Then one of two paths runs:

  • if the set stayed smaller than that, it is searched directly — cheaper, and exact;
  • otherwise the ANN runs and each candidate is tested against the filter. If the result lands under the limit, the probe carries on to the end and the whole set is searched exactly.

The probe changes when the rows are found, never the answer: it is the one finding the whole set first gave, row for row. What it saves is the scan. Over 200 000 × 128, year >= 2020, 23% of the rows, took 16.4 ms with the set found first, 0.14 ms of it the ANN, and takes 1.1 ms probed. A filter under the budget is still read in full unless an index narrows it: 17.9 ms for 1% of the rows, 0.16 ms over @sorted. The blocks are spread out so that a filter for the newest rows, which sit at the end of the id order, is found as fast as one for rows everywhere: 1.7 ms for the newest 23%.

The fallback is not an optimisation, it is required. The filter is tested 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.

Deleted documents do the same without a filter. Every write that changes or deletes a document's vector leaves a tombstone in the graph until compact, and a beam full of them holds fewer live rows than the page. A short answer is walked again with the beam wider by the tombstones, since no more than all of them can be in it — or, where a walk that wide would cost more than reading every vector, searched exactly. Either way the page is full, and explain says which ran.

Half precision

Store vectors in 16-bit floats to halve their size on disk and in memory, for a slightly slower search and almost no recall loss.

create collection docs (embed vector<768, f16> @hnsw(cosine))

Same data, 100 000 × 128:

 vector<128>vector<128, f16>
vector arena51.2 MB25.6 MB
file image59.3 MB33.7 MB
write, index included3.5 s4.2 s
ANN p500.070 ms0.096 ms
recall@10100%100%
What half precision saves in memory and loses in recall

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.

The runtime representation does not change: values are f32 everywhere and conversion happens only at the storage boundary.

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.

Quantized indexes

A quantized index keeps a small code for each vector instead of the vector itself: a byte a component with quant=int8, a bit a component with quant=bit. The index shrinks a lot, and the scores stay exact because the final order comes from the real vectors.

create collection docs (embed vector<768, f16> @hnsw(cosine, quant=int8))
  • int8 halves the index and keeps the recall of full vectors on every set measured.
  • bit makes the index about a fifteenth the size, and is the fastest — but how well it works depends on your vectors, so measure it on them.
How codes are searched, measured over a million vectors

Half precision shrinks the record and the arena together. A quantized index leaves the record alone and shrinks the arena further, by holding codes instead of vectors.

A code only estimates a distance, so it is used for what an estimate is good for: walking the graph to the right neighbourhood. The candidates the beam holds, ef of them, are then put in order by the documents' own vectors, read out of the store as rerank reads them, and the first limit are the answer. The scores are exact; the codes decide only which candidates are found.

Not every candidate has to be read. An int8 code rounds each component to a step of its scale, so it knows how far off its estimate can be: the candidates are read nearest estimate first, and one that can no longer make the page is left unread. A bit code keeps no step, and the whole beam is read. A filtered set too small for the walk to pay — fewer rows than the distances a walk would measure, ef × 2m — is ranked by its codes and read the same way.

A bit code holds the signs of how far a vector lies from its centre, not the signs of the vector. Vectors crowd around the few directions they are about, so most of a vector's own signs are its neighbours' too, and the signs could not tell the neighbours apart. The index learns 128 centres by k-means from its first 2 048 vectors, which it holds whole until then — a smaller index searches exactly — and keeps with each vector the centre it is nearest and two numbers that scale its signs into an estimate. A search takes its query's product with each centre once, and only the query's part away from the centre goes through the signs. The query goes through them cut to six bits a component, a bit plane each, so a code's signs meet it in popcounts: 72 over 768 dimensions where a float was added a component, which took a query at 100 000 × 768 from 0.51 ms to 0.29 at a beam of 200 with the same recall. The centres are kept in the file with the graph, so a graph restored codes its vectors as they were coded.

One million vectors of 768 dimensions, stored as f16, in 64 clusters each spread along 32 directions — embeddings vary along far fewer directions than they have — with a @hash field of 333 groups for the filtered rows, on an 8 GB machine that was swapping, so the latencies are high for all three (make quant-bench):

 vectorsquant=int8quant=bit
vector arena1 536 MB772 MB105 MB
heap, the documents included3 397 MB2 596 MB1 897 MB
index build1 017 s758 s874 s
recall@10, beam of 10097.4%97.1%97.9%
recall@10, beam of 20098.4%98.7%99.7%
recall@10, beam of 40098.6%98.8%99.9%
ANN p50, beam of 1001.30 ms0.62 ms0.41 ms
ANN p50, beam of 2002.08 ms1.09 ms0.66 ms
ANN p50, beam of 4003.38 ms1.66 ms1.25 ms
documents read a query, beam of 100 / 400—20 / 20100 / 400
filtered to 3 003 rows, p502.26 ms1.36 ms0.97 ms
filtered to 3 003 rows, documents read—12100

The bit column was measured again once the query met the codes in popcounts, in a run of bit codes alone: in that run the query whole took 0.80, 1.30 and 2.16 ms at the three beams, and 1.36 ms filtered.

int8 codes follow the vectors closely enough for the default beam of 100, and closely enough that 20 of its hundred candidates are read, with the recall of reading them all. What is left unread is further off than six times the spread of its rounding, which a candidate passes less than 1.5e-8 of the time. Bit codes, taken from the centres, find as many of the true ten at the same beam, but bound nothing, so every candidate is read; a quant=bit index searches 200 unless told otherwise. They are for the arena, a seventh of int8's, and walk the graph fastest of the three. The rest of the heap is the documents, whose vectors the answers are ordered by.

How well a bit code estimates depends on the vectors. Spread in every dimension instead — as many degrees of freedom as dimensions, the hardest case for any graph — 100 000 such vectors gave full vectors 97.8% at a beam of 100, int8 codes 98.0%, and bit codes 89.7%, 97.5% at their default of 200 and 99.6% at 400. The vectors' own signs, which bit codes were until they were taken from centres, held 36% and 75% there: when a cluster is crowded around its centre, most of its members' signs are the centre's, and they could not tell the members apart. int8 codes held with full vectors on every set measured; measure bit codes on the vectors they are for.

The kernels over codes are scalar on every target, in the eight-strip order of the vector kernels: natively LLVM vectorises them, and in the browser they add in the same order, so a graph over codes is the same graph in both. A bit code's popcounts are whole numbers, exact everywhere, and the few floats they are scaled by are taken in one order.

Sparse vectors

A sparse vector weighs the words a text is about, including related words it never uses. sparse<N> stores one, and @inverted indexes it so that near returns the exact top results.

create collection passages (body text @text, splade sparse<30522> @inverted)
get passages near splade $1 limit 10

Encoded with SPLADE++ and scored on BEIR (make beir, Benchmarks):

nDCG@10SciFactFiQA
match, BM250.6620.232
near, dense (all-MiniLM-L6-v2)0.6450.366
match … near … fuse, dense0.7000.366
near, sparse (SPLADE++)0.6930.331
match … near … fuse, sparse0.6980.314
How sparse vectors are indexed

A learned sparse model weighs a text's vocabulary: SPLADE gives each of BERT's 30 522 word pieces a weight, and all but a couple of hundred come out zero. It learns which words a text is about, including ones it never uses — an abstract on damage to cerebral white matter is weighted for disability as well as the disabilities it says. sparse<N> holds such a vector by its non-zero entries, and @inverted indexes it the way @text indexes words: a list per dimension of the documents with a weight there.

near over it scores by dot product and sums the query's lists a list at a time — or walks them with MaxScore, the pruning match uses, where they hold few documents in a wide span of ids — so what comes back is the exact top ten rather than an estimate of it; only documents sharing a dimension with the query are ranked.

On SciFact, where a claim shares its words with the evidence, SPLADE alone scores what BM25 and the dense vectors do fused, with one index. On FiQA, where a question shares few words with its answer, it lifts BM25 from 0.232 to 0.331 but stays under the dense vectors, and fusing it with BM25 there costs it: the two are both lexical, and the weaker one drags. The index holds 968 928 postings for SciFact's 5 183 documents — 186.9 a document — in 13.1 MB, built in 0.07 s; FiQA's 57 638 hold 8.8 million in 107 MB, built in 1.2 s. Like the text index it is derived from the documents, built by the first query that reads it after an open, and never written to the file.

A SPLADE query reaches most of the corpus. Its 37 to 65 expanded terms touch 38 262 of FiQA's 57 638 documents. MaxScore, walking the lists a document at a time, gave up on 99.7% of them only after reading their share of the lists: 2.58 ms at the median. Summed a list at a time into a slot a document, with nothing chosen among the lists' heads, the same exact top ten comes in 0.68 ms, against 0.38 ms for a dense near, which walks a graph instead, and 0.25 ms for match; on SciFact 0.10 ms, where the walk took 0.60. Bounds per block of a list, to let the walk skip blocks, did not pay: the stretch every list could skip together was short.

Bulk load first, index after

When you load a lot of data, create the vector index after the rows are in. The load is then a plain append, and the graph is built in one parallel pass.

create collection docs (title text, embed vector<768>)
put docs [ ... 100 000 documents ... ]
create index on docs (embed) @hnsw(cosine)
Why the index is built after the load

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.

This is already the recommended order for SQLite and PostgreSQL. In fenecdb create index exists for exactly this reason, and the comparison in Vector search benchmarks uses that order for all three engines.

Zero dependencies

The core is written against Rust's standard library alone, with no outside packages. That keeps the WebAssembly module small and every line of it auditable.

Why there are no dependencies, and what it costs

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, even its own decimal-to-f64 — str::parse reaches for a 12 KB table of powers of five, which is a twelfth of the module and incompressible. 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-server, fenec-wire, fenec-import and fenec-shard. 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 HTTP server, the JSON reader, the JWT checks and the PostgreSQL client the importer reads with are all hand-written.

Straight into the browser

The same engine runs in the page as a WebAssembly module, loaded by one dependency-free JavaScript file. A database can be kept in IndexedDB or in a file a server can open.

  • persist / restore keep it in IndexedDB, saving only what changed.
  • openFile, in a worker, keeps it as a real fenecdb file in the origin private file system.
The glue without wasm-bindgen, and how persist works

fenec-wasm exposes a C ABI and there is no wasm-bindgen. The glue is about 605 lines of JavaScript inside web/fenec.js, alongside persistence and the sync layer; the query builder and the HTTP client are dependency-free modules of their own it imports, which @fenecdb/web/client loads without the rest (Which package).

persist(db) and restore(db) move the database in and out of IndexedDB the way a file holds it: an image, and the writes since as chunks after it. The first persist stores the image; every later one stores only what was written since the last, from a journal the wasm side keeps once asked to. In Chrome 153, over a 32 MB database, persisting one new row takes 0.18 ms where the image took 92 ms; in Safari 26.1, 0.98 ms and 418 ms. Once the chunks outgrow half the image, a new image replaces them, and compact replaces them too. restore reads the image and its chunks back as one file, so the graph restored from the image takes the chunks' writes rather than being rebuilt.

In a worker, openFile(db, name) keeps it in a file of the origin private file system instead, which is the file itself: the bytes fenec-server keeps, so fenec opens a page's file and a page loads a server's. The same journal feeds it, a statement at a time: run appends the writes and flushes the file before it answers, 0.56 ms a row in Chrome and 0.36 ms in Safari, statement included, and the file opens again in half the time the chunks are restored in. A new image is written beside the file and flushed before it is written over it, so a crash leaves one of the two whole.

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

Many readers can work at once, but only one write runs at a time. Every write lands whole or not at all, which is what lets fenecdb do without the bookkeeping other databases need for concurrent writers.

  • A statement or a batch lands as one unit: a crash keeps all of it or none.
  • Keep batches short: other writes wait while one runs.
  • A long read does not hold the writer: an aggregate, a scan or a read-only batch reads what it pinned and lets writes land beside it.

Two processes opening the same file corrupts it. This is why the HTTP endpoint is a second listener inside fenec-server and never its own binary — one process, one file, one sync policy, one shutdown signal.

How the lock is taken

Reads take a shared lock (Database::query), writes take the exclusive one (execute_with). A /batch is a block: it takes the exclusive lock and holds it until it lands or is put back. One writer is what makes that isolation: nothing reads a block's writes before they land, because nothing else runs. There are no interactive transactions held open across requests: one writer would wait on a client's think time.

A long read holds the shared lock only as long as it takes to pin what it reads: on a server, an aggregate, a facet, an order, a filtered count or a read with no limit over a collection of 10 000 rows or more, and a read-only /batch holding one (Database::pin embedded). Under the lock it copies each collection's id index, 12 bytes a row, and shares its stored documents; the read then runs with no lock held, on the database as it stood at the pin, while writes land beside it. A read an index answers — an equality on a @hash field, a range on a @sorted one, match, near, lookup — is short, and runs under the lock as before: indexes change in place with every write, and copying one would cost as much as the read.

Every write is a block. What a statement writes — every document of a put of many, of a set or a del matching many — is held back from the file and the change feed until it ends, and lands as one record: a crash cuts all of it off or none, a replica applies it at once, and a subscriber is woken once for all of it. A statement that fails half way puts back what it did: each store is cut back to where it stood, and each document it wrote goes out of the indexes, the version before it back in. A /batch and the browser module's run of several statements are one block the same way. A create, a drop and a create index are put back with the rest — the collection made goes, the one dropped comes back, the index goes — and only a compact, which rewrites the file, runs on its own. Held back, a write costs within 2% of what it did: a lone put 841 ns in memory against 829 before blocks.

That is what removes MVCC row headers, the WAL and the visibility map — about 2.8 MB of headers over 100 000 rows. Most of the 2.4× between this file and the same data under pgvector is elsewhere: pgvector's HNSW index keeps its own copy of every vector, while this graph stores only the links (Vector search benchmarks).

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

A page can keep a local copy of part of the server's data and query it with no network round trip. The server says what changed, and the copy follows.

const db = await sync({ url, shapes: [{ collection: 'tasks', key: 'key' }] });
await db.from('tasks').where('priority', '>=', 3).rows();   // local, no network
Why a replica needs no new layer

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.

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.

Where it runs

The same engine ships four ways. Pick the one that matches where your data should live.

TargetWhat you getSize
BrowserWebAssembly module plus a dependency-free ES client330 KB gzip
the wasm module
Command lineThe fenec shell, fenec import, fenec types, fenec backup and restore1880–3013 KB
Serverfenec-server: HTTP, replication and tenants in one process2.99 MB image
Embeddedfenec-core as a Rust libraryzero dependencies

How the crates fit together

The code is a stack of small Rust crates; the engine at the bottom has no dependencies at all.

The crates and what each depends on

Dependencies point one way. Nothing points back up.

fenec-core   storage, indexes, plan executor, plugins, JSON, calendar, changes
     |       std only, zero dependencies
fenec-ql     FenecQL lexer and parser              fenec-wasm   browser ABI
     |                                                          (no wasm-bindgen)
     |
fenec-http   REST/JSON surface, SSE subscriptions, replication, tenants
     |
fenec-wire   the PostgreSQL client the importer reads with
     |
fenec-import SQLite file reader, PostgreSQL COPY source, --follow
     |
fenec-server the server: HTTP, replication, tenants, the --follow mirror
     |
fenec-cli    the `fenec` shell

fenec-bench is a measurement harness and the only crate allowed external dependencies — that is where rusqlite and postgres live, so the comparisons in Benchmarks can run against the real engines.