How search works¶
Search is hybrid: it blends a dense signal (embedding similarity, which catches paraphrase) with a sparse one (keyword matching, which catches exact terms). Neither alone is good enough for memory.
The pipeline¶
flowchart TD
Q[Query text] --> E[Embed locally]
Q --> K[Sanitize into an FTS5 query]
E --> C[Cosine similarity<br/>against every stored vector]
K --> B["FTS5 bm25() ranking"]
B --> N[Normalize to 0–1]
C --> S["score = min(1.0, cosine + 0.25 × keyword)"]
N --> S
S --> F[Filter by tags and min_score]
F --> R[Sort descending,<br/>ties broken by recency]
- Embed the query with the same local model used when storing.
- Score every row by cosine similarity against the query vector.
- Query FTS5 for keyword hits and normalize
bm25()ranks into[0, 1]. - Blend:
score = min(1.0, cosine + 0.25 × keyword). - Filter by tags and
min_score, then sort — ties broken toward newer memories.
Why the keyword bonus is additive¶
This is the design decision most worth understanding, because it changes what
min_score means.
The obvious approach is a weighted average: 0.75 × cosine + 0.25 × keyword.
The problem is that a perfect semantic match with no keyword overlap then caps
at 0.75. A user setting min_score=0.8 would filter out ideal results,
and the threshold would mean something different for every query depending on
whether keywords happened to overlap.
Making the bonus additive instead keeps every score on the familiar 0–1 cosine scale. A memory that matches semantically scores what it deserves; keyword overlap can only push it up, never dilute it.
Why hybrid at all¶
Pure vector search fails on exactly the things people store in memory:
| Query | Pure vector | Hybrid |
|---|---|---|
ERR_CONN_REFUSED_7 |
Weak — error codes carry little semantic signal | Strong — exact token match |
what database did we pick? |
Strong — matches "we went with SQLite" | Strong |
Priya's timezone |
Mixed — names embed poorly | Strong — exact token match |
Names, error codes, ticket IDs, and file paths are precisely the details worth remembering, and they're where embeddings are weakest. Meanwhile, keyword search alone can't connect "which database" to "SQLite". Hybrid covers both gaps.
Handling messy queries¶
User text reaches SQLite's FTS5 parser, which has its own query syntax. A query
containing AND, ", *, or : would either raise an error or silently mean
something different than intended.
So query text is stripped to alphanumerics and each word quoted into an explicit
OR chain:
The FTS query is also wrapped in a try/except, so a Python build whose
SQLite lacks FTS5 degrades to pure vector search instead of failing.
Why a full table scan¶
Every search scores every row. That's deliberate:
- It's exact. Approximate nearest-neighbour indexes trade recall for speed. At personal-memory scale that trade buys nothing.
- It's fast enough. Thousands to low tens of thousands of memories is imperceptible.
- It keeps the design honest. No index to rebuild, no staleness, no tuning.
If it ever needs to change, an ANN index belongs behind the same
MemoryStore.search() signature — the API shouldn't
change to accommodate the storage strategy.
Tuning results¶
Nothing comes back. Check memory_stats first — you may be searching a
different database than the one you stored to. Then try without min_score.
Too much noise. Raise min_score (start around 0.3–0.5) or lower
limit. Thresholds are model-dependent, so tune on your own data.
The right memory ranks too low. Usually the memory is written too tersely to
carry signal. "Use staging" embeds poorly; "Deploy artifacts go to the
acme-staging S3 bucket" embeds well.
Unrelated projects interfering. Use tags, and filter by them — or give each project its own database. See Configuration.
The embedding model¶
The default is BAAI/bge-small-en-v1.5:
384 dimensions, ~90 MB, English, and a good balance of quality against install
size and cold-start time.
Any fastembed-supported model
works via LOCALMEM_MODEL or --model.
Changing models invalidates existing embeddings
Vectors from different models aren't comparable. Existing memories keep
their old vectors, and comparing across models produces meaningless scores —
localmem stores embedding_model and dim per row and returns a similarity
of 0.0 on a dimension mismatch, so results degrade rather than mislead.
If you switch models, start a new database or re-store your memories.