Four vector databases went onto one box with the same 1.18 million vector dataset and the same HNSW settings. At matched recall the gap between the fastest and slowest on query latency was roughly 2x, on disk it was 4x, and on peak memory it was more than 4x. Those spreads, not the marketing pages, are what decide which engine belongs in a retrieval stack.
The candidates are the four most common self-hosted choices: Qdrant, Weaviate, Milvus, and pgvector, the vector extension for PostgreSQL. Picking the best vector database for a self-hosted RAG stack is usually framed as a feature checklist, but at a million vectors the operational numbers separate them more than the feature lists do. This is a measured comparison: same data, same index, same queries, every figure produced by a script that runs end to end. If you want the background on why cosine similarity search matters here, the difference between vector search and keyword search is covered separately.
Benchmarked September 2026 on Ubuntu 24.04, one 1.18M-vector glove-100 cosine dataset run against all four engines.
How the four engines were tested
One dataset drives every number here: glove-100-angular, the 1,183,514-vector, 100-dimension set from the ann-benchmarks project, shipped with a 10,000-query test set and exact 100-nearest-neighbor ground truth. Distance is cosine, which is what real embedding models produce, so recall maps to what a retrieval pipeline actually sees rather than to a synthetic toy.
Every engine got the same index type and the same knobs: HNSW with m=16 and ef_construction=200. Queries ask for the top 10, single client, one after another, over all 10,000 test vectors after a warmup. The ef_search parameter was swept across 32, 64, 128, and 256 so each engine produces a recall-versus-latency curve instead of one cherry-picked point. The honest comparison is latency at matched recall: read each engine at the ef that lands recall@10 near the same value, then compare the milliseconds.
The rig was a single Ubuntu 24.04 box, 8 vCPU and 16 GB RAM, each engine in Docker and run one at a time so RAM and disk readings belong to that engine alone. The builds were pinned so the numbers reproduce: PostgreSQL 17 with pgvector, Qdrant 1.19, Weaviate 1.27, and Milvus 2.5. Qdrant 1.19 was the current release at test time; since then Milvus has jumped to a 3.x line and Weaviate to a newer 1.3x line, so today’s latest images are different builds, and Milvus in particular reworked its architecture across that major version. Absolute numbers move with releases. The matched-parameter method and the operational findings below are the durable part. That box is a floor for reproducing the test, not a production recommendation. The real sizing driver for any HNSW store is memory: the vectors plus the graph have to sit in RAM for latency to stay flat, so a production node is sized from the working set (vector count times dimensions times four bytes, plus roughly a third for the graph), not from this lab.
Two limits are worth stating up front. Latency here is single-client and sequential, so it measures per-query response time, not maximum throughput under concurrency. And the vectors are 100-dimension; real embeddings at 768 or 1536 dimensions shift the absolute numbers, though the relative ordering holds. Matched parameters look like this for pgvector, which builds the graph as a Postgres index and reads ef_search as a session setting:
CREATE INDEX ON items USING hnsw (emb vector_cosine_ops) WITH (m = 16, ef_construction = 200);
SET hnsw.ef_search = 128;
Qdrant, Weaviate, and Milvus take the same m and ef_construction at collection-create time and the same ef at query time through their clients. The full harness (dataset download, every engine, the CSV that produced the charts below) is a single set of scripts, so the run reproduces end to end.
Ingest and index build time
Getting a million vectors queryable is two phases: load the data, then build the HNSW graph. The engines split that work differently, so the fair metric is total time to a queryable index, not the load phase alone.

Milvus reached a queryable index fastest at about 4.8 minutes, with a 13-second buffered load and a 275-second build. Qdrant took about 6 minutes, Weaviate about 7. pgvector was slowest by a wide margin at roughly 11 minutes, and almost all of that was one phase: the COPY of 1.18M rows finished in 45 seconds, but the parallel HNSW build ran for 617 seconds. The bars are not directly comparable phase for phase, because the engines index differently. Weaviate builds the graph online as objects arrive, so its build column is zero and that cost is folded into its long ingest bar. Milvus buffers inserts and does the real work in the build phase. The number to trust is the sum, and by that measure pgvector’s index build is the single longest operation in the whole comparison.
Query latency at matched recall
This is the number a RAG pipeline lives on. Reading each engine at the ef that lands recall@10 near 0.90, the p95 latencies separate cleanly.

Milvus and Weaviate answered fastest at that recall, both near 3 ms at p95, while Qdrant sat at 5.3 ms and pgvector at 6.3 ms. The four-point ef grid means these are the nearest matches, not identical recall: Milvus’s 3 ms lands at 0.914 recall, a notch above the others near 0.885 to 0.899, so it was both faster and slightly more accurate at that operating point. One caveat that the p95 hides: Qdrant’s median latency was excellent, around 2.3 ms at the same setting, but its tail was noisy because its background optimizer runs while queries land. If your SLA is a median, Qdrant looks much closer to the leaders than the p95 bar suggests. If your SLA is a tail, that variance is real and worth load-testing before you commit.
The recall and latency tradeoff
A single latency number is only as honest as the recall it was measured at. Sweeping ef_search from 32 to 256 draws the actual tradeoff curve, where lower and to the right is better.

Two things stand out. Milvus was the only engine to reach recall@10 of 0.95 within the sweep, hitting 0.9513 at ef 256, though it paid for that last stretch with an 8.5 ms p95. Every other engine topped out near 0.885 to 0.899 at ef 256, which means at m=16 they need a higher ef, or a higher m at build time, to match Milvus on recall. Weaviate held the best low-latency frontier up to about 0.885, and Milvus owned the high-recall end. Qdrant’s curve is the worst tradeoff in the middle band on p95, again because of tail variance rather than median. Raising m to 32 at build time would lift the recall ceiling for all four at the cost of more memory and slower builds; that was not tested here, though the relative ordering is unlikely to invert.
Memory and disk footprint
Speed is half the operational story. What each engine costs to hold a million vectors is the other half, and here the spread is the widest in the whole comparison.

Qdrant was the lean one by a distance: 937 MiB of peak RAM and 725 MiB on disk. pgvector held the same data in 2,765 MiB and 2.2 GiB. Weaviate ran at 3,689 MiB. Milvus was the heaviest on both axes at 4,195 MiB and nearly 3 GiB, and that is the honest cost of what Milvus is: even in single-node standalone mode it runs as three processes (the Milvus server plus etcd for metadata and MinIO for object storage), and all three show up in the footprint. Qdrant delivers a competitive index in a quarter of Milvus’s memory and disk, which matters the moment you are paying for the node.
What the numbers do not show
Latency and recall decide a shortlist, not the winner. The operational shape of each engine matters just as much, and it is where these four genuinely diverge. pgvector is not a server, it is an extension bolted onto a database you probably already run. Milvus is a cluster even in standalone mode. Qdrant and Weaviate sit in between as one self-contained service each.
| Trait | Qdrant | Weaviate | Milvus | pgvector |
|---|---|---|---|---|
| License | Apache 2.0 | BSD-3-Clause (core) | Apache 2.0 | PostgreSQL License |
| Runs as | One service | One service | Cluster (etcd + object store) | Postgres extension |
| Metadata filtering | Payload filters | where filters | Scalar filters | SQL WHERE |
| Hybrid (keyword + vector) | Native, sparse + dense | Native BM25 fusion | Native, sparse + dense | Via Postgres full-text search |
| Managed option | Qdrant Cloud | Weaviate Cloud | Zilliz Cloud | Any managed Postgres |
The practical read: if the data already lives in Postgres, adding pgvector removes a moving part rather than introducing one, and metadata filtering is a WHERE clause on columns you already index. If the workload is a distributed, billion-vector store with sharding and replication, Milvus is built for it and the extra components are the price of that reach. Qdrant and Weaviate are the middle ground: a single dedicated vector service with first-class filtering and hybrid search, deployable as one container. Install guides for each cover the setup this benchmark skipped past: running Qdrant, Weaviate on Ubuntu, and Milvus on Ubuntu.
Two Docker failures these benchmarks surfaced
could not resize shared memory segment … No space left on device
pgvector’s HNSW build runs in parallel and allocates from POSIX shared memory. A Docker container defaults /dev/shm to 64 MB, and the build blows through that the moment the dataset is real. The index creation dies with a message that reads like a full disk but is not:
psycopg2.errors.DiskFull: could not resize shared memory segment "/PostgreSQL.604953564"
to 2144374176 bytes: No space left on device
Give the container real shared memory. A few gigabytes covers a parallel build on a million-plus vectors:
docker run -d --name pgv --shm-size=6g -e POSTGRES_PASSWORD=postgres \
-p 5432:5432 pgvector/pgvector:pg17
pull access denied for minio/minio, repository does not exist
Milvus standalone pulls MinIO for object storage, and the compose files that ship with older Milvus releases point at minio/minio on Docker Hub. MinIO stopped publishing there, so a fresh pull fails outright:
Error response from daemon: pull access denied for minio/minio,
repository does not exist or may require 'docker login'
MinIO’s images now live on Quay. Point the MinIO service at quay.io/minio/minio with a current RELEASE tag and the stack comes up:
minio:
image: quay.io/minio/minio:RELEASE.2024-12-18T13-15-44Z
command: minio server /minio_data
Which is the best vector database
There is no single winner, there is a winner per constraint, and the numbers point cleanly at each one.
| If your constraint is | Pick | Because the numbers show |
|---|---|---|
| Smallest footprint per node | Qdrant | 937 MiB RAM and 725 MiB disk, a quarter of Milvus, with competitive median latency |
| Highest recall and scale | Milvus | Only engine to reach 0.95 recall; fastest to a queryable index; built to shard past one box |
| Fastest queries at high recall | Weaviate | Best low-latency frontier up to ~0.885 recall, native BM25 hybrid |
| Data already in Postgres | pgvector | No new service, filtering is plain SQL; accept the slow build and higher latency |
For most self-hosted RAG stacks under a few million vectors, Qdrant is the default that is hardest to regret: it holds the index in the least memory, its median latency is in the same class as the leaders, and it deploys as one container. Milvus earns its footprint once the vector count and the need for sharding grow past a single node. Weaviate is the pick when query latency at high recall is the priority and its heavier ingest is acceptable. And pgvector wins the argument it is designed to win: when the vectors belong next to relational data that is already in PostgreSQL, keeping them in one database beats running a second one, and the Ollama and pgvector RAG stack shows how far that setup goes before the numbers here start to bite.