Skip to content

pgvector vs Qdrant

17 min readPostgreSQLQdrantVector Search

Short version: if Postgres already holds the rows these embeddings belong to, start with pgvector, because one database means one backup, one transaction, and a join instead of a sync job. Move to Qdrant when your searches are heavily filtered, when the index stops fitting in the RAM you are willing to pay for, or when you need sparse-plus-dense hybrid retrieval in one call. Those three are the real dividing lines. Raw vector count is a weaker signal than any of them, because a million vectors with no filters is easy in Postgres and a million vectors filtered by tenant on every query is where pgvector starts to hurt.

I run Layerbase, which hosts managed Qdrant, so I have a stake here. It also does not host pgvector, which I say plainly in the Layerbase section below rather than burying it, and the comparison stands either way.

The two things are not the same category. pgvector is a Postgres extension: it adds vector types, distance operators, and two index access methods to a database you were already running. Qdrant is a standalone search engine written in Rust whose only job is vectors and the payloads attached to them. Choosing between them is mostly a question about your architecture, not about which one computes cosine distance faster.

Versions used throughout, as of September 2026: pgvector 0.8.6, released 2026-07-29, and Qdrant 1.19.1, released 2026-09-04.

Contents

Quick comparison

pgvector 0.8.6Qdrant 1.19
ShapePostgres extensionStandalone server (REST + gRPC)
LicensePostgreSQL LicenseApache-2.0
Index typesHNSW, IVFFlatHNSW, with payload-aware links
Vector typesvector, halfvec, bit, sparsevecDense, sparse, multivector
Max dimensions16,000 stored; 2,000 indexable for vector, 4,000 for halfvecNo practical ceiling for supported models
FilteringApplied after the index scan, with iterative scans to compensateFilter-aware graph traversal plus payload indexes
QuantizationStore as halfvec or bit and re-rank yourselfScalar, product, binary, TurboQuant, built in
Hybrid searchCombine with tsvector in SQL, fuse by handprefetch plus RRF or DBSF in one request
Transactions with your app dataYes, same transactionNo, separate system
Joins to relational rowsYesNo, denormalize into the payload
Horizontal scaleRead replicas, partitioning, sharding extensionsSharding and replication across a cluster
BackupsWhatever you already do for PostgresSnapshots, plus a second restore plan

What pgvector actually gives you

CREATE EXTENSION vector; and the database you already have grows four types and six distance operators: <-> for L2, <=> for cosine, <#> for negative inner product, <+> for L1, and <~> and <%> for Hamming and Jaccard on binary vectors. Two index access methods, HNSW and IVFFlat. That is the whole surface, and the small surface is the point.

The four types matter more than people expect. vector is 4 bytes per dimension plus 8, halfvec is 2 bytes per dimension plus 8, bit is one bit per dimension, and sparsevec is 8 bytes per non-zero element plus 16. Those numbers are from the pgvector README, and they are the ones to do arithmetic with before you pick a host. A million 1536-dimension embeddings stored as vector is about 6.2 GB in the column alone, before the HNSW graph on top of it. The same million as halfvec is about 3.1 GB. Nothing else in this comparison moves your bill as much as that one choice.

Dimensions have a ceiling that catches people using the larger OpenAI models. vector stores up to 16,000 dimensions but only indexes up to 2,000, and halfvec indexes up to 4,000. A 3,072-dimension embedding therefore cannot go under an HNSW index as a vector at all; you cast to halfvec for the index, which is a two-line fix but a surprising one to hit in production.

Build parameters are HNSW m = 16 and ef_construction = 64 by default, with hnsw.ef_search = 40 at query time. For IVFFlat the docs suggest starting at rows / 1000 lists up to a million rows and sqrt(rows) past that. Index builds go much faster when the graph fits in maintenance_work_mem, and parallel builds default to 2 workers, raisable through max_parallel_maintenance_workers. On a large table an HNSW build is a long, memory-hungry operation, so plan it like a migration rather than a CREATE INDEX you fire on a Tuesday afternoon.

The operational upside is the part no benchmark shows. The embedding sits in the same row as the document, so a similarity search can join to permissions, filter on a tenant column that is already indexed, and return relational fields in the same query. Inserting a document and its embedding is one transaction that either happens or does not. Your existing backup restores the vectors. Your existing replica serves them. There is no second set of credentials, no second dashboard, and no drift between two copies of the truth.

Where pgvector runs out

Three limits show up in roughly this order.

Filtered search is post-filtered. The README is direct about it: with approximate indexes, filtering is applied after the index is scanned. With hnsw.ef_search = 40 and a predicate matching 10% of rows, you get about 4 results back, not 10. Version 0.8.0 added iterative index scans to fix this, and they work: SET hnsw.iterative_scan = relaxed_order keeps scanning the index until it has enough rows that pass the filter, bounded by hnsw.max_scan_tuples (20,000) and hnsw.scan_mem_multiplier. But it is a compensation, not a design. A selective filter still means scanning far more of the graph than the same query would touch in an engine that knows about the filter while it walks. The escape hatches pgvector itself recommends are partial indexes when there are few distinct filter values and partitioning when there are many, both of which are real work.

The index wants RAM. HNSW is a graph, and graph traversal over a cold buffer cache is a latency disaster. Postgres will happily keep an index on disk and read pages as needed, which means your p99 becomes a function of how much of the index the cache is holding. There is no built-in compression lever to pull; halfvec and bit plus manual re-ranking is your quantization story, and you implement the re-ranking yourself.

Writes amplify. Every insert updates the HNSW graph, and heavy update churn on the vector column produces the same dead-tuple and bloat behaviour you know from any other index, on a structure that is expensive to rebuild. A workload that continuously re-embeds documents is harder on pgvector than one that writes once and reads forever.

Past those, you hit the plain fact that Postgres scales vertically for this workload. Read replicas multiply read capacity, and native partitioning helps, but there is no built-in way to shard one vector index across nodes.

What Qdrant actually gives you

Qdrant's answer to the filtering problem is the reason most teams switch. It builds payload indexes on the fields you filter by, then extends the HNSW graph with additional edges based on those indexed payload values, so the graph stays connected under a filter and the traversal itself is filter-aware. Below a full_scan_threshold (10,000 by default, measured in kilobytes) it skips the graph and scans directly, which is the right call on a small filtered subset. The one rule to remember from the indexing docs is that payload indexes should be created before you ingest, because the filter-aware edges are built as data goes in.

Quantization is first-class and configurable per collection. Per the quantization guide: scalar quantization converts 32-bit floats to 8-bit integers for 4x compression with minimal accuracy loss, product quantization goes to 64x but is not SIMD friendly and therefore slower, binary quantization reaches 32x compression and up to a 40x speedup on suitable high-dimensional embeddings, and TurboQuant, added in 1.18, reaches up to 32x with strong recall across most models. Combined with on-disk storage for vectors and payload indexes in cached or cold tiers, this is a set of dials for the memory-versus-latency tradeoff that pgvector simply does not have.

Multitenancy is a supported pattern rather than an accident. A tenant index tells Qdrant that a payload field identifies tenant membership so it can co-locate that tenant's data, and a principal index does the same for a dominant secondary filter such as time. Sharding and replication are per collection, with shard_number, replication_factor, and a write_consistency_factor for writes, coordinated by Raft. Sparse vectors are a native vector kind, not an encoding trick, which is what makes hybrid retrieval one request instead of two.

What the second datastore costs you

This is the half of the comparison that vendor pages skip, and it is the reason I would not move a small workload.

You now own a sync path. Something has to write the vector to Qdrant when the row changes in Postgres, delete it when the row is deleted, and reconcile when one of those fails. There is no transaction spanning both, so you are choosing between an outbox table, a queue, a change-data-capture pipeline, or accepting that the two will disagree sometimes. You also own a second backup and restore drill, a second set of credentials in the app, a second thing to monitor, and a second upgrade cadence. Filtering that depends on relational state (this user's teams, this document's current ACL) has to be denormalized into the payload and re-denormalized when it changes.

None of that is hard. All of it is permanent. Weigh it against what you are buying, which is filtered-search performance and memory control, and if those two are not your problem yet, you are paying an ongoing cost for a future benefit.

The same job in both

Chunks of documents, scoped by tenant, searched by embedding. Same schema, same query, both sides.

pgvector:

sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunk (
  id         bigserial PRIMARY KEY,
  tenant_id  text NOT NULL,
  source     text NOT NULL,
  body       text NOT NULL,
  embedding  vector(1536) NOT NULL
);

-- $4 is the embedding, bound from your client as a 1536-element array
INSERT INTO chunk (tenant_id, source, body, embedding)
VALUES ($1, $2, $3, $4);

CREATE INDEX chunk_embedding_idx ON chunk
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

CREATE INDEX chunk_tenant_idx ON chunk (tenant_id);

Build the HNSW index after the bulk load rather than before it. Postgres builds it far faster in one pass, and on a large table you want maintenance_work_mem raised for the duration.

The query, with the settings that make a filtered search behave:

sql
SET hnsw.ef_search = 100;
SET hnsw.iterative_scan = relaxed_order;

SELECT id, source, body, embedding <=> $1 AS distance
FROM chunk
WHERE tenant_id = 'acme'
ORDER BY embedding <=> $1
LIMIT 10;

Qdrant, over REST. Create the collection, then the payload index, then ingest, in that order:

bash
curl -X PUT "$QDRANT_URL/collections/chunks" \
  -H 'content-type: application/json' \
  -d '{"vectors": {"size": 1536, "distance": "Cosine"}}'

curl -X PUT "$QDRANT_URL/collections/chunks/index" \
  -H 'content-type: application/json' \
  -d '{"field_name": "tenant_id",
       "field_schema": {"type": "keyword", "is_tenant": true}}'

curl -X PUT "$QDRANT_URL/collections/chunks/points" \
  -H 'content-type: application/json' \
  -d '{"points": [
        {"id": 1,
         "vector": [0.021, -0.0134],
         "payload": {"tenant_id": "acme", "source": "handbook.md",
                     "body": "Expenses are reimbursed within 30 days."}}
      ]}'

And the same filtered search:

bash
curl -X POST "$QDRANT_URL/collections/chunks/points/query" \
  -H 'content-type: application/json' \
  -d '{"query": [0.021, -0.0134],
       "filter": {"must": [{"key": "tenant_id", "match": {"value": "acme"}}]},
       "limit": 10,
       "with_payload": true}'

The vectors above are shortened to two values so the commands fit on the page; a collection declared at "size": 1536 wants all 1536.

Two things to notice. The Qdrant version has no join, so source and body live in the payload, duplicated from wherever they came from. And the filter is an argument to the search rather than a WHERE applied to its output, which is the whole difference in one line.

Filtered search, the one that decides it

If you take one thing from this post, take this. Both engines do unfiltered nearest-neighbour search well enough that the comparison is boring. The moment every query carries WHERE tenant_id = ... or WHERE status = 'published' AND team_id = ..., the architectures diverge.

pgvector scans the index, then filters the results, then scans more of the index if you enabled iterative scans and it came up short. The cost of a selective filter is paid by touching more of the graph. Tools exist to bound it: partial indexes when the filter takes a handful of values, partitioning when it takes thousands, a higher ef_search, a capped max_scan_tuples. They work, and they are tuning you have to own.

Qdrant indexes the payload field and threads the filter into the traversal, then falls back to a full scan when the filtered subset is small enough that scanning is cheaper. You configure the payload index once and mostly stop thinking about it.

So the question is not "how many vectors do I have." It is "what fraction of my collection does a typical query actually search." If the answer is "all of it," pgvector is fine much further than people assume. If the answer is "one tenant's slice of it, on every request," that is a Qdrant-shaped problem sooner than the vector count suggests.

Hybrid retrieval

Keyword-plus-vector is where sparse vectors earn their place. Qdrant's Query API takes a prefetch list of sub-queries and applies a fusion step over their combined results, so a sparse retriever and a dense retriever run in one request and get merged by Reciprocal Rank Fusion or Distribution-Based Score Fusion. From the hybrid queries docs:

python
from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")
client.query_points(
    collection_name="chunks",
    prefetch=[
        models.Prefetch(
            query=models.SparseVector(indices=[1, 42], values=[0.22, 0.8]),
            using="sparse",
            limit=20,
        ),
        models.Prefetch(query=[0.01, 0.45, 0.67], using="dense", limit=20),
    ],
    query=models.RrfQuery(rrf=models.Rrf()),
    limit=10,
)

Postgres can do the same shape, and for many apps it does it well: tsvector with ts_rank_cd for the keyword half, pgvector for the dense half, a CTE each, and your own fusion in SQL. It is more code and you own the ranking math, but it is one query against one database and you can see exactly what it does. If hybrid ranking quality is the product rather than a feature, look at Weaviate too, where it is a single call with a tunable alpha; I compared those two in Qdrant vs Weaviate. The broader keyword-versus-meaning question is in full-text search vs vector search.

Four scenarios and what I would pick

RAG over a few hundred thousand chunks inside an existing Postgres app. pgvector, without hesitating. The chunks belong to documents that belong to users, all of which are already rows. One transaction, one backup, one connection pool. Adding Qdrant here buys latency you cannot measure and costs you a sync job you will debug at some point.

Multi-tenant semantic search where every query filters by tenant. Qdrant. This is the case the filterable graph and the tenant index were built for, and it is the one where pgvector's post-filtering turns into real tuning work. If you are early and the tenants are small, pgvector with a partial index per large tenant buys you time, but you are building toward the switch.

Tens of millions of vectors. Qdrant, mostly for the memory dials. At that size the question stops being "does it return the right rows" and becomes "how much RAM does this index need to be fast," and scalar or binary quantization plus on-disk payload indexes is a lever pgvector does not offer. The alternative, if you are committed to Postgres, is one of the extensions in the next section rather than stock pgvector.

Hybrid keyword plus vector as the core experience. Qdrant if you want fusion handled for you, Postgres if the keyword half is genuinely simple and you would rather own the ranking. Weaviate if hybrid is the product.

The Postgres extensions that change the math

Stock pgvector is not the ceiling of vector search in Postgres. pgvectorscale from Timescale adds StreamingDiskANN, a disk-friendly index inspired by Microsoft's DiskANN research, and publishes benchmark claims against Pinecone at 99% recall. VectorChord is the successor to pgvecto.rs and uses RaBitQ compression with automatic re-ranking to keep vectors small.

Two caveats before you plan around either. Neither ships with Postgres, so your host has to offer it, which narrows your hosting options considerably; check before you build. And VectorChord is dual-licensed under AGPLv3 or the Elastic License v2, which is a conversation to have with whoever owns licensing at your company if you are redistributing anything. Verify both licenses yourself at the links above rather than taking my word for it.

Where Layerbase fits, and where it does not

Being specific about this matters more than the pitch. Layerbase Cloud runs managed Qdrant as a first-class engine, currently defaulting to 1.18 with 1.16 still supported for databases already on it. It does not currently ship pgvector on its managed Postgres. Our Postgres images carry the standard contrib extensions (pg_trgm, pgcrypto, citext, hstore, ltree, and the rest), and vector is not among them. If pgvector is your plan, use a host that bundles it, and treat this post's pgvector half as vendor-neutral advice rather than a route to us.

What we are genuinely good for here is the Qdrant side and the prototyping loop around it. Flat monthly pricing with no per-query or per-vector meters, so an eval run that hammers the index does not produce a surprise on the invoice. A free tier with no card, which is enough to load a real corpus and find out whether your filtered queries behave. And Qdrant branches, which is unusual for a managed vector store: fork a collection before a reindex or an eval sweep, run the experiment against the fork, and throw it away. The rest of the branchable-engine list and how it works is in branching vector databases.

Where we are the wrong answer: you need multi-region replicas, a large Qdrant cluster with custom shard keys, or enterprise compliance paperwork. Qdrant's own managed cloud is the better fit for those, and I would rather say so than have you find out after migrating.

If a managed Qdrant is what you want, create one and point your existing client at it. Pricing is here.

FAQ

Is pgvector fast enough for production RAG?

For most RAG workloads, yes. The corpus behind a typical internal assistant or docs search is small, the queries are not heavily filtered, and the index fits in memory on a machine you were paying for anyway. The failure modes are selective filters and index size, not the basic operation.

At what vector count should I switch?

I am not going to give you a number, because the published ones disagree and they all depend on dimensions, filter selectivity, and how much RAM you will buy. Measure three things instead: what fraction of the collection a typical query has to search after filtering, how large your index is in bytes (dimensions times 4, or times 2 for halfvec, times row count, plus graph overhead), and whether that fits comfortably in your cache. When any of the three goes the wrong way, evaluate.

Can I use both?

Often the right answer. Keep the relational truth and small or infrequently searched embeddings in Postgres, and put the large, heavily filtered collection in Qdrant. You still own the sync for the second one, but only for the part that needed it.

Does Qdrant replace Postgres?

No, and treating it as a primary store is a mistake I have watched people make. It has no relational model, no joins, and no transactions across your application data. The payload is a denormalized copy for filtering and display. Something still has to own the truth.

What about Pinecone?

Managed-only, so the comparison is about a service rather than an engine, and the pricing model is metered rather than flat. I put it next to Qdrant and Weaviate in Pinecone vs Weaviate vs Qdrant, and the wider field is in vector databases compared in 2026.

Can I try Qdrant without signing up for anything?

Yes. The Layerbase CLI runs Qdrant on your own machine with lbase create q1 --engine qdrant --start, which is enough to load a corpus and try the filtered queries above before deciding anything. The hands-on walkthrough is getting started with Qdrant.

Wrapping up

The question in the title is really "do I need a second datastore yet," and the answer for most applications is not yet. pgvector's advantage is that the embeddings live next to the data they describe, which removes an entire class of consistency problems you would otherwise design around. Give that up when a specific thing forces you to: filters that shrink the searchable set on every query, an index that no longer fits in affordable RAM, or hybrid retrieval you do not want to hand-roll.

What I would not do is start with Qdrant because the collection might get big. Build on the database that already holds your rows, instrument the filtered queries, and switch when a measurement tells you to. If you want to see what Qdrant does with your real corpus before committing, spin one up and load it; an afternoon of real queries settles this better than any comparison table, including mine. What changed in the recent Qdrant releases, and why quantization is the number that decides your bill, is in Qdrant 1.18 on Layerbase.