DocsMemory

Retrieval and the vector store

Retrieval runs two arms in parallel and fuses them, because each one is blind exactly where the other is strong.

ArmIndexGood atBlind to
Semanticsqlite-vec — a vec0 KNN table over the stored embeddingsMeaning, paraphrase, synonymsRare exact tokens — identifiers, codes, ids
LexicalSQLite FTS5, BM25Exact terms, identifiers, SKUs, idsMeaning, paraphrase

Ask "how do we handle auth" and the semantic arm finds the decision that never used the word "auth". Ask about INV-2024-001 and the lexical arm finds the one fragment that mentions it, which no embedding would have ranked first.

Both are derived indexes, kept in lockstep with the fragment table. The durable source of truth is the embedding stored alongside each fragment — which is also what git sync serialises.

Fusion

Each arm returns a ranked list of ids. They're combined by Reciprocal Rank Fusion: a document's score comes from its rank position in each list, not its raw score. That is the point — cosine similarity and BM25 live on incompatible scales, and any attempt to normalise one into the other is a fudge factor waiting to drift.

The fused score is then normalised to a stable range and modulated by two things:

  • Authority — the fragment's confidence, its git-staleness, and what kind of source it came from.
  • File context — fragments referencing files you're currently working in rank higher.

Tuning that shows

  • The lexical tokenizer doesn't stem. The vector arm already owns morphology and synonyms, so the lexical arm is tuned for exact precision instead: user_id and INV-2024-001 stay single tokens, and it's accent- and locale-friendly.
  • Curated metadata outranks incidental mentions. FTS5 column weights rank a fragment's tags and title above a passing reference in its body.
  • Nothing relevant returns nothing. The semantic arm is gated by a minimum similarity and the lexical arm only returns real term matches, so an off-topic query yields no fragments rather than its nearest neighbours. A retrieval layer that always returns its top three is a retrieval layer that lies on every query it has no answer for.

Injection

Before each model call, memory is queried with your message and the files currently in play. Fragments that clear both an absolute relevance floor and a relative one — a fraction of the top score, so one strong hit doesn't drag four weak ones in with it — are prepended to the system prompt:

## Prior Decisions & Context

[DECISION] (95% confidence, files: packages/server/src/db/client.ts)
Chose SQLite over Postgres because the product is local-first

[REJECTION] (88% confidence, files: packages/server/src/state/)
Rejected Redux — Zustand is simpler for this app's state graph

The model treats these as context, not as commands. It can follow them, cite them, or — when you explicitly want a different direction — override them.

Degrading gracefully

  • If the native sqlite-vec loadable isn't present, the semantic arm falls back to brute-force cosine. Slower, same answers.
  • FTS5 is compiled into SQLite, so the lexical arm is only ever missing on a stripped build; recall then runs vector-only.
  • Changing the active embedder rebuilds both derived indexes from source, including the vector table's dimension.

Next