How pgvector Puts HNSW Inside Postgres — Vector Search as an Index Access Method thumbnail
TeardownJul 2, 2026Data infrastructure

How pgvector Puts HNSW Inside Postgres — Vector Search as an Index Access Method

pgvector is the 'just use Postgres' answer to vector search, and the contrast with a dedicated engine like Qdrant is the home, not the algorithm. Both are HNSW; pgvector implements it as a Postgres index access method. Read at v0.8.4 — the graph lives in WAL-logged Postgres pages, a search is plain SQL, and filtering is Postgres's job, patched by a new iterative scan.

By ArcYou Editorial. Source read against the pinned repository states listed in Methodology.

Decision brief

What this source read clarifies

What it really is
A Postgres extension that implements HNSW as an index access method
Best for
Systems where Postgres is already the ledger and SQL, WAL, replication, and backup boundaries should stay intact
Avoid if
The vector search engine needs to independently own filtering and index strategy
Core mechanism
HNSW in WAL-logged pages, SQL order-by access, iterative scan for filters

Read at: pgvector-v0.8.4 @ v0.8.4

The most common question a tech lead asks about vector search is “do I even need a vector database, or can Postgres do it?” pgvector is the answer, and I read it at v0.8.4, commit 1d458ad to see what “Postgres can do it” actually costs. The surprise isn’t the algorithm — pgvector uses HNSW, the same graph I tore down in Qdrant. The surprise is the home: pgvector implements HNSW as a Postgres index access method, and everything interesting follows from that one decision. Every claim points at a line.

HNSW as a Postgres index

pgvector registers an index type the same way the built-in B-tree does: a handler function returns an IndexAmRoutine full of hooks. hnswhandler (hnsw.c:266) wires .ambuild = hnswbuild, .aminsert = hnswinsert, .amrescan = hnswrescan, .amgettuple = hnswgettuple (hnsw.c:298) — the standard index lifecycle. The one unusual flag is .amcanorderbyop = true (hnsw.c:277): this index answers order-by-operator queries, not equality lookups. The distance operators <->, <#>, <=> are registered in the opclass as FOR ORDER BY strategy-1 operators (vector.sql:313).

So a vector search is just SQL — SELECT ... ORDER BY embedding <-> '[...]' LIMIT 10 — and the planner turns the ORDER BY <-> into an ordered index scan. Crucially, that’s the only thing this index does: hnswcostestimate returns infinite cost when there’s no order-by clause (hnsw.c:147, “Never use index without order”). The HNSW index cannot serve a WHERE predicate. Hold that thought — it’s the whole filtering story later.

What you get for free: Postgres’s durability

Because the graph is a Postgres index, it’s stored the way every Postgres index is: as tuples on 8 KB pages, through the shared buffer manager. A graph node is an HnswElementTupleData carrying its level, its heap TIDs, and a pointer to a separate neighbor tuple (hnsw.h:357); pages are read and dirtied with ReadBuffer/MarkBufferDirty, never a private mmap. And it’s WAL-logged: the whole index is written to the WAL after a build (hnswbuild.c:1138), and every page an insert touches goes through generic WAL (hnswinsert.c:194).

That single fact — the index is a WAL-logged, buffer-managed relation — is what “just use Postgres” really buys. Crash recovery, streaming replication, point-in-time recovery, base backups: the vector index rides all of it, because Postgres already does it for every relation. And because a scan returns heap TIDs and lets the executor check visibility (xs_heaptid with xs_recheck = false, hnswscan.c:323), your vector search is MVCC-correct and transactional — an uncommitted row isn’t visible to another session’s search. A standalone engine has to build every one of those properties itself; pgvector inherits them by being a Postgres index.

The search, and the filtering problem

The scan itself is textbook HNSW: from the entry point, greedily descend the layers, then run an ef-width beam search on the base layer via HnswSearchLayer with hnsw.ef_search (default 40, hnswscan.c:55). It streams one heap TID per hnswgettuple call, ranked purely by distance.

Here’s where being a Postgres index bites. The index is filter-blind — it errors without an order-by key and reads only the query vector, never your WHERE clause (hnswscan.c:213). So WHERE tenant = 'acme' ORDER BY embedding <-> q LIMIT 10 works like this: the index returns the 40 nearest rows ignoring the filter, and Postgres throws away the ones that aren’t acme. If acme is 1% of your data, almost all 40 are discarded and you may get far fewer than 10 results — the classic post-filter recall collapse.

pgvector HNSW returns distance candidates before Postgres applies the WHERE filter

pgvector’s HNSW index ranks vector candidates first. The relational WHERE filter is applied later by the Postgres executor.

pgvector 0.8’s answer is the iterative scan (hnsw.c:97, default off). When the working set empties, instead of stopping (hnswscan.c:249) it calls ResumeScanItems (hnswscan.c:280) to continue the graph walk from the candidates it previously discarded, pulling more results until enough survive the filter — bounded by hnsw.max_scan_tuples (default 20,000) and a work-mem budget. You pick relaxed_order (return as found, faster) or strict_order (only in non-decreasing distance, hnswscan.c:313).

This is the sharp contrast with a dedicated engine. Qdrant attacks filtered search at build time — it adds payload-aware graph links so a filtered subgraph stays connected. pgvector’s graph is fixed and predicate-agnostic; it attacks the same problem at query time, by resuming the beam search. Different bet, same goal: don’t let a selective filter wreck recall.

Build and knobs

The build holds the graph in memory up to maintenance_work_mem, then falls back to an on-disk build when it no longer fits (hnswbuild.c:530) — so a too-small maintenance_work_mem doesn’t fail, it just gets slower. Parallel workers are supported. The knobs are the familiar HNSW ones, as index reloptions and GUCs: m (default 16), ef_construction (default 64, and must be ≥ 2*m), and the query-time ef_search (default 40). pgvector also ships IVFFlat, a k-means/inverted-list index (lists, probes) that builds faster and uses less memory than HNSW but trades away recall — the other classic vector index, in the same extension.

Where does this fit?

For vector databases, the axis I’d draw is dedicated engine vs. embedded-in-your-database. Qdrant is the dedicated engine: it owns its storage and treats the filter as a first-class input to both index structure and query planning. pgvector is the opposite bet — it makes vectors a Postgres data type and HNSW a Postgres index, so your embeddings live in the same transactional store as the rest of your data, backed up and replicated by the machinery you already run. The cost is that filtering is bolted on the way Postgres bolts on everything (post-filter, now patched by iterative scan), rather than engineered into the graph.

When would you reach for it?

Reach for pgvector when your data already lives in Postgres and you want vector search without operating a second system. You get transactions, joins against your real tables, one backup story, and one thing to run — which for a huge number of applications is worth more than the last few points of filtered-search performance. Go in knowing the edges: HNSW build wants maintenance_work_mem, selective WHERE filters need hnsw.iterative_scan turned on to hold recall, and at very large scale or under heavy filtering a dedicated engine’s build-time filter machinery will pull ahead. If you’re not already on Postgres, most of that inheritance argument disappears and the dedicated engines are back in play.

Methodology & scope

I read pgvector v0.8.4 at commit 1d458ad, focused on src/hnsw.c, hnswbuild.c, hnswinsert.c, hnswscan.c, hnswutils.c, and the SQL install file. Three headline claims — HNSW as an index access method, the WAL-logged/page/MVCC storage, and the filter-blind scan plus iterative-scan recovery — were each run through an adversarial claim-verifier at this commit and confirmed.

Scope caveats, stated plainly:

  • The “inherits crash recovery / replication / backups” claim follows from the index being a WAL-logged, buffer-managed relation; those are standard Postgres properties, not something I benchmarked here.
  • The IVFFlat “faster build, lower recall” characterization is architectural (k-means + probe-limited scan), not a measured number from this repo.
  • I read the index internals, not a performance comparison; recall and latency depend on your data, ef_search, and whether iterative scan is on.

Methodology

This post is read against the pinned repository states below. Directory names are convenience keys; the real pin is the commit SHA.

Repo keyVersionCommitUpstream
pgvector-v0.8.4v0.8.41d458ad5d737github.com/pgvector/pgvector

Related analysis

The decision axis is existence. Running a separate engine, living inside Postgres, or opening files from the application process changes consistency, deployment, and filtering strategy.