Every RAG pipeline hits the same question sooner or later: where do I store the embeddings? Your Postgres database already speaks vectors through pgvector, and a dedicated engine like Qdrant promises speed at scale. Picking wrong costs you either an unnecessary second system or a painful migration six months later.
In this deep-dive I show you the minimum theory you need, a clear decision rule I use in production, and verified setup code for both engines so you can run either one today.
1. The Problem: Vectors Don’t Fit in a B-Tree
Embeddings turn text into long arrays of floats — hundreds or thousands of dimensions. A classic B-tree index cannot answer “find the 10 nearest neighbors” efficiently, so vector engines use approximate nearest neighbor search: they trade a sliver of recall for logarithmic-time queries. If you brute-force the comparison past a few thousand documents, latency collapses.
The Most Common Mistake
Adding a dedicated vector database on day one “just in case”. For most apps under a few million vectors, that second system buys you sync bugs, a second backup story, and a second bill — for performance you did not need yet.
The goal of this guide is practical: understand the one algorithm that matters, know exactly when Postgres plus pgvector stops being enough, and leave with runnable Docker, SQL, and Python code for both paths.
2. Minimum Concepts: HNSW, Distance, and Recall
Nearly every serious vector engine relies on the same algorithm, so learn it once and most vendor docs suddenly make sense. These are the only three ideas you need before touching code.
HNSW
index · approximate search
Hierarchical Navigable Small World graphs build a layered graph you traverse in roughly logarithmic time. It is greedy and memory-hungry: the graph wants to live in RAM. Both pgvector and Qdrant default to it.
Distance Metrics
cosine · L2 · inner product
pgvector exposes <=> for cosine distance, <-> for L2, and <#> for inner product, each with its own index operator class. For text embeddings, cosine distance is the usual default.
Recall vs Speed
tuning · ef_search
Approximate search trades a little accuracy for a lot of speed. At query time you tune it — pgvector with SET hnsw.ef_search, Qdrant with ef/hnsw settings — trading recall against latency.
Filtered Search
WHERE · payload filters
Production RAG almost never searches the whole corpus: you filter by tenant, permissions, or date first. Naive post-filtering wrecks recall on selective filters, which is where engine choice actually bites.
The One Paragraph to Remember
HNSW gives you fast approximate search if the graph fits in RAM. Distance metric must match your embedding model — cosine for text. And filtered search quality, not raw speed, is what separates the engines in production.
3. pgvector vs Qdrant: The Decision Rule
Both are open source and both index with HNSW. The difference is operational: pgvector adds vectors to the Postgres you already run, Qdrant is a second service written in Rust that is purpose-built for vector workloads at scale.
🟦 Choose pgvector when…
- • You already run Postgres and hold under roughly a few million vectors — one migration, zero new infrastructure.
- • Vectors must stay consistent with relational data: same transaction, same backups, SQL joins and WHERE filters for free.
- • Your team is small and every extra stateful service costs attention you do not have.
🟧 Choose Qdrant when…
- • You scale toward tens or hundreds of millions of vectors and need sharding plus replication across nodes.
- • Heavily filtered search is the heart of your product — its filter-aware HNSW traversal holds latency where post-filtering degrades.
- • Memory is the binding constraint: scalar, product, and binary quantization with rescoring keep huge indexes resident.
⚖️ My Default Rule for 2026
- • Already on Postgres with under ~10M vectors? Use pgvector on a recent version — iterative index scans closed most of the old filtering gap.
- • Pure vector workload at very large scale, or selective filters over a huge corpus? Qdrant earns its place.
- • Migrating later is a data-loading exercise, not a re-architecture — start simple and move when metrics tell you to.
Tip: Keep the Migration Path Open
Store the source text next to every vector and keep your embedding pipeline portable. Then moving from pgvector to Qdrant later is a bulk-load plus a query rewrite, not a rewrite of your app.
4. Tutorial: Running Both in 15 Minutes
Theory is done — let us run both engines. Every command below is verified against the official docs linked in Sources, so copy with confidence.
Step 1 — Start Qdrant with Docker
Pull the official image and run it. Under the default configuration all data lands in ./qdrant_storage, and the dashboard appears at localhost:6333/dashboard.
docker pull qdrant/qdrant
docker run -p 6333:6333 -p 6334:6334 \
-v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
qdrant/qdrantStep 2 — Enable pgvector in Postgres
One statement adds the vector type. Then create an HNSW index per distance function you query — cosine below — and tune recall at query time with hnsw.ef_search.
CREATE EXTENSION vector;
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
content TEXT,
embedding vector(1536)
);
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops);
-- higher = better recall, slightly slower
SET hnsw.ef_search = 100;
SELECT content
FROM documents
ORDER BY embedding <=> $1
LIMIT 5;Step 3 — Talk to Qdrant from Python
Install the official client, point it at your local container, and insert plus query documents. The add/query helpers embed text for you via FastEmbed; the search call shows a metadata-filtered query.
pip install 'qdrant-client[fastembed]'
from qdrant_client import QdrantClient
client = QdrantClient(url="http://localhost:6333")
client.add(
collection_name="demo_collection",
documents=["Qdrant has Langchain integrations",
"Qdrant also has Llama Index integrations"],
)
results = client.query(
collection_name="demo_collection",
query_text="vector search integrations",
limit=3,
)
print(results)Filtered Search Example
For permission or tenant scoping, pass a Filter with FieldCondition and MatchValue to the unified query_points call (1.10+, verified on client 1.19.0) — query accepts a dense vector, a sparse vector, or a hybrid fusion query, so one method replaces the legacy client.search. Filtering happens inside the index traversal instead of as a post-filter, which is Qdrant's real edge at scale, and with_payload=True returns the matching payload.
from qdrant_client.http.models import Filter, FieldCondition, MatchValue
results = client.query_points(
collection_name="demo_collection",
query=[0.2, 0.1, 0.9, 0.7],
query_filter=Filter(
must=[FieldCondition(
key="city",
match=MatchValue(value="London")
)]
),
with_payload=True,
limit=5,
).points5. Common Mistakes in Production
The demo always works. These are the unglamorous details that bite once real traffic and real data arrive — budget for them now.
Undersized RAM for HNSW
The HNSW graph wants to live in RAM. Underprovision memory and performance collapses long before any benchmark number applies. Size for the graph, not just the rows.
Two Systems, No Sync Story
Documents in Postgres plus vectors in Qdrant means every write touches two stores. Without retry logic or a reconciliation job you get orphaned vectors or invisible documents.
Never Tuning ef_search
Defaults are a starting point, not a decision. Leaving hnsw.ef_search at 40 while complaining about recall is like leaving the handbrake on and blaming the engine.
Trusting Vendor Benchmarks Blindly
Published numbers disagree because they measure different scales, recalls, and metrics — throughput vs tail latency. Reproduce the test on your own data before deciding.
No Recall Monitoring
As you add, update, and delete vectors, retrieval quality can drift silently until a reindex. Monitor retrieval quality, not just uptime and latency.
Golden rule
Start with pgvector until it hurts, and define “hurts” with numbers: p99 latency on filtered queries, RAM headroom, and index build time. Migrate to Qdrant when the metrics cross your threshold — not when the hype does.
Sources
- pgvector on GitHub — README: CREATE EXTENSION vector, HNSW indexing
- Qdrant docs — Local quickstart with Docker and Python client
- Qdrant python client — quickstart notebook (add, query, filtered search)
- pgvector 0.8.0 release — iterative index scans for filtered search
- Supabase docs — HNSW indexes in pgvector
Conclusion
For most RAG apps in 2026, pgvector inside the Postgres you already run is the honest default: one system, transactions, SQL filtering, and HNSW speed that covers you into the millions of vectors. Qdrant is the excellent specialist you reach for when scale, selective filtered search, or aggressive quantization genuinely demand a dedicated engine.
Run the tutorial above this week: stand up both, load a slice of your real corpus, and measure filtered p99 on your own queries. That one experiment is worth more than ten benchmark blog posts — and if you want help designing that evaluation, get in touch.
Your Action Plan This Week
Learn
- • HNSW in one sitting
- • Cosine vs L2 vs IP
- • Recall-latency tradeoff
Build
- • Qdrant via Docker
- • pgvector HNSW index
- • Python add + query
Measure
- • Filtered p99 latency
- • RAM headroom
- • Recall at your LIMIT



