Part II · Weeks 3–4

Memory & Knowledge Systems

RAG, embeddings, vector databases, and how agents remember across sessions — the knowledge layer every production agent needs

Chapter 6
RAG — Retrieval-Augmented Generation

RAG is the technique of giving an agent access to a large, up-to-date knowledge base by retrieving relevant documents at query time — rather than hoping everything fits in the context window or is captured in the model's training data. First proposed in the landmark paper Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (Lewis et al., 2020), it is the most important technique in production AI systems today.

Why RAG exists — the three fundamental problems it solves

  1. Knowledge cutoff — LLMs don't know what happened after their training data cutoff. RAG gives them access to current information.
  2. Private knowledge — Your company's internal docs, Confluence pages, Slack history, codebase — the model was never trained on these. RAG makes them accessible.
  3. Context window limits — You can't put a 10-million-document database into a 200K context window. RAG retrieves only the relevant slice at query time.
RAG — Two-phase Architecture
Run once when source data changes. Prepares and stores vectors.
📥 Raw Documents
PDF, Markdown, Code, HTML files.
✂️ Chunking
Split into 512-token semantic blocks.
🧬 Embeddings
Convert to 1536-dim float array.
💾 Storage
Store vector + text in Qdrant/pgvector.
Executed for every user query at runtime. Finds context dynamically.
👤 User Query
"Where is rate limiting?"
🧬 Embed Query
Convert search term to vector.
🔍 Vector Search
Retrieve top-5 matching chunks.
🤖 Inject to LLM
Context + Prompt → LLM → Answer.

Chunking strategies — the most impactful engineering decision

How you split documents into chunks determines what gets retrieved. A bad chunking strategy means relevant information never gets found, no matter how good your embedding model or vector database is.

Fixed-size chunking
Split every N characters or tokens, with M characters of overlap between chunks to preserve context across boundaries. Simple, fast, predictable.
chunk_size=512 tokens, overlap=64 tokens. Good for: homogeneous content (blog posts, news articles). Bad for: code, structured documents with sections.
Recursive character chunking
Try splitting on paragraph breaks ("\n\n") first. If chunks are still too large, split on sentence breaks. Then single newlines. Then spaces. Preserves semantic structure while respecting size limits. Default in LangChain's RecursiveCharacterTextSplitter.
The most practical default for mixed content. Start here before trying anything else.
Semantic chunking
Embed consecutive sentences and split where the embedding similarity drops sharply — indicating a topic change. Produces chunks that are semantically coherent but variable in size. Slower and more expensive than fixed-size.
Good for: long documents with clear topic shifts (research papers, technical manuals). Produces noticeably better retrieval quality than fixed-size for complex documents.
Late chunking
Embed the entire document first (using a long-context model like jina-embeddings-v3), then split the resulting token embeddings into chunks. Each chunk retains document-level context in its embedding because it was embedded with the full document in view. State of the art for 2025–2026.
Best quality, requires long-context embedding model. Use for high-stakes retrieval where quality matters more than cost.
Hierarchical / parent-child chunking
Index at two granularities simultaneously: small chunks (128 tokens) for precise retrieval, and large chunks (512 tokens, the "parent") for context. Retrieve the small chunk but pass the parent chunk to the LLM — precision of small + context of large.
Best for: documentation, books, long articles. Combines the precision of small chunks with the context richness of larger ones.

Hybrid search — the production default

Pure semantic search (vector similarity) misses exact keyword and code matches. Pure keyword search (BM25) misses semantic meaning ("car" doesn't match "automobile"). Production RAG uses both and merges results:

Python — Hybrid search with Qdrant
from qdrant_client import QdrantClient, models
from openai import OpenAI

qdrant = QdrantClient(url="http://localhost:6333")
openai = OpenAI()

def embed(text: str) -> list[float]:
    resp = openai.embeddings.create(model="text-embedding-3-large", input=text)
    return resp.data[0].embedding

def hybrid_search(query: str, collection: str, top_k: int = 10) -> list:
    # 1. Dense (semantic) search — finds conceptually similar content
    dense_results = qdrant.search(
        collection_name=collection,
        query_vector=embed(query),
        limit=top_k,
        with_payload=True
    )

    # 2. Sparse (keyword) search — finds exact term matches
    sparse_results = qdrant.search(
        collection_name=collection,
        query=query,          # Full-text BM25 search
        limit=top_k,
        with_payload=True
    )

    # 3. Reciprocal Rank Fusion — merge both result sets
    return rrf_merge(dense_results, sparse_results, k=60)

def rrf_merge(list1, list2, k: int = 60) -> list:
    """Reciprocal Rank Fusion — standard hybrid search merger"""
    scores = {}
    for rank, doc in enumerate(list1):
        scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank + 1)
    for rank, doc in enumerate(list2):
        scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank + 1)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

Re-ranking — a high-leverage quality improvement

After retrieval you have 10–50 candidate chunks. Re-ranking uses a cross-encoder model to score each chunk's actual relevance to the specific query. This is more accurate than embedding similarity because the cross-encoder sees query + document together:

Python — Cohere re-ranking
import cohere

co = cohere.Client(api_key="...")

def rerank_and_trim(
    query: str,
    documents: list[str],
    top_n: int = 5
) -> list[str]:
    """
    Take 20 retrieved chunks, return the 5 most relevant.
    Cross-encoder re-ranking often improves precision.
    Verify current provider pricing before production use.
    Quality improvement is usually strongest on complex or ambiguous queries.
    """
    results = co.rerank(
        model="rerank-english-v3.0",
        query=query,
        documents=documents,
        top_n=top_n,
        return_documents=True
    )
    return [r.document.text for r in results.results]

# Full pipeline:
raw_chunks   = hybrid_search(query, collection="codebase", top_k=20)
ranked_chunks = rerank_and_trim(query, raw_chunks, top_n=5)
answer       = llm.complete(f"Answer using these docs:\n{ranked_chunks}\n\nQ: {query}")

RAG evaluation with RAGAS — measuring quality

Never deploy a RAG system without measuring its quality. RAGAS provides four metrics that cover different failure modes:

MetricWhat it measuresFailure it catchesTarget
FaithfulnessIs every claim in the answer grounded in the retrieved docs?Hallucination — model adds facts not in context>0.85
Answer relevancyDoes the answer actually address the question asked?Off-topic answers, rambling responses>0.80
Context precisionAre the retrieved chunks actually relevant to the query?Retrieval noise — irrelevant chunks in context>0.75
Context recallWere all relevant chunks retrieved? (none missed)Missing information — answer is incomplete>0.70
Python — RAGAS evaluation
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall
from datasets import Dataset

# Build evaluation dataset
eval_data = {
    "question": ["Where is rate limiting implemented?", ...],
    "answer":   [agent_answer, ...],           # What the agent said
    "contexts": [[chunk1, chunk2, ...], ...],   # What was retrieved
    "ground_truth": ["In api/middleware/ratelimit.py", ...] # Correct answer
}
dataset = Dataset.from_dict(eval_data)

# Run RAGAS evaluation
result = evaluate(
    dataset=dataset,
    metrics=[faithfulness, answer_relevancy, context_precision, context_recall]
)
print(result)
# {'faithfulness': 0.89, 'answer_relevancy': 0.84, ...}

Advanced RAG Patterns

Standard RAG works well for simple questions, but fails when queries are complex, contain synonyms, or require tracing relationships. Here are the 4 key advanced RAG architectures with interactive visual flow comparisons and production examples:

Standard RAG Execution Pipeline
User Query
"How does Postgres MVCC prevent write blocking?"
Embedding Model
Embeds query text directly.
Vector Search
Finds closest match chunks.
LLM Generation
Generates final answer.
Standard RAG Limits: Query is embedded as-is. If the document uses different vocabulary (e.g. "concurrency control" instead of "prevent write blocking"), similarity scores can drop. Measure recall on your own corpus before deciding whether simple retrieval is enough.
HyDE (Hypothetical Document Embeddings) Pipeline
User Query
"How does Postgres MVCC prevent write blocking?"
Generate Hypothetical Answer
LLM writes a fake, detailed documentation snippet.
Embed & Search
Embeds the fake doc. Similarity matches real docs easily.
Final Generation
LLM answers using verified docs.
Production Example:
Query: "How does Postgres MVCC prevent write blocking?"
Hypothetical LLM Doc: "PostgreSQL MVCC keeps multiple copies of rows. When a transaction updates a row, it creates a new version instead of overwriting. Readers check snapshot times, meaning they do not block writers."
Outcome: The hypothetical document contains useful technical keywords, which can improve similarity matches on synonym-heavy or under-specified queries.
Multi-Query RAG Pipeline
User Query
"Optimize vector index latency in pgvector"
Query Rewriter
LLM generates 3 alternate phrasings.
Batch Vector Search
Queries DB 3 times. Merges & deduplicates.
Final Generation
LLM gets rich context covering multiple keywords.
Production Example:
Rewritten Queries: (1) "reducing search query times pgvector", (2) "how to speed up cosine similarity in postgres", (3) "pgvector HNSW tuning guidelines".
Outcome: Catches background information that a single query may miss. Uses Reciprocal Rank Fusion (RRF) for deduplication. Validate the recall gain with an eval set from your own documents.
GraphRAG Execution Pipeline
User Query
"Which DB modules are affected if we upgrade pgvector?"
Entity Extraction
Extracts "pgvector", "database modules", "upgrade".
Graph Traversal
Queries Neo4j paths (e.g. pgvector -> DEPENDS_ON -> Postgres backend).
Hybrid Synthesis
Combines relational Graph paths + vector text chunks.
Production Example:
Query: "Who reports to Alice?" or "What parts of code affect billing?"
Outcome: Traditional vector search has no native concept of relationships or hops. GraphRAG follows edges in a knowledge graph (e.g., `Alice` <-[:REPORTS_TO]- `Bob`), which is much better suited for multi-hop dependency questions when the graph is complete and up to date.

Chapter 7
Embeddings — The Complete Guide

An embedding is a fixed-size array of floating-point numbers that represents the semantic meaning of a piece of text. Texts with similar meanings produce similar vectors. This single idea powers RAG, semantic search, clustering, anomaly detection, and agent memory. For official API setups, reference the OpenAI Embeddings Documentation.

What an embedding actually is

Text → Vector → Semantic Space
Text is converted by the embedding model into a high-dimensional vector. Texts with similar meanings map to nearby points in the vector space, while unrelated topics land far away.
Cosine Sim:
• A ↔ B: 0.94 (Very similar)
• A ↔ C: 0.12 (Unrelated)
0.94 0.12 A: "CEO of Stripe" B: "Patrick Collison founded Stripe" C: "MongoDB database"

Cosine similarity — the standard distance metric

Cosine similarity measures the angle between two vectors, regardless of their magnitude. A score of 1.0 means identical direction (same meaning), 0 means perpendicular (unrelated), -1 means opposite meaning (rare in practice).

Python — Computing cosine similarity
import numpy as np

def cosine_similarity(v1: list, v2: list) -> float:
    """Standard measure of vector similarity for embeddings."""
    a, b = np.array(v1), np.array(v2)
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

# Vector DBs store cosine DISTANCE (1 - similarity) for sorting
# distance=0.0 → identical, distance=1.0 → completely unrelated

Embedding model families compared

Model familyProvider examplesTypical shapeBest forWatch-outs
General-purpose hostedOpenAI, Cohere, Voyage, JinaDense vectors, managed APIFast production start, stable operations, managed scalingPricing, data policy, and dimensions change; verify docs before launch
Cost-optimised hostedSmall/compact hosted embedding modelsLower cost per token, lower storage footprintHigh-volume search, support docs, product cataloguesMay lose recall on niche technical or multilingual corpora
Code/document-specialisedVoyage, Jina, domain-tuned optionsOften tuned for long documents or technical textCode search, API docs, technical knowledge basesBenchmark on your repo/docs; generic leaderboard results may not transfer
Open / self-hostedBGE, Nomic, mixedbread, E5-style modelsDense, sparse, or hybrid depending on modelPrivacy-sensitive workloads, predictable volume, local experimentsYou own serving, upgrades, monitoring, and hardware cost
Multilingual / hybridBGE-M3-style models, multilingual hosted APIsMay support dense + sparse retrievalInternational content, hybrid search, mixed-language corporaEvaluate per language; quality can vary sharply by domain
Choosing an embedding model

Start with a reliable hosted general-purpose embedding model if you want speed. Move to a larger, specialised, or self-hosted model only when evals show a quality, privacy, or cost reason. Always test on your actual data — public benchmark scores do not always match domain-specific performance.

Embedding dimensions — the tradeoff

More dimensions = richer representation, better quality, but more storage and slower search. OpenAI's text-embedding-3 models support Matryoshka Representation Learning (MRL) — you can truncate to fewer dimensions without re-embedding, trading quality for speed:

Python — MRL truncated embeddings
# text-embedding-3-large natively supports dimension reduction
resp = openai.embeddings.create(
    model="text-embedding-3-large",
    input=text,
    dimensions=256   # Truncate to 256 dims instead of default 3072
               # Smaller storage and faster search
               # Measure quality loss on your own eval set
)
# Great for: high-volume applications where cost and latency matter
# Avoid for: high-stakes retrieval where every percentage matters

Sparse embeddings — the other type

The embeddings above are dense — every dimension has a non-zero value. There are also sparse embeddings, where most values are zero and only a few are non-zero. These capture keyword information rather than semantic meaning:

Sparse embeddings (SPLADE, BM25)
High-dimensional vectors (30,000+ dims, one per vocabulary word) where most values are zero. Non-zero values indicate which words are important and how important. Can be searched with inverted indexes — extremely fast. Bad at semantic understanding, excellent at exact keyword matching.
Use alongside dense embeddings in hybrid search. Some modern embedding models can produce dense and sparse representations from the same text.

Chapter 8
Vector Databases — Complete Reference

A vector database stores high-dimensional embedding vectors and enables fast approximate nearest-neighbour search. It is the storage layer for RAG, semantic search, agent memory, and recommendation systems. For deployment reference, see the pgvector GitHub Repository and the Qdrant Documentation.

Why Use a Vector Database?

Traditional databases query by exact matches, ranges, or keywords (using indexes like B-Trees). However, AI models process unstructured data (text, images, audio) converted into high-dimensional vectors representing semantic meaning. A vector database uses specialized indexing structures to perform sub-millisecond similarity comparisons (like Cosine distance or L2 Euclidean distance) across millions of entries, which traditional engines cannot handle efficiently.

Core Agentic Use Cases

  • Long-Term Agent Memory: Persisting past user interactions, episodic memories, and session state to give agents historical context.
  • Retrieval-Augmented Generation (RAG): Pulling relevant documentation, database logs, or enterprise files to augment LLM context.
  • Semantic Caching: Storing previously generated LLM answers to identical or highly similar queries to reduce repeated LLM calls.
  • Deduplication & Classification: Scanning files or incoming user requests for near-duplicate tickets or topics.

When to Use What Database

Choosing a database is a trade-off between operational complexity, transactional guarantees (ACID), scaling limits, and cost. Here is how to navigate the landscape:
Decision Flowchart: Selecting the Right Database
1. Storage Requirements
Relational & Transactional: Need standard SQL query capabilities along with vectors?
Scale (Billion+): Need distributed clustering with massive sharding?
In-Memory (Real-time): Need ultra-low latency or semantic caching?
2. Architectural Alignment
Existing Postgres stack?
→ Consider pgvector (keeps vectors close to relational data).
Billion-scale or hybrid filters?
→ Consider Qdrant / Milvus (purpose-built vector search and filtering).
Serverless / On-device / Dev?
→ Consider LanceDB / Chroma (embedded or low-ops development path).
3. Production Target
pgvector: Strong when Postgres is already the system of record.
Qdrant / Milvus: Strong when vector search and filtering are core workloads.
Redis: Strong for low-latency cache-like retrieval patterns.

How approximate nearest-neighbour (ANN) search works

Finding the exact nearest vector in a database of 10 million 1536-dimensional vectors by brute force would require comparing 10 million × 1536 numbers — too slow for production. Vector databases use ANN algorithms that trade a small amount of accuracy for massive speed improvements:

HNSW — How fast vector search works
HNSW: Hierarchical Navigable Small World

Layer 2 (sparse, long-range):  A ─────────────── E
                                        │
Layer 1 (medium):              A ── B ──┤── D ── E
                                        │
Layer 0 (dense, all nodes):   A─B─C─D─E─F─G─H─I─J

Search starts at top layer (few nodes, fast traversal)
Navigates down to closest node at each layer
Arrives at approximate nearest neighbours in bottom layer

Parameters you tune:
  m=16           → connections per node (higher = better recall, more RAM)
  ef_construct=100 → build quality (higher = better index, slower build)
  ef=128         → search quality (higher = better recall, slower query)

Result: finds approximate neighbours much faster than brute force,
with recall/latency controlled by index parameters

Common vector database choices

DatabaseTypeGood fitDeployment shapeSpecial strength
QdrantPurpose-builtProduction agents, metadata filtering, hybrid retrievalLocal or managedPayload filtering, Rust implementation, practical API
PineconeManagedTeams that want minimal database operationsManaged cloudOperational simplicity and namespace-based organisation
pgvectorPostgres extensionTeams already running PostgresSelf-hosted or managed Postgres providersSQL, joins, transactions, and vectors in one database
WeaviatePurpose-builtSchema-rich vector apps and multi-tenant retrievalSelf-hosted or managedObject model, integrations, multi-tenancy
ChromaEmbedded / lightweightLocal development, notebooks, prototypesLocal or hosted optionsSimple Python-native developer experience
MilvusDistributedLarge-scale self-hosted vector infrastructureSelf-hosted or managed via ecosystem providersHorizontal scaling and multiple index types
LanceDBEmbedded columnarServerless, edge, local, or columnar workloadsEmbedded or managedLance columnar format, local-first workflows
MongoDB Atlas VectorDocument + vectorTeams already on MongoDBManaged AtlasDocument queries plus vector search in one platform
Redis / RediSearchIn-memory + vectorLow-latency retrieval and cache-adjacent use casesSelf-hosted or managed RedisVery fast cache-oriented retrieval patterns
OpenSearch / ElasticsearchSearch + vectorTeams with existing search infrastructureSelf-hosted or managedCombines lexical search, filters, faceting, and vector search

Qdrant — full production setup

Python — Complete Qdrant workflow
from qdrant_client import QdrantClient, models
import uuid

client = QdrantClient(
    url="https://your-cluster.qdrant.io",  # Or "http://localhost:6333"
    api_key="your-api-key"
)

# 1. Create collection with HNSW index
client.create_collection(
    collection_name="documents",
    vectors_config=models.VectorParams(
        size=1536,                          # Match your embedding model
        distance=models.Distance.COSINE,     # Standard for text embeddings
        hnsw_config=models.HnswConfigDiff(
            m=16,                             # More connections = better recall, more RAM
            ef_construct=100                 # Higher = better index quality, slower build
        )
    ),
    # Payload index for fast metadata filtering
    on_disk_payload=True                     # Store metadata on disk, vectors in RAM
)

# 2. Create payload index for fast filtering
client.create_payload_index(
    collection_name="documents",
    field_name="user_id",
    field_schema=models.PayloadSchemaType.KEYWORD
)

# 3. Upsert vectors with rich metadata (payload)
client.upsert(
    collection_name="documents",
    points=[
        models.PointStruct(
            id=str(uuid.uuid4()),
            vector=embed(chunk_text),        # 1536-dim float list
            payload={
                "text": chunk_text,
                "source": "confluence.md",
                "user_id": "prem",
                "section": "architecture",
                "date": "2026-01-15",
                "chunk_index": 3
            }
        )
        for chunk_text in chunks
    ]
)

# 4. Search with metadata filtering (ALWAYS filter before vector search!)
results = client.search(
    collection_name="documents",
    query_vector=embed("Where is rate limiting implemented?"),
    query_filter=models.Filter(
        must=[
            models.FieldCondition(key="user_id", match=models.MatchValue(value="prem")),
            models.FieldCondition(key="section", match=models.MatchValue(value="architecture"))
        ]
    ),
    limit=10,
    with_payload=True,
    search_params=models.SearchParams(ef=128)  # Higher ef = better recall at query time
)

for result in results:
    print(f"Score: {result.score:.3f} | {result.payload['text'][:100]}")

pgvector — Postgres with vector search

SQL — pgvector complete setup
-- Install extension (once per database)
CREATE EXTENSION IF NOT EXISTS vector;

-- Create table with vector column
CREATE TABLE documents (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    content     TEXT NOT NULL,
    source      TEXT,
    section     TEXT,
    user_id     TEXT,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    embedding   vector(1536)     -- Dimension must match your model
);

-- HNSW index for fast approximate search (Postgres 16+)
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- Standard indexes for metadata filtering
CREATE INDEX ON documents (user_id);
CREATE INDEX ON documents (section);

-- Semantic search with metadata filter
-- The $1 parameter is your query embedding as a vector literal
SELECT
    content,
    source,
    1 - (embedding <=> $1) AS similarity   -- <=> is cosine distance
FROM documents
WHERE
    user_id = $2                              -- Filter BEFORE vector search
    AND section = $3
    AND created_at > NOW() - INTERVAL '30 days'
ORDER BY embedding <=> $1                    -- Sort by similarity
LIMIT 10;

-- Full-text + vector hybrid search
SELECT content, source,
    1 - (embedding <=> $1) AS vec_score,
    ts_rank(to_tsvector('english', content), plainto_tsquery($2)) AS text_score
FROM documents
WHERE to_tsvector('english', content) @@ plainto_tsquery($2)
ORDER BY (vec_score * 0.7) + (text_score * 0.3) DESC   -- Weighted hybrid
LIMIT 10;
⚠️
Critical performance pattern — filter before search

Always apply metadata filters before the vector search. Filtering 100 user-specific documents and searching those is orders of magnitude faster than searching all 1 million documents and then filtering the results. Every production vector DB supports pre-filtering — always use it.

Chapter 9
Agent Memory Architecture

A stateless agent — one that forgets everything between sessions — is useful only for one-shot tasks. Production agents need memory: to remember past decisions, user preferences, ongoing project context, and learned procedures. Memory is the difference between a tool and a colleague.

The four types of memory — a complete taxonomy

Memory typeWhat it storesStorageRetrievalLifetime
Working memoryCurrent conversation, active task state, recent tool resultsContext window (RAM)Always present — no retrieval neededSingle session only
Episodic memoryPast interactions, decisions made, task outcomes, "what happened last Tuesday"Vector DB or PostgreSQL with embeddingsSemantic search on past episodesPersistent, configurable decay
Semantic memoryFacts, user preferences, domain knowledge, "user prefers TypeScript"Key-value store, vector DB, or graph DBDirect key lookup or semantic searchPersistent, updated when facts change
Procedural memoryHow to perform tasks, learned workflows, "always check auth before querying user data"Prompt store, system prompt, fine-tuned weightsRetrieved based on task typePersistent, updated by engineers

LangGraph checkpointers — session state persistence

Python — LangGraph with PostgresSaver
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.graph import StateGraph

# PostgresSaver persists every step to Postgres
# Enables: pause/resume, HITL, time-travel debugging, rollback
checkpointer = PostgresSaver.from_conn_string(
    "postgresql://user:pass@localhost/agentdb"
)

# Compile graph with checkpointer
graph = build_agent_graph()
app = graph.compile(checkpointer=checkpointer)

# Run with thread_id — state persists across calls
config = {"configurable": {"thread_id": "prem-session-2026-07"}}

# First message
result1 = app.invoke(
    {"messages": [("user", "Research Adyen for me")]},
    config=config
)

# Hours later — agent remembers the entire prior conversation
result2 = app.invoke(
    {"messages": [("user", "Now compare them with Stripe")]},
    config=config  # Same thread_id → same state is loaded
)

# Time-travel: inspect state at any past step
history = list(app.get_state_history(config))
past_state = app.get_state(config, checkpoint_id=history[3].checkpoint_id)

# Available checkpointers:
# MemorySaver     — in-memory, lost on restart (dev only)
# SqliteSaver     — SQLite file (single-process)
# PostgresSaver   — PostgreSQL-backed durable state
# RedisSaver      — Redis (low-latency, high-throughput)

Mem0 — managed multi-layer memory

Python — Mem0 multi-layer memory
from mem0 import Memory
from mem0 import MemoryClient  # Cloud managed version

# Initialise (uses Qdrant + OpenAI embeddings by default)
m = Memory()

USER_ID = "prem"

# ── STORING MEMORIES ─────────────────────────────────────────

# From a conversation (Mem0 extracts facts automatically)
m.add(
    messages=[
        {"role": "user",      "content": "I prefer TypeScript over Python for backends"},
        {"role": "assistant", "content": "Got it, I'll use TypeScript for your projects"}
    ],
    user_id=USER_ID
)
# Mem0 extracts: "User prefers TypeScript over Python for backend development"
# and stores it with a vector embedding

# From an explicit fact
m.add(
    messages=[{"role": "user", "content": "I decided to use LangGraph for the PR review pipeline"}],
    user_id=USER_ID,
    metadata={"project": "pr-agent", "decision": True}
)

# ── RETRIEVING MEMORIES ──────────────────────────────────────

# Semantic search — finds relevant memories
memories = m.search(
    query="What programming language should I use?",
    user_id=USER_ID,
    limit=5
)
# Returns: [{"memory": "User prefers TypeScript over Python", "score": 0.94}]

# Get all memories for a user
all_memories = m.get_all(user_id=USER_ID)

# ── MEMORY CRUD ──────────────────────────────────────────────

# Delete a specific memory (user privacy control)
m.delete(memory_id="mem_abc123")

# Delete all memories for a user (GDPR deletion)
m.delete_all(user_id=USER_ID)

# Update a memory when facts change
m.update(
    memory_id="mem_abc123",
    data="User now prefers Python for data pipelines, TypeScript for APIs"
)

Building memory from scratch — episodic store

Python — Custom episodic memory store
from datetime import datetime, timedelta
import json

class EpisodicMemoryStore:
    """
    Store and retrieve past agent episodes.
    Each episode = one completed agent run with its outcome.
    """

    def __init__(self, qdrant_client, embed_fn):
        self.qdrant = qdrant_client
        self.embed  = embed_fn
        self.collection = "episodic_memory"

    def store_episode(
        self,
        user_id: str,
        query: str,
        outcome: str,
        tools_used: list[str],
        result_summary: str
    ):
        """Store a completed agent run as an episode."""
        episode_text = f"Task: {query}\nOutcome: {outcome}\nSummary: {result_summary}"
        self.qdrant.upsert(
            collection_name=self.collection,
            points=[models.PointStruct(
                id=str(uuid.uuid4()),
                vector=self.embed(episode_text),
                payload={
                    "user_id":     user_id,
                    "query":       query,
                    "outcome":     outcome,
                    "tools_used":  tools_used,
                    "summary":     result_summary,
                    "timestamp":   datetime.utcnow().isoformat()
                }
            )]
        )

    def recall_relevant(self, user_id: str, current_query: str, limit: int = 3) -> list:
        """Find past episodes relevant to the current task."""
        results = self.qdrant.search(
            collection_name=self.collection,
            query_vector=self.embed(current_query),
            query_filter=models.Filter(must=[
                models.FieldCondition(key="user_id", match=models.MatchValue(value=user_id)),
                # Only recall recent episodes (configurable decay)
                models.FieldCondition(
                    key="timestamp",
                    range=models.DatetimeRange(
                        gte=datetime.utcnow() - timedelta(days=90)
                    )
                )
            ]),
            limit=limit
        )
        return [r.payload for r in results]

    def inject_into_prompt(self, user_id: str, current_query: str) -> str:
        """Build memory context string to prepend to agent system prompt."""
        episodes = self.recall_relevant(user_id, current_query)
        if not episodes:
            return ""
        memory_text = "\n\n## Relevant past work:\n"
        for ep in episodes:
            memory_text += f"\n- [{ep['timestamp'][:10]}] {ep['query']}: {ep['summary']}"
        return memory_text

Memory privacy and compliance

⚠️
GDPR and privacy requirements for agent memory

If your agent stores personal data (names, preferences, past conversations), you have legal obligations. Required capabilities: (1) Right to erasure — delete all memories for a user on request. (2) Right of access — show users exactly what you've stored. (3) Data minimisation — don't store more than necessary. (4) Retention limits — configure automatic expiry. Mem0 provides GDPR controls built-in. Building custom? Add a delete_user_data(user_id) endpoint from day one.

Choosing the right memory approach

Use caseRecommended approachWhy
Single session, no persistenceContext window onlySimplest — no extra infra
Multi-session LangGraph agentPostgresSaver checkpointerFull state persistence, time-travel, rollback
User preferences + factsMem0Managed, privacy controls, multi-layer
High-volume, many usersQdrant + custom episodic storeScale, cost control, full ownership
Simple key-value factsRedis or Postgres tableNo vector overhead when semantic search isn't needed
Shared knowledge base (not per-user)RAG over shared collectionAll users share the same knowledge source
· · ·