VectorOpsReport
Pink isometric server stack and a spiked node cluster linked by glowing pink lines and hexagons on a dark blue dotted grid, evoking distributed vector database nodes.
Comparison

Qdrant vs Weaviate vs Milvus: Filtered Recall, RAM and Scale-Out Compared

A qdrant vs weaviate vs milvus comparison for teams putting retrieval in production: index and quantization options, what a metadata filter does to each engine, multi-tenancy limits, cluster mechanics, and the one metric to decide on.

By VectorOpsReport Editorial · · 7 min read

The qdrant vs weaviate vs milvus question usually shows up as an incident, not a design review: a tenant_id filter was added to the retrieval hop of a RAG service, recall@10 on the golden set fell, and the p99 of that hop tripled because the engine quietly switched from a graph walk to a brute-force scan. All three are open source, all three run HNSW, and all three will look identical on a vendor QPS chart. They differ in what a filter does to the index, who pays for RAM, how a cluster is coordinated, and how many tenants you can have before something breaks. This piece works through those differences from the vendors’ own docs and flags every vendor benchmark as one.

What each one is, mechanically

Qdrant is a single Rust binary under Apache-2.0, currently v1.19.0. HNSW is its only dense index (defaults m: 16, ef_construct: 100). Its distinguishing piece is the filterable HNSW index: payload indexes (keyword, integer, float, bool, geo, datetime, text, uuid) add extra graph edges so a filter is applied during the walk rather than before or after it, and from v1.16.0 an ACORN-style search explores neighbours-of-neighbours when direct neighbours are filtered out. Vectors are always stored on disk with a cached or cold memory tier per collection.

Weaviate is Go under BSD-3-Clause, currently v1.39.2 with 1.38 and 1.37 maintenance lines. It ships HNSW (defaults efConstruction: 128, maxConnections: 32, dynamic ef between 100 and 500), a flat index, a dynamic index that converts flat to HNSW at 10,000 objects, and HFresh for memory-constrained nodes, per the vector index reference. Its other differentiator is server-side vectorization: enable a model provider integration and Weaviate calls OpenAI, Cohere, Hugging Face, Ollama or a dozen others to embed on insert.

Milvus is Go and C++ under Apache-2.0, with v3.0.0 released 29 July 2026 and the 2.6 line still maintained (2.6.22 on 4 August; the notes say 2.6 to 3.0 compatibility and rollback are guaranteed). It is a disaggregated system: stateless proxies, one coordinator, streaming, query and data nodes, and a storage layer of etcd, MinIO or S3, and the Woodpecker write-ahead log. It has the widest index menu: FLAT, IVF_FLAT, IVF_SQ8, IVF_PQ, HNSW, HNSW_SQ, HNSW_PQ, HNSW_PRQ, SCANN, DiskANN, and four GPU indexes including GPU_CAGRA, from the CAGRA paper that reports 33 to 77x the large-batch throughput of HNSW at 90 to 95% recall. HNSW defaults are M: 30, efConstruction: 360.

The metric that matters

Pick on QPS at target recall under your real filter, with the RAM bill attached, not on unfiltered QPS.

Define it on a golden set of N production queries with exact ground truth from a FLAT scan:

recall@k = mean over queries of |approx_topk ∩ exact_topk| / k
QPS@r    = sustained throughput at the ef where recall@k >= r

Unfiltered QPS is what every vendor chart shows and what you never serve. The reason the three engines diverge is the filter path. Weaviate pre-filters through its inverted index into an allow-list, walks HNSW with ACORN (default filterStrategy since v1.34), and switches to brute force when the allow-list falls below flatSearchCutoff (default 40,000). Milvus restricts the scope to entities matching the expression, then searches inside it, with an iterative mode for complex expressions. Qdrant walks the filter-aware graph directly and only drops to full scan below full_scan_threshold. The ACORN paper reports 2 to 1,000x throughput at fixed recall over prior filtered approaches, which is the gap a bad filter path can cost you. Measure at your own selectivity: a 1% tenant behaves nothing like a 40% region filter. The index families themselves are compared in HNSW vs IVF tradeoffs.

Where they actually differ

ConcernQdrantWeaviateMilvus
Compressionscalar 4x, binary up to 32x, product up to 64x, TurboQuant up to 32x (docs)PQ (trained), SQ 4x, BQ 32x, RQ 8/4/1-bit (docs)IVF_SQ8, IVF_PQ, HNSW_SQ/PQ/PRQ, DiskANN on NVMe
Beyond RAMcold memory tier for vectors, index and payloadHFresh; tenant offload to S3DiskANN; query nodes page segments from object storage
ClusterRaft for topology; shard_number defaults to node count; replication_factor default 1 (docs)Raft for metadata, leaderless data replication with ONE/QUORUM/ALL (docs)Coordinator plus stateless workers on Kubernetes; etcd, object store, Woodpecker
Multi-tenancyone collection, is_tenant: true payload index; Cloud caps 1,000 collections (docs)one shard per tenant; docs cite ~170k active tenants on 9 n1-standard-8 nodes, bounded by the open-file limit (docs)database (64 default), collection (65,536 default), partition (1,024 per collection) or partition key routing to 16 partitions (docs)
Hybridsparse vectors, prefetch with RRF (v1.10) and DBSF (v1.11)BM25 plus vector, alpha default 0.75, relativeScoreFusion default since v1.24built-in BM25 function to SPARSE_FLOAT_VECTOR; 3.0 adds Block-Max WAND

Two rows deserve a note. On Qdrant self-hosted, changing replication_factor after creation does nothing; you add shard replicas by hand, and only Qdrant Cloud reconciles automatically. On Milvus, the deployment guide is explicit about tiers: Lite for up to a few million vectors, Standalone up to 100 million, Distributed from 100 million to tens of billions. Sizing the RAM behind any of these is covered in vector database memory sizing.

Wiring it up

The same golden-set harness runs against all three so the number you compare is the one you will serve. Ground truth comes from an exact scan once; the loop below measures filtered recall@10 and p99 per engine.

import time, numpy as np
from qdrant_client import QdrantClient, models as qm
import weaviate
from weaviate.classes.query import Filter
from pymilvus import MilvusClient

queries = np.load("golden_queries.npy")          # (N, dim)
exact = np.load("golden_exact_top10.npy")        # (N, 10) ids from a FLAT scan
tenants = np.load("golden_tenants.npy")          # (N,) tenant per query
K = 10

def qdrant_search(c, q, t):
    r = c.query_points("docs", query=q.tolist(), limit=K,
        query_filter=qm.Filter(must=[qm.FieldCondition(
            key="tenant", match=qm.MatchValue(value=str(t)))]),
        search_params=qm.SearchParams(hnsw_ef=128))
    return [p.id for p in r.points]

def weaviate_search(coll, q, t):
    r = coll.query.near_vector(near_vector=q.tolist(), limit=K,
        filters=Filter.by_property("tenant").equal(str(t)))
    return [o.properties["doc_id"] for o in r.objects]

def milvus_search(c, q, t):
    r = c.search("docs", data=[q.tolist()], limit=K,
        filter=f'tenant == "{t}"', search_params={"params": {"ef": 128}})
    return [hit["id"] for hit in r[0]]

def evaluate(name, fn):
    hits, lat = [], []
    for q, gt, t in zip(queries, exact, tenants):
        t0 = time.perf_counter()
        ids = fn(q, t)
        lat.append(time.perf_counter() - t0)
        hits.append(len(set(ids) & set(gt.tolist())) / K)
    print(f"{name}: recall@{K}={np.mean(hits):.4f} "
          f"p50={np.percentile(lat,50)*1e3:.1f}ms p99={np.percentile(lat,99)*1e3:.1f}ms")

qc = QdrantClient("http://qdrant:6333")
wc = weaviate.connect_to_local(host="weaviate")
mc = MilvusClient("http://milvus:19530")
evaluate("qdrant",   lambda q, t: qdrant_search(qc, q, t))
evaluate("weaviate", lambda q, t: weaviate_search(wc.collections.get("Docs"), q, t))
evaluate("milvus",   lambda q, t: milvus_search(mc, q, t))

Sweep ef (Qdrant hnsw_ef, Weaviate ef, Milvus ef) from 32 to 512 and record the lowest value that clears your recall target; that is the setting you load-test, not the default. Weaviate’s docs note that ef above 512 shows diminishing recall gains.

What you’ll see

Good looks like three recall-vs-ef curves that flatten above your target at similar ef, with p99 rising roughly linearly as ef grows. Filtered and unfiltered curves sit close together at every selectivity you serve.

Bad has a shape. A filtered p99 that jumps by an order of magnitude at one selectivity is the brute-force cliff: Weaviate’s flatSearchCutoff, Qdrant’s full_scan_threshold, or a Milvus filter too complex for the standard path. Recall that is fine unfiltered and drops under a tight filter means the graph is disconnected for that tenant; on Qdrant the usual cause is a payload index created after ingestion, so the filter-aware edges were never built. Recall that decays week over week with no config change is the embedding model or the corpus drifting, which is a monitoring problem covered in sentryml’s metrics taxonomy, not an engine problem. See low vector search recall: causes and fixes for the triage order.

Caveats

Every published head-to-head here is a vendor benchmark. Qdrant’s benchmark page runs Qdrant, Weaviate, Milvus, Elasticsearch and Redis on an 8 vCPU, 32 GiB Azure D8s v3 with engines capped at 25 GB, on dbpedia-openai-1M (1536d), deep-image-96 (10M), gist-960 and glove-100, and was last updated in 2024. VectorDBBench is sponsored by Zilliz, the company behind Milvus, and covers 30-plus engines on Cohere, OpenAI 500K and 5M, and LAION 100M with QPS, recall, p99 and filter cases. The only independent suite that includes all three, ANN-Benchmarks, benchmarks single-query algorithm performance (qdrant, weaviate and Milvus via Knowhere among 38 entries), which says little about a clustered deployment under load.

Quantization numbers are ceilings. Qdrant’s 40x speedup for binary quantization and Weaviate’s 98 to 99% recall for 8-bit RQ are vendor figures for specific embedding models; anything below 8-bit needs rescore with oversampling on Qdrant or rescoring on Weaviate, and that rescoring reads full vectors from wherever they live. GPU indexes are a throughput tool: Milvus’s own docs say a GPU index “may not necessarily reduce latency compared to using a CPU index” and pays off under high request pressure or large query batches, and GPU_CAGRA uses about 1.8x the raw vector data in GPU memory.

Tenant cardinality is the trap that does not show in benchmarks. One collection per tenant on Qdrant or Milvus is the expensive path both vendors warn against; Weaviate’s shard-per-tenant model scales far but the tenant count is bounded by the process open-file limit, and offloaded tenants need S3. Qdrant shard_number cannot change without recreating the collection, so set it as a multiple of your eventual node count. Whichever engine wins, the retrieval layer is also an injection surface: retrieved chunks are untrusted input, as aisec’s piece on indirect injection in RAG pipelines lays out.

Which one

Qdrant when filtered search on one or a few nodes is the whole job and you want the smallest operational surface. Weaviate when you want server-side embedding, BM25 hybrid out of the box, or tens of thousands of isolated tenants. Milvus when you are past 100 million vectors, already run Kubernetes and object storage, or need DiskANN or GPU indexes. If none of those tilts it, the harness above decides, and the broader selection checklist is in how to choose a vector database.

Sources

  1. Qdrant docs: indexing (filterable HNSW, payload indexes, ACORN)
  2. Qdrant docs: quantization (scalar, binary, product, TurboQuant)
  3. Qdrant docs: distributed deployment (Raft, shards, replication_factor)
  4. Qdrant docs: multitenancy with payload partitioning
  5. Qdrant vector search benchmarks (vendor)
  6. Weaviate docs: vector index reference (HNSW defaults, flatSearchCutoff, filterStrategy)
  7. Weaviate docs: vector quantization (PQ, BQ, SQ, RQ)
  8. Weaviate docs: data structure and multi-tenancy
  9. Weaviate docs: replication architecture
  10. Milvus docs: deployment options (Lite, Standalone, Distributed)
  11. Milvus docs: in-memory index types
  12. Milvus docs: GPU index types
  13. Milvus docs: multi-tenancy strategies
  14. Milvus release notes (3.0.0)
  15. VectorDBBench, benchmark sponsored by Zilliz (vendor)
  16. ANN-Benchmarks (Aumüller, Bernhardsson, Faithfull), independent
  17. Malkov and Yashunin, HNSW (arXiv 1603.09320)
  18. Patel et al., ACORN: predicate-agnostic search over vector embeddings and structured data (arXiv 2403.04871)
  19. Ootomo et al., CAGRA: parallel graph construction and ANN search for GPUs (arXiv 2308.15136)
#qdrant#weaviate#milvus #vector-database #hnsw #comparison

Related