Skip to content

How Recall Works

Daniel Nevoigt edited this page May 29, 2026 · 1 revision

How Recall Works

🇬🇧 English

A recall(query, k) runs a multi-stage pipeline in @bastra-recall/core (search.ts). Each stage emits a start/stop event so latency can be streamed and logged.

The pipeline

query.parse → [cache.hit] → bm25.search → vector.search → rrf.fuse → hops.expand → staleness.rank → done
  1. query.parse — tokenize/validate the query.
  2. cache.hit (optional) — a 30-second LRU query cache (max 100 entries). On a hit, the rest of the pipeline is skipped and done fires immediately. Invalidated on any vault change.
  3. bm25.search — full-text search over a MiniSearch index. Field weights: recall_when ×5, title ×4, tags ×3, then summary, topic_path, body. recall_when is the highest-weighted field, which is why good recall_when phrases at save time matter most.
  4. vector.search (hybrid only) — embedding similarity, if an embedding provider is configured (Ollama embeddinggemma or OpenAI). Skipped when no embeddings exist (then recall is BM25-only).
  5. rrf.fuse (hybrid only) — Reciprocal-Rank-Fusion merges the BM25 and vector rankings into one score.
  6. hops.expand (if expand_hops=1) — after the direct hits, pull in their 1-hop neighbors via related_via links, with a reduced score, tagged hop: "1-hop".
  7. staleness.rank — re-rank by lifecycle. Each memory's score is multiplied by a freshness factor: fresh ×1.0, aging ×0.85, stale ×0.5, expired ×0.2. Type-specific expiration windows (e.g. lessons age slower than project-facts).

The result is the top-k hits, each with a score.

Score bands (what the assistant does with a hit)

Score Meaning
≥ ~100 Strong match. load_memory and apply before acting.
30–100 Read the summary; load only if directly relevant.
< 30 Noise. recall drops these by default (the score floor).

The score floor (BASTRA_RECALL_FLOOR, default 30) is applied in recallHandler: hits below it never leave the daemon, so tail-noise doesn't cost context. Override per call with min_score.

Lean vs. full payloads (#50)

recall returns lean payloads by default to keep Claude's context small. The assistant validates from the lean candidate, then load_memorys only what it needs — a two-step flow.

lean (default) verbosity: "full"
per hit id, title, type, scope, summary, score + matched_terms, mode, hop, topic_path
summary truncated to ~160 chars (word boundary + …) full (≤400 chars)
top-level stages block omitted included

Use verbosity: "full" for debugging or for UIs that render the extra fields (e.g. the Mac-App). On the Claude Code MCP path, the forwarder additionally drops its synthesized stages block and uses expand_hops=0 (exactly k hits).

Measured saving on a real 141-memory vault: ~32 % per recall vs. full, mostly from dropping matched_terms and stages. See scripts/measure-recall-payload.ts.

load_memory (step two)

load_memory(id) returns the full content. Lean by default: essential frontmatter + body with the auto-related section stripped; verbosity: "full" returns the complete frontmatter (related_via cosines, source, confidence, …) and the raw body.


🇩🇪 Deutsch

Ein recall(query, k) durchläuft eine mehrstufige Pipeline in @bastra-recall/core (search.ts). Jede Stage emittiert ein Start-/Stop-Event, damit Latenz gestreamt und geloggt werden kann.

Die Pipeline

query.parse → [cache.hit] → bm25.search → vector.search → rrf.fuse → hops.expand → staleness.rank → done
  1. query.parse — Query tokenisieren/validieren.
  2. cache.hit (optional) — ein 30-Sekunden-LRU-Query-Cache (max 100 Einträge). Bei Treffer wird der Rest der Pipeline übersprungen und done feuert sofort. Invalidiert bei jeder Vault-Änderung.
  3. bm25.search — Volltextsuche über einen MiniSearch-Index. Feld-Gewichte: recall_when ×5, title ×4, tags ×3, dann summary, topic_path, body. recall_when ist das höchstgewichtete Feld — deshalb sind gute recall_when-Phrasen beim Speichern am wichtigsten.
  4. vector.search (nur hybrid) — Embedding-Ähnlichkeit, falls ein Embedding-Provider konfiguriert ist (Ollama embeddinggemma oder OpenAI). Übersprungen, wenn keine Embeddings existieren (dann ist Recall BM25-only).
  5. rrf.fuse (nur hybrid) — Reciprocal-Rank-Fusion verschmilzt BM25- und Vector-Ranking zu einem Score.
  6. hops.expand (bei expand_hops=1) — nach den direkten Treffern deren 1-Hop-Nachbarn über related_via-Links einhängen, mit reduziertem Score, markiert als hop: "1-hop".
  7. staleness.rank — Re-Ranking nach Lifecycle. Der Score jeder Memory wird mit einem Frische-Faktor multipliziert: fresh ×1.0, aging ×0.85, stale ×0.5, expired ×0.2. Typ-spezifische Ablauffenster (z.B. altern Lessons langsamer als Project-Facts).

Ergebnis sind die Top-k-Hits, je mit score.

Score-Bänder (was der Assistent mit einem Hit tut)

Score Bedeutung
≥ ~100 Starker Match. load_memory und vor dem Handeln anwenden.
30–100 Summary lesen; nur laden, wenn direkt relevant.
< 30 Rauschen. recall dropt diese standardmäßig (Score-Floor).

Der Score-Floor (BASTRA_RECALL_FLOOR, Default 30) wird in recallHandler angewandt: Hits darunter verlassen den Daemon nie, sodass Tail-Rauschen keinen Context kostet. Pro Call mit min_score überschreibbar.

Lean vs. full Payloads (#50)

recall liefert standardmäßig lean Payloads, um Claudes Context klein zu halten. Der Assistent validiert anhand des lean-Kandidaten und load_memoryt dann nur das Nötige — ein Zwei-Schritt-Flow.

lean (Default) verbosity: "full"
pro Hit id, title, type, scope, summary, score + matched_terms, mode, hop, topic_path
summary auf ~160 Zeichen gekürzt (Wortgrenze + …) voll (≤400 Zeichen)
top-level stages-Block weggelassen enthalten

verbosity: "full" für Debugging oder UIs, die die Extra-Felder rendern (z.B. die Mac-App). Auf dem Claude-Code-MCP-Pfad dropt der Forwarder zusätzlich seinen synthetisierten stages-Block und nutzt expand_hops=0 (genau k Hits).

Gemessene Ersparnis auf einem echten 141-Memory-Vault: ~32 % pro Recall vs. full, vor allem durch Wegfall von matched_terms und stages. Siehe scripts/measure-recall-payload.ts.

load_memory (Schritt zwei)

load_memory(id) liefert den vollen Inhalt. Lean by default: essenzielle Frontmatter + body ohne Auto-Related-Section; verbosity: "full" liefert die komplette Frontmatter (related_via-Cosines, source, confidence, …) und den rohen body.