Australian cloud for business software — built and run in Sydney since 2003

NetLeverage
NetLeverage

pgvector and Qdrant: limits, filters and operations

Technical article
AI

About this article: AI-assisted writing, reviewed against the cited sources. This is source research, not a lab validation of the procedures. Check the stated versions and your own recovery requirements before making changes.

pgvector keeps the vectors in PostgreSQL, so the row, the permission check and the nearest-neighbour list commit and replicate together. A dedicated vector database is a separate service. Qdrant, the example here, adds filter-aware edges to its HNSW graph. The operational difference is not a slogan about speed. It is when the filter is applied, how many dimensions the index will accept, and which other jobs — vacuum, memory, tenants — you inherit.

An AI writing system produced this article. No index was built and no query was run. SQL and configuration below are the projects' documented examples. No NetLeverage database product is described.

The dedicated engine used as the concrete case is Qdrant, because its indexing documentation states how filters meet the graph. Other dedicated products are not assumed to work the same way.

Why it matters

Three surprises show up after the prototype:

  1. An embedding wider than the index limit stores, and then refuses the index.
  2. A WHERE clause on an approximate index is applied after the graph walk, so LIMIT 10 can return fewer rows, about four in the README's illustrative selectivity calculation.
  3. One shared graph means one tenant's vectors change recall for every other tenant.

PostgreSQL already solves transactions, joins, backups and point-in-time recovery. pgvector's own readme lists those as the reason to stay. You pay for them with PostgreSQL's memory, vacuum and planner. A second database does not remove that work. It moves the vector index out, and it adds a second thing to upgrade, monitor and restore.

How it works

What sits in the table

The pgvector readme on the master branch, opened 10 November 2026, defines the storage. The newest release in the v0.8.7 changelog, opened the same day, is 0.8.7 dated 1 October 2026. Confirm extversion on the server before you trust a default. The readme still says the build supports PostgreSQL 13 and newer. PostgreSQL 12 support was dropped in 0.8.0. Development-branch requirements can differ from released tags; check both extension and PostgreSQL support before upgrading.

A vector value is one real per dimension, and every element must be finite: no NaN and no infinity. Storage is 4×dimensions+8 bytes, and a value may hold up to 16,000 dimensions. A halfvec is two bytes per dimension plus 8, also up to 16,000 stored dimensions, same finite-element rule. A sparsevec is 8×non-zero elements+16 bytes, up to 16,000 non-zeros. The index is stricter than the column. HNSW and, except where noted, IVFFlat accept:

Type Indexed up to
vector 2,000 dimensions
halfvec 4,000 dimensions
bit 64,000 dimensions
sparsevec (HNSW only) 1,000 non-zero elements

So a column can store a 3,072-dimension vector — 3,072 is under 16,000 — and still be unable to carry an HNSW or IVFFlat index on that type. The readme's own answer is half-precision indexing up to 4,000 dimensions, binary quantisation up to 64,000 bits, subvector indexes where the model allows it, or a reduction you choose outside pgvector. Half-precision indexing casts at index time:

The documented expression-index pattern casts embedding to halfvec(3) and uses the halfvec_l2_ops operator class under HNSW.

The cast is the documented pattern. The 3 is their toy width, not a recommendation. Query with the same cast. Binary quantisation indexes binary_quantize(embedding)::bit(n) with Hamming distance, and the readme shows a second exact reorder over a larger candidate list. That second pass is doing work the bit index cannot do. Measure it. The readme does not publish a recall number for it.

Arithmetic from the storage rule, not a benchmark: one million vector(1536) values are 1,000,000×(4×1536+8) bytes, about 5.7 GiB, before the index, the heap overhead and the WAL. The readme does not give an HNSW size formula. It tells you to read pg_relation_size. Indexes need not fit in memory. The same answer says you should expect better performance if they do.

Without an index, search is exact. Adding an approximate index changes which rows come back. That sentence is in the indexing section, and it is the thing to tell anyone who thinks the index is only an accelerator.

HNSW and IVFFlat inside PostgreSQL

HNSW is a multilayer graph. The readme says it has the better speed-versus-recall tradeoff, slower builds and more memory than IVFFlat, and that it can be built on an empty table because there is no training step. Defaults: m = 16 connections per layer, ef_construction = 64, and at query time hnsw.ef_search = 40. Raising ef_construction spends build and insert time on recall. Raising ef_search spends query time on recall. Build the graph so it fits in maintenance_work_mem. The server notice is explicit once it does not: hnsw graph no longer fits into maintenance_work_mem. The readme's example sets 8 GB and warns not to set it so high that the server runs out of memory. Parallel builds use max_parallel_maintenance_workers (their example is 7, plus the leader). max_parallel_workers defaults to 8, so a large worker count needs that raised too. In production the readme says to create indexes with CONCURRENTLY so writes are not blocked. Docker builds need --shm-size at least as large as maintenance_work_mem or parallel HNSW builds error.

IVFFlat splits vectors into lists and searches the lists closest to the query. Faster builds, less memory, worse speed-recall tradeoff, in the readme's words. Three conditions it states: build the index after the table has data; start the list count at rows / 1000 up to one million rows and at sqrt(rows) above that; start probes at sqrt(lists). The default of ivfflat.probes is 1. Setting probes equal to the number of lists is an exact search, and at that point the planner will not use the index. Too few rows for the list count is a documented cause of a short result list. The README suggests delaying that approximate index until enough data exists. Removing or rebuilding a live index requires a reviewed migration and query-plan validation, not an ad hoc diagnostic action.

Distance operators are <-> (L2), <#> (negative inner product), <=> (cosine), <+> (L1). <#> is negative because PostgreSQL index scans on these operators are ascending only. Cosine on unit-length vectors can be replaced by inner product; the readme points at that for normalised embeddings. NULL vectors are not indexed. Zero vectors are not indexed for cosine. A query uses the index only with ORDER BY of the distance operator itself, ascending, plus LIMIT. Wrapping it as ORDER BY 1 - (embedding <=> q) DESC will not.

The 0.8 changelog is part of choosing a version, not folklore. 0.8.3 records a fix for possible HNSW index corruption during vacuum. 0.8.4 records a fix for hnsw graph not repaired and for errors on insert during HNSW vacuum. 0.8.7, the current release in that file, records a fix for a buffer overflow in the IVFFlat build. An 0.8.0 or 0.8.2 install is missing those fixes. Read extversion before a vacuum-heavy workload.

Vacuum of an HNSW index can take a long time. The readme's speed-up is REINDEX INDEX CONCURRENTLY first, then VACUUM. That reindex is a heavy lock-avoiding rebuild, not a free hint. Schedule it.

Recall monitoring is also specified: in a transaction, SET LOCAL enable_indexscan = off, run the same query, and compare. That is an exact scan. Do it on a statement you can afford, not on every production request.

Filters happen after the walk

This is the limit that decides the product. With an approximate index, pgvector applies the WHERE clause after the index scan. The readme works the default: if the condition matches 10% of rows and hnsw.ef_search is 40, about four rows match. LIMIT 10 may therefore be underfilled; the four-row figure is an average, not a hard deterministic limit. Iterative index scans, added in 0.8.0, keep walking until enough rows survive or a cap is hit. hnsw.iterative_scan = strict_order keeps distance order. relaxed_order can return rows slightly out of distance order and, the readme says, recalls better. A materialised CTE can re-sort; on PostgreSQL 17 and newer the readme says the sort key needs + 0 or the planner undoes it. The HNSW cap hnsw.max_scan_tuples defaults to 20,000 and does not affect the initial scan. hnsw.scan_mem_multiplier defaults to 1 times work_mem. If raising the tuple cap does not improve recall, the readme says to raise the multiplier.

For a filter with few distinct values, a partial HNSW index (WHERE category_id = 123) searches only that slice. For many values, list partitioning. A plain B-tree on the filter column is still the right first try when the condition matches a small fraction of the table: the readme says that path can be an exact neighbour search, because the approximate index is then the wrong tool.

Tenants share a failure mode the readme states directly. Vectors from one tenant in a shared approximate index affect recall and speed for the others. Isolation is list partitioning or a table per tenant, not a WHERE customer_id = ? on one global HNSW and a hope that iterative scan will cope.

A non-partitioned table is limited to 32 TB by default. Partitioned tables can have thousands of partitions of that size. That is a PostgreSQL limit the readme repeats. It is not a promise that an HNSW build at that size will finish in maintenance_work_mem.

Horizontal scale, in the same page, is replicas, or a sharding layer such as Citus or PgDog. The extension does not itself shard.

What Qdrant documents instead

Qdrant's indexing page, opened the same day, says a vector index alone is not enough once filters exist. A payload index on a field estimates how many points match, and the planner uses that cardinality to pick a strategy. Payload indexes cost memory. The page's advice is to index the fields you actually filter, and if you cannot index all of them, index the field that cuts the result the most. Create those indexes before you load data. The filterable HNSW graph only grows extra edges for payload indexes that already exist. Add the payload index later and you must rebuild the HNSW graph or you do not get the edges.

The page describes three regimes. A weak filter can use the HNSW graph as it is. A very strict filter can use the payload index and rescore. The middle is the failure of either extreme: too many points to scan fully, and a graph that "starts to fall apart" if you simply delete the non-matching neighbours as you walk. The documented remedy is extra edges in the HNSW graph, built from indexed payload values, so the walk can stay inside the filter. Those extra edges are per payload index, not for every combination of indexes. Two strict filters at once can still leave the graph disconnected. The same note covers a large number of soft-deleted points. From v1.16.0, ACORN is the documented response: when a neighbour is filtered out, also look at neighbours of neighbours. That costs search time. From v1.17.0, enable_hnsw: false on a payload index skips the extra edges when that field is not used to filter dense search, because the edges add index time, memory and disk.

Dense vectors in this documentation use HNSW only. The sample configuration sets m: 16 and ef_construct: 100. Search ef defaults to ef_construct. full_scan_threshold in the sample is 10,000 kilobytes of vector data, not a universal point count. The page equates 1 KB with one 256-dimensional vector and loosely relates the threshold to matching-point count. Using that convention, 10,000 KB corresponds to about 10,000 such vectors, or about 1,667 at 1,536 float32 dimensions; confirm the implementation's conversion before capacity planning. Unindexed filters are not only slower. The page says they can load the cluster and hurt other queries. Strict mode with unindexed_filtering_retrieve set false rejects them. On Qdrant Cloud the page says those settings are applied to all collections by default.

Payload indexes are always on disk and, by default, also pinned in memory. A large or rarely used index can be moved to a cached or cold tier; the page says latency can then include disk reads.

None of this was timed against pgvector here. The comparison that the two documents support is the order of work. pgvector's approximate index walks, then filters, unless you built a partial index or a partition that never contained the other rows. Qdrant's filterable HNSW tries to walk edges that already respect the indexed payload, and it will full-scan when the planner thinks the filter is small enough. Both still have a hole: pgvector HNSW iterative scans are capped by hnsw.max_scan_tuples and IVFFlat by ivfflat.max_probes, and Qdrant's extra edges do not cover every combination of filters.

What to choose and why

Situation Prefer Why
The vector is a column on a row you already join, authorise and back up in PostgreSQL pgvector, exact search until the table is large One transaction, WAL replication and point-in-time recovery, as the readme states. An approximate index is optional and changes results
Width above 2,000 and at most 4,000 halfvec index, or an expression index that casts to halfvec A vector HNSW or IVFFlat index stops at 2,000 dimensions
Width above 4,000 Binary quantisation plus a reorder, or a reduction outside the database That is the readme's documented path. It is not free recall
Filter matches a small slice of a PostgreSQL table B-tree on the filter, exact neighbour order The readme says exact indexes suit a low percentage match. Post-filtering an HNSW walk does not
Many tenants in one graph List partitioning or a table per tenant The readme says a shared approximate index lets one tenant change another's recall
Filtered search is the main query, and the filter is neither tiny nor "almost everyone" Qdrant, or another engine whose filtered-search behaviour you have verified Extra HNSW edges, a cardinality-based planner, and a full scan under the threshold. Create payload indexes before the load
You need a recall number The exact-versus-approximate comparison the pgvector readme shows, on your data Neither document's defaults (ef_search 40, Qdrant ef equal to ef_construct) are a measured recall

Our operational recommendation is to stay on pgvector when losing the join and the WAL would cost more than the post-filter. Move when the query is "nearest chunk among rows the user may see", the allowed set is a middle-sized fraction, and iterative scans are visiting tens of thousands of tuples to fill a page of ten. Partitioning by tenant is the step the pgvector readme already gives you before a new database is justified.

If you do run pgvector, pin 0.8.7 or newer until you have read a later changelog. Vacuum and IVFFlat builds are where 0.8.3, 0.8.4 and 0.8.7 say they fixed corruption and overflow. CREATE INDEX CONCURRENTLY belongs in the migration, not after the first blocked write. A graph fitting in build memory can build faster, but staying inside a safe whole-server memory budget takes priority. Evaluate search width and iterative scans on a representative copy before changing production query behaviour.

Risks and limits

  • An approximate index is allowed to disagree with exact search. The readme says you will see different results. ef_search 40 is a cap, and dead tuples shrink it further.
  • Post-filtering under-fills LIMIT. Ten percent selectivity against 40 candidates is the readme's own average of about four rows. Iterative scan is the fix they added in 0.8.0, with HNSW capped by hnsw.max_scan_tuples (20,000 by default) and IVFFlat by ivfflat.max_probes.
  • Half-precision and binary indexes are smaller types, not the same search. Store the original if you still reorder by it. The readme does not publish the recall loss.
  • Qdrant's extra edges are per field. Combinations of strict filters, and large numbers of soft-deleted points, are the cases the page sends to ACORN, from v1.16.0. A cluster that is not on that version does not have that option.
  • Unindexed Qdrant filters can be rejected when strict mode says so, and on Qdrant Cloud the page says that is the default. A query that worked without a payload index can start failing after that setting.
  • This article does not measure either system. No recall, latency or memory figure here was produced in a lab. Qdrant is one documented dedicated design, not a stand-in for every other vector database.
  • No NetLeverage product behaviour is claimed.

Sources

Claim-level notes are in the evidence file. Opened 10 November 2026.

  1. pgvector README (master) — https://github.com/pgvector/pgvector/blob/master/README.md
  2. pgvector changelog at tag v0.8.7 — https://github.com/pgvector/pgvector/blob/v0.8.7/CHANGELOG.md
  3. Qdrant, Indexing — https://qdrant.tech/documentation/concepts/indexing/
  4. Qdrant, Filtering — https://qdrant.tech/documentation/concepts/filtering/