Measured, never quoted

The honesty log

Every published number comes from verity-bench, at a stated corpus size, filter selectivity, and machine. Entries are append-only — never edited after the fact, so regressions, breaches, and the fixes that answered them all stay on the record. Numbers without corpus size, selectivity, and machine are not honest numbers. Nothing here is quoted from another vendor's benchmarks or marketing.

Newest first. The canonical log in the repo (docs/BENCHMARKS.md) is append-only chronological; here it reads latest run at the top, first curve at the bottom.

Reproduce it yourself — Docker + Rust, three commands, and the harness (the Scoped Recall Benchmark, srb-v0) is defined precisely enough to score any competing system against the same yardstick. Its leakage metric fails the run loudly on a single leak; a leak is never allowed to hide inside an average.
# 1. Start the Postgres profile (ParadeDB pg17: pgvector + pg_search)
docker compose -f deploy/docker-compose.yml up -d
# 2. Seed the 1M-chunk latency corpus with constructed ACL selectivities (~5 min)
cargo run --release -p verity-bench -- seed --chunks 1000000
# 3. Run the benchmark and emit docs/benchmark/RESULTS-<date>.{json,md}
cargo run --release -p verity-bench -- srb

2026-07-10 · entity-bound BM25 breach fixed: 542.7ms → 12.6ms p50 (43x)

Fix (migration 0008 + adapter): entity_tags and kind join the bm25 index with keyword tokenizers (the default tokenizer splits account:0, so term_set could never match raw values — first attempt returned 0 hits), and entity-bound sparse queries become two stages: a Tantivy boolean pre-filter bounding the candidate set to the entity's own chunks, then the exact <@ subset residual over the materialized candidates. Filter-then-rank, never truncate-then-authorize.

case (1M chunks)before p50/p95after p50after p95after p99
BM25 entity-bound + broad visibility542.7 / 724.5ms12.6ms16.5ms19.2ms
BM25 @ 1% (regression check)15.9 / 17.8ms18.3ms22.4ms24.8ms
hybrid @ 1% (regression check)16.0 / 17.9ms17.4ms21.3ms32.3ms
  • All retrieval paths back inside the <50ms p95 envelope at 1M.

2026-07-09 (later) · QPS under load, and the entity-bound BM25 breach

Setup: 1,000,000 chunks, same machine/profile. All numbers are in-process adapter calls — no HTTP hop, no query encoder (dense/hybrid end-to-end adds ~12ms p50 of client CPU). One shared adapter, 16-connection pool. New latency case: broad principal token AND entity_scope=["account:0"] — broad visibility maximizes the pushed-down candidate set the heap-side entity_tags <@ filter must chew through.

case (1M chunks)p50p95p99
BM25 entity-bound + broad visibility542.7ms724.5ms955.4ms

Load (load --sweep --duration-secs 20): closed loop, zero think time; N tasks looping a mixed workload — 70% hybrid recall (1%-selectivity token), 20% current_fact point reads, 10% activity-timeline reads. Latencies include waiting for one of the pool's 16 connections.

Noverall QPShybrid p50/p95/p99current_fact p50/p95/p99activity p50/p95/p99
416632.7 / 48.9 / 60.6ms1.2 / 5.4 / 10.5ms1.3 / 4.9 / 7.9ms
16167115.1 / 170.4 / 212.4ms41.5 / 68.7 / 84.7ms42.7 / 68.1 / 84.9ms
64170388.4 / 513.3 / 582.1ms317.4 / 428.8 / 508.7ms311.0 / 410.1 / 500.7ms
  • This box saturates at ~165–170 QPS for this mix, by concurrency 4. 16x-ing offered concurrency buys zero throughput, only queue depth. The honest operating point is N=4: hybrid 48.9ms p95 excluding the encoder — with ~12ms encode added, hybrid under load is outside the 50ms envelope even at low concurrency.
  • SPEC §4d targets are not met on this setup — target ≥300 QPS hybrid at p95 <50ms per 8-vCPU node; measured ~117 hybrid ops/s inside a 166-QPS mix, on a laptop running Postgres under a Docker VM. The ~2.5x gap is real; measuring on the reference shape is the required next pass.
  • Point reads queue behind recall under load: current_fact p95 is 5.4ms at N=4 (vs 0.43ms idle). The get target (≥5k QPS at p95 <5ms) cannot be met from an undifferentiated pool; the planned L3 in-memory projection is the designed fix.
  • Entity-bound + broad-visibility BM25 is the worst number ever recorded here: 724ms p95, ~14x the envelope. Dense recall is immune (the router sends tiny entity-bound subsets to exact scan).
  • Honesty notes: the bench tenant's activity timeline holds ~2 rows — path coverage, not a scale claim. No HTTP hop, no rate limiting, no encoder anywhere in the load loop; a served deployment adds all three.

2026-07-09 (later) · BM25 pushdown: all paths inside the envelope

Root cause (pg_search 0.24.1): the array-overlap operator && is not pushable into Tantivy, and valid_to wasn't indexed — BM25 queries heap-fetched the entire text match set (~540k rows for a 2-term OR query) to evaluate scope filters before top-k. Fix (migration 0004 + adapter): visibility expressed as a Tantivy term_set — exact overlap semantics inside the boolean, matches nothing on an empty principal set (fail-closed preserved) — and valid_to added to the bm25 index. Zero heap-filter nodes remain (~1.9k buffers/query vs ~480k).

case (1M chunks)before p50/p95after p50after p95after p99
BM25 @ 1% selectivity282.1 / 329.7ms15.9ms17.8ms18.7ms
hybrid (dense + BM25) @ 1%284.9 / 330.0ms16.0ms17.9ms21.8ms
  • Scoreboard at 1M, worst case per path (p95): get 0.5ms · dense 25.4ms · BM25 17.8ms · hybrid 17.9ms · +12ms encode ⇒ every retrieval path inside the <50ms p95 envelope, encoder included.
  • Caveats recorded: term_set shifts raw scores by a constant (+1.0) — ranking identical, RRF unaffected; entity_tags <@ subset filtering remains a heap filter — an entity-bound + broad-visibility case must be benched before claiming that combination; the migration briefly drops search availability during rebuild (~1.6s at 1M).

2026-07-09 (later) · 1M chunks: the valley collapses, then the selectivity router kills it

Setup: 1,000,000 chunks, same machine/profile. Bulk-loaded with secondary indexes dropped (292s; the incremental path was ~12x slower). Along the way: Docker's 64MB default shm silently fails parallel HNSW builds (compose now sets shm_size: 1gb), and an accidental run against the index-less table showed brute-force at 11ms p50 beating HNSW's 72.6ms on the same 1%-filtered query — the planner picks graph traversal exactly where it loses. Fix: an adapter-side selectivity router — a sub-millisecond EXPLAIN row estimate routes ≤20k estimated matches to exact top-k (perfect recall, no graph), the rest to HNSW iterative scan.

casepre-router p50/p95routed p50routed p95routed p99
unfiltered ANN9.5 / 53.1ms9.8ms18.6ms21.1ms
filtered ANN @ 0.1%1.9 / 2.8ms2.2ms3.7ms7.2ms
filtered ANN @ 1%72.6 / 155.1ms15.1ms25.4ms49.2ms
filtered ANN @ 10%18.8 / 77.3ms13.1ms19.7ms27.4ms
filtered ANN @ 50%6.0 / 7.9ms5.7ms10.1ms13.3ms
BM25 @ 1%282.1ms329.7ms359.9ms
hybrid @ 1%284.9ms330.0ms381.4ms
L1 point read0.29ms0.51ms0.94ms
  • The <50ms p95 claim holds at 1M for scoped dense recall, encoder included: worst dense case 25.4ms p95 + ~12ms encode ≈ ~37ms p95 end-to-end. The valley is eliminated by routing, not tuning.
  • The get path is flat with corpus size: 0.29ms p50 at 1M vs 0.33ms at 100k.
  • BM25 at 1M is the new breach: ~280ms p50. The fast-field fix that held at 100k does not carry — pg_search scores the full match set before the filters bite; hybrid inherits it. Until fixed, the honest hybrid number at 1M is ~330ms p95.
  • Planner-estimate routing depends on healthy pg_statsANALYZE after bulk loads is load-bearing.

2026-07-09 (later) · local query encoder measured

Setup: all-MiniLM-L6-v2 ONNX (384-d, matching the chunk schema), CPU-only via ONNX Runtime, single thread, 100 short queries, Apple M3 Pro. Semantic ordering verified by test (related queries must beat unrelated by >0.2 cosine).

casep50p95p99
local query encode (MiniLM-L6 ONNX)11.03ms12.21ms13.61ms
  • Inside the 5–15ms local-encoder budget. Warm-cache encoder load ~190ms; first-run model download ~4.6s.
  • End-to-end dense recall including encoding at 100k / 1% selectivity: ~37ms p50 / ~48ms p95 (additive worst case) — the number the <50ms p95 claim must hold against as corpus size grows.

2026-07-09 (later) · BM25 fast-field fix

Change: scope-filter columns (tenant_id, visibility, confidentiality) added to the bm25 index as fast fields (migration 0003), letting pg_search filter inside Tantivy instead of on the heap. Same corpus/machine as above.

casep50p95p99prior p99
BM25 @ 1% selectivity23.23ms30.46ms32.59ms87.17ms
hybrid (dense + BM25) @ 1%30.70ms44.86ms55.90ms58.17ms
  • 2.7x p99 improvement — BM25 back inside the 50ms envelope at p95/p99. Hybrid tail now dominated by the dense side's 1%-selectivity valley.

2026-07-09 · first curve (Milestone A, week 1)

Setup: 100,000 chunks (384-d vectors, HNSW m=16 / ef_construction=64), Postgres profile (ParadeDB pg17: pgvector iterative scans + pg_search BM25) in Docker on Apple M3 Pro / 36 GB. k=10, 200 queries per case, in-process client — no network hop, no query embedding (local ONNX encoder not yet built).

casep50p95p99
unfiltered ANN (broad token)3.30ms6.25ms10.98ms
filtered ANN @ 0.1% selectivity1.15ms1.82ms3.59ms
filtered ANN @ 1% selectivity25.89ms35.30ms41.53ms
filtered ANN @ 10% selectivity7.81ms17.33ms27.97ms
filtered ANN @ 50% selectivity3.90ms7.85ms9.56ms
BM25 @ 1% selectivity29.26ms46.94ms87.17ms
hybrid (dense + BM25) @ 1%35.17ms43.07ms58.17ms
L1 point read (current_fact)0.33ms0.60ms0.89ms
  • The get path already beats its claim — sub-millisecond L1 point reads straight from Postgres, wide margin under the ~2–5ms budget.
  • The filtered-ANN valley is real and now located. Latency is non-monotonic in selectivity: 0.1% is fastest (exact scan over a tiny posting set), 50% is near-unfiltered, and ~1% is the worst case — too selective for cheap graph traversal, too large for exact scan.
  • All dense paths meet <50ms p95 at this corpus size — but 100k is 1–2 orders of magnitude below the honest ceiling. The next entry must be 1M+ before any latency claim leaves the repo.
  • BM25's 87ms p99 is the first SLO breach — pg_search filtering on the heap after the Tantivy match.
  • Missing from these numbers: query embedding, network hop, concurrency. Single-query latency only.