Part V · Reference
All Databases for Agentic Systems
Every type of database an agent might need — vector, relational, document, graph, time-series, cache, and search. When to use each, how to connect agents to them, and production patterns.
Chapter 17Vector Databases — Production Patterns
Chapter 8 introduced vector databases. This chapter covers production patterns: multi-tenancy, filtering optimisation, index tuning, backup strategies, and migration between vector DBs.
Multi-tenancy patterns
| Pattern | When to use | Isolation | Complexity |
|---|---|---|---|
| Collection per tenant | <100 tenants, strict isolation required | Complete | High ops burden at scale |
| Payload filter per tenant | 100–100K tenants | Logical (filter enforced in query) | Low — one collection, filter by tenant_id |
| Namespace per tenant | Pinecone users, medium scale | Namespace isolation | Medium — managed by Pinecone |
| Dedicated cluster per tenant | Enterprise, highest security | Physical | Highest — separate infrastructure |
Production Qdrant — index tuning
Python — Qdrant production configuration
from qdrant_client import QdrantClient, models
client = QdrantClient(url="https://cluster.qdrant.io", api_key="...")
# Optimise for high-recall production search
client.create_collection(
collection_name="production_docs",
vectors_config=models.VectorParams(
size=1536,
distance=models.Distance.COSINE,
hnsw_config=models.HnswConfigDiff(
m=32, # Higher m = better recall, more RAM (default 16)
ef_construct=200, # Higher = better index quality (default 100)
full_scan_threshold=10000 # Use full scan below this size
)
),
optimizers_config=models.OptimizersConfigDiff(
indexing_threshold=50000, # Build HNSW after 50K vectors
memmap_threshold=50000 # Memory-map vectors above this threshold
),
quantization_config=models.ScalarQuantization(
scalar=models.ScalarQuantizationConfig(
type=models.ScalarType.INT8, # 4x storage reduction, small accuracy loss
quantile=0.99,
always_ram=True
)
)
)
# Always create payload indexes for your filter fields!
for field in ["tenant_id", "doc_type", "created_at"]:
client.create_payload_index(
collection_name="production_docs",
field_name=field,
field_schema=models.PayloadSchemaType.KEYWORD
)
Chapter 18Relational Databases — SQL Agents
PostgreSQL and MySQL are the most widely deployed databases in the world. Agents increasingly need to query them via natural language. This chapter covers NL-to-SQL patterns, safety constraints, and production SQL agent design. For library implementation examples, see the LangChain SQL Agent Tutorial.
NL-to-SQL — how it works
Natural Language → SQL Pipeline
1. User Query
"How many Q3 orders from enterprise users?"
2. SQL Gen (LLM)
SELECT COUNT(*)...
🔒 3. Safety Check
Block updates/drops.
Enforce SELECT limit.
Enforce SELECT limit.
4. DB Execution
Run on read-only replica.
Returns rows.
Returns rows.
5. Format Answer
"There were 1,847 orders placed last week."
Python — Production SQL agent with safety
import re, asyncpg
from langchain_community.utilities import SQLDatabase
from langchain.chains import create_sql_query_chain
# ALWAYS connect to a read-only replica for agent queries
db = SQLDatabase.from_uri(
"postgresql://readonly_user:pass@replica.db.internal/prod",
include_tables=["orders", "customers", "products", "invoices"],
# Only expose tables agents should see — never all tables
)
def validate_sql(sql: str) -> str:
"""Safety layer — reject any non-SELECT queries"""
sql_clean = sql.strip().upper()
dangerous = ["DROP", "DELETE", "UPDATE", "INSERT", "ALTER", "TRUNCATE", "CREATE"]
for keyword in dangerous:
if re.search(rf'\b{keyword}\b', sql_clean):
raise ValueError(f"Unsafe SQL operation detected: {keyword}")
if not sql_clean.startswith("SELECT"):
raise ValueError("Only SELECT queries are permitted")
# Enforce row limit
if "LIMIT" not in sql_clean:
sql += " LIMIT 1000"
return sql
chain = create_sql_query_chain(llm, db)
def ask_database(question: str) -> str:
raw_sql = chain.invoke({"question": question})
safe_sql = validate_sql(raw_sql)
result = db.run(safe_sql)
# Let LLM format the result naturally
return llm.invoke(f"Question: {question}\nSQL result: {result}\nAnswer naturally:").content
All relational databases agents use
| Database | Agent use case | Connection pattern | Production note |
|---|---|---|---|
| PostgreSQL | General analytics, NL-to-SQL, pgvector hybrid | MCP server or SQLAlchemy | Read-only replica for agents |
| MySQL / MariaDB | Legacy systems, WordPress data | SQLAlchemy + LangChain | Same safety constraints apply |
| SQLite | LangGraph checkpointing, local dev, file-based agents | Direct — sqlite3 Python library | No concurrent writes — single process |
| DuckDB | CSV/Parquet analytics, data agents, local OLAP | Direct Python library | Exceptional for in-process analytics |
| Snowflake | Enterprise data warehouse queries | Snowflake MCP (official) | Separate warehouse for agent queries |
| BigQuery | Google Cloud analytics agents | Google ADK native, BigQuery MCP | Set slot reservations to cap cost |
| Databricks | ML feature stores, large-scale pipelines | Databricks MCP server | Unity Catalog for governance |
| CockroachDB | Globally distributed transactional | PostgreSQL-compatible — same as PG | Multi-region agent deployments |
Chapter 19MongoDB & NoSQL Databases
Document databases like MongoDB store flexible JSON-like documents, making them natural fits for agent memory, conversation history, and flexible schema requirements. MongoDB Atlas adds vector search, making it a unified store for both operational data and embeddings.
MongoDB for agent episodic memory
Python — MongoDB agent memory store
from pymongo import MongoClient, DESCENDING
from datetime import datetime, timedelta
import uuid
client = MongoClient("mongodb+srv://...")
db = client["agent_platform"]
# Store agent conversations with full context
def save_conversation(user_id: str, session_id: str, messages: list, outcome: str):
db.conversations.insert_one({
"_id": session_id,
"user_id": user_id,
"messages": messages, # Full message history
"outcome": outcome,
"created_at": datetime.utcnow(),
"tools_used": list({m["tool"] for m in messages if "tool" in m}),
"token_count": sum(m.get("tokens", 0) for m in messages)
})
# Retrieve recent conversations for a user
def get_recent_context(user_id: str, days: int = 30) -> list:
return list(db.conversations.find(
{"user_id": user_id, "created_at": {"$gte": datetime.utcnow() - timedelta(days=days)}},
{"messages": 0} # Exclude full messages for efficiency — get summaries
).sort("created_at", DESCENDING).limit(10))
# MongoDB Atlas Vector Search — semantic search over agent history
def semantic_memory_search(user_id: str, query: str, limit: int = 5) -> list:
query_embedding = embed(query)
pipeline = [
{"$vectorSearch": {
"index": "conversation_vector_index",
"path": "embedding",
"queryVector": query_embedding,
"numCandidates": 100,
"limit": limit,
"filter": {"user_id": user_id} # Pre-filter by user
}},
{"$project": {"outcome": 1, "created_at": 1, "score": {"$meta": "vectorSearchScore"}}}
]
return list(db.conversations.aggregate(pipeline))
Other NoSQL databases agents use
| Database | Type | Agent use case | Key strength |
|---|---|---|---|
| Redis | In-memory KV + vector | Session cache, rate limiting, real-time state, pub/sub | Sub-millisecond latency |
| Cassandra | Wide-column | Time-series agent logs, high-write audit trails | Massive write throughput |
| DynamoDB | KV + document | Serverless agent state, AWS-native applications | Infinite scale, no ops |
| Firestore | Document | Mobile agent apps, GCP-native, real-time sync | Real-time listeners |
| Couchbase | Document + SQL | Edge deployments, offline-capable agents | Mobile sync, edge computing |
| HBase | Wide-column | Hadoop-integrated analytics, massive datasets | Petabyte-scale reads |
Redis for agent infrastructure
Python — Redis patterns for agents
import redis, json
from datetime import timedelta
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
# 1. Rate limiting — prevent runaway tool calls
def is_rate_limited(user_id: str, tool: str, max_per_hour: int = 100) -> bool:
key = f"ratelimit:{user_id}:{tool}"
count = r.incr(key)
if count == 1: r.expire(key, 3600) # 1 hour window
return count > max_per_hour
# 2. Tool result caching — same search query = cached result
def cached_search(query: str, ttl: int = 3600) -> dict:
key = f"search_cache:{hash(query)}"
cached = r.get(key)
if cached: return json.loads(cached)
result = actual_search(query)
r.setex(key, ttl, json.dumps(result))
return result
# 3. Agent session state (fast short-term memory)
def save_session_state(session_id: str, state: dict, ttl: int = 86400):
r.setex(f"session:{session_id}", ttl, json.dumps(state))
def load_session_state(session_id: str) -> dict:
raw = r.get(f"session:{session_id}")
return json.loads(raw) if raw else {}
# 4. Pub/sub for multi-agent coordination
def publish_task_complete(task_id: str, result: dict):
r.publish(f"task_results", json.dumps({"task_id": task_id, "result": result}))
Chapter 20Graph Databases
Graph databases store data as nodes and relationships. They are exceptionally powerful for agents working with connected data — knowledge graphs, organisational hierarchies, supply chains, social networks, and any domain where relationships between entities are as important as the entities themselves.
When graph databases beat relational
| Query type | Relational (SQL) | Graph (Cypher) | Winner |
|---|---|---|---|
| "Who are the friends of friends of Alice?" | Multiple JOINs, slow at depth 3+ | MATCH (a)-[:FRIEND*2]->(fof) WHERE a.name='Alice' | Graph — 10–1000x faster |
| "What's the shortest path from A to B?" | Recursive CTEs, complex | shortestPath() built-in | Graph — trivial |
| "Find all suppliers 2 hops from bankrupt supplier X" | Very complex, slow | MATCH (:Supplier{name:'X'})-[:SUPPLIES*1..2]->(s) | Graph — simple |
| "Total revenue by region last month" | Simple GROUP BY | Possible but awkward | Relational — designed for this |
Python — Neo4j agent tool
from neo4j import AsyncGraphDatabase
driver = AsyncGraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
# Agent tool: natural language → Cypher → graph query → answer
async def query_knowledge_graph(question: str) -> str:
# 1. LLM generates Cypher query from natural language
cypher = await llm.ainvoke(f"""
Convert to Neo4j Cypher query for a company knowledge graph.
Schema:
(Company)-[:COMPETES_WITH]->(Company)
(Company)-[:FOUNDED_BY]->(Person)
(Company)-[:ACQUIRED]->(Company)
(Person)-[:WORKS_AT {{role: 'CEO'}}]->(Company)
(Company)-[:BACKED_BY]->(Investor)
(Investor)-[:INVESTED_IN]->(Company)
Only generate MATCH...RETURN queries (no writes).
Question: {question}
Cypher:""")
# 2. Validate it's read-only
if any(kw in cypher.upper() for kw in ["CREATE", "DELETE", "SET", "MERGE"]):
return "Error: Only read queries are permitted"
# 3. Execute on graph database
async with driver.session() as session:
result = await session.run(cypher)
records = [dict(r) async for r in result]
# 4. LLM formats the graph result as natural language
return await llm.ainvoke(
f"Question: {question}\nGraph data: {records}\nAnswer naturally:"
)
# Example uses:
# "Who are all the investors who backed both Stripe and Adyen?"
# "Which companies did Patrick Collison found?"
# "What companies compete with Zetwerk in the B2B manufacturing space?"
Graph database options
| Database | Query language | Best for | Managed cloud |
|---|---|---|---|
| Neo4j | Cypher | Knowledge graphs, fraud detection, recommendation | Neo4j AuraDB |
| Amazon Neptune | Gremlin / SPARQL | AWS-native, compliance graphs, identity | AWS Neptune |
| TigerGraph | GSQL | Massive graph analytics, ML on graphs | TigerGraph Cloud |
| ArangoDB | AQL | Multi-model: document + graph + key-value | ArangoDB AuraGraph |
| FalkorDB | Cypher (Redis-based) | High-performance, Redis-compatible graph | FalkorDB Cloud |
Chapter 21VectorLess Retrieval Approaches
Not every retrieval problem needs a vector database. Several approaches achieve semantic or keyword retrieval without the embedding pipeline complexity. Knowing when NOT to use vectors is as important as knowing how to use them.
BM25 — the keyword retrieval standard
BM25 (Best Match 25) is the default ranking algorithm in Elasticsearch, OpenSearch, and Solr. It ranks documents by keyword relevance using term frequency, inverse document frequency, and document length normalisation. No embeddings, no GPU, no complexity:
Python — BM25 retrieval
from rank_bm25 import BM25Okapi
import nltk
def build_bm25_index(documents: list[str]) -> tuple:
tokenised = [nltk.word_tokenize(doc.lower()) for doc in documents]
return BM25Okapi(tokenised), tokenised
bm25, _ = build_bm25_index(your_documents)
def bm25_search(query: str, top_k: int = 10) -> list[str]:
tokens = nltk.word_tokenize(query.lower())
scores = bm25.get_scores(tokens)
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
return [your_documents[i] for i in top_indices]
# Great for: code search, API docs, exact terminology
# Poor for: synonyms ("automobile" won't match "car"), paraphrases
Postgres full-text search — no vector DB needed
SQL — Full-text search in Postgres
-- Create a GIN index for full-text search (very fast)
CREATE INDEX docs_fts_idx ON documents USING gin(to_tsvector('english', content));
-- Full-text search query
SELECT id, content,
ts_rank(to_tsvector('english', content), query) AS relevance
FROM documents, plainto_tsquery('english', 'rate limiting middleware') query
WHERE to_tsvector('english', content) @@ query
ORDER BY relevance DESC
LIMIT 10;
-- Phrase search (exact phrase)
SELECT * FROM documents
WHERE to_tsvector('english', content) @@ phraseto_tsquery('english', 'connection pool exhausted');
-- Weighted search (title matters more than body)
SELECT id,
ts_rank(
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', content), 'B'),
plainto_tsquery('english', $1)
) AS rank
FROM documents
ORDER BY rank DESC LIMIT 20;
When to use what — retrieval decision guide
| Scenario | Best approach | Reason |
|---|---|---|
| Semantic questions, synonyms, paraphrases | Dense vector (Qdrant, pgvector) | Captures meaning, not just keywords |
| Exact code/API symbol search | BM25 or Postgres FTS | Exact match beats semantic for code |
| Production, best quality | Hybrid (vector + BM25) + rerank | Combines strengths of both |
| Already on Postgres, simple use case | Postgres FTS (tsvector) | No new infrastructure needed |
| Small corpus (<10K docs), quality over speed | LLM reranking only | Read all, let LLM pick best — expensive but accurate |
| Multilingual content | Multilingual embeddings (BGE-M3) | Language-agnostic semantic search |
| Real-time, sub-10ms required | Redis Vector Search | In-memory speed |
| Billion-scale | Milvus + GPU acceleration | Distributed, GPU-accelerated ANN |
Chapter 22Time-Series, Search & Specialised Databases
Time-series databases for agents
Agents monitoring systems, analysing metrics, or working with IoT data need time-series databases:
| Database | Agent use case | Key strength |
|---|---|---|
| InfluxDB | Infrastructure monitoring agents, IoT | Native time-series, excellent query performance |
| TimescaleDB | Postgres-based time-series, analytics agents | SQL-compatible, continuous aggregations |
| Prometheus | Incident diagnosis agents, SRE automation | Metrics standard, AlertManager integration |
| ClickHouse | High-speed analytics agents, log analysis | Columnar, extremely fast aggregations |
Elasticsearch for agents
Python — Elasticsearch agent tool
from elasticsearch import AsyncElasticsearch
es = AsyncElasticsearch("https://my-cluster.es.io", api_key="...")
async def search_logs(query: str, index: str, time_range: str = "1d") -> dict:
"""Search production logs — agent uses this to diagnose incidents"""
return await es.search(
index=index,
body={
"query": {
"bool": {
"must": [{"match": {"message": query}}],
"filter": [{"range": {"@timestamp": {"gte": f"now-{time_range}"}}}]
}
},
"aggs": {
"error_count_over_time": {
"date_histogram": {"field": "@timestamp", "fixed_interval": "5m"}
}
},
"size": 50,
"sort": [{"@timestamp": "desc"}]
}
)
· · ·