LLM Vector Database Patterns in Production: Pinecone vs pgvector vs Chroma
Choosing a vector database for your LLM application? This guide covers production patterns for Pinecone, pgvector, and Chroma — embedding strategies, index tuning, chunking, and when each database is the right choice.
The vector database you choose determines how fast your RAG application retrieves context and how much it costs at scale. Most guides compare benchmarks. This post covers what actually matters in production: embedding strategies, chunking, and operational patterns.
The Vector Search Problem
Your LLM application needs to find the 5 most relevant documents out of 500,000. Brute-force comparison is O(n) — impossibly slow at scale. Vector databases solve this with approximate nearest neighbor (ANN) indexes that find similar vectors in O(log n) or better.
Embedding Strategy First
Before picking a database, choose your embedding model:
import anthropic
# Anthropic embeddings (claude-3 family)
# Use for: general text, code, documentation
client = anthropic.Anthropic()
# Note: Anthropic uses voyage-* models for embeddings via the API
# Integration via voyageai package:
import voyageai
vo = voyageai.Client()
result = vo.embed(
["Deploy Kubernetes pods to production", "kubectl apply -f deployment.yaml"],
model="voyage-code-2", # Best for code/DevOps content
input_type="document"
)
embeddings = result.embeddings # List of 1536-dim vectorsEmbedding model selection:
| Use Case | Model | Dimensions | Notes |
|---|---|---|---|
| General text/docs | voyage-large-2 | 1536 | Best quality |
| Code + docs | voyage-code-2 | 1536 | Best for DevOps RAG |
| High speed | text-embedding-3-small (OpenAI) | 1536 | 5x cheaper |
| Multilingual | multilingual-e5-large | 1024 | Self-hosted |
Chunking Strategy
Poor chunking destroys retrieval quality regardless of database choice.
from langchain.text_splitter import RecursiveCharacterTextSplitter
def chunk_runbook(text: str, source: str) -> list[dict]:
"""
Chunk a runbook intelligently — preserve section context.
"""
splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # ~600 tokens for voyage-code-2
chunk_overlap=200, # Overlap prevents context loss at boundaries
separators=[
"\n## ", # Split on H2 headings first
"\n### ", # Then H3
"\n\n", # Then paragraphs
"\n", # Then lines
" " # Last resort: words
]
)
chunks = splitter.split_text(text)
# Add metadata to each chunk
return [
{
"text": chunk,
"source": source,
"chunk_index": i,
"char_count": len(chunk)
}
for i, chunk in enumerate(chunks)
]
# For code files: chunk by function/class, not character count
def chunk_code(code: str, language: str) -> list[dict]:
"""Preserve function boundaries when chunking code."""
import ast
if language == "python":
try:
tree = ast.parse(code)
chunks = []
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
start = node.lineno - 1
end = node.end_lineno
chunk_text = "\n".join(code.split("\n")[start:end])
chunks.append({
"text": chunk_text,
"type": type(node).__name__,
"name": node.name
})
return chunks
except SyntaxError:
pass
# Fallback: character-based splitting
return chunk_runbook(code, language)Option 1: pgvector (PostgreSQL)
Best for: teams already running PostgreSQL, small-to-medium scale, need transactional consistency with your application data.
import psycopg2
import numpy as np
# Setup
conn = psycopg2.connect("postgresql://user:pass@localhost/mydb")
with conn.cursor() as cur:
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
cur.execute("""
CREATE TABLE IF NOT EXISTS documents (
id SERIAL PRIMARY KEY,
content TEXT,
source VARCHAR(500),
embedding vector(1536),
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW()
)
""")
# Create HNSW index for fast ANN search
cur.execute("""
CREATE INDEX IF NOT EXISTS documents_embedding_idx
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
""")
conn.commit()
def insert_documents(chunks: list[dict], embeddings: list[list[float]]):
with conn.cursor() as cur:
for chunk, embedding in zip(chunks, embeddings):
cur.execute("""
INSERT INTO documents (content, source, embedding, metadata)
VALUES (%s, %s, %s, %s)
""", (
chunk["text"],
chunk.get("source", ""),
embedding,
json.dumps({k: v for k, v in chunk.items() if k not in ("text", "source")})
))
conn.commit()
def semantic_search(query_embedding: list[float], top_k: int = 5, source_filter: str = None) -> list[dict]:
"""Search with optional metadata filter — pgvector's killer feature."""
with conn.cursor() as cur:
if source_filter:
cur.execute("""
SELECT content, source, 1 - (embedding <=> %s::vector) AS similarity
FROM documents
WHERE source LIKE %s
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_embedding, f"%{source_filter}%", query_embedding, top_k))
else:
cur.execute("""
SELECT content, source, 1 - (embedding <=> %s::vector) AS similarity
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_embedding, query_embedding, top_k))
rows = cur.fetchall()
return [{"content": r[0], "source": r[1], "similarity": float(r[2])} for r in rows]pgvector limitations: HNSW index performance degrades above ~1M vectors. For 10M+ vectors, use Pinecone or Weaviate.
Option 2: Pinecone
Best for: large scale (millions of vectors), fully managed, need low-latency globally.
from pinecone import Pinecone, ServerlessSpec
pc = Pinecone(api_key="your-api-key")
# Create index (one time)
pc.create_index(
name="devops-runbooks",
dimension=1536,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region="ap-south-1")
)
index = pc.Index("devops-runbooks")
def upsert_to_pinecone(chunks: list[dict], embeddings: list[list[float]]):
"""Upsert in batches of 100 (Pinecone limit per batch)."""
vectors = [
{
"id": f"chunk-{i}",
"values": embedding,
"metadata": {
"text": chunk["text"][:1000], # Pinecone metadata limit
"source": chunk.get("source", ""),
"chunk_index": chunk.get("chunk_index", 0)
}
}
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings))
]
# Batch upserts
batch_size = 100
for i in range(0, len(vectors), batch_size):
index.upsert(vectors=vectors[i:i+batch_size])
def query_pinecone(query_embedding: list[float], top_k: int = 5, filter: dict = None) -> list[dict]:
results = index.query(
vector=query_embedding,
top_k=top_k,
include_metadata=True,
filter=filter # e.g., {"source": {"$eq": "kubernetes-runbooks"}}
)
return [
{
"content": match.metadata.get("text", ""),
"source": match.metadata.get("source", ""),
"similarity": match.score
}
for match in results.matches
]Option 3: Chroma (Self-hosted)
Best for: development, small teams, no cloud vendor dependency.
import chromadb
from chromadb.config import Settings
client = chromadb.PersistentClient(
path="./chroma_db",
settings=Settings(anonymized_telemetry=False)
)
collection = client.get_or_create_collection(
name="devops-docs",
metadata={"hnsw:space": "cosine"}
)
def add_to_chroma(chunks: list[dict], embeddings: list[list[float]]):
collection.add(
embeddings=embeddings,
documents=[c["text"] for c in chunks],
metadatas=[{"source": c.get("source", "")} for c in chunks],
ids=[f"chunk-{i}" for i in range(len(chunks))]
)
def query_chroma(query_embedding: list[float], top_k: int = 5) -> list[dict]:
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k
)
return [
{
"content": doc,
"source": meta.get("source", ""),
"similarity": 1 - dist # Chroma returns distances
}
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
)
]Choosing the Right Database
| Scale | Choice | Reason |
|---|---|---|
| < 100k vectors | pgvector | Zero extra infra if you have Postgres |
| 100k–2M vectors | pgvector with HNSW | Still fast, SQL filtering power |
| 2M–50M vectors | Pinecone Serverless | Managed, scales automatically |
| 50M+ vectors | Weaviate / Qdrant | Distributed, more control |
| Dev/testing | Chroma | No external deps, fast setup |
The right answer for most DevOps tooling RAG applications: pgvector. You already have PostgreSQL, the SQL filtering lets you scope searches by namespace/team/environment, and HNSW handles millions of vectors without issues.
More LLMOps? Read our RAG for DevOps runbooks with ChromaDB and LLM production observability with OpenTelemetry.
Today I Fixed
Short real fixes from production — posted daily
Stay ahead of the curve
Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam — just practical engineering content.
Related Articles
Build an AI Log Pattern Classifier with Claude API
Build a production-ready log pattern classifier using Claude API that automatically categorizes log lines into errors, warnings, anomalies, and noise — saving on-call engineers hours of manual log triage.
Build an AI SRE Incident Commander with Claude API
Step-by-step tutorial to build an AI incident commander that takes an alert, gathers context from Kubernetes and AWS, generates a structured runbook, and coordinates the incident response — using Claude API with tool use.
LLM Context Window Strategies for Production: Summarization, Chunking, and RAG
Running out of context window in production LLM apps? This guide covers summarization pipelines, sliding window patterns, RAG for infinite context — with Python code for each strategy.