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.
Summary
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.
Recipe
- Load a size-capped profile (whitelisted keys) for every turn.
- Build a retrieval query from the user message + active goal (not the entire history).
- Filter by
user_id/ tenant, namespace, and expiry before ranking. - Rank with keywords, embeddings, or hybrid search; take top-k.
- Optionally re-rank for task fit and diversity (avoid near-duplicate episodes).
- Format memories as a short, labeled block the model can cite or ignore.
- Log which memory ids were injected so you can debug wrong behavior.
- Keep total memory tokens under a fixed budget that leaves room for tools and history.
Working Example
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:
- Profile always loads; dog name does not outrank deploy notes for a deploy question.
- Keyword scoring is a teaching stand-in - swap in embeddings or hybrid search for production scale.
- Truncation enforces a hard budget even when top-k is large.
Deep Dive
Two-lane retrieval
| 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.
Query construction
Better queries beat bigger k.
- Use the current user utterance + short goal summary.
- Add entity hints (repo name, ticket id) when known.
- Avoid embedding the full transcript as the query; it dilutes the signal.
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).
Ranking options
| 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.
Budgeting and formatting
- Prefer bullet lines with stable ids for auditability.
- Instruct the model that memories can be wrong or stale.
- Reserve token budget: profile ≤ ~5-10% of context; retrieved episodes another small slice; short-term history and tools get the rest.
- Deduplicate near-identical episode texts before inject.
Observability
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.
Cold start
New users have empty LTM.
Do not invent defaults that look like memories; keep the block absent and learn via extraction or settings UI.
Gotchas
- Injecting all memories "just in case." Context pollution causes confident wrong answers.
- Skipping tenant/user filters. Cross-user retrieval is a severe security bug.
- Using chat history as the only query. Old topics keep resurfacing irrelevant episodes.
- No min score threshold. Random weak matches still occupy budget.
- Formatting memories as unmarked prose. The model cannot distinguish policy from recalled fact.
- Ignoring expiry at read time. Write-time TTL is useless if readers do not filter.
- Retrieving secrets that should never have been stored. Defense in depth: filter sensitive categories on read too.
Alternatives
| 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 |
FAQs
What is a good default k?
Start with k=3 to 5 episodes plus a small profile. Raise only when evals show missing recall, not when demos feel sparse.
Should retrieval run every tool loop turn?
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.
How do I combine RAG docs and personal memory?
Separate sections in context: "User memory" vs "Knowledge base." Different trust levels and citations.
What if two retrieved facts conflict?
Prefer newer updated_at, higher trust source, or ask the user. Dedicated conflict policy is covered in the next article.
Is keyword search enough to ship?
For small personal stores, yes. Move to hybrid when paraphrase misses show up in evals.
Where should this live in frameworks?
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.
How do I prevent memory from overriding system policy?
Order and label messages so system policy outranks memory, and never let memory change tool allowlists or authz.
Can I retrieve for multi-agent workers?
Yes, but scope queries to the worker's task and the shared principal. Do not give every worker the full personal history by default.
How do I measure retrieval quality?
Offline: recall@k on labeled queries. Online: fraction of turns where injected memories were cited or clearly used, plus user correction rate.
What belongs in short-term history vs LTM retrieval?
Short-term holds the active thread. LTM retrieval brings durable facts from other sessions or long-past turns already dropped from the window.
Related
- Long-Term Memory Basics - profile injection starter
- Memory Extraction: Deciding What's Worth Remembering - write-side quality
- Memory Conflicts: Handling Contradictory or Stale Saved Facts - when rows disagree
- Architecture Guide: A Tiered Memory System for Production Agents - full tier design
- How Vector Search Gives Agents a Searchable Memory - embedding retrieval
- Building a Retrieval Tool an Agent Can Call - tool-shaped memory search
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.