Skip to content

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]
  1. Embed the query with the same local model used when storing.
  2. Score every row by cosine similarity against the query vector.
  3. Query FTS5 for keyword hits and normalize bm25() ranks into [0, 1].
  4. Blend: score = min(1.0, cosine + 0.25 × keyword).
  5. 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.

score = min(1.0, cosine + KEYWORD_WEIGHT * keyword_score)  # KEYWORD_WEIGHT = 0.25

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:

_fts_query('drop "table" AND *')   # → '"drop" OR "table" OR "AND"'
_fts_query("!!!")                   # → ''

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.30.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.