{"slug": "pgvector-vs-dedicated-vector-databases-a-production-benchmark", "title": "pgvector vs. Dedicated Vector Databases: A Production Benchmark", "summary": "A production benchmark comparing pgvector 0.8.6 against dedicated vector databases such as Qdrant measures throughput, latency and filtering tradeoffs to help teams decide whether to keep vector search inside Postgres or migrate to a purpose-built service. The article documents a hands-on local setup using Docker Compose with the pgvector/pgvector:pg17 image, Python 3.10 or later and roughly 8GB of free RAM, and notes pgvector supports Postgres 13 and later with single-precision, half-precision, binary and sparse vector types across five distance functions (L2, inner product, cosine, L1 and Hamming). The comparison also covers Pinecone, Weaviate, Milvus and ChromaDB as alternatives, framing the choice as an architectural one that adds a separate system to deploy, monitor, back up and synchronize.", "body_md": "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.\n\nTopics we will cover include:\n\n- What pgvector and dedicated vector databases are, and how they differ architecturally.\n- How to set up and run a hands-on benchmark comparing pgvector and Qdrant on your own machine.\n- What production-scale benchmark data actually shows about throughput, latency, and the tradeoffs between the two approaches.\n\nA 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.\n\n## What pgvector Actually Is\n\n[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.\n\nWhat 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.\n\n## What a Dedicated Vector Database Is\n\nA 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.\n\nHere’s a plain rundown of the five most commonly reached for:\n\n| Database | Best for | \n|---|---|\n| [Pinecone](https://www.pinecone.io/) | A fully managed, zero-setup cloud database, best for low-ops enterprise scale | \n| [Qdrant](https://qdrant.tech/) | A high-performance Rust-based database, best for fast queries with complex JSON payload filtering | \n| [Weaviate](https://weaviate.io/) | An open-source, AI-native system, best for built-in vectorization and hybrid BM25 search | \n| [Milvus](https://milvus.io/) | A distributed, GPU-accelerated database, best for massive, billion-scale enterprise workloads | \n| [ChromaDB](https://www.trychroma.com/) | A lightweight, embedded database, best for local prototyping and Python development | \n\n### Prerequisites\n\nEverything 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.\n\n## Setting Up pgvector Locally\n\nStart with a Docker Compose file that runs Postgres with pgvector already built in:\n\n```\n# docker-compose.yml\nservices:\n  postgres:\n    image: pgvector/pgvector:pg17\n    ports:\n      - \"5432:5432\"\n    environment:\n      - POSTGRES_PASSWORD=benchmark\n      - POSTGRES_DB=vectorbench\n\n123456789\n\n# docker-compose.ymlservices:  postgres:    image: pgvector/pgvector:pg17    ports:      - \"5432:5432\"    environment:      - POSTGRES_PASSWORD=benchmark      - POSTGRES_DB=vectorbench\n```\n\nRun **docker compose up -d**, then connect and set up the extension and table:\n\n``` python\nimport psycopg2\n\nconn = psycopg2.connect(\n    host=\"localhost\", port=5432, dbname=\"vectorbench\",\n    user=\"postgres\", password=\"benchmark\",\n)\nconn.autocommit = True\ncur = conn.cursor()\n\n# Enables the vector type and index access methods for this database\ncur.execute(\"CREATE EXTENSION IF NOT EXISTS vector;\")\n\n# 1536 dimensions matches OpenAI's text-embedding-3-small, a realistic\n# stand-in for a genuine production embedding model\ncur.execute(\"\"\"\n    CREATE TABLE IF NOT EXISTS items (\n        id bigserial PRIMARY KEY,\n        embedding vector(1536)\n    );\n\"\"\")\n\n1234567891011121314151617181920\n\nimport 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)    );\"\"\")\n```\n\n**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).\n\n## Setting Up Qdrant Locally\n\nFor the dedicated-database side of the comparison, run Qdrant with a single Docker command:\n\n```\ndocker run -d --name qdrant-bench -p 6333:6333 -p 6334:6334 qdrant/qdrant\n\n1\n\ndocker run -d --name qdrant-bench -p 6333:6333 -p 6334:6334 qdrant/qdrant\n```\n\nThen connect from Python and create a matching collection:\n\n``` python\nfrom qdrant_client import QdrantClient\nfrom qdrant_client.models import Distance, VectorParams\n\nqdrant = QdrantClient(url=\"http://localhost:6333\")\n\nqdrant.create_collection(\n    collection_name=\"items\",\n    vectors_config=VectorParams(size=1536, distance=Distance.COSINE),\n)\n\n123456789\n\nfrom 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),)\n```\n\nQdrant 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.\n\n## Generating and Loading a Real Test Dataset\n\nWith both systems running, generate a realistic set of synthetic vectors and load them into each:\n\n``` python\nimport numpy as np\n\nNUM_VECTORS = 100_000\nDIM = 1536\n\nrng = np.random.default_rng(seed=42)\nvectors = rng.normal(size=(NUM_VECTORS, DIM)).astype(np.float32)\n\n# Real embedding models produce normalized vectors, so normalize\n# these to length 1 for a realistic cosine-distance comparison\nvectors /= np.linalg.norm(vectors, axis=1, keepdims=True)\n\n1234567891011\n\nimport 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)\n```\n\nLoad into pgvector using pgvector-python’s adapter, which lets **psycopg2** accept **NumPy** arrays directly:\n\n``` python\nfrom pgvector.psycopg2 import register_vector\nfrom psycopg2.extras import execute_values\n\nregister_vector(conn)\n\ndata = [(v,) for v in vectors]\nexecute_values(cur, \"INSERT INTO items (embedding) VALUES %s\", data, template=\"(%s)\")\n\n1234567\n\nfrom 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)\")\n```\n\nLoad the same vectors into Qdrant in batches:\n\n``` python\nfrom qdrant_client.models import PointStruct\nbatch_size = 1000\nfor start in range(0, NUM_VECTORS, batch_size):\n    batch = vectors[start:start + batch_size]\n    points = [\n        PointStruct(id=start + i, vector=batch[i].tolist())\n        for i in range(len(batch))\n    ]\n    qdrant.upsert(collection_name=\"items\", points=points)\n\n123456789\n\nfrom 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)\n```\n\n**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.\n\n## Building the Indexes\n\nCreate an HNSW index in pgvector, straight from [pgvector’s own indexing documentation](https://github.com/pgvector/pgvector#hnsw):\n\n```\ncur.execute(\n    \"CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops) \"\n    \"WITH (m = 16, ef_construction = 64);\"\n)\n\n1234\n\ncur.execute(    \"CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops) \"    \"WITH (m = 16, ef_construction = 64);\")\n```\n\nApply matching HNSW parameters in Qdrant, so both systems are running the same underlying algorithm with the same settings:\n\n``` python\nfrom qdrant_client.models import HnswConfigDiff\n\nqdrant.update_collection(\n    collection_name=\"items\",\n    hnsw_config=HnswConfigDiff(m=16, ef_construct=64),\n)\n\n123456\n\nfrom qdrant_client.models import HnswConfigDiff qdrant.update_collection(    collection_name=\"items\",    hnsw_config=HnswConfigDiff(m=16, ef_construct=64),)\n```\n\n**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.\n\n## Running the Benchmark\n\nHere’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:\n\n``` python\nimport time\nimport statistics\n\nquery_vectors = rng.normal(size=(200, DIM)).astype(np.float32)\nquery_vectors /= np.linalg.norm(query_vectors, axis=1, keepdims=True)\n\ndef benchmark_pgvector(cur, query_vectors, ef_search=64, limit=10):\n    cur.execute(f\"SET hnsw.ef_search = {ef_search};\")\n    latencies = []\n    for qv in query_vectors:\n        start = time.perf_counter()\n        cur.execute(\n            \"SELECT id FROM items ORDER BY embedding <=> %s LIMIT %s\",\n            (qv, limit),\n        )\n        cur.fetchall()\n        latencies.append((time.perf_counter() - start) * 1000)  # ms\n    return latencies\n\ndef benchmark_qdrant(client, query_vectors, limit=10):\n    latencies = []\n    for qv in query_vectors:\n        start = time.perf_counter()\n        client.query_points(collection_name=\"items\", query=qv.tolist(), limit=limit)\n        latencies.append((time.perf_counter() - start) * 1000)\n    return latencies\n\ndef summarize(name, latencies, total_time):\n    latencies_sorted = sorted(latencies)\n    p50 = statistics.median(latencies_sorted)\n    p95 = latencies_sorted[int(len(latencies_sorted) * 0.95)]\n    p99 = latencies_sorted[int(len(latencies_sorted) * 0.99)]\n    qps = len(latencies) / total_time\n    print(f\"{name}: p50={p50:.2f}ms  p95={p95:.2f}ms  p99={p99:.2f}ms  QPS={qps:.1f}\")\n\nstart = time.perf_counter()\npg_latencies = benchmark_pgvector(cur, query_vectors)\nsummarize(\"pgvector\", pg_latencies, time.perf_counter() - start)\n\nstart = time.perf_counter()\nqdrant_latencies = benchmark_qdrant(qdrant, query_vectors)\nsummarize(\"Qdrant\", qdrant_latencies, time.perf_counter() - start)\n\n123456789101112131415161718192021222324252627282930313233343536373839404142\n\nimport 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)\n```\n\nA 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.\n\n**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.\n\n## What the Local Results Actually Show\n\nRun 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.\n\n## How This Holds at Production Scale\n\nThis 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/).\n\nOne 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.\n\n| Metric | pgvector + pgvectorscale | Qdrant | \n|---|---|---|\n| p50 latency @ 99% recall | 31.07 ms | 30.75 ms | \n| p95 latency @ 99% recall | 60.42 ms | 36.73 ms | \n| p99 latency @ 99% recall | 74.60 ms | 38.71 ms | \n| Throughput @ 99% recall | 471.57 QPS | 41.47 QPS | \n| p50 latency @ 90% recall | 9.54 ms | 4.74 ms | \n| Throughput @ 90% recall | 1,589.79 QPS | 360.81 QPS | \n| Index build time (50M vectors) | 11.1 hours | 3.3 hours | \n\nThe 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.\n\n## When pgvector Is Good Enough\n\nScale 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.\n\n## When to Reach for a Dedicated Vector Database\n\nThe 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.\n\n## Conclusion\n\nThe 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.", "url": "https://wpnews.pro/news/pgvector-vs-dedicated-vector-databases-a-production-benchmark", "canonical_source": "https://machinelearningmastery.com/pgvector-vs-dedicated-vector-databases-a-production-benchmark/", "published_at": "2026-10-20 11:00:00+00:00", "updated_at": "2026-10-20 14:18:23.676298+00:00", "lang": "en", "topics": ["ai-infrastructure", "ai-tools", "mlops", "developer-tools"], "entities": ["pgvector", "Postgres", "Qdrant", "Pinecone", "Weaviate", "Milvus", "ChromaDB", "Docker"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/pgvector-vs-dedicated-vector-databases-a-production-benchmark", "markdown": "https://wpnews.pro/news/pgvector-vs-dedicated-vector-databases-a-production-benchmark.md", "text": "https://wpnews.pro/news/pgvector-vs-dedicated-vector-databases-a-production-benchmark.txt", "jsonld": "https://wpnews.pro/news/pgvector-vs-dedicated-vector-databases-a-production-benchmark.jsonld"}}