Recall and Retrieval
Recall is how an agent searches its memory. The generated recall tool builds a scoped query and hands it to MemoryStore.search. Use it when the question is “which memories help this run?” For administrative filtering, see Browse and Manage Memory.
The generated recall toolCopy link to section: The generated recall tool
Here is a typical call:
await recall({
query: "customer shipping preference",
kind: "semantic",
tags: ["customer"],
since: "-30d",
limit: 8,
})The memory.ts scope declaration chooses which dimensions exist, and runtime values plus memory.resolveScope construct the namespace for those dimensions. The workspace and route values come from the app root and route path. resolveScope receives only routePath and appRoot. It does not receive verified identity, so tenant and user scope still need authenticated wiring in your application.
since and until accept ISO instants or relative expressions, resolved against the request clock. The tool passes one fixed evaluation timestamp as now. That timestamp filters expired rows and anchors recency scoring, so fix it in tests that assert order or window boundaries.
MemoryStore.search defaults to active records and a limit of eight. A query-less search filters by namespace, status, kind, tags, expiry, and an optional event-time window, without scoring relevance. Without a window it orders by updatedAt. With since or until it orders by effectiveAt (falling back to createdAt).
Tags are applied after the result limit in both in-repo stores. Query-less search limits the ordered rows before filtering tags, and ranked and hybrid search slice the ranked list first. Eligible tagged rows below that boundary are omitted, so a tagged result can hold fewer than limit records. Tags narrow a bounded result. They won't page through every tagged record.
How recall ranksCopy link to section: How recall ranks
A keyword query tokenizes the query and the record content. candidatePool keeps the newest token-matching rows before scoring them, so a relevant older match outside that pool can't reach the result. As histories grow, raise the pool and measure latency and quality.
The pool is then ranked with three signals:
| Signal | Default weight | Purpose |
|---|---|---|
| IDF-weighted relevance | 0.6 | Rewards matching rare, specific tokens |
| Recency | 0.3 | Exponential decay with a 14-day half-life |
| Confidence | 0.1 | Uses the stored record confidence |
Ties break by newest updatedAt, then id. Matching compares whole tokens with no stemming, so use consistent words for IDs, product names, and domain terms.
Here are the defaults in b4.config.ts:
export default {
memory: {
recall: {
weights: { relevance: 0.6, recency: 0.3, confidence: 0.1 },
recencyHalfLifeMs: 14 * 24 * 60 * 60 * 1000,
candidatePool: 256,
},
},
} satisfies import("@b4run/core").B4ConfigReproducing a ranking needs the same store snapshot, tuning, and fixed evaluation timestamp. Advancing now alone can change expiry and recency.
Semantic recall (opt-in)Copy link to section: Semantic recall (opt-in)
Add an embedder when paraphrases must match without sharing tokens:
import { openaiEmbedder } from "@b4run/langchain"
export default {
memory: {
vector: {
embedder: openaiEmbedder(),
weights: { keyword: 1, vector: 1 },
rrfK: 60,
vectorK: 64,
recencyWeight: 0.3,
confidenceWeight: 0.1,
},
},
} satisfies import("@b4run/core").B4ConfigHybrid search takes the union of the keyword list and the vector-nearest list, combines their ranks with Reciprocal Rank Fusion, and then applies bounded recency and confidence. Keyword matches stay in because embeddings are weak on IDs, codes, and names.
Every stored embedding carries the embedder model id. Vector comparison only includes rows whose model id matches the active embedder, so incompatible vector spaces are never compared. After you change embedders, older rows still take part in keyword search until you re-embed them.
If embedding fails, writes and recall fall back to keyword-only without an error. Set B4_DEBUG_MEMORY=1 to see those fallbacks. For deterministic tests, use a fixed embedder such as fakeEmbedder() from @b4run/testing.
Postgres backend (pgvector)Copy link to section: Postgres backend (pgvector)
Install @b4run/memory-pgvector when several app instances need a shared memory store:
import { openaiEmbedder } from "@b4run/langchain"
import { pgvectorMemoryStore } from "@b4run/memory-pgvector"
const embedder = openaiEmbedder()
export default {
memory: {
store: pgvectorMemoryStore({
connectionString: process.env.DATABASE_URL!,
dimensions: embedder.dims,
}),
vector: { embedder },
},
} satisfies import("@b4run/core").B4Configdimensions must match the embedder output dimension. Sharing one embedder value makes this a complete hybrid configuration. If you omit memory.vector, the pgvector store stays on keyword-only recall even though it has a vector column.
SQLite loads matching-model vectors and runs an exact vector scan in process. The pgvector store asks an HNSW index for approximate candidates before applying the shared hybrid stages. Candidate sets can differ, so the two backends can return different orders. Evaluate retrieval quality on each backend you deploy, with realistic data.
A custom store brings its own ranking, and the built-in memory.recall and memory.vector tuning is ignored. Document and test how that store's MemoryStore.search behaves yourself.
The injected indexCopy link to section: The injected index
Before the model starts, B4.run runs a query-less search for a small index of active memories and adds id: content lines to the prompt. It's a recency-ordered sample of the store, so the agent should call recall for a specific topic or time window.
Evaluate retrievalCopy link to section: Evaluate retrieval
Build an evaluation set that expects record IDs rather than prose. Include:
- Literal codes and names that require keyword matching.
- Paraphrases that need vectors.
- Expired and out-of-window records.
- Equal-relevance records with different timestamps or confidence.
- Rows embedded by an older model id.
- Enough data to exercise the configured candidate bounds.
Pin the store contents, now, the embedder output, and the ranking config. Run the set against SQLite and pgvector separately, and expect a different order from each.
TroubleshootingCopy link to section: Troubleshooting
- No results: confirm the full namespace, active status, kind, tags, and time window.
- A record vanished: compare
expiresAtwith the fixed evaluation timestamp. - Literal ID misses: keep keyword recall enabled and use the literal token.
- Paraphrase misses: verify the query and rows have embeddings with the same model id.
- Different Postgres ordering: inspect HNSW candidate recall and ties before changing second-stage weights.
- Admin page looks unlike recall: that's expected.
browsefilters and sorts records without semantic ranking.