Benchmarks
Search accuracy on real BEIR benchmarks, DuckDB mirror speedups at scale, write-path tuning, and how to run the benchmark suite yourself.
Measured 2026-08-20, commit 00a361c, Apple Silicon MacBook Pro, CPython 3.13. Search accuracy uses real IR benchmarks (BEIR); speed numbers are smoke-scale (50 docs / 50 graph nodes / 1,000 analytics rows) unless noted. Full-scale runs (--benchmark-full) exist but were not part of this study.
Search accuracy (BEIR)
Evaluated on two BEIR datasets with real queries and relevance judgments: NFCorpus (3,633 medical docs, 324 queries, graded 0–3) and SciFact (5,183 scientific abstracts, 301 queries, binary). Embeddings: ChromaDB’s bundled MiniLM.
| Dataset | Mode | nDCG@10 | Recall@10 | P@10 | MRR |
|---|---|---|---|---|---|
| NFCorpus | keyword | 0.3083 | 0.1489 | 0.2167 | 0.5128 |
| NFCorpus | semantic | 0.3145 | 0.1542 | 0.2418 | 0.5063 |
| NFCorpus | hybrid | 0.3429 | 0.1699 | 0.2467 | 0.5548 |
| SciFact | keyword | 0.6683 | 0.7956 | 0.0883 | 0.6348 |
| SciFact | semantic | 0.6484 | 0.7883 | 0.0890 | 0.6068 |
| SciFact | hybrid | 0.7031 | 0.8443 | 0.0943 | 0.6649 |
Fusion-weight sensitivity (hybrid nDCG@10)
| fts_weight | 0.2 | 0.5 (default) | 0.8 |
|---|---|---|---|
| NFCorpus | 0.3271 | 0.3429 | 0.3332 |
| SciFact | 0.6741 | 0.7031 | 0.6948 |
Findings
- Hybrid fusion is the right default. It beats both single modes on every metric in both domains (+11% nDCG over keyword on NFCorpus, +5% on SciFact). RRF captures the lexical and semantic regimes.
fts_weight=0.5is the sweet spot and the curve is flat (spread < 0.03) — no tuning needed.- The hash embedding fallback is a 5.3× accuracy cliff (nDCG 0.059 vs 0.315, near-random). The fallback exists for offline smoke tests, not production.
Analytics: DuckDB mirror vs raw SQLite
Same queries on the SQLite table (raw_query) and the DuckDB mirror (olap.query), median of 5 runs.
| Rows | Query | SQLite | DuckDB | Speedup |
|---|---|---|---|---|
| 10k | full-scan agg | 1.46ms | 0.24ms | 6.2× |
| 10k | group-by | 3.42ms | 0.47ms | 7.3× |
| 100k | group-by | 34.13ms | 1.37ms | 24.9× |
| 100k | filtered agg | 8.85ms | 0.19ms | 46.4× |
| 1M | full-scan agg | 70.98ms | 1.23ms | 57.6× |
| 1M | group-by | 378.31ms | 2.83ms | 133.6× |
| 1M | filtered agg | 76.86ms | 0.22ms | 351.9× |
| 1M | join | 640.17ms | 8.45ms | 75.8× |
Findings
- The DuckDB mirror pays off immediately and compounds with scale. Even at 10k rows it is 4–7× faster; at 1M rows, 58–352×. There is no “SQLite wins at small scale” regime for analytical queries.
- Filtered aggregation is the columnar sweet spot (352× at 1M rows) — vectorized scans over
WHEREclauses. - Point lookups are the mirror’s weak spot (2.4× at 1M) — the mirror has no index. Use
get()/query()for point lookups. - The mirror is cheap to maintain: full sync copies 1M rows in ~0.6s; incremental journal sync adds no measurable write overhead.
Write path
2026-08-20 tuningProfiling insert_batch revealed the bottleneck was not journal writes but a fresh SQLite connection per row — schema lookups opened ~200k connections per 100k rows. Fixes: the batch cursor is threaded through the hot paths, per-row schema reads are hoisted, and _process_journal uses one connection end-to-end.
The journal remains the write bottleneck (~2,000 rows/s) — dominated by per-row Python + journal overhead. Optimization candidates: batched journal writes, dropping the per-row re-select.
Versioned-table rollback (0.7.0)
- Removal-heavy rollbacks ~20× faster: 1,000 removed rows 1,980ms → 92ms (batched path; ~122ms with Chroma + DuckDB live). Root cause:
rollback()looped per removed row through publicdelete(), paying one connection + transaction and ~10 statements per row. Removals are now set-based — one transaction, chunkedDELETE … WHERE pk IN (…), hash-chain tombstones viaexecutemany. - Batched restores: update-heavy rollbacks re-apply state as new versions — 1,000 restored rows were 3,931ms (41× the removal path); now 107ms at 100k-row tables via split batched
INSERT/UPDATE. Chroma re-embedding of restoredLONGTEXTremains by design.
Running the benchmarks yourself
Benchmarks are separate from the default test run:
uv run python -m pytest -q
# 262 passed, 48 skipped (benchmarks under tests/benchmarks/)uv run python -m pytest tests/benchmarks -q --run-benchmarks --benchmark-disable
# 48 passed — smoke scale: 50 docs, 50 graph nodes, 1,000 analytics rowsuv run python -m pytest tests/benchmarks -q --run-benchmarks
# timed loops; full-scale via --benchmark-full Split smoke runs are the recommended pre-release benchmark checks: search, then analytics+graph, then storage+concurrent.
Source studies live in the repo: docs/PERFORMANCE.md and docs/BENCHMARKS.md.
Source of truth for this page: HybridDB · open-assistants-lab/HybridDB