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 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:
vector_in, L178 parses the textual form'[1,2,3]'withstrtof, rejectsNaNandInfinity, and enforces the declared dimension throughCheckExpectedDimat L274 — that is howvector(3)refuses a 4-element literal. The struct it fills in lives in vector.h, L18: a varlena header, anint16dimension, and a flexible float array, capped at 16,000 dimensions (VECTOR_MAX_DIM, L11).l2_distance, L581 and its siblings —inner_product, L624,cosine_distance, L673,l1_distance, L742 — are plain loops the compiler auto-vectorizes. They back the operators<->,<#>,<=>, and<+>declared in vector.sql, L254.hnswhandler, L468 fills PostgreSQL’sIndexAmRoutinestruct:ambuildpoints athnswbuild,aminsertathnswinsert,amgettupleathnswgettuple. This is the standard index access method interface, soCREATE INDEX ... USING hnswneeds no special syntax.hnswbuild, L1166 runs a two-phase build. The graph first lives entirely in memory; when it no longer fits inmaintenance_work_mem,FlushPages, L297 materializes what exists and the build switches to inserting tuples one by one, WAL-free, exactly like normal inserts.hnswinsert, L836 is the per-row hook.HnswInsertTuple, L817 picks neighbors level by level, from the top of the hierarchy down, then links the new element into each layer.HnswSearchLayer, L1017 is the shared greedy beam search used by both inserts and queries. At query timehnswgettuple, L197 drives it with a candidate list sized byhnsw.ef_search, 40 by default (HNSW_DEFAULT_EF_SEARCH, L71).ivfflatbuild, L1058 samples the table, clusters it, and delegates the math toIvfflatKmeans, L554, which uses the Elkan triangle-inequality trick (ElkanKmeans, L247) to skip most distance recomputations between iterations.ivfflatgettuple, L364 scans only the lists whose centers are closest to the query —ivfflat.probesof them, 1 by default (IVFFLAT_DEFAULT_PROBES, L56).
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:
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.
CreateMetaPage, L82 stamps the metapage with a magic number, the fixed dimensions,m,ef_construction, and the entry point. The persistence format is defined byHnswMetaPageDataat L347 and the element and neighbor tuple layouts at L371 and L385.HnswGetMetaPageInfo, L312 reads that metadata back on every scan and insert, whileHnswUpdateMetaPageInfo, L363 advances the entry point as the graph grows taller.HnswInsertTupleOnDisk, L760 wraps each page modification inGenericXLog, which is the reason HNSW indexes replicate and support point-in-time recovery like any other index.hnswbulkdelete, L793 andhnswvacuumcleanup, L818 keep dead heap tuples from polluting search results during VACUUM.ivfflatoptions, L172 validates thelistsreloption against the 32,768 ceiling (IVFFLAT_MAX_LISTS, L55); the k-means stage is why IVFFlat needs data before you can build the index, while HNSW’shnswbuildempty, L1184 happily creates an index on an empty table.
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
WHEREclause can return fewer rows than requested; 0.8.0’s iterative index scans mitigate this by scanning more of the index when needed, bounded byhnsw.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 CONCURRENTLYbeforeVACUUMon 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 useEXPLAIN (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. Enjoyed this post? Never miss out on future posts by following us