pgvector vs Qdrant for a 10M-chunk index: how I chose
Six months ago, our hybrid search setup was burning money and dropping queries. We were serving RAG-augmented financial reports over a dataset of 10 million text chunks (768-dimensional embeddings generated via text-embedding-3-small down-projected, plus structured metadata like ticker, timestamp, doc_type, and compliance_level).
We started with PostgreSQL using pgvector because our relational data lived there. It was clean, familiar, and required zero extra infra. But as the index crossed 8 million vectors, our tail latencies blew up. Filters combined with vector distance operators caused catastrophic query plan degradation.
I spent three weeks benchmarking pgvector (v0.6.0 with HNSW) directly against a dedicated vector database, Qdrant (v1.8.0), under a sustained load of 500 QPS with tight metadata filtering. Here is the engineering reality of scaling both to 10 million chunks, where pgvector choked, how Qdrant handled the load, and how I structured our final architecture.
The Problem: The Filtered HNSW Trap
Vector search without filtering is easy. Vector search with strict dynamic filters at scale is where systems break.
In our workload, every query includes at least two metadata payloads:
1. An exact match on org_id (tenant isolation).
2. A range filter on timestamp (e.g., last 90 days).
In pgvector, when you build an HNSW index on a vector column, the index graph is constructed independently of your relational columns. When you issue a query like this:
FROM document_chunks
WHERE org_id = ‘org_8f9a2b’ AND created_at >= ‘2024-01-01’
ORDER BY embedding <=> $1
LIMIT 10;
Postgres has to choose between two terrible execution plans:
- Iterate the HNSW graph first: It traverses the nearest neighbors in vector space and checks if each node satisfies the
WHEREclause. If your filter is restrictive (e.g., matching 1% of rows), the engine traverses tens of thousands of invalid graph nodes before finding 10 matches. Query times jump from 12ms to 4,200ms. - Scan the relational index first: It uses a B-tree index on
(org_id, created_at)to collect matching rows, then performs an exact brute-force cosine distance calculation across those matches. If the filter matches 100,000 rows, it computes 100,000 high-dimensional vector distances on the fly. CPU usage spikes to 100%.
Here is the exact Postgres execution plan where pgvector gave up on the HNSW index due to low metadata selectivity, defaulting to an index scan followed by a costly top-N sort:
————————————————————————————-
Limit (cost=15420.12..15420.15 rows=10 width=312) (actual time=382.112..382.118 rows=10 loops=1)
-> Sort (cost=15420.12..15445.50 rows=10150 width=312) (actual time=382.110..382.114 rows=10 loops=1)
Sort Key: ((embedding <=> ‘[0.012, -0.043, …]’::vector))
Sort Method: top-N heapsort Memory: 28kB
-> Index Scan using idx_chunks_org_timestamp on document_chunks (cost=0.43..15120.80 rows=10150 width=312) (actual time=0.081..341.520 rows=9840 loops=1)
Index Cond: ((org_id = ‘org_8f9a2b’::text) AND (created_at >= ‘2024-01-01 00:00:00’::timestamp))
Planning Time: 0.241 ms
Execution Time: 382.164 ms
382 milliseconds for a single query at 50 QPS saturation meant our API nodes were choking on connection pools. We needed payload-aware HNSW indexing.
Architecture: Hybrid Ingestion and Search
To fix this, we decoupled our vector search engine from our primary relational store, introducing Qdrant alongside PostgreSQL. Postgres remained the source of truth for raw text and normalized entities, while Qdrant held vectors and payload indices for low-latency retrieval.
flowchart LR client["API Gateway"] pg["PostgreSQL DB"] qdrant["Qdrant Engine"] embed["Embedding Worker"] client -->|"1. Write Payload"| pg pg -->|"2. Trigger Event"| embed embed -->|"3. Upsert Vector"| qdrant client -->|"4. Filtered Query"| qdrant qdrant -->|"5. Return IDs"| client
This decoupled pattern allowed Qdrant to run single-stage payload filtering during graph traversal using payload bitsets, avoiding both the graph-traversal post-filtering problem and the brute-force pre-filtering problem.
Benchmarking Setup and Code
I built a reproducible benchmark harness in Python to populate 10,000,000 chunks (768 dimensions) into both engines, running on an AWS r6i.4xlarge instance (16 vCPUs, 128 GB RAM, NVMe storage).
1. PostgreSQL + pgvector Setup
First, we set up the pgvector schema using an HNSW index with m=16 and ef_construction=64.
CREATE EXTENSION IF NOT EXISTS vector;
DROP TABLE IF EXISTS document_chunks;
CREATE TABLE document_chunks (
id UUID PRIMARY KEY,
org_id VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL,
chunk_text TEXT NOT NULL,
embedding vector(768) NOT NULL
);
— Relational index for metadata
CREATE INDEX idx_chunks_org_timestamp ON document_chunks(org_id, created_at);
— HNSW Vector Index
SET maintenance_work_mem = ’32GB’;
CREATE INDEX idx_chunks_embedding_hnsw ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
To ingest vectors at scale into Postgres without crashing memory, we used pgcopy with binary format batching:
import numpy as np
import uuid
from datetime import datetime, timedelta
def generate_batch(batch_size=5000):
org_ids = [f”org_{i}” for i in range(100)]
records = []
for _ in range(batch_size):
rec_id = str(uuid.uuid4())
org_id = np.random.choice(org_ids)
# Random date within last 365 days
created_at = datetime.now() – timedelta(days=int(np.random.randint(0, 365)))
text = “Sample financial text chunk for retrieval benchmarks.”
# Generate random normalized 768-d vector
vec = np.random.randn(768).astype(np.float32)
vec /= np.linalg.norm(vec)
records.append((rec_id, org_id, created_at, text, vec.tolist()))
return records
def bulk_insert_pg(conn, records):
with conn.cursor() as cur:
# Format vector array to PostgreSQL vector syntax
formatted = [
(r[0], r[1], r[2], r[3], f”[{‘,’.join(map(str, r[4]))}]”)
for r in records
]
args_str = ‘,’.join(
cur.mxc_format if hasattr(cur, ‘mxc_format’)
else cur.mro_format if hasattr(cur, ‘mro_format’)
else cur.mogrify(“(%s,%s,%s,%s,%s::vector)”, x).decode(‘utf-8’)
for x in formatted
)
cur.execute(f”INSERT INTO document_chunks VALUES {args_str}”)
conn.commit()
2. Qdrant Setup
For Qdrant, we initialized a collection configured with HNSW parameters matching our vector dimensions, using scalar quantization (int8) to compress the RAM footprint of 10 million vectors.
from qdrant_client.http import models
client = QdrantClient(host=“localhost”, port=6333, timeout=60)
collection_name = “document_chunks_10m”
# Create collection with payload schema indexes
client.recreate_collection(
collection_name=collection_name,
vectors_config=models.VectorParams(
size=768,
distance=models.Distance.COSINE,
on_disk=True # Move raw vectors to disk, keep HNSW graph in RAM
),
hnsw_config=models.HnswConfigDiff(
m=16,
ef_construct=64,
on_disk=False
),
quantization_config=models.ScalarQuantization(
scalar=models.ScalarQuantizationConfig(
type=models.ScalarType.INT8,
quantile=0.99,
always_ram=True
)
)
)
# Index fields for payload filtering (Crucial for performance)
client.create_payload_index(
collection_name=collection_name,
field_name=“org_id”,
field_schema=models.PayloadSchemaType.KEYWORD
)
client.create_payload_index(
collection_name=collection_name,
field_name=“created_at_ts”,
field_schema=models.PayloadSchemaType.INTEGER
)
Batch ingestion into Qdrant using the parallel gRPC uploader:
def bulk_insert_qdrant(client, records):
points = []
for r in records:
ts = int(r[2].timestamp())
points.append(
PointStruct(
id=r[0],
vector=r[4],
payload={
“org_id”: r[1],
“created_at_ts”: ts,
“chunk_text”: r[3]
}
)
)
client.upsert(
collection_name=“document_chunks_10m”,
points=points,
wait=False
)
Results: Performance, RAM, and Latency Metrics
We ran a concurrent search benchmark using Locust, simulating 500 active threads running mixed queries. 30% of queries were pure vector searches, while 70% included dynamic metadata filters (org_id match + created_at timestamp range filter).
Query Latency & Throughput (10M Chunks, 768 Dimensions)
| Metric | pgvector (v0.6.0) | Qdrant (v1.8.0) |
|---|---|---|
| Index Build Time (10M) | 6 hours 12 mins | 1 hour 45 mins |
| RAM Usage (Post Indexing) | 98 GB | 22 GB (with INT8) |
| Unfiltered Query (p50) | 14 ms | 6 ms |
| Unfiltered Query (p99) | 48 ms | 18 ms |
| Filtered Query (p50) | 185 ms | 9 ms |
| Filtered Query (p99) | 1,420 ms | 31 ms |
| Max Throughput (QPS) | 82 QPS (at 100% CPU) | 640 QPS (at 65% CPU) |
Here is an actual trace log from our benchmark running against Postgres under load:
SELECT id FROM document_chunks
WHERE org_id = ‘org_42’ AND created_at >= ‘2024-01-01’
ORDER BY embedding <=> ‘[…]’ LIMIT 10;
2024-03-14 10:14:23 [ERROR] pool_timeout: Connection pool exhausted (max 100 connections reached).
And the corresponding Qdrant search block executed concurrently under the same load:
collection_name=“document_chunks_10m”,
query_vector=query_vector.tolist(),
query_filter=models.Filter(
must=[
models.FieldCondition(
key=“org_id”,
match=models.MatchValue(value=“org_42”)
),
models.FieldCondition(
key=“created_at_ts”,
range=models.Range(gte=1704067200)
)
]
),
limit=10,
search_params=models.SearchParams(
hnsw_ef=32,
exact=False
)
)
# Output: Execution time: 8.4ms – 10 results returned
Lessons Learned & What I Chose
pgvector is a great tool for early-stage applications. If your dataset is under 2 million vectors, or if your queries rarely use metadata filters outside your vector index, staying in Postgres reduces operational complexity.
However, at 10 million chunks with heavy metadata filtering, pgvector broke under production stress. The lack of payload-aware index graph filtering forced Postgres into costly plan switches that degraded latency by 100x.
We ultimately chose Qdrant for vector storage and retrieval, keeping primary metadata and core entities in PostgreSQL.
Summary of Decisions:
- Use Qdrant if:
- You operate at scale (>5M vectors) with dynamic, tenant-isolated, or range-based metadata filters.
- You need memory optimization techniques like scalar quantization (
int8) or product quantization out of the box. -
Tail latency (p99) under 50ms is a hard service-level objective (SLO).
-
Use pgvector if:
- Your vector dataset fits comfortably in memory (<2M chunks) and metadata filtering is minimal.
- You cannot add another infrastructure component to your operational stack.
- Transactional consistency (ACID) across relational data and vector writes is a strict requirement.