Query latency: where the time goes, from 10k to 1M beliefs¶
A semantic query reads every ACTIVE belief that has an embedding, scores each
one against the question, and keeps the best top_k. There is no vector index.
Latency and memory therefore grow with the store, and a fair reader asks how
large a store can get before a query is slow. This page answers with numbers,
measured before any change to the query path and published whatever they
showed.
The run measures the selection half of a query (retrieve_ranked): it embeds
the question, loads the candidates, filters and scores them, sorts, and
collapses co-evidential duplicates. It does not generate an answer and makes no
LLM call, so the figures are the floor under every particles query, the MCP
query tool and the session digest.
The answer, in four lines¶
On the benchmark host (an Apple M4 Mac mini with 16 GB), one unfiltered query:
- takes 5.7 s at 56k beliefs, the size of the owner's own store, and grows linearly, at about 100 µs per belief, to 10.3 s at 100k;
- takes 61 s at 300k, worse than linear, because the 3.5 GB store no longer stays in the operating system's file cache;
- did not finish at 1M: it needs about 20 GiB of memory, and the run stopped it after 21 minutes of swapping on a 16 GB machine;
- spends about three quarters of its time decoding embedding JSON and building Python models, and about 1.4% on the cosine similarity the store is usually suspected of.
A query filtered by subject reads about ten rows, yet it also grows with the store (0.13 s at 56k, 0.78 s at 300k, 2.8 s at 1M), because SQLite answers it by walking every ACTIVE row.
These figures were measured before any change to the query path. They are the baseline the next changes are measured against, and they are published as they came out.
Since then. Release 1.192.23 made the changes this baseline pointed at. On the same machine and stores, a warm unfiltered query takes 358 ms at 56k (5.69 s before), 2.25 s at 300k (61 s) and 8.9 s at 1M, which now finishes; a fresh process's first query costs what it did. The measurement is in After the candidate cache, beside these figures, which are unchanged.
Results (2026-10-17)¶
One run of the grid, on one machine. Each row is one cell; warm rows pool 50 questions (40 at 300k, where the 40-minute cell budget ran out) and cold rows pool 5 fresh processes. p95 over 5 samples describes those 5, not a tail. Reproduction tuple: seed
20261017; sizes 10k, 56k, 100k, 300k, 1M ACTIVE beliefs; harness commit83dafe37, released in 1.192.21 (its result files record the branch's earlier label, 1.192.16, a version number that release tagged on a different commit); Python 3.11.15, NumPy 2.4.6, SQLite 3.50.4, SQLAlchemy 2.1.4, Pydantic 2.14.1; macOS 27.0.1 on an Apple M4 (10 cores, 16 GB); encoderall-MiniLM-L6-v2; load average 1.9 / 1.6 / 1.5 at the start (Docker Desktop and the host's own services were running; the Ollama server, version 0.40.3, was idle with no model loaded). The run held the benchmark host's lock throughout, so no other measurement shared the machine. Raw JSON is linked from every row, one file per store size.
Which figures travel. The warm rows below 300k and every count (rows read, bytes decoded, models hydrated, peak memory) are the portable result: they depend on the code and the store, and on the hardware only through a per-row cost. The cold rows, the 300k rows and the 1M result depend on the disk, the file cache and the memory of this machine, and are labelled that way. The reference run on a GitHub-hosted runner, below, is the second platform.
The benchmark host¶
Warm, unfiltered. The figure most readers want: a long-running process (the MCP server, the API) answering an ordinary semantic query.
| Store | n | p50 | p95 | rows read (p50) | embedding JSON decoded (p50) | peak RSS | status | raw |
|---|---|---|---|---|---|---|---|---|
| 10k | 50 | 993 ms | 1.05 s | 10,000 | 80 MiB | 872 MiB | ok | json |
| 56k | 50 | 5.69 s | 5.85 s | 56,000 | 450 MiB | 1.98 GiB | ok | json |
| 100k | 50 | 10.28 s | 10.69 s | 100,000 | 803 MiB | 3.04 GiB | ok | json |
| 300k | 40 | 60.57 s | 61.82 s | 300,000 | 2.35 GiB | 6.56 GiB | ok | json |
| 1M | 0 | — | — | 0 | — | — | skipped | json |
| Store | setup | embed | SQL fetch | decode | hydrate | filters | sources | discounts | scoring | sort | collapse | other |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10k | 5.4 ms (1%) | 6.1 ms (1%) | 79 ms (10%) | 514 ms (52%) | 243 ms (25%) | 6.3 ms (4%) | 11 ms (1%) | 1.6 ms (0%) | 46 ms (5%) | 2.7 ms (0%) | 8.0 ms (1%) | 13 ms (1%) |
| 56k | 27 ms (0%) | 18 ms (0%) | 572 ms (10%) | 2.93 s (51%) | 1.48 s (26%) | 54 ms (2%) | 65 ms (1%) | 11 ms (0%) | 270 ms (6%) | 20 ms (0%) | 8.2 ms (0%) | 87 ms (2%) |
| 100k | 48 ms (0%) | 18 ms (0%) | 1.09 s (11%) | 5.26 s (51%) | 2.80 s (27%) | 120 ms (2%) | 120 ms (1%) | 19 ms (0%) | 495 ms (6%) | 36 ms (0%) | 8.3 ms (0%) | 161 ms (2%) |
| 300k | 4.37 s (7%) | 114 ms (0%) | 26.16 s (43%) | 16.34 s (27%) | 9.82 s (16%) | 526 ms (2%) | 419 ms (1%) | 57 ms (0%) | 1.60 s (3%) | 114 ms (0%) | 15 ms (0%) | 643 ms (1%) |
Warm, subject-filtered.
| Store | n | p50 | p95 | rows read (p50) | embedding JSON decoded (p50) | peak RSS | status | raw |
|---|---|---|---|---|---|---|---|---|
| 10k | 50 | 28 ms | 72 ms | 18 | 0 MiB | 639 MiB | ok | json |
| 56k | 50 | 127 ms | 293 ms | 13 | 0 MiB | 683 MiB | ok | json |
| 100k | 50 | 237 ms | 375 ms | 12 | 0 MiB | 714 MiB | ok | json |
| 300k | 50 | 784 ms | 964 ms | 10 | 0 MiB | 712 MiB | ok | json |
| 1M | 50 | 2.79 s | 3.12 s | 16 | 0 MiB | 1.15 GiB | ok | json |
| Store | setup | embed | SQL fetch | decode | hydrate | filters | sources | discounts | scoring | sort | collapse | other |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10k | 4.4 ms (13%) | 6.4 ms (19%) | 11 ms (34%) | 1.1 ms (13%) | 0.3 ms (4%) | 0.0 ms (0%) | 0.8 ms (3%) | 0.0 ms (0%) | 0.1 ms (1%) | 0.0 ms (0%) | 3.5 ms (12%) | 0.2 ms (1%) |
| 56k | 27 ms (18%) | 5.7 ms (4%) | 90 ms (61%) | 0.8 ms (8%) | 0.2 ms (5%) | 0.0 ms (0%) | 0.9 ms (1%) | 0.0 ms (0%) | 0.1 ms (1%) | 0.0 ms (0%) | 2.5 ms (3%) | 0.2 ms (0%) |
| 100k | 46 ms (18%) | 5.6 ms (2%) | 179 ms (70%) | 0.7 ms (5%) | 0.2 ms (2%) | 0.0 ms (0%) | 0.9 ms (1%) | 0.0 ms (0%) | 0.1 ms (0%) | 0.0 ms (0%) | 2.2 ms (1%) | 0.2 ms (0%) |
| 300k | 140 ms (17%) | 5.9 ms (1%) | 633 ms (79%) | 0.6 ms (2%) | 0.2 ms (1%) | 0.0 ms (0%) | 0.9 ms (0%) | 0.0 ms (0%) | 0.1 ms (0%) | 0.0 ms (0%) | 1.9 ms (0%) | 0.2 ms (0%) |
| 1M | 466 ms (16%) | 15 ms (1%) | 2.31 s (81%) | 1.0 ms (2%) | 0.3 ms (1%) | 0.0 ms (0%) | 1.0 ms (0%) | 0.0 ms (0%) | 0.1 ms (0%) | 0.0 ms (0%) | 3.8 ms (0%) | 0.2 ms (0%) |
Cold, unfiltered. One question per fresh process, five processes per row. The encoder load (3.5 to 5.2 s) and the import of the query path (0.5 to 0.8 s) are not in these figures. At 1M the first query never finished: the run stopped it after 20 minutes 44 seconds of swapping, the remaining 1M unfiltered cells were recorded as skipped, and the peak memory shown is the resident part only, sampled before the stop.
| Store | n | p50 | p95 | rows read (p50) | embedding JSON decoded (p50) | peak RSS | status | raw |
|---|---|---|---|---|---|---|---|---|
| 10k | 5 | 1.14 s | 1.57 s | 10,000 | 80 MiB | 870 MiB | ok | json |
| 56k | 5 | 5.96 s | 6.10 s | 56,000 | 450 MiB | 1.96 GiB | ok | json |
| 100k | 5 | 10.72 s | 10.93 s | 100,000 | 803 MiB | 2.94 GiB | ok | json |
| 300k | 5 | 48.20 s | 49.29 s | 300,000 | 2.35 GiB | 6.65 GiB | ok | json |
| 1M | 0 | — | — | 0 | — | 5.40 GiB | memory_guard | json |
| Store | setup | embed | SQL fetch | decode | hydrate | filters | sources | discounts | scoring | sort | collapse | other |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10k | 14 ms (1%) | 102 ms (15%) | 83 ms (7%) | 505 ms (41%) | 344 ms (28%) | 6.7 ms (1%) | 12 ms (1%) | 1.5 ms (0%) | 51 ms (4%) | 2.6 ms (0%) | 8.8 ms (1%) | 14 ms (1%) |
| 56k | 37 ms (1%) | 71 ms (1%) | 662 ms (12%) | 2.90 s (49%) | 1.44 s (24%) | 379 ms (6%) | 63 ms (1%) | 11 ms (0%) | 275 ms (5%) | 19 ms (0%) | 9.5 ms (0%) | 89 ms (1%) |
| 100k | 58 ms (1%) | 69 ms (1%) | 1.18 s (11%) | 5.25 s (49%) | 2.71 s (25%) | 629 ms (6%) | 117 ms (1%) | 19 ms (0%) | 492 ms (5%) | 34 ms (0%) | 10 ms (0%) | 159 ms (1%) |
| 300k | 4.49 s (8%) | 83 ms (0%) | 14.03 s (30%) | 16.38 s (34%) | 10.23 s (21%) | 373 ms (1%) | 416 ms (1%) | 56 ms (0%) | 1.57 s (3%) | 109 ms (0%) | 16 ms (0%) | 591 ms (1%) |
Cold, subject-filtered. The 1M row's p95 (23.9 s) is the first query to touch the 11.6 GB store after the unfiltered attempt evicted it from the file cache.
| Store | n | p50 | p95 | rows read (p50) | embedding JSON decoded (p50) | peak RSS | status | raw |
|---|---|---|---|---|---|---|---|---|
| 10k | 5 | 115 ms | 117 ms | 25 | 0 MiB | 624 MiB | ok | json |
| 56k | 5 | 214 ms | 238 ms | 10 | 0 MiB | 630 MiB | ok | json |
| 100k | 5 | 319 ms | 325 ms | 4 | 0 MiB | 623 MiB | ok | json |
| 300k | 5 | 863 ms | 5.15 s | 12 | 0 MiB | 624 MiB | ok | json |
| 1M | 5 | 2.84 s | 23.89 s | 25 | 0 MiB | 639 MiB | ok | json |
| Store | setup | embed | SQL fetch | decode | hydrate | filters | sources | discounts | scoring | sort | collapse | other |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10k | 13 ms (12%) | 71 ms (62%) | 14 ms (12%) | 1.7 ms (2%) | 0.4 ms (0%) | 0.0 ms (0%) | 1.7 ms (1%) | 0.0 ms (0%) | 5.8 ms (5%) | 0.0 ms (0%) | 5.6 ms (5%) | 0.3 ms (0%) |
| 56k | 38 ms (17%) | 70 ms (32%) | 93 ms (42%) | 1.0 ms (2%) | 0.2 ms (1%) | 0.0 ms (0%) | 1.7 ms (1%) | 0.0 ms (0%) | 6.0 ms (3%) | 0.0 ms (0%) | 2.9 ms (2%) | 0.3 ms (0%) |
| 100k | 58 ms (18%) | 69 ms (22%) | 182 ms (57%) | 0.6 ms (0%) | 0.1 ms (0%) | 0.0 ms (0%) | 1.6 ms (1%) | 0.0 ms (0%) | 6.0 ms (2%) | 0.0 ms (0%) | 1.5 ms (1%) | 0.3 ms (0%) |
| 300k | 154 ms (56%) | 68 ms (6%) | 626 ms (38%) | 1.0 ms (0%) | 0.3 ms (0%) | 0.0 ms (0%) | 1.8 ms (0%) | 0.0 ms (0%) | 6.0 ms (0%) | 0.0 ms (0%) | 4.3 ms (0%) | 0.3 ms (0%) |
| 1M | 474 ms (65%) | 67 ms (1%) | 2.28 s (34%) | 1.7 ms (0%) | 0.4 ms (0%) | 0.0 ms (0%) | 2.8 ms (0%) | 0.0 ms (0%) | 5.9 ms (0%) | 0.0 ms (0%) | 8.7 ms (0%) | 0.3 ms (0%) |
Scaling. The slope of log p50 on log N is 1.0 for linear growth. The
per-row cost is the p50 divided by the rows the loader read; it is flat while
the store fits in the file cache and doubles at 300k.
| Mode, variant | sizes | slope | µs per row read |
|---|---|---|---|
| warm, unfiltered, 10k to 100k | 10k, 56k, 100k | 1.01 | 99, 102, 103 |
| warm, unfiltered, with 300k | 10k to 300k | 1.18 | 300k: 202 |
| cold, unfiltered, with 300k | 10k to 300k | 1.08 | 114, 106, 107, 161 |
| warm, subject | 10k to 1M | 1.01 | (about ten rows read; the cost is the scan, see below) |
The reference run: a GitHub-hosted runner¶
One run, on a shared runner. Commit
50a98fcb, which measures with the same harness and the same query path as the host run (it adds only a fix to how the harness reads swap on Linux, and workflow files); Python 3.11.17, NumPy 2.4.6, SQLite 3.53.1, SQLAlchemy 2.1.4, Pydantic 2.14.1; Linux 6.17 on an AMD EPYC 9V74 with 2 vCPUs and 7.75 GiB;--no-encoder, so the query vector is a fixed seeded one and no model is loaded (the embed column is zero and the peak memory has no encoder in it); load average 1.0 / 0.7 / 0.3 at the start. A shared runner holds no lock, and its neighbours are unknown. 1M was not attempted: the runner has 7.75 GiB of memory and 14 GB of disk.
Warm, unfiltered.
| Store | n | p50 | p95 | rows read (p50) | embedding JSON decoded (p50) | peak RSS | status | raw |
|---|---|---|---|---|---|---|---|---|
| 10k | 50 | 2.17 s | 2.29 s | 10,000 | 80 MiB | 344 MiB | ok | json |
| 56k | 50 | 12.05 s | 12.54 s | 56,000 | 450 MiB | 1.36 GiB | ok | json |
| 100k | 50 | 21.19 s | 22.14 s | 100,000 | 803 MiB | 2.22 GiB | ok | json |
| 300k | 16 | 149.59 s | 170.73 s | 300,000 | 2.35 GiB | 6.56 GiB | ok | json |
| Store | setup | embed | SQL fetch | decode | hydrate | filters | sources | discounts | scoring | sort | collapse | other |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10k | 10 ms (0%) | 0.0 ms (0%) | 192 ms (10%) | 901 ms (42%) | 621 ms (29%) | 18 ms (5%) | 33 ms (3%) | 5.4 ms (0%) | 104 ms (7%) | 9.1 ms (0%) | 19 ms (1%) | 35 ms (2%) |
| 56k | 47 ms (0%) | 0.0 ms (0%) | 1.51 s (12%) | 5.01 s (41%) | 4.12 s (33%) | 130 ms (3%) | 206 ms (2%) | 38 ms (0%) | 600 ms (6%) | 57 ms (0%) | 19 ms (0%) | 234 ms (2%) |
| 100k | 81 ms (0%) | 0.0 ms (0%) | 2.85 s (13%) | 8.94 s (42%) | 5.97 s (29%) | 1.30 s (6%) | 368 ms (2%) | 65 ms (0%) | 1.07 s (5%) | 100 ms (0%) | 19 ms (0%) | 420 ms (2%) |
| 300k | 12.16 s (9%) | 0.0 ms (0%) | 79.62 s (53%) | 27.06 s (18%) | 19.62 s (13%) | 806 ms (1%) | 1.40 s (1%) | 198 ms (0%) | 3.24 s (3%) | 298 ms (0%) | 35 ms (0%) | 1.41 s (1%) |
Warm, subject-filtered.
| Store | n | p50 | p95 | rows read (p50) | embedding JSON decoded (p50) | peak RSS | status | raw |
|---|---|---|---|---|---|---|---|---|
| 10k | 50 | 50 ms | 136 ms | 18 | 0 MiB | 277 MiB | ok | json |
| 56k | 50 | 242 ms | 462 ms | 13 | 0 MiB | 292 MiB | ok | json |
| 100k | 50 | 430 ms | 734 ms | 12 | 0 MiB | 295 MiB | ok | json |
| 300k | 50 | 1.45 s | 2.42 s | 10 | 0 MiB | 315 MiB | ok | json |
The runner is about 2.1 times slower per belief than the M4 (215 µs against 100 µs), and the split is the same: decode 41 to 42%, hydration 29 to 33%, SQL fetch 10 to 13%, scoring 5 to 7%, up to 100k. At 300k the runner's 7.75 GiB cannot hold the file and the process together, and the SQL fetch becomes 53% of a 150 s query, the same break the host shows at the same size, sooner and harder. Every count (rows read, bytes decoded, models hydrated) is identical on the two platforms, because it depends only on the store. The cold figures are in the raw files.
After the candidate cache (2026-10-17, 1.192.23)¶
The baseline above pointed at three changes. Release 1.192.23 makes them and the two smaller ones, and this section measures the result with the same command, the same seed and the same stores (the cached files of the baseline run, reused). Nothing above this heading was changed by it.
What changed.
- The candidates stay decoded between queries. A process now keeps each
store's ACTIVE candidates, their
Particlemodels and decoded vectors, in memory, and on each query re-reads only the rows that changed since the last one. The store says which: a change journal, kept by SQLite triggers, records the id of every row any process inserts, updates or deletes, so a write by the session hooks or the CLI is seen by a running MCP server. The cache is checked against the journal on every query and returns the uncached loader's rows, vectors and order exactly; the test suite compares the two directly, and whole rankings with the cache on and off. - The similarity is one matrix pass over all candidates instead of one call per candidate.
- A subject-filtered query starts from the subject's links instead of walking every ACTIVE row; the subject's existing index was enough once SQLite was kept off the status index.
- The schema-version check is two index lookups instead of a scan of the table.
- The source lookup sends its ids in chunks (released separately in 1.192.22), so no store size makes a query fail.
Why a cache rather than a new storage format. Storing embeddings as
float32 bytes would remove the JSON decode but not the model hydration, a
quarter of the baseline query, which only keeping the models avoids. It would
also rewrite every stored embedding in a migration and change a column that
every embedding reader reads, including an older install sharing the same
store. The cache needs no change to stored rows: its migration adds the journal
and an index, an older install neither reads nor writes the journal, and the
triggers record that install's writes as well. The as-of lens and the window
route read across statuses with predicates of their own, so they keep their own
loaders and are untouched; the cache covers only the ACTIVE set the ordinary
query reads.
The benchmark host, after¶
One run of the grid, on the same machine. Each row is one cell; warm rows pool 50 questions and cold rows 5 fresh processes, as above. Reproduction tuple: seed
20261017; the baseline's stores (unchanged rows; the journal and the new index added in place); harness commit60c01b64(1.192.22 plus this change, released as 1.192.23); Python 3.11.15, NumPy 2.4.6, SQLite 3.50.4, SQLAlchemy 2.1.4, Pydantic 2.14.1; macOS 27.0.1 on the same Apple M4 (10 cores, 16 GB); encoderall-MiniLM-L6-v2; load average 1.4 / 1.6 / 1.6 at the start (the Ollama server, version 0.40.3, idle with no model loaded). The run held the benchmark host's lock throughout. Raw JSON is linked from every row.
Before and after, p50. The baseline's figures beside this run's, from the two tables of record.
| Store | warm, unfiltered | warm, subject | cold, unfiltered | cold, subject | peak RSS, warm unfiltered |
|---|---|---|---|---|---|
| 10k | 993 ms → 68 ms | 28 ms → 13 ms | 1.14 s → 1.23 s | 115 ms → 102 ms | 872 MiB → 803 MiB |
| 56k | 5.69 s → 358 ms | 127 ms → 12 ms | 5.96 s → 6.02 s | 214 ms → 95 ms | 1.98 GiB → 1.37 GiB |
| 100k | 10.28 s → 660 ms | 237 ms → 12 ms | 10.72 s → 10.84 s | 319 ms → 93 ms | 3.04 GiB → 1.92 GiB |
| 300k | 60.57 s → 2.25 s | 784 ms → 12 ms | 48.20 s → 36.49 s | 863 ms → 101 ms | 6.56 GiB → 4.36 GiB |
| 1M | did not finish → 8.88 s | 2.79 s → 21 ms | did not finish → 373 s | 2.84 s → 116 ms | (about 20 GiB) → 6.96 GiB |
Warm, unfiltered.
| Store | n | p50 | p95 | rows read (p50) | embedding JSON decoded (p50) | peak RSS | status | raw |
|---|---|---|---|---|---|---|---|---|
| 10k | 50 | 68 ms | 71 ms | 10,000 | 0 MiB | 803 MiB | ok | json |
| 56k | 50 | 358 ms | 368 ms | 56,000 | 0 MiB | 1.37 GiB | ok | json |
| 100k | 50 | 660 ms | 669 ms | 100,000 | 0 MiB | 1.92 GiB | ok | json |
| 300k | 50 | 2.25 s | 2.29 s | 300,000 | 0 MiB | 4.36 GiB | ok | json |
| 1M | 50 | 8.88 s | 9.38 s | 1,000,000 | 0 MiB | 6.96 GiB | ok | json |
| Store | setup | embed | SQL fetch | decode | hydrate | filters | sources | discounts | scoring | sort | collapse | other |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10k | 1.2 ms (2%) | 6.2 ms (9%) | 0.4 ms (1%) | 1.2 ms (2%) | 0.0 ms (0%) | 4.4 ms (7%) | 11 ms (15%) | 1.8 ms (3%) | 31 ms (45%) | 2.6 ms (4%) | 7.8 ms (11%) | 1.3 ms (2%) |
| 56k | 1.3 ms (0%) | 5.7 ms (2%) | 0.4 ms (0%) | 6.8 ms (2%) | 0.0 ms (0%) | 46 ms (13%) | 72 ms (20%) | 13 ms (4%) | 184 ms (51%) | 18 ms (5%) | 7.8 ms (2%) | 5.1 ms (1%) |
| 100k | 1.3 ms (0%) | 6.1 ms (1%) | 0.4 ms (0%) | 12 ms (2%) | 0.0 ms (0%) | 101 ms (15%) | 127 ms (19%) | 23 ms (3%) | 338 ms (51%) | 34 ms (5%) | 7.9 ms (1%) | 8.9 ms (1%) |
| 300k | 1.4 ms (0%) | 16 ms (1%) | 0.5 ms (0%) | 61 ms (3%) | 0.0 ms (0%) | 453 ms (20%) | 405 ms (18%) | 69 ms (3%) | 1.11 s (49%) | 100 ms (4%) | 8.1 ms (0%) | 28 ms (1%) |
| 1M | 1.7 ms (0%) | 199 ms (2%) | 0.9 ms (0%) | 343 ms (4%) | 0.0 ms (0%) | 1.76 s (20%) | 1.57 s (18%) | 234 ms (3%) | 4.19 s (49%) | 351 ms (4%) | 8.8 ms (0%) | 103 ms (1%) |
Warm, subject-filtered.
| Store | n | p50 | p95 | rows read (p50) | embedding JSON decoded (p50) | peak RSS | status | raw |
|---|---|---|---|---|---|---|---|---|
| 10k | 50 | 13 ms | 62 ms | 18 | 0 MiB | 642 MiB | ok | json |
| 56k | 50 | 12 ms | 162 ms | 13 | 0 MiB | 685 MiB | ok | json |
| 100k | 50 | 12 ms | 173 ms | 12 | 0 MiB | 725 MiB | ok | json |
| 300k | 50 | 12 ms | 271 ms | 10 | 0 MiB | 719 MiB | ok | json |
| 1M | 50 | 21 ms | 913 ms | 16 | 0 MiB | 1.17 GiB | ok | json |
| Store | setup | embed | SQL fetch | decode | hydrate | filters | sources | discounts | scoring | sort | collapse | other |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10k | 1.1 ms (6%) | 5.3 ms (27%) | 0.8 ms (10%) | 1.0 ms (23%) | 0.2 ms (6%) | 0.0 ms (0%) | 0.6 ms (4%) | 0.0 ms (0%) | 0.1 ms (1%) | 0.0 ms (0%) | 3.5 ms (20%) | 0.5 ms (3%) |
| 56k | 1.1 ms (3%) | 5.2 ms (14%) | 0.7 ms (18%) | 0.8 ms (33%) | 0.2 ms (14%) | 0.0 ms (0%) | 0.7 ms (4%) | 0.0 ms (0%) | 0.1 ms (2%) | 0.0 ms (0%) | 2.3 ms (10%) | 0.4 ms (2%) |
| 100k | 1.1 ms (3%) | 5.3 ms (14%) | 0.8 ms (17%) | 0.7 ms (34%) | 0.2 ms (14%) | 0.0 ms (0%) | 0.6 ms (4%) | 0.0 ms (0%) | 0.1 ms (2%) | 0.0 ms (0%) | 2.1 ms (9%) | 0.4 ms (2%) |
| 300k | 1.0 ms (2%) | 5.3 ms (9%) | 1.5 ms (43%) | 0.7 ms (22%) | 0.1 ms (12%) | 0.0 ms (0%) | 0.6 ms (3%) | 0.0 ms (0%) | 0.1 ms (1%) | 0.0 ms (0%) | 1.6 ms (5%) | 0.3 ms (1%) |
| 1M | 1.1 ms (0%) | 5.5 ms (1%) | 5.7 ms (78%) | 0.9 ms (11%) | 0.2 ms (5%) | 0.0 ms (0%) | 0.7 ms (1%) | 0.0 ms (0%) | 0.1 ms (1%) | 0.0 ms (0%) | 2.9 ms (1%) | 0.5 ms (0%) |
Cold, unfiltered.
| Store | n | p50 | p95 | rows read (p50) | embedding JSON decoded (p50) | peak RSS | status | raw |
|---|---|---|---|---|---|---|---|---|
| 10k | 5 | 1.23 s | 1.27 s | 10,000 | 80 MiB | 806 MiB | ok | json |
| 56k | 5 | 6.02 s | 10.24 s | 56,000 | 450 MiB | 1.36 GiB | ok | json |
| 100k | 5 | 10.84 s | 20.35 s | 100,000 | 803 MiB | 1.91 GiB | ok | json |
| 300k | 5 | 36.49 s | 51.52 s | 300,000 | 2.35 GiB | 4.39 GiB | ok | json |
| 1M | 5 | 373.26 s | 375.85 s | 1,000,000 | 7.84 GiB | 6.31 GiB | ok | json |
| Store | setup | embed | SQL fetch | decode | hydrate | filters | sources | discounts | scoring | sort | collapse | other |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10k | 9.4 ms (1%) | 73 ms (6%) | 182 ms (15%) | 651 ms (53%) | 248 ms (20%) | 5.0 ms (0%) | 12 ms (1%) | 1.8 ms (0%) | 38 ms (3%) | 2.7 ms (0%) | 8.8 ms (1%) | 1.5 ms (0%) |
| 56k | 9.1 ms (0%) | 71 ms (1%) | 1.05 s (30%) | 3.38 s (48%) | 1.15 s (16%) | 48 ms (1%) | 71 ms (1%) | 13 ms (0%) | 199 ms (3%) | 18 ms (0%) | 8.6 ms (0%) | 5.4 ms (0%) |
| 100k | 9.3 ms (0%) | 69 ms (1%) | 1.47 s (29%) | 6.00 s (45%) | 2.65 s (20%) | 85 ms (1%) | 131 ms (1%) | 24 ms (0%) | 363 ms (3%) | 33 ms (0%) | 10 ms (0%) | 9.3 ms (0%) |
| 300k | 8.4 ms (0%) | 69 ms (0%) | 7.09 s (28%) | 17.87 s (44%) | 9.21 s (23%) | 262 ms (1%) | 415 ms (1%) | 70 ms (0%) | 1.14 s (3%) | 101 ms (0%) | 13 ms (0%) | 29 ms (0%) |
| 1M | 9.2 ms (0%) | 83 ms (0%) | 274.12 s (73%) | 61.17 s (17%) | 30.16 s (8%) | 1.12 s (0%) | 1.70 s (0%) | 237 ms (0%) | 4.65 s (1%) | 396 ms (0%) | 21 ms (0%) | 103 ms (0%) |
Cold, subject-filtered.
| Store | n | p50 | p95 | rows read (p50) | embedding JSON decoded (p50) | peak RSS | status | raw |
|---|---|---|---|---|---|---|---|---|
| 10k | 5 | 102 ms | 106 ms | 25 | 0 MiB | 627 MiB | ok | json |
| 56k | 5 | 95 ms | 125 ms | 10 | 0 MiB | 634 MiB | ok | json |
| 100k | 5 | 93 ms | 99 ms | 4 | 0 MiB | 626 MiB | ok | json |
| 300k | 5 | 101 ms | 126 ms | 12 | 0 MiB | 629 MiB | ok | json |
| 1M | 5 | 116 ms | 675 ms | 25 | 0 MiB | 642 MiB | ok | json |
| Store | setup | embed | SQL fetch | decode | hydrate | filters | sources | discounts | scoring | sort | collapse | other |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10k | 8.4 ms (8%) | 73 ms (72%) | 3.3 ms (3%) | 2.0 ms (2%) | 0.5 ms (0%) | 0.0 ms (0%) | 1.4 ms (1%) | 0.0 ms (0%) | 6.5 ms (6%) | 0.0 ms (0%) | 5.6 ms (6%) | 0.8 ms (1%) |
| 56k | 8.9 ms (9%) | 71 ms (69%) | 3.0 ms (4%) | 1.1 ms (4%) | 0.2 ms (1%) | 0.0 ms (0%) | 1.3 ms (2%) | 0.0 ms (0%) | 5.8 ms (6%) | 0.0 ms (0%) | 3.1 ms (4%) | 0.5 ms (1%) |
| 100k | 7.9 ms (9%) | 69 ms (74%) | 5.2 ms (6%) | 0.8 ms (1%) | 0.1 ms (0%) | 0.0 ms (0%) | 1.4 ms (1%) | 0.0 ms (0%) | 5.6 ms (6%) | 0.0 ms (0%) | 1.8 ms (2%) | 0.3 ms (0%) |
| 300k | 10 ms (9%) | 71 ms (66%) | 7.0 ms (11%) | 1.2 ms (2%) | 0.3 ms (0%) | 0.0 ms (0%) | 1.4 ms (1%) | 0.0 ms (0%) | 6.4 ms (6%) | 0.0 ms (0%) | 3.3 ms (4%) | 0.5 ms (1%) |
| 1M | 10 ms (4%) | 71 ms (34%) | 20 ms (51%) | 1.8 ms (4%) | 0.4 ms (1%) | 0.0 ms (0%) | 1.4 ms (1%) | 0.0 ms (0%) | 6.1 ms (3%) | 0.0 ms (0%) | 5.6 ms (2%) | 0.7 ms (0%) |
Scaling, after. A warm query now costs 6.4 to 8.9 µs per candidate against
about 100 µs before, and the slope of log p50 on log N is 1.06 from 10k to
1M: it still grows linearly with the store, with a smaller constant. A warm
subject-filtered query no longer grows with the store at all (12 ms from 56k
to 300k, 21 ms at 1M).
Reading the after numbers¶
A long-running process gets the gain; a fresh one does not. The warm rows
are a process that has answered a query before: the MCP server, the API. Its
first unfiltered query builds the cache, which costs what every query used to
cost (the warm cells' untimed first query took 1.2 s at 10k, 6.0 s at 56k,
10.8 s at 100k, 34 s at 300k and 386 s at 1M), and every later one re-reads
only what changed. A cold row is a fresh process answering one question, which
is what a one-shot particles query on the command line does, and it still
reads and decodes every candidate: its figures are within a few percent of the
baseline's up to 100k. The cold 300k query is faster (36 s against 48 s)
because the build streams its rows and holds less memory at once, which leaves
more of the 3.5 GB store in the file cache. A cold subject-filtered query
builds no cache and is fast at every size.
Where a warm query's time goes now. At 56k, the 358 ms are the per-candidate scoring loop (trust, decay, effective confidence) at 184 ms (51%), the source lookup at 72 ms (20%), the list filters at 46 ms (13%), and the sort at 18 ms (5%). The decode stage (7 ms) is now the selection of the cached rows, and hydration is zero. The similarity itself is a small part of the scoring stage: stacking 56k vectors into a matrix takes 19 ms and the product 1.3 ms (the reference timing in the raw JSON). The remaining cost is Python work done once per candidate, which is what a further change would have to reduce; a vector index still has no measured stage to remove.
Memory. A warm process now holds the decoded candidates for its lifetime: 1.37 GiB at 56k and 6.96 GiB at 1M, peak, including the encoder. That is less than the baseline's peak at every size (1.98 GiB at 56k), because the baseline built the whole candidate set again on every query, JSON text and ORM rows included, and the cache is built in partitions. It is also why 1M now finishes on a 16 GB machine.
The 1M cold query reads its store from disk. Of a cold 1M query's 373 s, 274 s is the SQL fetch: an 11.6 GB store does not fit in the file cache beside the process. That figure is this machine's, as the 300k figures were before.
A first attempt, measured and fixed. The first after-run, on commit
2b518640, stopped at 100k when its 56k warm figures came out bimodal: most
queries took about 0.75 s and the rest about 0.35 s, a median of 754 ms (10k,
56k,
100k, cold only).
The extra 0.4 s was Python's cyclic garbage collector walking the cache's
1.4 million tracked objects on most queries. The run of record ends a large
build by exempting those objects from the collector (gc.freeze; reference
counting still frees them, which is how a replaced cache's rows go), set by
query.candidate_cache_freeze_rows. It is a process-wide setting, which an
application embedding the SDK may want to turn off.
Not repeated. The reference run on a GitHub-hosted runner has not been repeated after the change, so the after figures come from one platform.
What it does¶
particles benchmark query-latency run builds one synthetic store per size,
then times queries against it in separate processes.
The stores. Scoring cost does not depend on what the text says, so each store holds random unit vectors, which lets the run reach sizes no real store has yet. What does affect cost is the shape of each row and of the tables the query joins, so the generator copies the aggregate shape of the owner's store (counts and column sizes only, measured 2026-10-17 over 56,196 ACTIVE rows):
| Property | Synthetic store | Owner's store |
|---|---|---|
| Embedding JSON per row | about 8.4 KB, the production writer's own format | 8,430 bytes on average |
| Subjects per belief | 5% none, 68% one, 22% two, the rest three or more | the same distribution |
| Beliefs per subject | Zipf, exponent 0.8: median 3, p90 9, p99 about 50 at 56k | median 2, p90 9, p99 52 |
| Corpus entries, snapshots | one per 82 and one per 32 beliefs | 686 and 1,755 |
| Rows that are not ACTIVE | 20% more rows (stale, superseded, retracted) | 11,458 |
| Beliefs with properties, with a structured claim | 21.6%, 36.7% | the same |
| Co-evidential pairs | 1.3% of beliefs | 724 relations |
Every row is a real Particle serialised by the same function the write path
uses, stamped with the current embedding model so the loader does not skip it.
A (seed, size) pair is the fixture: two builds are identical, and the store's
manifest records a hash of every id to prove it.
The stages. The harness wraps the functions retrieve_ranked already
calls, so nothing in the query path was changed to measure it:
| Stage | What it covers |
|---|---|
| setup | the schema-version guard, the extractor trust cache, the trust and decay policies |
| embed | encoding the question (the encoder load is reported apart) |
| SQL fetch | the loader's one SELECT, including building the ORM row objects |
| decode | json.loads of each row's embedding and the float32 array built from it |
| hydrate | turning each row into a Particle model |
| filters | the list filters over the candidates (document-meta, polarity, stance) |
| sources | the batch lookup of each candidate's source snapshot and entry |
| discounts | the stale-support discount lookup |
| scoring | the per-candidate loop: trust, decay, effective confidence, cosine |
| sort | the sort of the scored list and the cut to top_k |
| collapse | the co-evidential collapse of the top k |
| other | the residual, so the stages always sum to the total |
The cells. Each store size runs four ways:
- cold: a fresh Python process answering one question, five times over, with the import of the query path and the encoder load timed separately. The operating system's file cache is not purged (that needs root), so cold means a cold process, not a cold disk;
- warm: one process answering 50 questions after one untimed warm-up query, one database session per question, as the query verbs open one;
- unfiltered: no filter, so every ACTIVE belief is a candidate;
- subject:
subject_idset, the one filter the loader applies in SQL. The subject for each question is the first subject of a random belief, so a popular subject is asked about as often as it is used.
top_k is the default, 40. The encoder is the default all-MiniLM-L6-v2. The
cells load an empty configuration, so every setting is the compiled default.
What is counted, apart from time. For every query: rows the SQL returned, characters of embedding JSON decoded, models hydrated, candidates scored, and the size of the source lookup. For every cell, the process's peak resident memory. These counts do not depend on the hardware, and they are what makes the timings portable: a reader can multiply them by their own per-row costs.
Each cell runs in its own process and writes its own result file when it finishes. A run interrupted by a reboot or a lost connection resumes where it stopped, losing only the cell in flight. The run watches each cell and stops it at a two-hour timeout, or when system swap grows by more than 8 GiB, or when free disk falls under 4 GiB; a stopped cell is a result, and the remaining cells of that size and variant are recorded as skipped.
Reading the numbers¶
Where the time goes. At 56k, warm and unfiltered, a 5.7 s query is 2.9 s of
embedding decode (51%), 1.5 s of model hydration (26%), 0.6 s of SQL fetch
(10%), 0.3 s of scoring (6%), and well under a tenth of a second for every
other stage. The split is the same at 10k and 100k to within two points, which
is what linear growth looks like from the inside. Decode is the cost of
storing each embedding as JSON text: about 8.4 KB of digits per belief,
json.loads into 384 Python floats, then a float32 array. Hydration is one
Pydantic Particle per row, built for every candidate although only top_k
(40) are returned.
The cosine is not what hurts first. The scoring stage is the whole per-candidate loop (trust, decay, effective confidence and the similarity). The similarity alone, timed apart over the same vectors, is 81 ms at 56k when called once per candidate as the loop calls it, and 5.8 ms as one matrix product (plus 21 ms to stack the vectors into a matrix). The arithmetic is about 1.4% of the query. A vector index would remove that 1.4% and leave the decode and the hydration of every candidate in place, so on this evidence an index alone would not make the query fast; whether it would after the cheap changes below is for the re-measurement to show.
Memory. Peak memory grows by about 21 KB per ACTIVE belief, on top of roughly 0.9 GB for the interpreter and the encoder: 2.0 GB at 56k, 3.0 GB at 100k, 6.6 GB at 300k (the reference runner, with no encoder loaded, grows by the same amount per belief). At that rate 1M needs about 20 GiB, which is why the 1M query never finished on a 16 GB machine: the process swapped for 21 minutes at under 5% CPU before the run's swap guard stopped it. The cause is that every candidate is held as a decoded array and a model at once.
Where 300k breaks the line. Decode and hydration stay close to linear at 300k (16.3 s and 9.8 s, 3.1 and 3.5 times their 100k values). The two stages that read the file do not: the SQL fetch is 26 s where linear growth predicts about 3.3 s, and setup is 4.4 s where it predicts 0.15 s. Setup includes the schema-version guard, which counts the ACTIVE rows by schema version on every query and so reads the whole table too. A 3.5 GB store plus a 6.6 GB process does not fit in this machine's file cache, so each query reads from the SSD. That part of the figure is the machine's, and a host with more memory will show less of it.
The subject filter does not narrow the read. With subject_id set, the
loader returns about ten rows, yet the SQL fetch grows with the store: 11 ms at
10k, 90 ms at 56k, 179 ms at 100k. SQLite's plan for the loader's query, read
with EXPLAIN QUERY PLAN against the owner's own store as well as the
synthetic ones, searches ix_particles_status_confidence for every ACTIVE row
and probes particle_subjects once per row, rather than starting from the
subject's handful of links. The filter therefore saves the decode and the
hydration, which is why a filtered query is 45 times faster at 56k, but not the
scan: it grows linearly (slope 1.01) to 2.3 s of SQL fetch at 1M. The
schema-version guard in setup adds another O(N) read (0.47 s at 1M).
Cold is the import and the encoder, not the query. A fresh process spends about 0.5 s importing the query path and 3.5 s loading the encoder (which, run this way, also revalidates the model against the Hugging Face Hub). Its first query then costs within 5% of a warm one at 56k and 100k (15% at 10k, where the first encode and the first models are a larger share), because the file cache was already warm (purging it needs root).
A limit found on the way. The source lookup sends the snapshot ids of every
candidate in one IN list, and SQLite refuses a statement with more than
32,766 variables (too many SQL variables, checked directly against the
lookup). At the owner's ratio, one source snapshot per 32 beliefs, that is
about 1.05M beliefs: past it an unfiltered query fails instead of slowing down.
A store whose sources each yield fewer beliefs reaches it sooner. No size in
this grid crossed it (1M sends 31,250). Release 1.192.22 removes the limit:
the lookups now send their ids in chunks of 500.
What this measurement does not cover. The stores are freshly written, so
their pages are in insertion order, where a store that has lived for months is
fragmented. The vectors are random, so the similarity values (and which beliefs
rank first) mean nothing; only the cost does. Every other query feature (tags,
as_of, windows, the project observer, structured claims) is off. The answer
step, when a query has one, adds a model call on top.
What comes next¶
The baseline points at three changes, none of them made here: store embeddings
as float32 bytes or keep a matrix in memory, so decoding stops being per row;
score in one vectorised step; and build models only for the top_k survivors.
The subject filter's plan and the IN-list limit are separate, smaller fixes.
Each change is a separate decision, and each is re-measured with the same
command and the same seed, with the before and after figures on this page. The
vector index (deferred until a measured stage says the similarity is the cost)
waits for that re-measurement.
Release 1.192.23 made the first three together, with the two smaller fixes; the re-measurement is above. It finds the remaining warm cost in per-candidate Python work rather than in the similarity, and leaves one-shot processes as slow as before, so the vector index still waits.
Reproducing¶
# The run of record (the encoder runs; about two hours on an M4 mini):
particles benchmark query-latency run --work-dir ~/bench/query-latency/work
# The hardware-reference run: a fixed seeded query vector, no model loaded.
particles benchmark query-latency run --no-encoder --sizes 10k,56k,100k,300k
# The after-measurement: a new work directory whose stores/ is a link to the
# baseline run's, so the same store files are reused.
mkdir -p ~/bench/query-latency/after
ln -s ~/bench/query-latency/work/stores ~/bench/query-latency/after/stores
particles benchmark query-latency run --work-dir ~/bench/query-latency/after
# The tables on this page, and one raw JSON per store size:
particles benchmark query-latency report --work-dir ~/bench/query-latency/work \
--export-dir out --prefix query-latency-mini-2026-10-17 \
--raw-link "query-latency-mini-2026-10-17-{size}.json"
The seed (20261017) and the sizes are the defaults, so the commands above
rebuild the same stores. A re-run with the same --work-dir skips every cell
already finished; --retry-failed re-runs the ones that stopped.