# pgvector vs. Dedicated Vector Databases: A Production Benchmark

> Source: <https://machinelearningmastery.com/pgvector-vs-dedicated-vector-databases-a-production-benchmark/>
> Published: 2026-10-20 11:00:00+00:00

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](https://github.com/pgvector/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](https://github.com/pgvector/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](https://github.com/pgvector/pgvector). 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](https://www.pinecone.io/) | A fully managed, zero-setup cloud database, best for low-ops enterprise scale | 
| [Qdrant](https://qdrant.tech/) | A high-performance Rust-based database, best for fast queries with complex JSON payload filtering | 
| [Weaviate](https://weaviate.io/) | An open-source, AI-native system, best for built-in vectorization and hybrid BM25 search | 
| [Milvus](https://milvus.io/) | A distributed, GPU-accelerated database, best for massive, billion-scale enterprise workloads | 
| [ChromaDB](https://www.trychroma.com/) | 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:

```
# docker-compose.yml
services:
  postgres:
    image: pgvector/pgvector:pg17
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_PASSWORD=benchmark
      - POSTGRES_DB=vectorbench

123456789

# docker-compose.ymlservices:  postgres:    image: pgvector/pgvector:pg17    ports:      - "5432:5432"    environment:      - POSTGRES_PASSWORD=benchmark      - POSTGRES_DB=vectorbench
```

Run **docker compose up -d**, then connect and set up the extension and table:

``` python
import psycopg2

conn = psycopg2.connect(
    host="localhost", port=5432, dbname="vectorbench",
    user="postgres", password="benchmark",
)
conn.autocommit = True
cur = conn.cursor()

# Enables the vector type and index access methods for this database
cur.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 model
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](https://github.com/pgvector/pgvector#docker) 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](https://github.com/pgvector/pgvector#getting-started).

## 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:

``` python
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 Loading a Real Test Dataset

With both systems running, generate a realistic set of synthetic vectors and load them into each:

``` python
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)

# Real embedding models produce normalized vectors, so normalize
# these to length 1 for a realistic cosine-distance comparison
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:

``` python
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:

``` python
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](https://github.com/pgvector/pgvector#hnsw):

```
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:

``` python
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:

``` python
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](https://www.tigerdata.com/blog/pgvector-vs-qdrant) 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](https://ann-benchmarks.com/).

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](https://lib.rs/crates/streaming-diskann) — 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.
