Six Months of the Wrong Name
Support escalated a chat where the assistant opened with "Welcome back, Dr. Alvarez" for a user who had never given a name — and, a week later, a second user started receiving the same greeting. The name had been extracted once from an unrelated conversation, embedded without provenance, and matched to everyone whose recent turns were loosely adjacent.
Durable memory is a write path with four stages: extract, embed, upsert with source metadata, gate. Writing is the easy half; provenance is what makes a belief debuggable six months later.
EXTRACT = """Extract durable facts about the user from this turn.
Return at most 2. Skip pleasantries, questions, and anything about
the assistant. Each fact is one standalone third-person sentence."""
def remember(pool, user_id, conv_id, turn, llm, embed):
facts = llm.extract(EXTRACT, turn.content) # candidates, not memory yet
facts = [f for f in facts
if cosine(embed(f.text), embed(turn.content)) >= 0.62]
if not facts:
return 0 # small talk writes nothing
vecs = embed([f.text for f in facts]) # one batched call, not one per fact
with pool.cursor() as cur:
for fact, vec in zip(facts, vecs):
cur.execute(
"""INSERT INTO memories
(user_id, text, embedding, source_conv, source_turn,
content_hash, created_at, last_seen)
VALUES (%s, %s, %s::vector, %s, %s, %s, now(), now())
ON CONFLICT (user_id, content_hash)
DO UPDATE SET embedding = EXCLUDED.embedding,
last_seen = now()""",
(user_id, fact.text, str(vec), conv_id, turn.seq, digest(fact.text)),
)
return len(facts)
Input: the turn "Call me Priya, and I'm always in UTC."
Output: 1 — one row: u-17 | Priya prefers UTC. | conv c-9f2 | turn 4
The pre-write similarity check compares the extracted fact against the turn it came from. An extractor that invents a credential the user never stated scores low against its own source and is discarded before storage — the first defense against a false memory, and the cheapest one. content_hash, a digest of normalized text, turns a restated fact into an update rather than a duplicate, so a preference repeated a year later refreshes last_seen instead of adding a twin. source_conv and source_turn are the provenance that turns "why is it saying that?" into one SQL query.
Where the memories table itself lives is Choosing a Vector Store; the distance operator and index syntax are Vector Search with pgvector.
The Current Turn Is the Probe
Every memory read asks the same question: given what the user just said, which stored facts belong in this request? The probe is the current turn — not the whole conversation, not the last summary — because the current turn is what the answer must be conditioned on.
def recall(pool, user_id, probe, embed, k=5, min_sim=0.78):
vec = str(embed([probe])[0])
with pool.cursor() as cur:
cur.execute(
"""SELECT text, 1 - (embedding <=> %s::vector) AS sim, source_conv
FROM memories
WHERE user_id = %s AND deleted_at IS NULL
ORDER BY embedding <=> %s::vector
LIMIT %s""",
(vec, user_id, vec, k),
)
rows = cur.fetchall()
# distance becomes cosine similarity; a low score is coincidence, not memory
return [r for r in rows if r.sim >= min_sim]
Input: probe "What time did I say my standup moves to?" against a store holding Standup moves to 9:15 UTC (0.86), Uses a MacBook Pro (0.71), Dislikes emoji in emails (0.64).
Output: [('Standup moves to 9:15 UTC', 0.86, 'c-9f2')] — one row; the other two fall under min_sim.
Four decisions carry the read. ORDER BY embedding <=> ... lets the vector index do the sorting, so the database returns k candidates at index speed rather than shipping the whole table to Python. The min_sim floor is applied after the limit, and it is not optional: top-k with no floor returns k rows no matter how irrelevant, which is precisely how an unrelated name reached users who never said it. Filtering deleted_at IS NULL makes a user's delete take effect on the very next request. And the user_id predicate is inside the statement again, for the same reason it is on turns.
Retrieved memories enter the request as a system note beside the rolling summary from Summarizing Long Conversations, and what the query embedding may and may not discriminate is Cosine Similarity.
Three Tiers, One Visible Shelf
Users do not experience memory as one mechanism. They experience three, with different lifetimes, different costs, and different ways of being wrong:
per request per session durable
┌──────────────────────┐ ┌───────────────────────┐ ┌─────────────────────────┐
│ context window │ │ conversation log │ │ semantic memory store │
│ system prompt │ │ all turns by seq │ │ embedded, deduplicated │
│ recent turns │──▶│ trimmed by policy │──▶│ survives sign-out │
│ recalled memories │ │ + head summary │ │ user-visible, deletable │
└──────────────────────┘ └───────────────────────┘ └─────────────────────────┘
volatile, cheap complete, session-bound expensive, curated
dies with the request dies with retention window must be correctable
Only the rightmost tier can stay wrong for months, so only it needs a shelf the user can see:
@app.get("/memories")
def list_memories(dep=Current_user):
return db.select(
"SELECT text, source_conv, created_at FROM memories "
"WHERE user_id = %s AND deleted_at IS NULL", [dep.id])
@app.delete("/memories/{memory_id}")
def forget(memory_id, dep=Current_user):
# ownership lives in the statement, the same rule as turn reads
db.execute("UPDATE memories SET deleted_at = now() "
"WHERE id = %s AND user_id = %s", [memory_id, dep.id])
Soft delete rather than a hard DELETE: incident review still needs to know what the system believed, while the read path — which already filters deleted_at IS NULL — hides it immediately.
Failure modes to design against:
- Write-through without a gate — every greeting lands in the store and retrieval returns noise. Threshold the write, not only the read.
- Probing with the whole conversation — embedding 40 turns averages away the actual question, so retrieval matches the topic instead of the need.
- No floor under top-k —
LIMIT 5returns five rows always; withoutmin_sim, unrelated facts are injected with full confidence. - Silent persistence — a memory the user cannot inspect is one they cannot correct. The wrong-name incident shipped without a delete endpoint.
- Missing tenant predicate — a shared store without
WHERE user_idis the same hole as an unscoped turn read in Managing Conversation History.
Whether the store actually remembers — and whether it remembers things that were never said — is measured in Evaluating Memory Quality.
Quiz
1. Why compare each extracted fact against the turn it came from before embedding it?
2. What is wrong with retrieving top-k memories and injecting all of them?
3. What does source_conv and source_turn on a memory row buy you?
4. A user deletes a stored memory. Why use deleted_at instead of a hard DELETE?
Flashcards
Question
What are the four stages of the memory write path?
Click to reveal answer
Answer
Extract candidate facts from the turn, gate them against their source by similarity, embed them in one batched call, and upsert with content_hash plus source_conv/source_turn provenance.
Question
What should the embedding probe be at read time?
Click to reveal answer
Answer
The current turn, embedded alone. Probing with the full conversation averages the actual question away and retrieves by topic instead of by need.
Question
Why does top-k retrieval need a similarity floor?
Click to reveal answer
Answer
LIMIT k returns k rows regardless of quality, so without min_sim the store injects its least-bad match as if it were relevant — the mechanism behind confidently wrong personalization.
Question
Name the three memory tiers and their lifetimes.
Click to reveal answer
Answer
Context window (per request, dies with the request), conversation log (per session, trimmed and summarized), semantic memory store (durable across sessions, embedded and user-visible).
Question
Why must a durable memory store be inspectable and deletable by the user?
Click to reveal answer
Answer
It is the only tier that can stay wrong for months. A memory the user cannot see is one they cannot correct, and silent persistence is what turns a single bad extraction into a standing wrong belief.
Revision Notes
Key Takeaways
- 1. Extraction produces candidates, not memory — gate each fact against the turn it came from before writing
- 2. Embed all candidates in one batched call and upsert on a content hash so restatements update rather than duplicate
- 3. Retrieve with the current turn as probe, ordered by distance in SQL, and apply a cosine floor after the limit
- 4. Carry source_conv and source_turn on every row so a wrong belief is traceable to one conversation
- 5. Give the durable tier a visible shelf: list and delete endpoints, soft-deleted, ownership inside the statement
Interview Tips
- • Walk the write path stage by stage and say which stage stops a hallucinated fact — interviewers probe for the gate
- • Explain why k and min_sim are separate controls: k bounds cost, min_sim bounds relevance
- • Answer "how would you fix the wrong-name bug?" with provenance plus a delete path, not with a better prompt
- • Contrast the three tiers by lifetime and failure mode; then say which one a user can observe and correct
Cheat Sheet
Semantic Memory Store Cheat Sheet
Write path
facts = llm.extract(EXTRACT, turn.content) # max 2, no pleasantries
facts = [f for f in facts if cosine(f, turn.content) >= 0.62] # grounded in source
vecs = embed([f.text for f in facts]) # one batched call
# INSERT ... ON CONFLICT (user_id, content_hash) DO UPDATE SET last_seen = now()
Read path
# ORDER BY embedding <=> probe::vector LIMIT k (index does the sort)
return [r for r in rows if r.sim >= min_sim and r.deleted_at is None]
Rules
- Probe = current turn only, never the whole conversation
- Floor after limit: top-k returns k rows no matter how bad they are
content_hashdedupes restatements;source_conv/source_turngive provenanceWHERE user_idon every read and delete; soft delete viadeleted_at
Tiers
| Tier | Lifetime | Failure |
|---|---|---|
| Context window | one request | overflow, cost |
| Conversation log | one session | wrong trim, replay leak |
| Semantic store | forever | wrong belief, no correction path |
Failure modes: un-gated writes, whole-conversation probe, no similarity floor, silent persistence, missing tenant predicate.
Measure it: Evaluating Memory Quality.
Continue Learning
Before You Start
- No prerequisites listed.