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
Why RAG exists — the three fundamental problems it solves
- Knowledge cutoff — LLMs don't know what happened after their training data cutoff. RAG gives them access to current information.
- Private knowledge — Your company's internal docs, Confluence pages, Slack history, codebase — the model was never trained on these. RAG makes them accessible.
- 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.
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.
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:
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:
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:
| Metric | What it measures | Failure it catches | Target |
|---|---|---|---|
| Faithfulness | Is every claim in the answer grounded in the retrieved docs? | Hallucination — model adds facts not in context | >0.85 |
| Answer relevancy | Does the answer actually address the question asked? | Off-topic answers, rambling responses | >0.80 |
| Context precision | Are the retrieved chunks actually relevant to the query? | Retrieval noise — irrelevant chunks in context | >0.75 |
| Context recall | Were all relevant chunks retrieved? (none missed) | Missing information — answer is incomplete | >0.70 |
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:
Chapter 7
Embeddings — The Complete Guide
What an embedding actually is
• A ↔ B: 0.94 (Very similar)
• A ↔ C: 0.12 (Unrelated)
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).
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 family | Provider examples | Typical shape | Best for | Watch-outs |
|---|---|---|---|---|
| General-purpose hosted | OpenAI, Cohere, Voyage, Jina | Dense vectors, managed API | Fast production start, stable operations, managed scaling | Pricing, data policy, and dimensions change; verify docs before launch |
| Cost-optimised hosted | Small/compact hosted embedding models | Lower cost per token, lower storage footprint | High-volume search, support docs, product catalogues | May lose recall on niche technical or multilingual corpora |
| Code/document-specialised | Voyage, Jina, domain-tuned options | Often tuned for long documents or technical text | Code search, API docs, technical knowledge bases | Benchmark on your repo/docs; generic leaderboard results may not transfer |
| Open / self-hosted | BGE, Nomic, mixedbread, E5-style models | Dense, sparse, or hybrid depending on model | Privacy-sensitive workloads, predictable volume, local experiments | You own serving, upgrades, monitoring, and hardware cost |
| Multilingual / hybrid | BGE-M3-style models, multilingual hosted APIs | May support dense + sparse retrieval | International content, hybrid search, mixed-language corpora | Evaluate per language; quality can vary sharply by domain |
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:
# 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:
Chapter 8
Vector Databases — Complete Reference
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
• Scale (Billion+): Need distributed clustering with massive sharding?
• In-Memory (Real-time): Need ultra-low latency or semantic caching?
→ Consider pgvector (keeps vectors close to relational data).
→ Consider Qdrant / Milvus (purpose-built vector search and filtering).
→ Consider LanceDB / Chroma (embedded or low-ops development path).
• 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: 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
| Database | Type | Good fit | Deployment shape | Special strength |
|---|---|---|---|---|
| Qdrant | Purpose-built | Production agents, metadata filtering, hybrid retrieval | Local or managed | Payload filtering, Rust implementation, practical API |
| Pinecone | Managed | Teams that want minimal database operations | Managed cloud | Operational simplicity and namespace-based organisation |
| pgvector | Postgres extension | Teams already running Postgres | Self-hosted or managed Postgres providers | SQL, joins, transactions, and vectors in one database |
| Weaviate | Purpose-built | Schema-rich vector apps and multi-tenant retrieval | Self-hosted or managed | Object model, integrations, multi-tenancy |
| Chroma | Embedded / lightweight | Local development, notebooks, prototypes | Local or hosted options | Simple Python-native developer experience |
| Milvus | Distributed | Large-scale self-hosted vector infrastructure | Self-hosted or managed via ecosystem providers | Horizontal scaling and multiple index types |
| LanceDB | Embedded columnar | Serverless, edge, local, or columnar workloads | Embedded or managed | Lance columnar format, local-first workflows |
| MongoDB Atlas Vector | Document + vector | Teams already on MongoDB | Managed Atlas | Document queries plus vector search in one platform |
| Redis / RediSearch | In-memory + vector | Low-latency retrieval and cache-adjacent use cases | Self-hosted or managed Redis | Very fast cache-oriented retrieval patterns |
| OpenSearch / Elasticsearch | Search + vector | Teams with existing search infrastructure | Self-hosted or managed | Combines lexical search, filters, faceting, and vector search |
Qdrant — full production setup
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
-- 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;
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
The four types of memory — a complete taxonomy
| Memory type | What it stores | Storage | Retrieval | Lifetime |
|---|---|---|---|---|
| Working memory | Current conversation, active task state, recent tool results | Context window (RAM) | Always present — no retrieval needed | Single session only |
| Episodic memory | Past interactions, decisions made, task outcomes, "what happened last Tuesday" | Vector DB or PostgreSQL with embeddings | Semantic search on past episodes | Persistent, configurable decay |
| Semantic memory | Facts, user preferences, domain knowledge, "user prefers TypeScript" | Key-value store, vector DB, or graph DB | Direct key lookup or semantic search | Persistent, updated when facts change |
| Procedural memory | How to perform tasks, learned workflows, "always check auth before querying user data" | Prompt store, system prompt, fine-tuned weights | Retrieved based on task type | Persistent, updated by engineers |
LangGraph checkpointers — session state persistence
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
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
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
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 case | Recommended approach | Why |
|---|---|---|
| Single session, no persistence | Context window only | Simplest — no extra infra |
| Multi-session LangGraph agent | PostgresSaver checkpointer | Full state persistence, time-travel, rollback |
| User preferences + facts | Mem0 | Managed, privacy controls, multi-layer |
| High-volume, many users | Qdrant + custom episodic store | Scale, cost control, full ownership |
| Simple key-value facts | Redis or Postgres table | No vector overhead when semantic search isn't needed |
| Shared knowledge base (not per-user) | RAG over shared collection | All users share the same knowledge source |