pgvector

Draft

The vectors lesson computed distances by hand, with arrays. It worked, but every search read and sorted every row. pgvector is the PostgreSQL extension that turns this into a real feature: a vector column type, distance operators, and indexes that find the nearest rows without reading them all.

Same eight documents, same questions, same embeddings: three numbers from mobilebert-uncased-mnli, scoring how much a text is about [databases, web, AI]. Score your own question and use its vector in any query below. Only the column type changes. Then four ways to search many rows: exact, HNSW, IVFFlat, and smaller vectors.

The documents

-- CREATE EXTENSION also makes PGlite load pgvector for this database.
CREATE EXTENSION vector;

CREATE TABLE documents (
  id SERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  -- Exactly 3 dimensions: [databases, web, AI].
  embedding vector(3) NOT NULL
);
Loaded Database:pgvector.database-init
INSERT INTO documents (title, body, embedding)
VALUES
  ('SQL indexes', 'Indexes help PostgreSQL find rows without reading the whole table.', '[0.95, 0.03, 0.02]'),
  ('JOIN patterns', 'Joins combine rows from related tables into one result.', '[0.66, 0.17, 0.17]'),
  ('Query tuning', 'Slow queries get faster with the right index and a better plan.', '[0.62, 0.15, 0.23]'),
  ('React components', 'Components split a user interface into small reusable pieces.', '[0.25, 0.26, 0.49]'),
  ('CSS grid layouts', 'Grid and flexbox position elements on a web page.', '[0.15, 0.82, 0.03]'),
  ('Neural networks', 'Neural networks learn patterns from training data.', '[0.14, 0.08, 0.78]'),
  ('Chatbots', 'Language models answer questions written in natural language.', '[0.32, 0.26, 0.42]'),
  ('Vector search', 'Embeddings let a database find documents with a similar meaning.', '[0.91, 0.04, 0.04]');

Same question, one operator

'speed up my database' is '[0.94, 0.04, 0.03]'. <-> is the L2 distance: the same l2_distance the vectors lesson wrote by hand, and the same three results.

Loaded Database:pgvector.database-init
SELECT
  title,
  round((embedding <-> '[0.94, 0.04, 0.03]')::numeric, 3) AS distance
FROM documents
ORDER BY embedding <-> '[0.94, 0.04, 0.03]'
LIMIT 3;
titledistance
SQL indexes0.017
Vector search0.032
JOIN patterns0.339
Limit  (cost=30.74..30.75 rows=3 width=72)
  ->  Sort  (cost=30.74..32.32 rows=630 width=72)
        Sort Key: ((embedding <-> '[0.94,0.04,0.03]'::vector))
        ->  Seq Scan on documents  (cost=0.00..22.60 rows=630 width=72)

The distance operators

'AI that understands text' is '[0.16, 0.10, 0.75]'. <=> is cosine distance (1 - cosine similarity). <#> is the negative inner product, negative so that ascending order still means closest first. All three: smaller is closer.

Loaded Database:pgvector.database-init
SELECT
  title,
  round((embedding <-> '[0.16, 0.10, 0.75]')::numeric, 3) AS l2,
  round((embedding <=> '[0.16, 0.10, 0.75]')::numeric, 3) AS cosine,
  round((embedding <#> '[0.16, 0.10, 0.75]')::numeric, 3) AS inner_product
FROM documents
ORDER BY embedding <=> '[0.16, 0.10, 0.75]';
titlel2cosineinner_product
Neural networks0.0410.001-0.615
React components0.3180.079-0.433
Chatbots0.4000.138-0.392
Query tuning0.6960.453-0.287
JOIN patterns0.7690.540-0.250
Vector search1.0350.745-0.180
SQL indexes1.0780.769-0.170
CSS grid layouts1.0180.801-0.128
Sort  (cost=61.34..62.92 rows=630 width=136)
  Sort Key: ((embedding <=> '[0.16,0.1,0.75]'::vector))
  ->  Seq Scan on documents  (cost=0.00..32.05 rows=630 width=136)

Which operator?

Use the one your embedding model was trained for; its documentation says. Most text models, including OpenAI's, return vectors of length 1, and then all three give the same order. <=> is the safe default. With length-1 vectors, <#> gives the same order and skips a division.

A real type

vector(3) is checked: inserting '[1, 2]' fails with 'expected 3 dimensions, not 2'. pgvector also ships helper functions, and casts to and from arrays.

Loaded Database:pgvector.database-init
SELECT
  title,
  vector_dims(embedding) AS dims,
  round(vector_norm(embedding)::numeric, 3) AS length,
  embedding::real[]::text AS as_array
FROM documents
LIMIT 3;
titledimslengthas_array
SQL indexes30.951{0.95,0.03,0.02}
JOIN patterns30.702{0.66,0.17,0.17}
Query tuning30.678{0.62,0.15,0.23}
Limit  (cost=0.00..0.13 rows=3 width=100)
  ->  Seq Scan on documents  (cost=0.00..27.33 rows=630 width=100)

More like this

Related documents, with the stored embedding as the question. It's still SQL: joins and WHERE work as usual.

Loaded Database:pgvector.database-init
SELECT other.title
FROM documents AS source
JOIN documents AS other ON other.id <> source.id
WHERE source.title = 'Vector search'
ORDER BY other.embedding <=> source.embedding
LIMIT 3;
title
SQL indexes
JOIN patterns
Query tuning
Limit  (cost=91.64..91.65 rows=3 width=40)
  ->  Sort  (cost=91.64..96.36 rows=1887 width=40)
        Sort Key: ((other.embedding <=> source.embedding))
        ->  Nested Loop  (cost=0.00..67.25 rows=1887 width=40)
              Join Filter: (other.id <> source.id)
              ->  Seq Scan on documents other  (cost=0.00..16.30 rows=630 width=68)
              ->  Materialize  (cost=0.00..17.89 rows=3 width=36)
                    ->  Seq Scan on documents source  (cost=0.00..17.88 rows=3 width=36)
                          Filter: (title = 'Vector search'::text)

Searching thousands of rows

All the plans so far are Seq Scan + Sort: fine for eight rows. To compare the ways pgvector can search, the next examples use the eight documents plus 5,000 generated ones, and the same question: "speed up my database".

Without a vector index, Postgres measures the distance to every row and sorts. A B-tree can't help: "closest to this point in 1,536 dimensions" has no sort order. Exact search is always right, and fine up to tens of thousands of rows.

Exact search on 5,008 rows

No vector index: the plan is a Seq Scan and a Sort. Postgres measures the distance to all 5,008 rows and sorts them. Slow on big tables, but always the true nearest rows: Vector search and SQL indexes come first.

SELECT title
FROM documents
ORDER BY embedding <=> '[0.94, 0.04, 0.03]'
LIMIT 5;
title
Vector search
SQL indexes
Python indexes note 4648
Redis networks note 4293
Python joins note 3938
Limit  (cost=212.78..212.79 rows=5 width=32)
  ->  Sort  (cost=212.78..225.30 rows=5008 width=32)
        Sort Key: ((embedding <=> '[0.94,0.04,0.03]'::vector))
        ->  Seq Scan on documents  (cost=0.00..129.60 rows=5008 width=32)

HNSW: a graph of neighbours

HNSW stands for Hierarchical Navigable Small World. It links the vectors into a graph of layers: the top layers have few, long links to cross the space quickly, the bottom one links every vector to its close neighbours. A search starts at the top and moves towards the question, one layer down each time it can't get closer.

layer 2: few vectors, long linkslayer 1layer 0: every vectorquestion
HNSW links every vector to its nearest neighbours, in layers. Upper layers hold fewer vectors and longer links. A search enters at the top, jumps towards the question (★), drops down a layer, and repeats, reaching the nearest vectors in a few hops instead of measuring every one.

The usual choice: fast, with high recall, and it can be created on an empty table. Building it is slow and memory hungry.

-- HNSW: a graph of vectors linked to their neighbours. A search walks the
-- graph towards the question instead of measuring every row.
-- The operator class must match the operator you ORDER BY:
--   vector_l2_ops for <->, vector_cosine_ops for <=>, vector_ip_ops for <#>.
CREATE INDEX documents_embedding_idx ON documents
USING hnsw (embedding vector_cosine_ops);

The same search with HNSW

Same rows, same query, with an HNSW index. The plan is an Index Scan whose Order By is the distance: Postgres walks the graph instead of sorting every row. Compare the results with the exact search: an approximate index can miss some of the true nearest rows.

SELECT title
FROM documents
ORDER BY embedding <=> '[0.94, 0.04, 0.03]'
LIMIT 5;
title
Vector search
SQL indexes
Python indexes note 4648
Redis networks note 4293
Python joins note 3938
Limit  (cost=83.96..85.03 rows=5 width=32)
  ->  Index Scan using documents_embedding_idx on documents  (cost=83.96..1156.16 rows=5008 width=32)
        Order By: (embedding <=> '[0.94,0.04,0.03]'::vector)

Approximate, on purpose

To be fast, an approximate index can miss a true neighbour now and then. SET hnsw.ef_search = 100 (default 40) searches more of the graph: better recall, slower queries. With a WHERE filter, the index returns the nearest rows first and the filter runs after, so a very selective filter can return fewer rows than the LIMIT; recent pgvector versions add hnsw.iterative_scan for that.

IVFFlat: search the nearest lists

IVFFlat stands for Inverted File with Flat storage. Inverted file: the vectors are filed in lists, one per cluster centre, and a search reads only the lists nearest to the question. Flat: inside each list the vectors are stored whole and compared exactly, not compressed.

list read (probes = 1)closer, but in another listquestion
IVFFlat splits the vectors into lists around centroids (×). A search reads only the list whose centroid is nearest to the question (★), here the blue one. The vector circled in red is closer, but sits in another list, so it is missed unless ivfflat.probes reads more lists.

IVFFlat partitions the vectors: it picks centroids from the rows already in the table, and files every vector in the list of its nearest centroid. Quicker to build and smaller than HNSW, but lower recall at the same speed. Create it after loading the data, and rebuild it when the data changes a lot: centroids picked from an empty or different table describe the wrong clusters.

-- IVFFlat: split the vectors into lists (clusters) around centroids.
-- A search only reads the lists whose centroids are nearest to the question.
-- The centroids come from the rows already in the table: create it after
-- loading the data. Rule of thumb: lists = rows / 1000 up to 1M rows.
CREATE INDEX documents_embedding_ivfflat_idx ON documents
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50);

-- How many lists a search reads (default 1). More: better recall, slower.
SET ivfflat.probes = 5;

The same search with IVFFlat

Same rows, same query, with an IVFFlat index of 50 lists. The plan is an Index Scan again. With the default ivfflat.probes = 1 it reads only the nearest list, so a true neighbour sitting in the next list can be missed. Here it isn't: compare with the exact search.

SELECT title
FROM documents
ORDER BY embedding <=> '[0.94, 0.04, 0.03]'
LIMIT 5;
title
Vector search
SQL indexes
Python indexes note 4648
Redis networks note 4293
Python joins note 3938
Limit  (cost=3.35..3.85 rows=5 width=32)
  ->  Index Scan using documents_embedding_ivfflat_idx on documents  (cost=3.35..498.16 rows=5008 width=32)
        Order By: (embedding <=> '[0.94,0.04,0.03]'::vector)

Smaller vectors: halfvec and bits

A 1,536-dimension vector takes 6 KB per row, and HNSW indexes stop at 2,000 dimensions. pgvector has smaller types: halfvec stores 16 bits per number (half the space, indexes up to 4,000 dimensions), and bit stores 1 bit per number (32 times smaller, much rougher).

halfvec and bits

The same embeddings in smaller types. halfvec keeps 16 bits per number: 0.95 becomes 0.9501953, close enough. binary_quantize keeps 1 bit per number, positive or not: every document here becomes 111, because the model never scores a topic exactly 0.

Loaded Database:pgvector.database-init
SELECT
  title,
  embedding::halfvec(3)::text AS half,
  binary_quantize(embedding)::text AS bits
FROM documents;
titlehalfbits
SQL indexes[0.9501953,0.02999878,0.020004272]111
JOIN patterns[0.66015625,0.17004395,0.17004395]111
Query tuning[0.6201172,0.15002441,0.22998047]111
React components[0.25,0.26000977,0.48999023]111
CSS grid layouts[0.15002441,0.8198242,0.02999878]111
Neural networks[0.14001465,0.08001709,0.77978516]111
Chatbots[0.32006836,0.26000977,0.41992188]111
Vector search[0.91015625,0.040008545,0.040008545]111
Seq Scan on documents  (cost=0.00..25.75 rows=630 width=96)

Bits first, then re-rank

The usual pattern: let the cheap bit distance (<~>, Hamming) pick a few candidates, then order only those by the full vectors. Here it fails: all eight documents are 111, so the 6 candidates are arbitrary and Vector search, the true nearest, isn't among them. With 1,536-number vectors the bits differ and the candidates are good.

Loaded Database:pgvector.database-init
SELECT title
FROM (
  SELECT title, embedding
  FROM documents
  ORDER BY binary_quantize(embedding)::bit(3) <~> binary_quantize('[0.94, 0.04, 0.03]'::vector(3))
  LIMIT 6
) AS candidates
ORDER BY embedding <=> '[0.94, 0.04, 0.03]'
LIMIT 3;
title
SQL indexes
JOIN patterns
Query tuning
Limit  (cost=32.49..32.49 rows=3 width=40)
  ->  Sort  (cost=32.49..32.50 rows=6 width=40)
        Sort Key: ((candidates.embedding <=> '[0.94,0.04,0.03]'::vector))
        ->  Subquery Scan on candidates  (cost=32.32..32.41 rows=6 width=40)
              ->  Limit  (cost=32.32..32.33 rows=6 width=72)
                    ->  Sort  (cost=32.32..33.89 rows=630 width=72)
                          Sort Key: (((binary_quantize(documents.embedding))::bit(3) <~> '111'::"bit"))
                          ->  Seq Scan on documents  (cost=0.00..21.02 rows=630 width=72)

Indexing smaller vectors

You don't need a second column: index an expression that casts the embedding, and use the same expression in the query.

-- Index a smaller copy of the vector, without storing a second column.
-- halfvec: 16-bit numbers. Half the index size, almost the same results.
CREATE INDEX ON documents
USING hnsw ((embedding::halfvec(1536)) halfvec_cosine_ops);

-- bit: 1 bit per number (positive or not). 32x smaller, much rougher.
CREATE INDEX ON documents
USING hnsw ((binary_quantize(embedding)::bit(1536)) bit_hamming_ops);

-- Queries must use the same expression to hit the index:
SELECT title FROM documents
ORDER BY embedding::halfvec(1536) <=> $1::halfvec(1536)
LIMIT 10;

In a real app

The only differences are the number of dimensions and where the numbers come from: your app sends the text to an embedding model and stores the result.

-- A real embedding model, e.g. text-embedding-3-small, returns 1,536 numbers.
CREATE TABLE documents (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  embedding vector(1536) NOT NULL
);

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);

-- The app calls the model, then stores the vector like any other value:
INSERT INTO documents (title, body, embedding)
VALUES ($1, $2, $3);

-- And searches with the question's vector:
SELECT id, title
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;

Vector search finds meaning, but it misses exact words, product codes and names. Full-text search and fuzzy search are better at those, and Text search in Postgres combines them.

Try Yourself

Loaded Database:pgvector.database-init

The database for this lesson is already loaded. Write any query you want and run it directly in your browser.

Exercises

0 OF 2 DONE
1.'How do I build a web UI?' is '[0.01, 0.97, 0.02]'. Return the title of the closest document by L2 distance (<->).
2.Return the titles of the 2 documents closest to 'Neural networks' by cosine distance (<=>), without 'Neural networks' itself.

What We Learned

  • vector(n)a column type for embeddings, with a fixed number of dimensions.
  • <->L2 (Euclidean) distance.
  • <=>cosine distance: 1 - cosine similarity. The usual choice for text.
  • <#>negative inner product: fastest, for vectors of length 1.
  • Exact searchno index: always the true nearest rows, but every row is read.
  • HNSW indexa layered graph of neighbours: fast, approximate, slow to build.
  • IVFFlat indexlists around centroids: quick to build, approximate, create it after the data.
  • halfvec, bitsmaller vectors: less space, less precision; re-rank with the full ones.