fenecdb

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
 fenecdbSQLitePostgreSQL + pgvector
data write, no index900 k rows/s415 k/s64 k/s
index build10.2 s HNSW0.03 s, B-tree only14.2 s HNSW
total10.3 s0.27 s, no ANN15.7 s
disk size58.4 MB59.8 MB145.0 MB
scalar filter, indexed4.3 ms14.5 ms7.8 ms
vector top-10, exact4.0 ms31.3 ms14.8 ms
vector top-10, approximate0.18 msnone0.91 ms
recall@10100%100%
reopen117 ms1.0 msserver, always open
empty query round trip0, embedded0, embedded0.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 index exists for exactly this.
  • SQLite: WAL with synchronous=NORMAL, the bulk write in one transaction, the index built afterwards. fenecdb does not fsync per write.
  • PostgreSQL: in Docker, bulk load with COPY, then hnsw (m=16, ef_construction=200) — the same parameters as fenecdb. At query time hnsw.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
write9 478 documents/s (10.6 s), HNSW indexing included, 8 cores
storage52.7 MB · 527 bytes/document · 7 segments · no page cache
ANN k=10p50 0.139 ms · p95 0.202 ms · p99 0.226 ms
ANN with a filterp50 0.239 ms, candidate set n/4
recall@10100% against an exact scan
snapshot38 ms, producing 58.4 MB with the graph, +11%
reopen110 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@10ANN p50
6499.0%0.100 ms
100 (default)100%0.131 ms
128100%0.169 ms
160100%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_constructionbuildrecall@10
646 450 rows/s94.0%
1005 849 rows/s98.5%
200 (default)4 929 rows/s99.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 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%

Memory

make memory       # for calibrating --max-memory
 measured footprintpeak RSSratio
100 000 × 128128.2 MB172.9 MB74%
200 000 × 457.6 MB97.2 MB59%

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

BuildSize
cargo build --release -p fenec-cli, default863 KB
--no-default-features, no import717 KB
--profile cli --no-default-features (make small)636 KB
wasm32, browser302 KB
container image, scratch + musl1.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.