Benchmark redesign: scaling, resources, and correctness-at-scale
| Field | Value |
|---|---|
| Date | 2026-06-21 |
| Author | Rômulo Febasi (4 specialised research subagents, web) |
| Scope | Replace the useless single-latency chart (SYN-290) with benchmarks that answer “will it handle MY project, and where does it break?”: scaling curves, resource curves, and correctness-under-load, premium and reproducible. |
| Status | accepted (drives a v1.1 benchmark-v2 plan + 2 code-finding tickets) |
| Inputs | 4 parallel web-enabled subagents: scaling/bottlenecks, resource measurement, premium presentation, workload-profiles/correctness. |
Executive summary
The current README benchmark: median 7.97 ms / p95 10.21 / p99 11.53 on a 300-doc
stub corpus: proves the renderer works, not that Synapse scales. It has no
scale axis, no resources (RAM/CPU/disk), no correctness-under-load, no workload
profiles, no breaking point, and no honest comparison. Every credible benchmark in
the field (ClickBench, DuckDB, ann-benchmarks, Qdrant, Meilisearch, TigerBeetle, uv)
replaced bars with curves over a scaling variable + a floor-vs-slope resource
model + percentiles + machine stamp + raw data + one-command reproduce.
The redesign: benchmark across 5–6 scale tiers (T1 daily ~500 → T4/T5 stress 50k–1M), measure per-subsystem latency, RAM (floor vs slope), disk, reindex, throughput, and recall-at-scale, render curves with confidence bands and a breaking-point band, and compare Synapse only to itself (FTS / vector-flat / hybrid; fp32 / int8; cold / warm; flat / future-HNSW) plus ripgrep as a labelled naive floor: never to a hosted cluster, never at unequal recall.
Two real code findings surfaced en route (each → its own ticket): the FTS5 table is self-content despite a comment claiming external-content (~doubles the text footprint, ~3 KB/entity waste); and the “flat vector correct below ~100k” rationale (SYN-225 deferral) is conservative by ~5–10×: flat is fine to ~500k–1M at 1024-dim on a laptop; the real wall is page-cache, not 100k.
1. Why the current benchmark is useless (the critique)
- No scale axis. A single N=300 number. The reader’s question is latency × N: a curve from 1k → 10k → 100k. Without it, “will it handle my vault?” is unanswerable.
- No resources. Nothing on RAM (the BGE-M3 model floor alone is ~2.5 GB!), CPU, or
.synapse/index.dbsize. For a local tool, “does it fit on my machine?” is the question. stubhides the real path. Stub latency skips the semantic path (embeddings + sqlite-vec). And vector search is flat brute-force O(N·d): it degrades linearly; that is exactly where it breaks and the chart never shows it.- No correctness under load. “Fast” is worthless if retrieval stops surfacing the right fact as the graph grows. Recall/precision × N is the proof of usable.
- No workload profiles, no breaking point. No “daily (medium)” vs “extreme (stress)”, no cliff.
- No comparison. 8 ms vs what?
2. The cost model (verified against the source)
Let N = entities (= FTS rows = vectors), E = edges, W = federated workspaces, d = 1024 (vector dim).
| Subsystem | Complexity | Class | Note |
|---|---|---|---|
| Flat vector KNN | O(N·d) | linear | the headline wall: full scan every query (hybrid.rs embedding MATCH … ORDER BY distance) |
| FTS5 BM25 | ~O(matched·log N) | sub-linear | tracks the result set, not N: essentially never the bottleneck |
| Graph 1-hop | O(log E + deg) | ~log | index-backed (idx_links_from/to) |
Ego-graph / connections | O(Σ deg over frontier) | O(N) on hubs | degree-driven; a mega-hub is the only graph cliff |
| EAV filter | O(log F + matches) | ~log | index-backed |
| RRF fusion | O(pool) | ~O(1) in N | trivial |
| Federated search | O(W·N·d) | linear in W | each ws pays its own full vector scan, serially |
| Full reindex | O(N) parse + O(N·d) embed | linear | embed dominates; content-hash-gated incremental (SYN-251) |
| DB size | O(B + N·d) | linear | §4 |
The two that bite, in order: flat vector KNN (linear in N), then federation (multiplies it by W). Everything else is sub-linear or free until N is enormous.
Crossover estimates (1024-dim fp32, laptop): to be confirmed by measurement
Anchored on the sqlite-vec author’s own M1 benchmarks (GIST 500k×960 = 41 ms; 100k×1536 = 105 ms):
| N vectors | flat-search median (est.) |
|---|---|
| 10k | ~1 ms |
| 50k | ~4 ms |
| 100k | ~9–30 ms |
| 500k | ~44 ms |
| 1M | ~100–250 ms (IO-bound) |
- < 10 ms (snappy): ~50–100k vectors.
- < 100 ms (interactive): ~500k–1M.
- < 1 s (usable): 1M+.
- Breaking point: ~1M vectors or whenever the fp32 vector blob (4 KB × N) outgrows page cache → every query becomes disk-bandwidth-bound and jumps a tier. This validates that the “100k flat ceiling” is conservative; the bench must locate the real page-cache cliff.
3. Workload tiers (grounded in real vault data)
Anchors: Obsidian forum self-reports (median ~5,500 notes; large 18k–20k; 60k+ “approaching indexing failure”); Luhmann’s Zettelkasten ~90k notes / 40 yrs (~2,250/yr), the lifetime single-user ceiling.
| Tier | Persona | Entities | Edges | People/Projects | Words | WS |
|---|---|---|---|---|---|---|
| T0 Smoke | (current bench) | 300 | ~600 | 20 / 5 | ~150k | 1 |
| T1 Daily | solo dev, months in | ~500–2k | ~3–6k | 40–150 / 8–20 | 150k–1M | 1–3 |
| T2 Power | heavy user @ 1yr | ~6k–20k | ~20k–80k | 250–1k / 30–100 | 1.5M–10M | 3 |
| T3 Heavy | multi-year, near ceiling | ~20k–100k | ~200k–500k | 5k / 500 | 10M–50M | 5 |
| T4 Stress | synthetic stress | 100k–500k | ~3M | 20k / 2k | 250M | 10 |
| T5 Break | past every wall | 1M (+ a 10k-degree hub) | ~8M | 50k / 5k | 500M | 25 |
Realistic single-user upper bound: ~50k–100k entities. T1–T3 are must-pass; T4/T5 are the “does flat still hold or do we need HNSW (SYN-225)” probe.
The corpus must look like a real graph, not uniform random
- Scale-free (Barabási–Albert, γ≈2.2–2.5), mean degree ~4–10, with a few hub projects/people carrying 100–1,000+ edges. Uniform-random graphs lie: they have no near-miss distractors, so recall@k looks artificially perfect.
- Log-normal note lengths (median ~150–400 words, p95 ~2,000+, a long tail).
- Zipfian tags; 20–50% AI-authored (exercise trust-ranking + audit under load);
supersession chains (exercise
--as-of+ “stale shouldn’t outrank current”).
4. Index / disk growth (.synapse/index.db)
Per-entity (≈500 words ≈ 3 KB text), under current defaults (fp32, self-content FTS):
| Component | per entity | note |
|---|---|---|
| Vectors (fp32) | 4.0 KB | 1024×4 B; int8 → 1 KB; binary → 128 B |
| FTS5 (self-content) | ~4 KB | text copy + inverted index: ~3 KB is waste vs true external-content (Finding #1) |
| core row + 5 indexes | ~1 KB | |
| links (deg 10) | ~1.2 KB | |
| EAV + meta | ~0.5 KB | |
| Total | ~10.7 KB/entity |
| N | DB ≈ | int8 vec | + external-content FTS |
|---|---|---|---|
| 1k | ~11 MB | ~8 MB | ~8 MB |
| 10k | ~107 MB | ~77 MB | ~74 MB |
| 100k | ~1.07 GB | ~770 MB | ~740 MB |
| 1M | ~10.7 GB | ~7.7 GB | ~7.4 GB |
Vectors + self-content FTS are 75% of the DB. Two cheap wins: true
external-content FTS (−28%) and int8 vectors (~−28%) ≈ roughly halve the index.
And disk size is the latency story, the slowdown happens when that blob outgrows page cache.
5. Resource measurement methodology (RAM/CPU/disk)
The honesty trap: (a) don’t report peak RSS as “required RAM” (page cache is opportunistic/evictable: Qdrant: “10 GB RSS ≠ needs 10 GB”); (b) don’t use dhat for the headline number: it sees only the Rust global allocator and is blind to the 2.5 GB ONNX model (off by ~100×).
Floor vs slope (the only honest method):
RSS(N) ≈ baseline + Model_floor(~constant) + SQLite_working_set(N) + Vector_set(N)
- Baseline: RSS of a no-op (
syn --version). - Floor: RSS with the model loaded on an empty workspace → report as “baseline RSS with model loaded (fp32 / int8)”. This is the dominant, ~constant cost.
- Slope: Δ RSS per 10k entities (vectors = +4 KB/entity fp32, +1 KB int8). Subtract the floor: never attribute the 2.5 GB to “100k entities”.
Measurement stack: /usr/bin/time -v (Linux) / -l (macOS) for peak RSS envelope;
in-process VmHWM / getrusage phase checkpoints (after model-load, after index-N,
after search); dhat only for the Rust working-set slope (labelled, not as total);
heaptrack (Linux) for one-off full-process attribution incl. ORT.
Landmines (verified): ru_maxrss is KiB on Linux, bytes on macOS (1024× error
if unbranched: sanity-assert peak > model floor); measure index.db only after
OPTIMIZE + VACUUM (fragmentation over-reports); don’t gate wall-time in CI (~50%
variance): gate memory-peak regressions (stable) and run timing on a pinned box,
reporting CPU-seconds + thread/core count, not bare wall time.
CPU/throughput: warmup-then-median; cold (model load + first embed) vs warm (steady state) labelled separately; embedding throughput in entities/sec + tokens/sec with thread count stamped; reindex in CPU-s/entity (machine-comparable) + wall-s (felt).
The “will it fit?” headline this produces: the model is the floor (~2.5 GB fp32 / sub-GB int8); each 10k entities adds ~41 MB (fp32) of vectors, so on a 16 GB machine you fit millions of entities; the binding constraint is the model, not your data.
6. The chart catalogue (curves, not bars)
x-axis = N (log scale) on the scaling charts. Every chart: median line + p5–p95 confidence band (≥5 reps), breaking-point band (green/amber/red threshold rules), dark/light via CSS, and a caption with the machine stamp + reproduce command + commit + date.
| # | Chart | y | Proves | Hero? |
|---|---|---|---|---|
| 1 | Search latency vs N | p50 & p95 (log): 3 lines: FTS / vector-flat / hybrid | how each mode degrades; where flat goes linear | ★ |
| 2 | Scale-tiers strip | banded usable·interactive·breaking over N tiers | one-glance “what tier am I in” | ★ |
| 3 | RAM vs N | peak RSS: floor line + slope, fp32 vs int8 | the resource ceiling / fit | ★ |
| 4 | Index size vs N | stacked FTS / vector / graph | disk cost + what dominates | |
| 5 | Reindex time vs N | wall: full vs incremental | the disposable-index claim is credible only if bounded | |
| 6 | Embedding throughput | docs/sec, fp32 vs int8, cold-load separate | onboarding cost | |
| 7 | Recall/quality vs N | recall@k / nDCG@10 vs a labelled gold set | retrieval stays correct as the graph grows | ★ |
| 8 | Latency CDF (small multiples per tier) | the tail, not just p50 | the worst case the reader feels |
Charts 1, 2, 3, 7 are the hero set. SVG (crisp, diffable, themeable), confidence
bands, annotated knee (“≈42k entities: p95 crosses 200 ms”), committed results.json
beside the SVGs, --quick/--full reproduce tiers.
7. Correctness-at-scale (the premium differentiator)
The claim to prove: the right fact still ranks top-k when 50k entities compete. Measure rank-aware IR metrics as a curve over N, not a single number:
- recall@{1,5,10,20}, MRR, nDCG@10, precision@k, computed per tier for FTS / vector / RRF separately (so degradation is attributable).
- Tier-stable planted-needle query bank (≥100 queries/tier): synthesize the graph, so truth is known; re-run the same queries as the corpus grows → pure haystack-growth degradation (the LongMemEval “same question, bigger haystack” design).
- Hard negatives: 2–5 near-miss entities per needle sharing the hub/tags/words.
- Abstention queries (invalidated/never-existed): test it declines vs hallucinates.
- Watch RRF k=60 × corpus-size interaction: verify RRF doesn’t win at T1 but lose at T3 purely from a small-scale-tuned k.
Agent metric: Recall@k_budget(N) under a declared context budget (e.g. top-10 ≈ 3k tokens), reported separately from end-to-end task success (decouple “was it retrieved” from “did the LLM use it”) + distractor load = noise fraction of the window. Field anchor: LongMemEval shows a 30–60% accuracy drop without targeted retrieval: exactly what this catches.
8. Honest comparison (and what to refuse)
Ship: Synapse vs itself: FTS / vector-flat / hybrid-RRF; fp32 vs int8; cold vs warm; flat vs future-HNSW (flat = the recall=1.0 ceiling once SYN-225 lands); ripgrep as a labelled naive floor (ripgrep should win raw latency and lose relevance: state the asymmetry).
Refuse: comparing at unequal recall (Qdrant’s “fixed precision threshold” rule); warm-only with cold hidden; cherry-picked N as a bar; tuned-vs-untuned asymmetry; hosted-cluster comparisons (apples-to-oranges for a single-user embedded tool); mean-only latency.
9. Two code findings (→ tickets)
- FIND-1: FTS5 is self-content, not external-content.
migrations/0001__initial.sqldeclaresfts5(…)with nocontent=clause, but the comment claims external-content. The table stores its own copy of title/body/tags → ~doubles the text footprint (~3 KB/entity, ~28% of the DB at 100k). Fix → expand-contract migration to a contentless/external-content FTS5 table. A genuine, measurable storage win. - FIND-2: the “flat correct below ~100k” rationale is conservative ~5–10×. Per the sqlite-vec numbers, flat 1024-dim search stays interactive (<100 ms) to ~500k–1M on a laptop; the real wall is the page-cache cliff. The HNSW deferral (SYN-225) is more justified than stated, but the specific “100k” figure in CLAUDE.md / the ADR should be re-validated by chart #1 before HNSW is ever built.
10. Recommended implementation phases (→ plan 021 / a benchmark-v2 ticket)
This supersedes the thin SYN-290 chart (which proved the renderer, not the product).
- Phase A: scaling latency + DB-size curves (highest value, ships first). Scale-free corpus generator (BA, hubs, log-normal lengths); per-subsystem timing (FTS / vector / hybrid / graph) at T1–T4; median+p5–p95 bands; chart #1, #2, #4; committed raw JSON.
- Phase B: resources. Floor-vs-slope RSS, disk (post-VACUUM), reindex, throughput;
/usr/bin/time+VmHWMcheckpoints; chart #3, #5, #6; fp32/int8 series. - Phase C: correctness-at-scale. Planted-needle tier-stable query bank + hard negatives; recall@k / nDCG / MRR per tier per ranker; chart #7; the agent Recall@k_budget metric.
- Phase D: premium polish + CI. Confidence bands, breaking-point bands, dark/light,
captions;
--quick/--full; pinned-runner release workflow + artifact; README embeds the hero trio. - Parallel: FIND-1 (external-content FTS) + FIND-2 (re-validate the flat ceiling) as their own tickets.
Discipline: real, reproducible, honest: curves not bars, cold+warm, percentiles, floor-vs-slope, equal-recall comparisons, raw data + one-command reproduce, machine stamp on every figure. Quality over a pretty single number.
Sources (selected)
sqlite-vec benchmarks https://alexgarcia.xyz/blog/2024/sqlite-vec-stable-release/index.html · SQLite FTS5 https://sqlite.org/fts5.html · ClickBench https://benchmark.clickhouse.com/ · DuckDB db-benchmark https://duckdblabs.github.io/db-benchmark/ · ann-benchmarks https://ann-benchmarks.com/ · Qdrant benchmarks https://qdrant.tech/benchmarks/ · getrusage(2) https://man7.org/linux/man-pages/man2/getrusage.2.html · dhat https://docs.rs/dhat/latest/dhat/ · BEIR https://arxiv.org/pdf/2104.08663 · LongMemEval https://arxiv.org/abs/2410.10813 · Obsidian vault sizes https://forum.obsidian.md/t/how-many-notes-do-you-have/36987 · Luhmann Zettelkasten https://www.ernestchiang.com/en/posts/2025/niklas-luhmann-original-zettelkasten-method/ · pythonspeed CI benchmarking https://pythonspeed.com/articles/consistent-benchmarking-in-ci/.