Skip to content
advanced Phase 11 · Memory and Conversation State

Semantic Memory with Vectors

Store durable facts as embeddings and retrieve relevant memories during turns.

55m
0 problems
Topic Progress 0%

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.

Quiz

1. Why compare each extracted fact against the turn it came from before embedding it?

Question 1 options

2. What is wrong with retrieving top-k memories and injecting all of them?

Question 2 options

3. What does source_conv and source_turn on a memory row buy you?

Question 3 options

4. A user deletes a stored memory. Why use deleted_at instead of a hard DELETE?

Question 4 options

Flashcards

Question

What are the four stages of the memory write path?

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?

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?

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.

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?

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_hash dedupes restatements; source_conv/source_turn give provenance
  • WHERE user_id on every read and delete; soft delete via deleted_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.

Related Reading