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.