Memory Retrieval: Surfacing the Right Fact at the Right Time
Good long-term memory fails in production more often at read time than write time: either nothing useful is injected, or the prompt drowns in irrelevant facts.
Search across all documentation pages
Good long-term memory fails in production more often at read time than write time: either nothing useful is injected, or the prompt drowns in irrelevant facts.
Split retrieval into a tiny always-on profile, a query-time top-k over episodic/semantic memory, and hard token budgets so each turn gets only what the current goal needs.
user_id / tenant, namespace, and expiry before ranking.import math
import re
from dataclasses import dataclass
@dataclass
class MemoryHit:
id: str
kind: str # profile | episode
text: str
score: float
PROFILE_KEYS = ("preferred_language", "timezone", "tone")
def load_profile(rows: list[dict], user_id: str) -> list[MemoryHit]:
hits = []
for row in rows:
if row["user_id"] != user_id or row.get("namespace", "profile") != "profile":
continue
if row["key"] in PROFILE_KEYS:
hits.append(
MemoryHit(
id=f"profile:{row['key']}",
kind="profile",
text=f"{row['key']}={row['value']}",
score=1.0,
)
)
return hits
def tokenize(text: str) -> set[str]:
return {t for t in re.findall(r"[a-z0-9_]+", text.lower()) if len(t) > 2}
def keyword_score(query: str, doc: str) -> float:
q, d = tokenize(query), tokenize(doc)
if not q or not d:
return 0.0
overlap = len(q & d)
return overlap / math.sqrt(len(q) * len(d))
def retrieve_episodes(
rows: list[dict],
user_id: str,
query: str,
*,
k: int = 3,
min_score: float = 0.08,
) -> list[MemoryHit]:
scored: list[MemoryHit] = []
for row in rows:
if row["user_id"] != user_id or row.get("namespace") != "episode":
continue
if row.get("expired"):
continue
text = row["content"]
score = keyword_score(query, text)
if score >= min_score:
scored.append(MemoryHit(id=row["id"], kind="episode", text=text, score=score))
scored.sort(key=lambda h: h.score, reverse=True)
return scored[:k]
def format_memory_block(profile: list[MemoryHit], episodes: list[MemoryHit], max_chars: int = 800) -> str:
lines = ["## Long-term memory (may be incomplete)"]
if profile:
lines.append("### Profile")
for h in profile:
lines.append(f"- ({h.id}) {h.text}")
if episodes:
lines.append("### Relevant past notes")
for h in episodes:
lines.append(f"- ({h.id}, score={h.score:.2f}) {h.text}")
if len(lines) == 1:
return ""
block = "\n".join(lines)
return block if len(block) <= max_chars else block[: max_chars - 20] + "\n...[truncated]"
def build_system_with_memory(
base_system: str,
store_rows: list[dict],
user_id: str,
user_message: str,
) -> str:
profile = load_profile(store_rows, user_id)
episodes = retrieve_episodes(store_rows, user_id, user_message, k=3)
block = format_memory_block(profile, episodes)
if not block:
return base_system
return base_system + "\n\n" + block + "\n\nUse memories only when relevant. Prefer fresher facts if they conflict."
# Demo data
rows = [
{"user_id": "u1", "namespace": "profile", "key": "preferred_language", "value": "Portuguese"},
{"user_id": "u1", "namespace": "profile", "key": "tone", "value": "concise"},
{
"id": "e1",
"user_id": "u1",
"namespace": "episode",
"content": "User deploys the billing API to ECS on Fridays.",
"expired": False,
},
{
"id": "e2",
"user_id": "u1",
"namespace": "episode",
"content": "User's dog is named Pixel.",
"expired": False,
},
]
system = build_system_with_memory(
"You are a support agent.",
rows,
"u1",
"Help me plan this Friday's billing API deploy.",
)
print(system)Notes:
| Lane | Contents | Cadence | Failure mode if misused |
|---|---|---|---|
| Always-on profile | Stable prefs, identity-safe defaults | Every turn | Too many keys → permanent bloat |
| On-demand episodes | Events, notes, past decisions | Per message / goal | Dumping all episodes → noise |
Never promote the entire episode table into always-on context.
Better queries beat bigger k.
Agent-directed retrieval is valid too: expose a search_memory(query) tool so the model pulls facts only when needed (see vector/RAG section for tool-shaped retrieval).
| Method | Good for | Watch-outs |
|---|---|---|
| Key lookup | Profile fields | Only known keys |
| Keyword / BM25 | Exact terms, ids | Vocabulary mismatch |
| Embeddings | Paraphrase recall | Irrelevant semantic neighbors |
| Hybrid + filters | Production default | More moving parts |
| Re-ranker model | Precision at small k | Extra latency/cost |
Filters first (user, tenant, namespace, TTL, ACL), rank second.
Log memory_ids, scores, and truncated flag on every turn.
When the agent "hallucinates a preference," check whether a bad row was retrieved before blaming the model weights.
New users have empty LTM.
Do not invent defaults that look like memories; keep the block absent and learn via extraction or settings UI.
| Approach | Pros | Cons |
|---|---|---|
| Profile-only | Simple, cheap | Weak for episodic needs |
| Always full dump | Easy | Does not scale, noisy |
| Tool-based search_memory | Selective, inspectable | Extra turn latency |
| Pure vector top-k | Soft matching | Needs filters + hygiene |
| Hybrid + re-rank | Best quality at scale | Ops complexity |
Start with k=3 to 5 episodes plus a small profile. Raise only when evals show missing recall, not when demos feel sparse.
Profile can stay cached per session. Re-run episode retrieval when the user goal shifts or when the agent issues a memory search tool call.
Separate sections in context: "User memory" vs "Knowledge base." Different trust levels and citations.
Prefer newer updated_at, higher trust source, or ask the user. Dedicated conflict policy is covered in the next article.
For small personal stores, yes. Move to hybrid when paraphrase misses show up in evals.
As a context builder before decide, or as a tool inside the loop. Both are valid; tool mode is better when memory is large and rarely needed.
Order and label messages so system policy outranks memory, and never let memory change tool allowlists or authz.
Yes, but scope queries to the worker's task and the shared principal. Do not give every worker the full personal history by default.
Offline: recall@k on labeled queries. Online: fraction of turns where injected memories were cited or clearly used, plus user correction rate.
Short-term holds the active thread. LTM retrieval brings durable facts from other sessions or long-past turns already dropped from the window.
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.
Reviewed by Chris St. John·Last updated Jul 16, 2026