Benchmarks
Every number here comes from crates/fenec-bench
and can be reproduced with a make target. The method is written out below the
table, because a number without one is a claim, not a measurement.
Against SQLite and pgvector
Same data, same process, same distance kernel. SQLite 3.46 and PostgreSQL 17 with pgvector 0.8.6, on an Apple M-series, 100 000 rows × 128 dimensions with clustered embeddings.
make pgvector-up # start PostgreSQL + pgvector in Docker
make compare # without it, that arm is skipped| fenecdb | SQLite | PostgreSQL + pgvector | |
|---|---|---|---|
| data write, no index | 900 k rows/s | 415 k/s | 64 k/s |
| index build | 10.2 s HNSW | 0.03 s, B-tree only | 14.2 s HNSW |
| total | 10.3 s | 0.27 s, no ANN | 15.7 s |
| disk size | 58.4 MB | 59.8 MB | 145.0 MB |
| scalar filter, indexed | 4.3 ms | 14.5 ms | 7.8 ms |
| vector top-10, exact | 4.0 ms | 31.3 ms | 14.8 ms |
| vector top-10, approximate | 0.18 ms | none | 0.91 ms |
| recall@10 | 100% | — | 100% |
| reopen | 117 ms | 1.0 ms | server, always open |
| empty query round trip | 0, embedded | 0, embedded | 0.49 ms |
Method
- All three follow the same workflow: bulk load first, index after.
That is already the recommended order for SQLite and PostgreSQL; in fenecdb
create indexexists for exactly this. - SQLite: WAL with
synchronous=NORMAL, the bulk write in one transaction, the index built afterwards. fenecdb does notfsyncper write. - PostgreSQL: in Docker, bulk load with COPY, then
hnsw (m=16, ef_construction=200)— the same parameters as fenecdb. At query timehnsw.ef_search = 64, again matching fenecdb's setting. - Vector distance is computed by the same code in all three. What is measured is the engine's cost of fetching the data, not the arithmetic.
- SQLite's core has no ANN index, so a vector search there is a full scan. The comparison is therefore exact against exact first, with ANN on a separate row.
- The write is split in two stages, rows and index. A single "total" would weigh an engine that builds an ANN index and one that does not on the same scale.
- The PostgreSQL figures include a 0.40 ms TCP round trip, about 40% of its ANN time. That is not a measurement flaw, it is the client-server architecture.
How to read the numbers
fenecdb leads on raw write speed: 900 k rows/s, more than twice SQLite's. The append-only segment store reduces a write to appending to an arena — no B-tree rebalancing, no page splits.
It leads on index build too, 10.2 s against pgvector's 14.2 s at the
same m and ef_construction. SQLite's 0.03 s is not an
ANN index but a single-column B-tree; that cost does not vanish, it is deferred
to query time, where a vector search takes 31.3 ms — 171× slower than fenecdb's
ANN.
The one real loss is reopen. SQLite opens in 1 ms because it loads nothing, reading pages as it needs them. fenecdb pulls the vector arena into memory and validates the graph: 117 ms. That is not a shortcoming but the other side of the same coin — it is why queries are 3–171× faster. The measured break-even is four vector queries; after that fenecdb is ahead on the total.
fenecdb leads on disk as well, 2.5× smaller than pgvector. PostgreSQL's extra is MVCC row headers, WAL and the visibility map: the price of concurrent transactions. The single-writer model does not need it.
Scale measurement
Apple M-series, single thread, --release. 100 000 documents ×
128 dimensions, clustered embedding distribution.
make bench
cargo run --release -p fenec-core --example bench| write | 9 478 documents/s (10.6 s), HNSW indexing included, 8 cores |
| storage | 52.7 MB · 527 bytes/document · 7 segments · no page cache |
| ANN k=10 | p50 0.139 ms · p95 0.202 ms · p99 0.226 ms |
| ANN with a filter | p50 0.239 ms, candidate set n/4 |
| recall@10 | 100% against an exact scan |
| snapshot | 38 ms, producing 58.4 MB with the graph, +11% |
| reopen | 110 ms, the graph validated and restored |
Without a persisted graph, reopening the same data takes 10.2 seconds — the index is rebuilt from scratch, which would make a page refresh in the browser unusable. The graph record makes that roughly 90× faster and costs 11% more space in the image, 52.1 MB to 57.9 MB.
The ef and recall trade-off
cargo run --release -p fenec-core --example bench -- 100000 128 --ef 200
make sweep # walks the trade-off on both distributions| ef (search) | recall@10 | ANN p50 |
|---|---|---|
| 64 | 99.0% | 0.100 ms |
| 100 (default) | 100% | 0.131 ms |
| 128 | 100% | 0.169 ms |
| 160 | 100% | 0.172 ms |
The default is 100: recall is complete and it is still roughly 7× faster
than the engines compared above. It is tunable per query —
get docs near embed $1 ef 200 limit 10 — and
exact performs a full scan when you want ground truth.
--example sweep walks the same trade-off on both
distributions, but its latency column is the mean over 50 cold queries, so
the two tables are not directly comparable.
On uniformly random vectors — the pathological case for any ANN index, where
in 128 dimensions all distances converge and no neighbourhood structure
survives — recall at the same ef is around 82%. Real embedding
models output clustered data. --uniform measures that worst case
deliberately.
Build-side tuning
100 000 × 128, single-threaded measurement:
| ef_construction | build | recall@10 |
|---|---|---|
| 64 | 6 450 rows/s | 94.0% |
| 100 | 5 849 rows/s | 98.5% |
| 200 (default) | 4 929 rows/s | 99.0%+ |
If write speed matters more than the last point of recall,
@hnsw(cosine, ef_construction=100) gains 19%.
Half precision
Same data, 100 000 × 128, clustered. The trade is explained in How it works.
| 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% |
Memory
make memory # for calibrating --max-memory| measured footprint | peak RSS | ratio | |
|---|---|---|---|
| 100 000 × 128 | 128.2 MB | 172.9 MB | 74% |
| 200 000 × 4 | 57.6 MB | 97.2 MB | 59% |
The footprint is an early warning rather than a guarantee. A third of the
container memory is a sensible --max-memory: it covers both this
60–75% ratio and compact's 3× peak. Details in
Limits.
Binary size
| Build | Size |
|---|---|
cargo build --release -p fenec-cli, default | 863 KB |
--no-default-features, no import | 717 KB |
--profile cli --no-default-features (make small) | 636 KB |
| wasm32, browser | 302 KB |
| container image, scratch + musl | 1.55 MB |
fenec-pg stays on the release profile on purpose:
a connection thread that panics unwinds and takes down only its own session,
and the server stays up. The cli profile turns panic unwinding
into abort, which is right for a single process with nothing to recover.