In this article, you will learn how pgvector compares to dedicated vector databases in terms of real, measured performance, and how to decide which approach suits your production workload.
Topics we will cover include:
- What pgvector and dedicated vector databases are, and how they differ architecturally.
- How to set up and run a hands-on benchmark comparing pgvector and Qdrant on your own machine.
- What production-scale benchmark data actually shows about throughput, latency, and the tradeoffs between the two approaches.
A team adds pgvector to their existing Postgres database on a Tuesday afternoon, embeddings and relational data sitting in the same table, one query doing both the filter and the similarity search. Six months later, a vendor pitch lands in their inbox insisting they need to migrate to a “real” vector database before they hit a wall. No benchmark accompanies the claim in either direction, just confidence. That gap — strong opinions with no numbers behind them — is exactly what this article exists to close.
What pgvector Actually Is #
pgvector is an open-source Postgres extension, currently at version 0.8.6 and supporting Postgres 13 and later, according to its official GitHub repository. It adds a native vector column type to Postgres, along with exact and approximate nearest-neighbor search, directly inside the database you’re likely already running.
What that means in practice: a vector embedding lives in the same table as the record it describes, in the same row if you want, queried with the same SQL you already know. There’s no separate service to stand up, no second system to keep in sync, no network hop between your relational data and your vector data. pgvector supports single-precision, half-precision, binary, and sparse vector types, and five distance functions — L2, inner product, cosine, L1, and Hamming — covering essentially every distance metric a real embedding model uses.
What a Dedicated Vector Database Is #
A dedicated vector database is a separate, purpose-built service, run as its own process with its own storage engine, designed from the ground up around vector search rather than having it added onto a general-purpose database. It’s a genuinely different architectural choice, not just a different product, since it means a new system to deploy, monitor, back up, and keep synchronized with whatever holds your actual source of truth.
Here’s a plain rundown of the five most commonly reached for:
| Database | Best for |
|---|---|
| Pinecone | A fully managed, zero-setup cloud database, best for low-ops enterprise scale |
| Qdrant | A high-performance Rust-based database, best for fast queries with complex JSON payload filtering |
| Weaviate | An open-source, AI-native system, best for built-in vectorization and hybrid BM25 search |
| Milvus | A distributed, GPU-accelerated database, best for massive, billion-scale enterprise workloads |
| ChromaDB | A lightweight, embedded database, best for local prototyping and Python development |
Prerequisites
Everything in this tutorial runs entirely on your own machine — no cloud account required. You’ll need Docker and Docker Compose installed, Python 3.10 or later, roughly 8GB of free RAM, and basic comfort reading SQL and Python. The hands-on benchmark below is sized to run comfortably on a laptop.
Setting Up pgvector Locally #
Start with a Docker Compose file that runs Postgres with pgvector already built in:
services:
postgres:
image: pgvector/pgvector:pg17
ports:
- "5432:5432"
environment:
- POSTGRES_PASSWORD=benchmark
- POSTGRES_DB=vectorbench
123456789
Run docker compose up -d, then connect and set up the extension and table:
import psycopg2
conn = psycopg2.connect(
host="localhost", port=5432, dbname="vectorbench",
user="postgres", password="benchmark",
)
conn.autocommit = True
cur = conn.cursor()
cur.execute("CREATE EXTENSION IF NOT EXISTS vector;")
cur.execute("""
CREATE TABLE IF NOT EXISTS items (
id bigserial PRIMARY KEY,
embedding vector(1536)
);
""")
1234567891011121314151617181920
import psycopg2 conn = psycopg2.connect( host="localhost", port=5432, dbname="vectorbench", user="postgres", password="benchmark",)conn.autocommit = Truecur = conn.cursor() # Enables the vector type and index access methods for this databasecur.execute("CREATE EXTENSION IF NOT EXISTS vector;") # 1536 dimensions matches OpenAI's text-embedding-3-small, a realistic# stand-in for a genuine production embedding modelcur.execute(""" CREATE TABLE IF NOT EXISTS items ( id bigserial PRIMARY KEY, embedding vector(1536) );""")
pgvector/pgvector:pg17 is the official Docker image that bundles the extension with Postgres 17, so there’s no separate compile step. CREATE EXTENSION IF NOT EXISTS vector is the one-time step that registers the vector type and its index methods in this specific database, per the pgvector documentation.
Setting Up Qdrant Locally #
For the dedicated-database side of the comparison, run Qdrant with a single Docker command:
docker run -d --name qdrant-bench -p 6333:6333 -p 6334:6334 qdrant/qdrant
1
docker run -d --name qdrant-bench -p 6333:6333 -p 6334:6334 qdrant/qdrant
Then connect from Python and create a matching collection:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
qdrant = QdrantClient(url="http://localhost:6333")
qdrant.create_collection(
collection_name="items",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)
123456789
from qdrant_client import QdrantClientfrom qdrant_client.models import Distance, VectorParams qdrant = QdrantClient(url="http://localhost:6333") qdrant.create_collection( collection_name="items", vectors_config=VectorParams(size=1536, distance=Distance.COSINE),)
Qdrant is worth picking specifically for this comparison because it’s the subject of the most detailed, methodologically transparent public benchmark against pgvector available, which this article draws on directly in a later section. VectorParams(size=1536, distance=Distance.COSINE) mirrors the same dimensionality and distance metric used in the pgvector table above, which matters — a fair comparison needs both systems measuring the same thing.
Generating and a Real Test Dataset #
With both systems running, generate a realistic set of synthetic vectors and load them into each:
import numpy as np
NUM_VECTORS = 100_000
DIM = 1536
rng = np.random.default_rng(seed=42)
vectors = rng.normal(size=(NUM_VECTORS, DIM)).astype(np.float32)
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
1234567891011
import numpy as np NUM_VECTORS = 100_000DIM = 1536 rng = np.random.default_rng(seed=42)vectors = rng.normal(size=(NUM_VECTORS, DIM)).astype(np.float32) # Real embedding models produce normalized vectors, so normalize# these to length 1 for a realistic cosine-distance comparisonvectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
Load into pgvector using pgvector-python’s adapter, which lets psycopg2 accept NumPy arrays directly:
from pgvector.psycopg2 import register_vector
from psycopg2.extras import execute_values
register_vector(conn)
data = [(v,) for v in vectors]
execute_values(cur, "INSERT INTO items (embedding) VALUES %s", data, template="(%s)")
1234567
from pgvector.psycopg2 import register_vectorfrom psycopg2.extras import execute_values register_vector(conn) data = [(v,) for v in vectors]execute_values(cur, "INSERT INTO items (embedding) VALUES %s", data, template="(%s)")
Load the same vectors into Qdrant in batches:
from qdrant_client.models import PointStruct
batch_size = 1000
for start in range(0, NUM_VECTORS, batch_size):
batch = vectors[start:start + batch_size]
points = [
PointStruct(id=start + i, vector=batch[i].tolist())
for i in range(len(batch))
]
qdrant.upsert(collection_name="items", points=points)
123456789
from qdrant_client.models import PointStructbatch_size = 1000for start in range(0, NUM_VECTORS, batch_size): batch = vectors[start:start + batch_size] points = [ PointStruct(id=start + i, vector=batch[i].tolist()) for i in range(len(batch)) ] qdrant.upsert(collection_name="items", points=points)
register_vector(conn) is the key line on the pgvector side — it teaches psycopg2 how to convert a NumPy array into the format pgvector expects, so execute_values can insert raw arrays without manual string formatting. On the Qdrant side, upsert is batched deliberately at 1,000 vectors per call rather than one giant request, since Qdrant’s client, like most vector database clients, performs better with reasonably sized batches than either single inserts or one massive payload.
Building the Indexes #
Create an HNSW index in pgvector, straight from pgvector’s own indexing documentation:
cur.execute(
"CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops) "
"WITH (m = 16, ef_construction = 64);"
)
1234
cur.execute( "CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops) " "WITH (m = 16, ef_construction = 64);")
Apply matching HNSW parameters in Qdrant, so both systems are running the same underlying algorithm with the same settings:
from qdrant_client.models import HnswConfigDiff
qdrant.update_collection(
collection_name="items",
hnsw_config=HnswConfigDiff(m=16, ef_construct=64),
)
123456
from qdrant_client.models import HnswConfigDiff qdrant.update_collection( collection_name="items", hnsw_config=HnswConfigDiff(m=16, ef_construct=64),)
m controls the maximum number of connections per node in the HNSW graph, and ef_construction controls how thorough the search is while building it. Both are set to 16 and 64 here — pgvector’s own defaults — deliberately mirrored on the Qdrant side. This matters more than it might seem: comparing two systems running different index parameters isn’t a benchmark, it’s two separate, incomparable results. Matching them is what makes the numbers in the next section mean anything.
Running the Benchmark #
Here’s the actual measurement script, querying both systems with a fresh, held-out set of query vectors that weren’t part of the loaded dataset:
import time
import statistics
query_vectors = rng.normal(size=(200, DIM)).astype(np.float32)
query_vectors /= np.linalg.norm(query_vectors, axis=1, keepdims=True)
def benchmark_pgvector(cur, query_vectors, ef_search=64, limit=10):
cur.execute(f"SET hnsw.ef_search = {ef_search};")
latencies = []
for qv in query_vectors:
start = time.perf_counter()
cur.execute(
"SELECT id FROM items ORDER BY embedding <=> %s LIMIT %s",
(qv, limit),
)
cur.fetchall()
latencies.append((time.perf_counter() - start) * 1000) # ms
return latencies
def benchmark_qdrant(client, query_vectors, limit=10):
latencies = []
for qv in query_vectors:
start = time.perf_counter()
client.query_points(collection_name="items", query=qv.tolist(), limit=limit)
latencies.append((time.perf_counter() - start) * 1000)
return latencies
def summarize(name, latencies, total_time):
latencies_sorted = sorted(latencies)
p50 = statistics.median(latencies_sorted)
p95 = latencies_sorted[int(len(latencies_sorted) * 0.95)]
p99 = latencies_sorted[int(len(latencies_sorted) * 0.99)]
qps = len(latencies) / total_time
print(f"{name}: p50={p50:.2f}ms p95={p95:.2f}ms p99={p99:.2f}ms QPS={qps:.1f}")
start = time.perf_counter()
pg_latencies = benchmark_pgvector(cur, query_vectors)
summarize("pgvector", pg_latencies, time.perf_counter() - start)
start = time.perf_counter()
qdrant_latencies = benchmark_qdrant(qdrant, query_vectors)
summarize("Qdrant", qdrant_latencies, time.perf_counter() - start)
123456789101112131415161718192021222324252627282930313233343536373839404142
import timeimport statistics query_vectors = rng.normal(size=(200, DIM)).astype(np.float32)query_vectors /= np.linalg.norm(query_vectors, axis=1, keepdims=True) def benchmark_pgvector(cur, query_vectors, ef_search=64, limit=10): cur.execute(f"SET hnsw.ef_search = {ef_search};") latencies = [] for qv in query_vectors: start = time.perf_counter() cur.execute( "SELECT id FROM items ORDER BY embedding <=> %s LIMIT %s", (qv, limit), ) cur.fetchall() latencies.append((time.perf_counter() - start) * 1000) # ms return latencies def benchmark_qdrant(client, query_vectors, limit=10): latencies = [] for qv in query_vectors: start = time.perf_counter() client.query_points(collection_name="items", query=qv.tolist(), limit=limit) latencies.append((time.perf_counter() - start) * 1000) return latencies def summarize(name, latencies, total_time): latencies_sorted = sorted(latencies) p50 = statistics.median(latencies_sorted) p95 = latencies_sorted[int(len(latencies_sorted) * 0.95)] p99 = latencies_sorted[int(len(latencies_sorted) * 0.99)] qps = len(latencies) / total_time print(f"{name}: p50={p50:.2f}ms p95={p95:.2f}ms p99={p99:.2f}ms QPS={qps:.1f}") start = time.perf_counter()pg_latencies = benchmark_pgvector(cur, query_vectors)summarize("pgvector", pg_latencies, time.perf_counter() - start) start = time.perf_counter()qdrant_latencies = benchmark_qdrant(qdrant, query_vectors)summarize("Qdrant", qdrant_latencies, time.perf_counter() - start)
A few things worth understanding here. The 200 query vectors are freshly generated, not pulled from the 100,000 already loaded, since querying with a vector that’s literally already in the index trivially returns itself at zero distance, which would make the benchmark meaningless.
SET hnsw.ef_search = 64 controls how thorough pgvector’s search is at query time — higher values trade speed for better recall, and setting it explicitly rather than relying on the default makes the test reproducible. embedding <=> %s is pgvector’s cosine distance operator, matching the Distance.COSINE setting used on the Qdrant collection. And client.query_points() is Qdrant’s current recommended search method, replacing the older search() call in recent versions of the client. The summarize function computes the same three percentiles — p50, p95, p99 — plus queries-per-second for both systems, so the output is directly comparable line for line.
What the Local Results Actually Show #
Run this on a typical laptop and both systems will report latencies in the low single-digit to tens of milliseconds range, with the exact numbers depending heavily on your CPU, available RAM, and whatever else is running on your machine at the time. That variability is the honest point of this section: 100,000 vectors on a laptop is a fine way to learn the mechanics of both systems and confirm your setup works correctly, but it’s genuinely too small a scale to draw a real production conclusion from. Both systems are fast enough at this size that the difference between them barely matters yet. The real question — which one holds up as data and query load actually grow — needs numbers gathered at real production scale, not a laptop benchmark.
How This Holds at Production Scale #
This is where Tiger Data’s published benchmark becomes genuinely useful, since it’s the most detailed, methodology-transparent public comparison available. They tested 50 million real Cohere embeddings at 768 dimensions, on matched AWS r6id.4xlarge hardware (16 vCPUs, 128GB RAM) for both systems, using a modified fork of the industry-standard ANN-Benchmarks tool.
One important accuracy note before the numbers: this specific benchmark used Postgres with pgvector plus pgvectorscale — Timescale’s own additional extension that adds a different index type called StreamingDiskANN — not vanilla pgvector alone. That distinction gets blurred in a lot of secondary coverage of this benchmark, and it matters, since the numbers below reflect that combined setup.
| Metric | pgvector + pgvectorscale | Qdrant |
|---|---|---|
| p50 latency @ 99% recall | 31.07 ms | 30.75 ms |
| p95 latency @ 99% recall | 60.42 ms | 36.73 ms |
| p99 latency @ 99% recall | 74.60 ms | 38.71 ms |
| Throughput @ 99% recall | 471.57 QPS | 41.47 QPS |
| p50 latency @ 90% recall | 9.54 ms | 4.74 ms |
| Throughput @ 90% recall | 1,589.79 QPS | 360.81 QPS |
| Index build time (50M vectors) | 11.1 hours | 3.3 hours |
The pattern that emerges is genuinely two-sided, not a clean win for either system. At 99% recall, Postgres with pgvectorscale delivered 11.4 times the query throughput of Qdrant — 471.57 QPS against 41.47. Qdrant, in turn, held a real, measurable advantage on tail latency: 48% better p99 (38.71ms vs. 74.60ms), meaning its slowest queries stayed noticeably faster even though its overall capacity was lower. Qdrant also built its index roughly 3.4 times faster. Both systems kept every measured percentile under 100 milliseconds at 99% recall on a 50-million-vector dataset, which is itself worth sitting with — since it directly undercuts the assumption that a general-purpose database can’t keep up at real scale.
When pgvector Is Good Enough #
Scale under roughly 5 to 10 million vectors is comfortably within pgvector’s range on reasonable hardware. A genuine need for transactional consistency between your relational data and its embeddings — updating a document and its vector in the same atomic transaction — is a real structural advantage pgvector has that a separate database simply can’t offer. An existing Postgres investment, a team that already knows how to operate, monitor, and back it up, means adding vector search costs an extension and an index, not a new system. And moderate query concurrency, rather than thousands of simultaneous queries per second, keeps you well within what a single well-tuned Postgres instance handles comfortably.
When to Reach for a Dedicated Vector Database #
The other side of the line is equally concrete. Genuinely massive scale — hundreds of millions to billions of vectors — is where purpose-built distributed architectures earn their operational cost. A hard tail-latency SLA under heavy concurrent filtered search, the kind of requirement where a p99 above 25 to 40 milliseconds is a real problem, favors Qdrant’s demonstrated tail-latency advantage specifically. GPU-accelerated indexing needs point toward Milvus, the one system on the list built around that from the ground up. And a genuine organizational preference for zero-operations managed infrastructure — no database expertise on the team, no appetite for running and tuning anything — is exactly what a fully managed service like Pinecone exists to remove from your plate entirely.
Conclusion #
The benchmark data in this article doesn’t support either of the clean stories people tend to reach for — “pgvector is always fine” or “** dedicated databases always win**.” It shows two systems with genuinely different tradeoffs measured under matched, transparent conditions: one wins decisively on throughput, the other wins decisively on tail latency, and both comfortably clear the bar most real applications actually need. The honest answer to “which one should I use” was never going to be a single name. It’s whichever side of that tradeoff your actual workload — not a vendor’s pitch — actually sits on.