Every retrieval-augmented application eventually faces the same storage question: where do the embeddings live? Many teams end up syncing a vector file with a relational database, and paying for two systems that drift apart. pgvector takes the opposite approach: it is a compact C extension, currently at version 0.8.7, that teaches PostgreSQL itself to store vectors and search them by similarity. Because it runs inside the database, your embeddings inherit everything Postgres already does well — ACID transactions, write-ahead logging, point-in-time recovery, JOINs, and any client language with a Postgres driver.

The feature list is longer than the codebase suggests. pgvector supports exact and approximate nearest neighbor search, four storage types (single-precision vector, half-precision halfvec, bit, and sparsevec), and six distance metrics: L2, inner product, cosine, L1, Hamming, and Jaccard. Two index types cover the approximate side — HNSW, a multi-layer proximity graph that is the recommended default, and IVFFlat, the classic inverted-file approach with k-means clustering. There is no Python here at all; it is plain C compiled against the PostgreSQL server headers, which is exactly why a source tour is so readable.

This post walks that source tree from master, with every symbol and line number verified against the working tree. Two diagrams were generated for it: an overview of how the pieces connect, and a detail map of the two index access methods. The post also shows the LLM tooling side of the ecosystem elsewhere on this site, but first, let’s see what 28 files under src/ actually do.

pgvector overview architecture: vector type layer, distance kernels, HNSW and IVFFlat index access methods

pgvector at a glance: the SQL catalog wires a varlena type and two index access methods to C callbacks; both index types funnel into the same distance kernels.

Reading the overview from left to right:

Two Index Access Methods Under One Extension

The elegant part of pgvector’s design is how little custom machinery it needs beyond the PostgreSQL extension points. In sql/vector.sql, the type is declared at L6, then the two access methods with just two lines each: CREATE ACCESS METHOD ivfflat TYPE INDEX HANDLER ivfflathandler at L355 and CREATE ACCESS METHOD hnsw TYPE INDEX HANDLER hnswhandler at L364. Everything else — build, insert, scan, vacuum, cost estimation — is the AM author filling in a function-pointer table. hnswhandler even declares .amcanbuildparallel = true, which is why large HNSW builds can use parallel workers.

Operator classes do the semantic gluing. vector_l2_ops binds <-> to l2_distance, vector_cosine_ops binds <=> to cosine_distance, and so on for each storage type, so one C function such as l2_distance serves both plain queries and index scans. Because Postgres only supports ascending index scans on operators, <#> returns the negative inner product — a small but important subtlety when you multiply by -1 to recover the true similarity.

Inside the Detail Diagram

The detail map zooms into the two AMs and the type layer. It is worth reading top to bottom if you plan to modify the index code:

pgvector detail architecture: type I/O, distance kernels, HNSW build, insert and scan, IVFFlat and k-means

The detail graph: on-disk format structs in hnsw.h and ivfflat.h, the shared HnswSearchLayer core, and the k-means stage that only IVFFlat pays for.

From Install to Your First Model

The README’s quick start is honest about the compile step, and it is the right place to start. On Linux or Mac, with Postgres 13 or newer:

git clone --branch v0.8.7 https://github.com/pgvector/pgvector.git
cd pgvector
make
make install # may need sudo

Windows builds through an x64 Native Tools Command Prompt with nmake /F Makefile.win, and there are Docker, Homebrew, APT, Yum, and PGXN packages if you would rather not compile. Once the extension is on the server, everything else is plain SQL:

CREATE EXTENSION vector;

CREATE TABLE items (id bigserial PRIMARY KEY, embedding vector(3));
INSERT INTO items (embedding) VALUES ('[1,2,3]'), ('[4,5,6]');

CREATE INDEX ON items USING hnsw (embedding vector_l2_ops);

SELECT * FROM items ORDER BY embedding <-> '[3,1,2]' LIMIT 5;

Four lines worth unpacking. CREATE EXTENSION vector runs vector.sql and registers the type, operators, and both access methods in one transaction. vector(3) fixes the dimension through the typmod path (vector_typmod_in at L343), so a mismatched insert fails immediately instead of silently. The CREATE INDEX line builds an HNSW graph over the column; add one index per metric you query (vector_ip_ops, vector_cosine_ops, vector_l1_ops). And the final SELECT combines ORDER BY with a distance operator and LIMIT — that trio is what lets the planner choose an index scan instead of a sequential scan over every row.

Tuning is two knobs. Before the query: SET hnsw.ef_search = 100 enlarges the candidate list for better recall at the cost of speed. Before the build: m (16 by default, HNSW_DEFAULT_M, L65) and ef_construction (64 by default) trade build time and memory against graph quality. The README’s advice — index after bulk-loading, raise maintenance_work_mem so the in-memory build phase does not spill — falls straight out of the build code we read above.

Storage Types, Distances, and Quantization

The vector type stores 4 bytes per dimension plus an 8-byte header, with a hard limit of 16,000 dimensions but an index limit of 2,000 (HNSW_MAX_DIM, L43). The companion types attack that ceiling from both ends. halfvec halves storage with 2-byte elements and indexes up to 4,000 dimensions; its distance math lives in the SIMD helpers of halfutils.c, L47. sparsevec stores {index:value}/dim pairs for text-heavy workloads where only a few components are nonzero. And binary quantization indexes up to 64,000 dimensions by hashing each vector to bits, then re-ranking with the original floats — a two-stage pattern you can express entirely in SQL with expression indexes.

Metric choice matters more than most people expect. If your embedding model produces unit-normalized vectors (common for OpenAI-style embeddings), inner product search and cosine search return the same ordering, and inner product is cheaper. Cosine distance is the safe default otherwise. This matters when comparing pgvector with dedicated in-process libraries like USearch or FAISS: the graph algorithms are cousins (USearch also uses HNSW), but pgvector’s payoff is transactional co-location with your rows, not raw in-memory throughput.

The practical integration point is upstream: whatever generates the embeddings — a sentence-transformer served via Transformers, or a locally trained tokenizer — writes floats into INSERT statements, and retrieval happens with one operator in a WHERE clause alongside your other filters.

Honest Limits

  • Approximate means approximate. An HNSW index trades recall for speed, and results can differ between queries after the index exists. Filters are applied after the index scan, so a highly selective WHERE clause can return fewer rows than requested; 0.8.0’s iterative index scans mitigate this by scanning more of the index when needed, bounded by hnsw.max_scan_tuples (20,000 by default).
  • The 2,000-dimension index ceiling is real. Beyond it you must use half-precision indexing (4,000), binary quantization (64,000), subvector indexing, or dimensionality reduction. The type itself stores 16,000 dimensions, but those rows will not be indexed as-is.
  • Vacuum on HNSW is slow. The README itself recommends REINDEX INDEX CONCURRENTLY before VACUUM on large HNSW indexes — the graph structure makes bulk deletion expensive.
  • No built-in sharding or ANN benchmarks are provided. The project scales vertically, with replicas, or via external sharding examples; there are no published QPS or recall figures in the repository, and any numbers you see elsewhere are someone else’s benchmark, not the project’s.
  • The README’s own recommendation: monitor recall by comparing approximate results against exact search with SET LOCAL enable_indexscan = off, and use EXPLAIN (ANALYZE, BUFFERS) before tuning anything.

What makes pgvector compelling is not that it beats a GPU index library on raw speed — it does not try to. It is that the entire approximate search engine, from hnswhandler down to the WAL-logged page tuples, fits in a directory you can read in an afternoon, and it deploys wherever Postgres already runs. For teams whose embeddings must live beside their transactions, that combination is hard to beat.

Watch PyShine on YouTube

Contents