Skip to content

Benchmarks

How fast are add() and search(), and where does the time actually go? These numbers come from benchmarks/run.py in the repository, which anyone can re-run.

Absolute numbers are machine-specific

The shape — what dominates, and how it grows — holds anywhere. The milliseconds don't. Run the harness on your own machine before drawing conclusions about your setup.

Results

Measured on Linux x86_64, CPython 3.11.15, with a stub embedder producing 384-dimension vectors (the same width as the default BAAI/bge-small-en-v1.5), a seeded synthetic corpus of 10–50 word memories, and limit=5. 50 queries per size at 1k and 10k; 10 at 100k, where a single search takes seconds.

memories add p50 add p95 search p50 search p95 db size
1,000 0.144 ms 0.379 ms 65.4 ms 72.8 ms 2.2 MB
10,000 0.150 ms 0.386 ms 683.6 ms 750.9 ms 21.1 MB
100,000 0.152 ms 0.399 ms 7,269.5 ms 10,330.2 ms 210.0 MB

Where each search spends its time:

memories embedding SQLite scoring scoring share
1,000 0.02 ms 4.4 ms 60.9 ms 93%
10,000 0.03 ms 59.7 ms 623.2 ms 91%
100,000 0.03 ms 526.3 ms 6,741.9 ms 93%

Search latency is linear in corpus size — roughly 70 ms per 1,000 memories on this machine. Around the point where a tool call starts to feel sluggish:

memories search p50 search p95
1,500 103.8 ms 110.8 ms
2,000 135.1 ms 141.9 ms
3,000 203.5 ms 242.3 ms

What this means for you

Search stays comfortable to about 1,500 memories and crosses 100 ms there. Most personal stores live well inside that. At 10k a search is roughly two thirds of a second, which an agent tool call will feel; at 100k it is seconds, which is too slow to use interactively.

Writes are free and stay free. add() is ~0.15 ms whether the store holds a thousand memories or a hundred thousand — the insert and its FTS5 trigger cost the same either way. Storing memories liberally costs you nothing.

Disk is about 2.2 KB per memory, mostly the 1,536-byte float32 embedding plus the FTS5 index. A hundred thousand memories is 210 MB.

If your store is large and search feels slow, split it. Per-project databases via LOCALMEM_DB_PATH keep each one small, and they keep unrelated projects from interfering in results either way. See Configuration.

What this means for the project

Two findings decide where optimisation effort goes.

Embedding is not the bottleneck. A query embed is a rounding error next to scoring — 0.03 ms of a 683 ms search with the stub. Even the real model, at roughly 5–20 ms per query, would be under 3% of search time at 10k.

The pure-Python cosine loop is, at 91–93% of search time at every size. _cosine() makes three Python-level passes over 384 floats per row, and every stored blob is materialised into a Python list before that. SQLite — fetching all rows plus the FTS5 lookup — is the remaining 7–9%.

So the first optimisation worth making is vectorising the scoring loop, which targets ~92% of the cost while keeping MemoryStore.search() exact and its signature unchanged. An approximate-nearest-neighbour index attacks a different term — the number of rows scored — and only starts to pay once per-row scoring is cheap. See Why a full table scan.

Would an index help?

Measured, rather than argued about. benchmarks/sqlite_vec_eval.py runs the same queries through the current loop, a vectorised scan, and sqlite-vec KNN:

memories today vectorised scan sqlite-vec
1,000 43.9 ms 6.5 ms 5.6 ms
10,000 466.0 ms 69.3 ms 20.8 ms
100,000 4,815.3 ms 1,313.0 ms 134.6 ms

Both alternatives returned exactly the same top 5 as the exact scan at every size — sqlite-vec's KNN is brute force, so it is exact rather than approximate. Vectorising is enough up to ~10k; past that, the index is what avoids either a per-query read of every embedding or a cache of the whole corpus in memory. The full write-up, including recall, install footprint, and the platforms where the extension cannot load at all, is in benchmarks/SQLITE_VEC.md.

Running it yourself

git clone https://github.com/OpenAgentHQ/localmem-mcp
cd localmem-mcp
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/python benchmarks/run.py --sizes 1000,10000,100000

The corpus is seeded, so the same --seed produces the same memories and queries on any machine. Useful flags:

Flag What it does
--sizes 1000,10000 corpus sizes to measure
--queries 50 searches timed per size
--real use the real fastembed model instead of the stub
--format markdown emit a table to paste into an issue (text, markdown, json)
--db-dir DIR keep the benchmark databases instead of using a temp dir
--seed 1234 corpus seed

The stub embedder is the default so the numbers isolate localmem's own work — SQLite and the scoring loop — rather than ONNX inference. --real adds the model back for end-to-end figures.

The committed results, including the machine they came from and the caveats behind each number, live in benchmarks/RESULTS.md.