Pick pgvector if you already run Postgres and expect fewer than about ten million vectors. Pick Qdrant if filtered search at high query rates is your bottleneck and you have someone to operate it. Pick Weaviate if you want keyword and vector search fused in one query, with hard tenant isolation built in. Pick Pinecone if you want to never see a node again and the bill is smaller than the engineer-hours it replaces.
Key Takeaways
- The index matters more than the brand. All four run graph-based approximate search. Recall, latency and memory come from your parameters.
- Filtering is the real differentiator. Nearly every production query carries a tenant, date or category filter, and that is where these systems diverge.
- pgvector wins more often than vendors admit. One database means one backup, one migration path, no dual-write drift.
- Memory is the wall. Once the index stops fitting in RAM or page cache, latency jumps two orders of magnitude. Quantization buys room.
Every retrieval project reaches the same fork: the prototype ran fine on a list in memory, and now there are five million chunks and a latency budget. This comparison is written from that point, not from a feature matrix, and the code runs as written against the versions named.
The Short Answer: Which One to Pick
Under roughly 10 million vectors with Postgres already in production, use pgvector. Above that, or when selective metadata filters dominate your queries, use Qdrant. Use Weaviate when hybrid keyword-plus-vector relevance and per-tenant isolation are product requirements. Use Pinecone when you have no platform engineer and want the storage layer to be someone else's problem.
| Dimension | pgvector | Qdrant | Weaviate | Pinecone |
|---|---|---|---|---|
| Index types | HNSW, IVFFlat | HNSW + exact fallback | HNSW, flat, dynamic | Managed, not exposed |
| Filtered search | Iterative scan (0.8+) | Filterable HNSW links | ACORN traversal | Built in, not tunable |
| Compression | halfvec, binary, sparse | Scalar, binary, TurboQuant | PQ, SQ, BQ, RQ | Handled internally |
| Keyword + vector hybrid | Write it yourself with tsvector | Sparse vectors + fusion | BM25 hybrid, one call | Sparse-dense supported |
| Joins to relational data | Native SQL | Application-side | Application-side | Application-side |
| Multi-tenancy | Row filters or partitions | Payload filter, shard keys | Per-tenant index, offloadable | Namespaces |
| Operational burden | Whatever Postgres already costs | You own nodes and upgrades | You own nodes and upgrades | None |
| Where it gets uncomfortable | ~10M vectors per node | Cluster rebalancing | Memory per tenant at scale | The invoice, and lock-in |
What Actually Differs Is the Index, Not the Database
All four answer the same question: given a query vector, return the k closest stored vectors under cosine, dot product or L2 distance. All four do it approximately, with a navigable small-world graph. What differs is how the graph handles filters, how vectors are compressed, and who runs the process.
Three index families cover almost everything in production:
- HNSW builds a layered proximity graph and hops greedily toward the query. Recall is tuned at query time with an
efparameter controlling how many candidates stay in the frontier. Slow to build, hungry for memory, fast to query. The 2026 default everywhere. - IVFFlat clusters vectors into lists and probes only the nearest few. Builds fast, uses far less memory, degrades badly if the data distribution shifts after the build. Indexing an empty table produces useless clusters.
- Disk-resident graphs such as DiskANN keep the graph on SSD with compressed vectors in memory. In Postgres that arrives through the separate
pgvectorscaleextension, not pgvector itself.
Quantization is the second lever and the one people underuse. Scalar quantization stores each dimension as int8 instead of float32, roughly 4x smaller with small recall loss. Binary keeps one bit per dimension for up to 32x, and only works if you rescore top candidates. Qdrant 1.18 added TurboQuant, a rotation-based method from Google Research reaching scalar-level recall at half the memory. Our embeddings explainer and vector databases guide cover the math assumed here.
Filtering Is Where These Systems Separate
A pure nearest-neighbour benchmark tells you almost nothing, because production queries are never pure. They carry a tenant id, a date range, a document type or a permission scope. Filtering breaks the assumption HNSW rests on, and each system breaks it differently.
Graph search reaches the query neighbourhood by hopping through neighbours. If a filter excludes 99% of rows, the survivors may not be connected to each other, so a greedy walk gets stranded and returns three results when you asked for ten. That is overfiltering, and its signature is distinctive: results look fine in testing and go strangely empty for your smallest customer.
Search first, discard after
Fetch the top 100 by distance, drop rows failing the predicate. Fast, simple, silently short whenever the filter is selective. Source of most "why is my RAG missing documents" tickets.
Filter inside the traversal
Evaluate the predicate during traversal, walk through non-matching nodes, fall back to an exact scan once the candidate set is small enough. Correct results, more engineering inside the database.
How each system handles it:
- pgvector 0.8+ added iterative index scans. Set
hnsw.iterative_scanand the planner keeps pulling batches until enough rows survive the WHERE clause orhnsw.max_scan_tuplesis hit. - Qdrant builds extra HNSW links restricted to indexed payload fields, controlled by
payload_m. Belowfull_scan_thresholdit does an exact scan instead. - Weaviate offers filter strategies on the HNSW config. ACORN steps over non-matching nodes rather than treating them as dead ends.
- Pinecone filters inside its own search path with a MongoDB-style predicate language. It works, and you cannot tune it.
Test This Before You Commit
Benchmark your real filter selectivity, not a uniform random one. Run the same query with the filter matching 50%, 5% and 0.5% of rows, and measure both latency and the count of results returned. A system that returns 4 rows when you asked for 10 has failed, however fast it did so.
pgvector: When the Postgres You Already Run Wins
pgvector is an extension that adds vector types and index access methods to Postgres. Release 0.8.2 shipped in February 2026. If your application data already lives in Postgres, pgvector removes an entire distributed-systems problem: no second store to keep in sync, no dual write, no reconciliation job, no separate backup schedule.
That last point deserves weight. Once embeddings live outside your primary database, every insert has to succeed in two places, and the resulting drift stays invisible until a user reports that a document visible in the app is unfindable in search. Teams end up writing a nightly reconciler, a permanent tax no benchmark chart shows.
-- one time, per database
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunk (
id bigserial PRIMARY KEY,
doc_id bigint NOT NULL REFERENCES doc(id),
tenant_id uuid NOT NULL,
published date NOT NULL,
body text NOT NULL,
embedding vector(1536) NOT NULL
);
-- build is CPU and memory hungry; give it room or it spills to disk
SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 7;
CREATE INDEX chunk_embedding_hnsw
ON chunk USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- index the columns you filter on, separately
CREATE INDEX chunk_tenant_published ON chunk (tenant_id, published);
The query side is ordinary SQL, which is the whole point: join the parent document, apply row-level security, return everything in one round trip.
SET hnsw.ef_search = 100; -- recall knob, higher is slower
SET hnsw.iterative_scan = strict_order; -- 0.8+, avoids overfiltering
SET hnsw.max_scan_tuples = 40000; -- ceiling on the extra work
SELECT c.id, d.title, c.body,
c.embedding <=> $1 AS distance
FROM chunk c
JOIN doc d ON d.id = c.doc_id
WHERE c.tenant_id = $2
AND c.published >= now() - interval '2 years'
ORDER BY c.embedding <=> $1
LIMIT 10;
Two limits catch people. The vector type indexes up to 2,000 dimensions, so a 3,072-dimension embedding stores fine and then refuses to index; the fix is halfvec, float16 up to 4,000 dimensions, half the storage, very little recall cost. And HNSW builds are slow on large tables, so create the index after bulk loading.
-- 3072-dim model: index the half-precision cast
CREATE INDEX ON chunk
USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);
-- binary quantization: 32x smaller index, rescore for accuracy
CREATE INDEX ON chunk
USING hnsw ((binary_quantize(embedding)::bit(1536)) bit_hamming_ops);
SELECT id, body, embedding <=> $1 AS distance
FROM (
SELECT id, body, embedding
FROM chunk
ORDER BY binary_quantize(embedding)::bit(1536)
<~> binary_quantize($1)
LIMIT 200
) candidates
ORDER BY distance
LIMIT 10;
pgvector stops being comfortable around ten million vectors on a single node, because Postgres does not shard for you. Before migrating, try pgvectorscale, which adds a StreamingDiskANN index built to keep search fast when vectors exceed available memory. Our PostgreSQL guide and database indexing guide cover the tuning that applies here too.
Qdrant: Filtered Search and Aggressive Compression
Qdrant is a Rust vector database with the most direct answer to filtered search: filter-aware links built into the HNSW graph itself, plus more compression options than anything else here. Version 1.18, May 2026, added TurboQuant. It self-hosts as a single container and offers a managed cloud with a free 1 GB tier.
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
client.create_collection(
collection_name="chunks",
vectors_config=models.VectorParams(
size=1536,
distance=models.Distance.COSINE,
on_disk=True, # originals on SSD
),
hnsw_config=models.HnswConfigDiff(
m=16, ef_construct=100,
payload_m=16, # extra links for filtered traversal
),
quantization_config=models.ScalarQuantization(
scalar=models.ScalarQuantizationConfig(
type=models.ScalarType.INT8,
quantile=0.99,
always_ram=True, # quantized copy stays hot
)
),
)
# a payload index is required for filter-aware search to help
client.create_payload_index(
collection_name="chunks",
field_name="tenant_id",
field_schema=models.PayloadSchemaType.KEYWORD,
)
hits = client.query_points(
collection_name="chunks",
query=query_vector,
query_filter=models.Filter(
must=[
models.FieldCondition(
key="tenant_id",
match=models.MatchValue(value="acme"),
)
]
),
limit=10,
search_params=models.SearchParams(
hnsw_ef=128,
quantization=models.QuantizationSearchParams(
rescore=True, # re-rank with full-precision vectors
oversampling=2.0, # fetch 2x candidates first
),
),
).points
Beginners hit two failure modes. Forgetting the payload index means filters still apply but the graph gains no filter-aware structure, so selective queries fall back to slow paths. Enabling on_disk without quantization means every graph hop touches SSD. Quantized-in-RAM plus originals-on-disk is what works.
Operationally, one Qdrant node is easy. A cluster is not: sharding, replication factor and resharding during growth are real work, and the pager is yours.
Weaviate: Hybrid Search and Tenant Isolation
Weaviate's strengths are hybrid retrieval and multi-tenancy. BM25 and dense scoring fuse in a single call with a tunable alpha, and each tenant gets its own index that can be deactivated or offloaded to object storage when idle. Version 1.38 shipped in June 2026.
Hybrid is not a nice-to-have. Dense retrieval is weakest exactly where users are most precise: part numbers, statute citations, error codes, quoted names. A hybrid query recovers those cases without a second pipeline. Weaviate will also run a vectorizer inside the database, so text goes in and embedding happens server-side.
import weaviate
from weaviate.classes.config import (
Configure, Property, DataType, VectorFilterStrategy
)
from weaviate.classes.query import MetadataQuery
client = weaviate.connect_to_local()
client.collections.create(
"Chunk",
properties=[
Property(name="body", data_type=DataType.TEXT),
Property(name="doc_type", data_type=DataType.TEXT),
],
vector_config=[
Configure.Vectors.self_provided(
name="content",
vector_index_config=Configure.VectorIndex.hnsw(
filter_strategy=VectorFilterStrategy.ACORN,
quantizer=Configure.VectorIndex.Quantizer.sq(),
),
)
],
multi_tenancy_config=Configure.multi_tenancy(
enabled=True, auto_tenant_creation=True
),
)
chunks = client.collections.get("Chunk").with_tenant("acme")
res = chunks.query.hybrid(
query="retention schedule for contract files",
vector=query_vector,
target_vector="content",
alpha=0.6, # 1.0 = pure vector, 0.0 = pure BM25
limit=10,
return_metadata=MetadataQuery(score=True),
)
The cost is complexity. Collections, named vectors, tenants, modules and replication settings are more surface area than the alternatives, and if you only need "store vectors, search vectors" you pay for it in onboarding time. Multi-tenancy also multiplies memory: a thousand active tenants means a thousand HNSW graphs, and idle tenants must actually be deactivated for offloading to help.
Pinecone: Paying to Never Think About It
Pinecone is managed only; there is no self-hosted build. Create a serverless index, upsert into namespaces, query. Nothing about the index is exposed: no m, no ef, no quantization setting. In exchange you never size a node, never run an upgrade, and pay nothing for an idle index.
import os
from pinecone import Pinecone, ServerlessSpec
pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
pc.create_index(
name="chunks",
dimension=1536,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
)
index = pc.Index("chunks")
# namespaces are the tenancy boundary; queries never cross them
index.upsert(
vectors=[
{"id": "c-1", "values": vec,
"metadata": {"doc_type": "policy", "year": 2026}},
],
namespace="acme",
)
res = index.query(
vector=query_vector,
top_k=10,
namespace="acme",
filter={"doc_type": {"$eq": "policy"},
"year": {"$gte": 2024}},
include_metadata=True,
)
Two honest cautions. Read-unit billing scales with namespace size, so one large namespace makes every query more expensive than the same data split across many small ones, and reshaping the layout later means a full re-upsert. And because the index is opaque, fixing a recall problem means changing your embeddings, your chunking or your top_k, because the index is not yours to change.
Scale Limits: What Breaks and at What Number
The limit is not vector count, it is bytes resident in memory. A 1,536-dimension float32 vector is 6,144 bytes. One million is about 6 GB. Five million is about 30 GB, plus roughly 10 to 30 percent for HNSW graph links. Once that exceeds RAM plus page cache, every graph hop becomes a random disk read and p99 latency moves from single-digit milliseconds to hundreds.
Memory Budget for 5M Vectors at 1,536 Dimensions
- float32, no compression: ~30 GB of vectors, ~35 GB with graph links. Needs a large-memory instance.
- float16 (halfvec, 16-bit SQ): ~15 GB. Recall loss is usually under a point.
- int8 scalar quantization: ~7.7 GB. Fits a mid-size instance with room to grow. Rescore the top candidates.
- Binary, 1 bit per dimension: under 1 GB. Fast and coarse. Always rescore, and expect recall to depend heavily on the embedding model.
Practical thresholds, assuming finite RAM. Below one million vectors everything works and the choice is about operations. From one to ten million, pgvector holds up on a properly sized instance, and most applications live here. From ten to fifty million, quantization is mandatory and dedicated systems earn their keep. Above fifty million you are running a distributed system whether you wanted one or not. One more number: rebuilding an HNSW index over tens of millions of vectors takes hours, so never assume a re-embed and reindex fits inside a deploy.
What It Actually Costs
Compare total cost of ownership, not per-vector list prices. Managed pricing is easy to forecast. Self-hosted pricing looks cheaper on the invoice and hides the engineer-hours, the on-call rotation and the upgrade weekends. The published models, as of mid-2026:
- pgvector: free extension. Your cost is incremental RAM and storage on a Postgres instance you already pay for, often the cheapest path here.
- Qdrant: open source under Apache 2.0. Self-host and you pay for the instance. Qdrant Cloud has a permanently free 1 GB tier and bills paid clusters by resource-hour.
- Weaviate: open source, with Weaviate Cloud billing on stored vector dimensions against a tier-dependent monthly minimum. Check the pricing page; the tiers were restructured recently.
- Pinecone: serverless usage billing. $0.33 per GB-month of storage plus per-million read and write units, $50 monthly minimum on Standard, $20 flat on Builder. Idle indexes cost nothing.
The honest framing: at small scale, managed services are cheap enough that self-hosting to save money is a bad trade. At large scale, self-hosted Qdrant or Weaviate is the lower bill and the gap widens with data. The crossover is not a vector count. It is the moment you have someone whose job includes running databases.
Operational Burden, Honestly
Ask five questions of any candidate: how do I back it up, how do I restore it, how do I upgrade without downtime, what happens when a node dies, and who gets paged. pgvector answers all five with "the same way you already do for Postgres," which is worth more than a benchmark chart.
Backup and restore
Snapshot APIs exist; scheduling, storage and restore testing are yours. A snapshot you have never restored is a hope, not a backup.
Upgrades
Minor versions ship often. Weaviate supports the latest three, so falling behind forces a multi-hop upgrade.
Reindexing
Changing embedding model, dimension or metric means a rebuild. Keep raw text and chunk boundaries durable so re-embedding needs no re-crawl.
Observability
Track recall, not just latency. A degrading index still answers fast. Keep fixed query-and-expected-document pairs and run them every deploy.
When Not to Use a Vector Database at All
Below roughly 50,000 vectors, a NumPy matrix multiply gives exact results in a few milliseconds with no server, no index and no tuning. Approximate search exists to trade accuracy for speed. If exact search is already fast enough, you are trading accuracy for nothing.
import numpy as np
# vectors: (n, d) float32, L2-normalised at write time
def search(query, vectors, ids, k=10):
q = query / np.linalg.norm(query)
scores = vectors @ q # cosine, since rows are unit norm
top = np.argpartition(-scores, k)[:k]
top = top[np.argsort(-scores[top])]
return [(ids[i], float(scores[i])) for i in top]
# 50k x 1536 float32 = ~300 MB, ~5 ms per query on a laptop CPU
Three more cases where dense retrieval is the wrong tool. When users search with exact identifiers, quoted phrases or product codes, keyword search wins and Postgres full-text search is already installed. When the corpus fits in a model's context window, retrieval may be unnecessary. And when the real problem is chunking or ranking quality, swapping databases fixes nothing. Our RAG tutorial in Python walks that pipeline, and RAG explained covers where quality comes from.
Migration and Lock-In
Vectors are portable: an array of floats with an id and a metadata blob moves anywhere. Everything around them does not. Filter syntax, hybrid scoring, tenancy and consistency guarantees all differ, so a migration rewrites query code and re-tunes recall even when the data copies cleanly.
Two habits keep the door open. Keep source text, chunk boundaries and embedding model version in your own durable storage, so any store can be rebuilt. And put a thin interface in front of the store: upsert, delete, search, search_with_filter. LangChain and LlamaIndex provide that abstraction but expose only the intersection of features, so hand-writing it is often better once you need per-store tuning. A small FastAPI retrieval endpoint means swapping engines changes one deployment, not forty call sites.
The bottom line: start with pgvector on the Postgres you already run, and instrument recall and p99 latency from week one. Move to Qdrant when filtered throughput is the bottleneck and you have someone to operate it, to Weaviate when hybrid relevance and tenant isolation are product requirements, and to Pinecone when engineer-hours cost more than the invoice. Choosing a store before you have a measured bottleneck is how teams end up running two databases to serve one feature.
Frequently Asked Questions
Is pgvector good enough for production, or do I need a dedicated vector database?
Under roughly 5 to 10 million vectors, pgvector on a Postgres you already operate is good enough and often better: one backup story, one set of credentials, and vector results joined to relational rows in a single query. Move to a dedicated store when throughput under selective filters stalls, when the index outgrows the memory you will pay for, or when you need something Postgres lacks such as multi-tenant index isolation.
Which vector database handles metadata filtering best?
Qdrant and Weaviate, because both build filtering into graph traversal instead of applying it before or after the search. Qdrant adds HNSW links for indexed payload fields so the graph stays connected inside a filtered subset; Weaviate's ACORN strategy lets traversal step through non-matching nodes. pgvector 0.8 closed much of the gap with iterative index scans, and Pinecone filters inside its own search path with no tuning knobs exposed.
How much memory does an HNSW index need?
A 1,536-dimension float32 vector is 6,144 bytes, so one million is about 6 GB before any index exists, and HNSW links add roughly 10 to 30 percent depending on m. Five million land near 35 GB resident. Exceed RAM plus page cache and latency shifts from single-digit milliseconds to hundreds. Half-precision halves it, int8 cuts it fourfold, binary up to thirty-twofold with a rescoring pass.
Is Pinecone worth the cost compared to self-hosting Qdrant?
It depends what an engineer-hour costs you. Pinecone serverless list pricing in mid-2026 is $0.33 per GB-month plus per-million read and write units against a $50 monthly minimum on Standard, and you never touch a node. Self-hosted Qdrant is cheaper per stored vector at scale, but you own upgrades, backups, replica placement, disk growth and the pager.
Can I use more than one of these at the same time?
Yes. Keep authoritative chunks and embeddings in Postgres with pgvector, and mirror a hot subset into a dedicated store for high-throughput serving. Postgres stays the source of truth so rebuilds stay possible and the fast store stays disposable. The cost is the dual-write problem, so take it on only after measuring a real bottleneck.
Sources: pgvector on GitHub, Qdrant documentation, Weaviate release notes, Pinecone cost documentation