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.
