If you're building a RAG pipeline or a semantic search feature in Python, you've hit the same decision point: which vector database do I use?
In 2026, the answer depends on one question: are you optimizing for local dev speed or production scale?
Here's the honest comparison — no benchmarks from vendor blogs.
The Three Choices That Actually Matter
| pgvector | Chroma | Qdrant | |
|---|---|---|---|
| Best for | Existing Postgres stack | Local dev/prototyping | Production self-hosted |
| Deployment | PostgreSQL extension | Embedded or server | Docker or cloud |
| Python client |
psycopg + pgvector
|
chromadb |
qdrant-client |
| Filtering | SQL (full power) | Metadata filters | Rich payload filters |
| Persistence | Postgres (ACID) | File-based or in-memory | WAL-based |
| Free tier | Yes (self-hosted) | Yes (open source) | Yes (open source) |
| Managed cloud | Supabase, Neon | None (self-hosted only) | Qdrant Cloud |
pgvector — If You Already Run Postgres
pgvector turns your existing PostgreSQL database into a vector store. No new infrastructure, no separate service, no operational overhead.
pip install psycopg pgvector
import psycopg
from pgvector.psycopg import register_vector
import numpy as np
conn = psycopg.connect("postgresql://user:pass@localhost/mydb")
# The extension must exist before register_vector() can find the vector type
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.commit()
register_vector(conn)
# Create table with vector column
conn.execute("""
CREATE TABLE IF NOT EXISTS documents (
id SERIAL PRIMARY KEY,
content TEXT,
embedding vector(1536)
)
""")
# Insert document with embedding
embedding = np.array(get_embedding("my document text")) # 1536-dim for text-embedding-3-small
conn.execute(
"INSERT INTO documents (content, embedding) VALUES (%s, %s)",
("my document text", embedding)
)
conn.commit()
# Similarity search (cosine distance)
results = conn.execute("""
SELECT content, 1 - (embedding <=> %s::vector) AS similarity
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT 5
""", (embedding, embedding)).fetchall()
When pgvector wins:
- You already have Postgres in production
- You need SQL joins between vectors and relational data (e.g.,
WHERE user_id = ?) - You want ACID guarantees on your vector data
- You don't want to run a separate service
When pgvector loses:
- You need ANN (approximate nearest neighbor) at scale — pgvector's HNSW is good but not Qdrant-level
- Your embeddings dimension exceeds ~2000 (pgvector performance degrades above this)
- You need advanced payload filtering with complex conditions
Chroma — For Local Dev and Prototyping
Chroma is the fastest path from "I have documents" to "I have semantic search." It runs in-process, needs no configuration, and persists to disk with one line.
pip install chromadb
import chromadb
from chromadb.utils import embedding_functions
# Persistent client — survives process restart
client = chromadb.PersistentClient(path="./chroma_db")
# Use OpenAI or any embedding function
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
api_key="sk-...",
model_name="text-embedding-3-small"
)
collection = client.get_or_create_collection(
name="documents",
embedding_function=openai_ef
)
# Add documents — Chroma handles embedding automatically
collection.add(
documents=["Python is a high-level language", "pgvector extends PostgreSQL"],
metadatas=[{"source": "tutorial"}, {"source": "blog"}],
ids=["doc1", "doc2"]
)
# Query
results = collection.query(
query_texts=["vector storage in postgres"],
n_results=2,
where={"source": "blog"} # metadata filter
)
print(results["documents"])
When Chroma wins:
- You're prototyping and want zero setup
- Your dataset fits comfortably in memory/local disk (under a few million vectors)
- You're doing research or building a demo
- You want embedded mode (no server process)
When Chroma loses:
- Production workloads — Chroma is not designed for multi-process or high-concurrency access
- You need fine-grained access control
- Dataset size exceeds ~10M vectors
Qdrant — For Production Self-Hosted
Qdrant is built for production. It ships as a single Docker container, handles billions of vectors, and has the richest filtering API of the three.
pip install qdrant-client
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, PointStruct,
Filter, FieldCondition, MatchValue, DatetimeRange,
)
import numpy as np
client = QdrantClient("localhost", port=6333)
# Create collection
client.create_collection(
collection_name="documents",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)
# Upsert points
embedding = get_embedding("my document text")
client.upsert(
collection_name="documents",
points=[
PointStruct(
id=1,
vector=embedding,
payload={
"content": "my document text",
"source": "blog",
"category": "python",
"published_at": "2026-10-01T00:00:00Z"
}
)
]
)
# Search with payload filtering
results = client.query_points(
collection_name="documents",
query=get_embedding("vector database tutorial"),
query_filter=Filter(
must=[
FieldCondition(key="category", match=MatchValue(value="python")),
FieldCondition(
key="published_at",
range=DatetimeRange(gte="2026-01-01T00:00:00Z"),
),
]
),
limit=5,
with_payload=True,
)
for point in results.points:
print(point.score, point.payload["content"])
When Qdrant wins:
- You need production-grade reliability and HNSW performance
- Complex filtering logic (nested conditions, ranges, geo)
- You want on-premise / self-hosted with no usage-based pricing
- Billion-scale vector collections
When Qdrant loses:
- You already run Postgres and don't want another service (use pgvector)
- You're in prototype phase and want zero setup (use Chroma)
- You need managed cloud with zero ops (use Qdrant Cloud or Pinecone)
The Decision Tree
Are you in production?
├─ No → Is your dataset > 1M vectors?
│ ├─ No → Chroma (embedded, fastest to start)
│ └─ Yes → Qdrant (even for dev at this scale)
└─ Yes → Do you already run Postgres?
├─ Yes → pgvector (no new service)
└─ No → Qdrant (best ANN + filtering at scale)
Embedding the Embeddings — Don't Compute Twice
Regardless of which vector database you use, the most expensive operation is generating embeddings. Cache them.
import hashlib
import json
from functools import lru_cache
# Simple disk cache for embeddings
def get_cached_embedding(text: str, cache_dir: str = ".embedding_cache") -> list[float]:
cache_key = hashlib.md5(text.encode()).hexdigest()
cache_file = f"{cache_dir}/{cache_key}.json"
try:
with open(cache_file) as f:
return json.load(f)
except FileNotFoundError:
pass
# Only calls the API on cache miss
embedding = client.embeddings.create(
input=text,
model="text-embedding-3-small"
).data[0].embedding
os.makedirs(cache_dir, exist_ok=True)
with open(cache_file, "w") as f:
json.dump(embedding, f)
return embedding
For production, replace the file cache with Redis or a proper KV store. The pattern is the same.
Evaluating Your Vector Store Choice
Once you have vectors in a database, the next question is: how do you know your similarity search is actually returning good results?
This is where most developers skip a critical step. "It looks right" isn't a metric.
The LLM Evaluation Workflow Pack includes a ready-to-run RAGAS evaluation harness that works with all three databases covered here. It measures retrieval precision, recall, and faithfulness — the three metrics that actually predict whether your RAG pipeline will fail in production.
Plug it in before you ship.
What's Next
If you're building a RAG pipeline and need to go beyond the vector store setup, the next article in this series covers the full production walkthrough: chunking strategies, embedding model selection, retrieval patterns, and the common failure modes. → RAG in Python Without the Bloat: A Production Walkthrough (coming Nov 3)
peytongreen_dev — I build Python tools for developer workflows. Find my production-tested templates at kazdispatch.gumroad.com.
Top comments (0)