Skip to content
Back to student guides
pgvectorLLMsVector databases3 levels95 sectionsCovers pgvector 0.8

The Complete pgvector Guide

Store and query embeddings inside PostgreSQL with the pgvector extension. Taught at three levels — Beginner, Mid-level and Senior — each with an in-depth guide, interview prep, and practical tips.

Official docs AI-drafted · community review in progressHelp review it
12sections
35examples

This is part one of three. It covers everything you need to start using pgvector for real work, not a teaser. By the end you will be able to install the extension, create a table that stores embeddings, load data into it, ask "which rows are most similar to this one?", make that question fast with an index, call all of it from Python, and read the errors you will certainly meet along the way. Mid-level and Senior take the same topics further; nothing here is thrown away.

Each section ends with a Try it task. Do them as you go. They take a few minutes each, and these ideas only stick once you have watched a query return the wrong rows and then fixed it yourself. The guide was checked against pgvector 0.8.6, the current release at the time of writing, so every command here is written for that version.

You do not need to know machine learning to follow along. You do need to be comfortable with basic SQL: CREATE TABLE, INSERT, SELECT ... WHERE, and ORDER BY. If those are unfamiliar, spend an hour with a PostgreSQL tutorial first, because pgvector is not a new language. It is a handful of new column types and operators dropped into the SQL you already know.

What pgvector is, and the problem it solves

Modern AI applications constantly need to answer a question of the form "what is like this?" A support bot wants the three help articles closest in meaning to a customer's question. A shop wants products similar to the one you are viewing. A retrieval-augmented generation (RAG) application wants the paragraphs most relevant to a prompt before it calls a language model. None of these can be answered by a normal WHERE clause, because "similar in meaning" is not something you can express as name = 'x' or even name LIKE '%x%'.

The standard solution is the embedding. An embedding model (a neural network such as the ones behind the OpenAI, Cohere, or open-source sentence-transformer families) reads a piece of text, or an image, and outputs a fixed-length list of numbers, for example 384 or 1,536 of them. That list is called a vector. The model is trained so that things with similar meaning produce vectors that sit close together, and unrelated things sit far apart. "How do I reset my password?" and "I forgot my login credentials" produce vectors that are near each other, even though they share almost no words.

Once your data is vectors, "find similar items" turns into a geometry problem: given a query vector, find the stored vectors with the smallest distance to it. This is called nearest-neighbour search, and it needs two things: somewhere to store vectors, and a fast way to search them.

pgvector is an open-source extension for PostgreSQL that gives Postgres both. It adds a vector column type, operators that compute distance between vectors, and two kinds of index that make nearest-neighbour search fast on large tables. Its licence is the PostgreSQL License, it is written in C, and it is maintained as an open-source project at github.com/pgvector/pgvector.

A fair question is why anyone would not just use a dedicated vector database. Several exist, and this series covers some of them, such as Qdrant, Chroma and Pinecone. The honest answer is that for many teams, the vectors are not the whole system. Your documents also have owners, permissions, timestamps, categories, and relations to other tables. When the vectors live in the same database as the rest of that data, a lot of things come free:

  • One system to run. You already operate Postgres: backups, monitoring, access control, and on-call runbooks exist. There is no second cluster to learn, secure, or pay for.
  • Transactions. Inserting a document and its embedding is one atomic operation. You never have a row in one system and a missing vector in another.
  • Joins and filters. "Find the five most similar articles that this user is allowed to see and that were published this year" is a single SQL query, not a vector search followed by a manual filter in application code.
  • Everything Postgres already does. Replication, point-in-time recovery, row-level security, and pg_dump all work on your vector columns because they are ordinary columns.
TEXTa sentence or document
→
EMBEDDING MODELoutside the database
→
VECTORa list of numbers
→
POSTGRES + PGVECTORstore and search

Notice what pgvector does not do. It does not create embeddings. The model that turns text into numbers runs outside the database: you call an API, or run a local model, and then hand the resulting list of numbers to Postgres. pgvector only stores, compares, and indexes the numbers. Beginners often assume the extension "understands" text. It does not; it understands lists of numbers, and the meaning lives in the model that produced them.

A word on naming, because it trips people up on day one. The project, the repository, the Docker image and the Linux packages are all called pgvector. But the extension inside the database is called vector. You type CREATE EXTENSION vector;, not CREATE EXTENSION pgvector;, and the column type is vector(3), not pgvector(3).

Check the version early Many blog posts and chatbot answers describe pgvector as it was in versions 0.5 to 0.7. Since then it gained half-precision and sparse vectors (0.7.0), iterative index scans (0.8.0), and several corruption and overflow fixes (0.8.2 and 0.8.3). When something you read disagrees with this guide, trust the version in your database, which you will learn to check shortly.
Try it
  1. Write down three "find something similar" features you have used this week (a search box, a recommendation, a "related posts" list).
  2. For each one, name the ordinary database data that would have to be combined with the similarity search, such as a user, a date, or a category.
  3. Decide which of those would be easier if the vectors lived in the same database as that data.

The mental model: vectors, distance, and indexes

pgvector rests on a handful of nouns. Learn them well and the rest of the guide is variations on a theme.

A vector is a list of numbers of fixed length. In SQL you write one as text in square brackets: '[1,2,3]'. The length is called its dimension or number of dimensions. A column declared as vector(3) holds vectors of exactly three numbers and rejects any other length. Real embedding models produce many more dimensions: 384, 768, 1,024 and 1,536 are all common. Three dimensions is only for learning, because you can imagine three numbers as a point in a room.

That picture is worth keeping. Think of each vector as a point in space. Two points that are close together mean similar things. A query is just another point, and "search" means "list the stored points nearest to this one."

A distance is a number measuring how far apart two points are. There are several ways to measure it, and pgvector gives each its own operator, which is a short symbol you place between two vectors in SQL:

Operator Measures Smaller means
<-> L2 (Euclidean, straight-line) distance closer
<=> cosine distance pointing in a more similar direction
<#> negative inner product more similar (see the trap below)
<+> L1 (taxicab) distance closer

All of them return a number where smaller means more similar, so "nearest first" is always ORDER BY ... ASC. There are also <~> (Hamming) and <%> (Jaccard) operators for a special kind of vector called a bit vector, which you can ignore for now.

An exact search is the simplest thing that could work. With no index at all, Postgres reads every row, computes the distance from your query to each stored vector, sorts the results, and returns the top few. This is guaranteed to give the true nearest neighbours, which is called perfect recall. It is also perfectly fine for a few thousand or tens of thousands of rows. It only becomes slow when the table is large.

An approximate search is what an index gives you. For large tables, comparing against every row is too slow, so pgvector offers indexes that find very likely nearest neighbours without checking everything. The trade-off is that the answer might occasionally miss a true neighbour. The fraction of true neighbours that the search actually returns is called recall. A recall of 0.95 means that, on average, 95 of the true top 100 came back. This is the one genuinely new idea compared with ordinary SQL indexes. A B-tree index never changes the answer, only the speed. A vector index can change the answer. The official documentation says it directly: unlike typical indexes, you will see different results for queries after adding an approximate index.

pgvector ships two index types:

  • HNSW (Hierarchical Navigable Small World) builds a layered graph of connections between vectors, and a search hops through the graph toward the query. It gives the best speed-versus-recall trade-off, needs no training step, and can be created on an empty table. The cost is slower builds and more memory.
  • IVFFlat (inverted file with flat lists) divides the vectors into clusters using k-means, then searches only the few clusters nearest to the query. It builds faster and uses less memory but has a worse speed-versus-recall trade-off, and it must be built after the table holds representative data.
NO INDEXexact, scans every row
→
HNSWapproximate, graph
→
IVFFLATapproximate, clusters

Finally, an operator class (usually shortened to "opclass") tells an index which distance it is built for. An index built with vector_cosine_ops speeds up queries that use <=>. It does not speed up queries that use <->. This is the number-one reason a beginner's index "does nothing", and we return to it more than once.

Which metric to choose is simple: use the one your embedding model was trained for, which is usually cosine for text. If the vectors are normalised to length 1, inner product gives the same ranking and is the cheapest to compute. Use the same operator in every query and in the index.

Inner product is negative on purpose The <#> operator returns the negative inner product. The reason, given in the pgvector documentation, is that Postgres only supports ascending-order index scans on operators, so a larger inner product has to be shown as a more negative number to sort first. If you want the real inner product, multiply by -1. Cosine similarity is 1 - (a <=> b).
Try it
  1. On paper, draw three points on a grid: A at (1,1), B at (2,2), and C at (10,0).
  2. Without calculating anything, decide which of B and C is nearer to A. That is an exact search done by eye.
  3. Now imagine a million points. Why does doing this by checking every point become a problem, and what would "good enough" nearest neighbours mean?

Installing pgvector and checking it works

Installation has two parts, and beginners often forget the second. First the extension's files must exist on the machine that runs Postgres. Then, inside each database where you want to use it, you must run CREATE EXTENSION vector;. Installing the files does not switch anything on.

Pick the route that matches how you run Postgres. Whichever you choose, the destination is the same: a server where CREATE EXTENSION vector; works.

Route 1: Docker (the fastest way to learn). The pgvector project publishes an image that is the official postgres image with the extension already added. It runs exactly like postgres, with the same environment variables. If you have not used containers before, the Docker guide explains the ideas used here.

BASH
docker pull pgvector/pgvector:pg18-trixie
docker run -d --name pg -e POSTGRES_PASSWORD=secret -p 5432:5432 --shm-size=1g pgvector/pgvector:pg18-trixie

The tag pg18-trixie means Postgres 18 on the Debian "trixie" base. Tags follow the pattern pg13 to pg18, combined with trixie or bookworm, and you can pin the extension version too, as in 0.8.6-pg18-trixie. One detail worth knowing: there is no latest tag, so a bare pgvector/pgvector pull will fail. If you find an old tutorial using ankane/pgvector, that is the legacy name; use pgvector/pgvector. The --shm-size=1g flag raises the container's shared memory, which matters later when you build large indexes in parallel. Docker's default is only 64 MB.

Route 2: a package. On Linux the extension is available from the PostgreSQL project's package repositories, once you have enabled them:

BASH
# Debian / Ubuntu (PGDG apt repository enabled)
sudo apt install postgresql-18-pgvector

# Red Hat family (PGDG yum repository enabled)
sudo dnf install pgvector_18

# macOS with Homebrew
brew install pgvector

Replace 18 with the major version of your Postgres server. On macOS, Homebrew adds pgvector only for the postgresql@18 and postgresql@17 formulas. Postgres.app ships with pgvector preinstalled from Postgres 15 onward.

Route 3: from source. This works on Linux and macOS when no package fits. You need the Postgres server development headers (on Debian or Ubuntu, sudo apt install postgresql-server-dev-18).

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

If you have several Postgres installations on one machine, point the build at the right one with export PG_CONFIG=/path/to/pg_config before running make. Building for the wrong Postgres is a classic cause of "it installed but the database cannot find it". Windows has its own build route with nmake, described in the official README, and Docker also works on Windows.

Route 4: a hosted database. Many managed Postgres services ship pgvector already, including AWS RDS and Aurora, Google Cloud SQL and AlloyDB, Azure Database for PostgreSQL, Supabase and Neon. On most you simply run CREATE EXTENSION vector;. The version you get depends on the provider and on your Postgres minor version, so always check it. If you work with employers in the Gulf or Egypt, this route is common, because it lets you keep the database in a regional cloud location that satisfies data-residency requirements. Details differ per provider, so follow the provider's own documentation. On Azure Database for PostgreSQL flexible server, for example, you must also allow-list VECTOR in the azure.extensions server parameter before the command works.

Now connect to the database with psql and turn the extension on. If you used the Docker command above:

BASH
docker exec -it pg psql -U postgres
SQL
CREATE EXTENSION IF NOT EXISTS vector;
SELECT extversion FROM pg_extension WHERE extname = 'vector';
SELECT '[1,2,3]'::vector <-> '[4,5,6]'::vector;

The first line enables the extension in the current database. The second reports which version is installed there; you should see 0.8.6 if you used the commands above. The third computes the straight-line distance between two three-dimensional points. The answer is the square root of 27, which Postgres prints as 5.196152422706632. If you get that number, pgvector works.

CREATE EXTENSION needs elevated privileges. The pgvector control file does not mark the extension as "trusted", so on a database you run yourself a superuser has to create it (the default postgres user in Docker is one). On managed services, an admin role does it for you, such as rds_superuser on AWS or azure_pg_admin on Azure. Remember that extensions are enabled per database. If you create a new database later, run the command again there.

In psql, the shortcut \dx vector also lists the installed extension. And if you ever need to see which version the server has available on disk versus installed, query pg_available_extensions:

SQL
SELECT default_version, installed_version
FROM pg_available_extensions
WHERE name = 'vector';
Installed on disk is not enabled in the database If you see ERROR: type "vector" does not exist, the files may be fine and you simply have not run CREATE EXTENSION vector; in this database. If you see could not open extension control file (or on newer Postgres, extension "vector" is not available), the files are missing for this particular server, often because you installed against a different Postgres version.
Try it
  1. Start the Docker container above (or use any Postgres where you can install the extension).
  2. Run CREATE EXTENSION IF NOT EXISTS vector; and then the extversion query. Write down the version you see.
  3. Run the <-> test query and confirm you get 5.196152422706632.
  4. Create a second database with CREATE DATABASE other;, connect to it with \c other, and try SELECT '[1,2]'::vector; before creating the extension. Read the error, then fix it.

Let us build something small and complete, using three-dimensional vectors so you can verify every answer in your head. We will make a table of fruits where the three numbers loosely mean "how sweet", "how sour" and "how crunchy" on a scale of 0 to 10. A real project uses an embedding model; the mechanics are identical.

Create the table. A vector(3) column holds three numbers, and an ordinary text column holds the label.

SQL
CREATE TABLE fruits (
    id bigserial PRIMARY KEY,
    name text NOT NULL,
    traits vector(3)
);

Insert some rows. The vector is passed as a string literal in square brackets, and Postgres converts it to the vector type for you.

SQL
INSERT INTO fruits (name, traits) VALUES
    ('apple',      '[6,4,9]'),
    ('banana',     '[9,1,2]'),
    ('lemon',      '[1,10,3]'),
    ('orange',     '[8,5,3]'),
    ('green apple','[4,7,9]'),
    ('mango',      '[10,2,2]');

Now ask the question this whole extension exists for: which fruits are nearest to "sweet, a little sour, and soft", the point [9,3,2]?

SQL
SELECT name, traits <-> '[9,3,2]' AS distance
FROM fruits
ORDER BY traits <-> '[9,3,2]'
LIMIT 3;

You will get something close to the following. Mango is at distance about 1.41 (it differs by 1 in the first number and 1 in the second), banana is exactly 2, and orange is the third at about 2.45.

TEXT
 name   |      distance
--------+--------------------
 mango  | 1.4142135623730951
 banana |                  2
 orange | 2.449489742783178

Read the query as a sentence. ORDER BY traits <-> '[9,3,2]' means "sort rows by their distance to this point, smallest first." LIMIT 3 means "keep only the nearest three." That pairing, ORDER BY a distance, then LIMIT, is the shape of every nearest-neighbour query you will write, and it is also the shape an index needs in order to help. We will rely on that later.

You can show the distance as a column, as we did, or leave it out. Showing it is useful for debugging because it tells you how much closer the first result is than the fifth. If the top results all have almost the same distance, the vectors are not discriminating well, which usually points at a problem in the embedding step rather than in SQL.

Notice there is no index yet. Postgres is doing an exact search: it computes the distance for all six rows and sorts them. That is the correct choice for a table this small, and you should stay with it until you have a reason not to.

Let us use a different question: find fruits similar to an existing row, say the apple, but excluding the apple itself.

SQL
SELECT name
FROM fruits
WHERE id != 1
ORDER BY traits <-> (SELECT traits FROM fruits WHERE id = 1)
LIMIT 2;

The subquery fetches the apple's vector, and the outer query ranks every other fruit by distance to it. This "more like this" pattern, a row used as the query, is how "related articles" features work.

Finally, see why the choice of distance matters. Try cosine distance on the same data:

SQL
SELECT name, traits <=> '[9,3,2]' AS cosine_distance
FROM fruits
ORDER BY traits <=> '[9,3,2]'
LIMIT 3;

Cosine distance ignores how long the vectors are and only compares their direction. Two fruits with traits [1,1,1] and [10,10,10] have different L2 distance but a cosine distance of zero, because both point the same way. For text embeddings, direction usually carries the meaning, which is why cosine is the common default.

Use a small table to learn, a real model to build Hand-made three-dimensional vectors let you predict every result, which is the best way to learn the operators. Once the pattern feels natural, swap the three numbers for a real embedding of, say, 384 or 1,536 numbers and nothing else in the SQL changes except the column's declared size.
Try it
  1. Build the fruits table and run the nearest-three query. Check that mango comes first.
  2. Add a fruit of your own with a vector you choose, then predict where it will appear for the query [9,3,2] before you run it.
  3. Run the same query with <=> and compare the order with the <-> order. Did it change? Why might it?

Everyday commands: insert, update, delete, query

Having built the first project, here are the operations you will use daily, grouped by what you are trying to do. All of them are plain SQL; the vector-specific part is only how you write the vector.

Adding a vector column to a table that already exists. Real projects usually start with a table of documents and add embeddings later:

SQL
ALTER TABLE documents ADD COLUMN embedding vector(1536);

Pick the dimension to match your model exactly. Changing models later usually means a new column, because the old vectors came from a different "space" and cannot be compared with the new ones.

Inserting. You can insert one or many rows, as you did above. To insert a vector in a normal application, you pass it as a parameter and the client library formats it (we cover Python shortly). In pure SQL, a text literal with square brackets is enough.

Upserting. If a row may already exist, use ON CONFLICT:

SQL
INSERT INTO items (id, embedding) VALUES (1, '[1,2,3]'), (2, '[4,5,6]')
ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding;

Updating and deleting. These behave exactly as for any other column:

SQL
UPDATE items SET embedding = '[1,2,3]' WHERE id = 1;
DELETE FROM items WHERE id = 1;

Querying by similarity. The workhorse, as you have seen:

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

Querying within a distance. Sometimes you want "everything closer than this threshold" rather than "the nearest five":

SQL
SELECT * FROM items WHERE embedding <-> '[3,1,2]' < 5;

This works, but remember the rule from the mental-model section: to make an index help, a query must have ORDER BY on the distance and a LIMIT. A bare distance filter with no ORDER BY and LIMIT will not use an approximate index, and will scan the whole table.

Combining similarity with ordinary conditions. Because this is SQL, you can add any filter you like:

SQL
SELECT id, title
FROM documents
WHERE published_year >= 2025 AND language = 'en'
ORDER BY embedding <=> '[3,1,2]'
LIMIT 5;

Read it as: filter first (conceptually), then rank what remains by closeness. With no vector index, this is simple and exact. With an approximate index there is a subtlety, because the index returns its candidates before your WHERE clause is applied. We deal with that in the section on making indexes behave.

Scores versus ordering. If you want a similarity score for display, compute 1 - (embedding <=> q) in the select list, but keep the ORDER BY as the bare operator. Ordering by 1 - (embedding <=> ...) DESC is mathematically the same ranking, but the planner cannot use an approximate index for it. Always order by the plain operator expression in ascending order.

Averages. pgvector supports AVG on vectors, which computes the element-wise mean. This is handy for "the centre of a group":

SQL
SELECT AVG(embedding) FROM items;
SELECT category_id, AVG(embedding) FROM items GROUP BY category_id;

You can use a category's average vector as a rough "topic vector" and compare new items against it. The sum aggregate also exists for vector and halfvec columns.

Handling NULLs. A row whose embedding is NULL is allowed in the table, and sorting by a distance on it behaves like sorting by any NULL. More importantly, NULL vectors are not indexed, so they never appear in results that come through an approximate index. If an embedding job fails halfway, you may end up with such rows; a quick SELECT count(*) FROM items WHERE embedding IS NULL; is a good health check.

Always write the query vector as a parameter In application code you should pass the query embedding as a bound parameter, never paste it into the SQL string. It is safer, it lets the database cache the plan, and a client library will format the numbers for you. The Python section below shows how.
Try it
  1. Create a table notes (id bigserial PRIMARY KEY, body text, topic text, embedding vector(3)) and insert six rows with two topics.
  2. Write a query that returns the three notes of topic 'a' nearest to a point of your choice.
  3. Write one that returns the average vector for each topic with GROUP BY.
  4. Add the similarity column 1 - (embedding <=> ...) to a query and check that its order matches the ORDER BY on the distance.

Indexes: making search fast

So far every query scanned the whole table. That is fine for the fruit example and genuinely fine for tens of thousands of rows. When searches start taking noticeable time on a larger table, you add an approximate index. Here is how to decide, and how to do it without the usual mistakes.

Do you need an index at all? Check by timing a query with EXPLAIN ANALYZE. If the exact search is fast enough, an index would only add build time, memory use, and the risk of results changing. Many applications under about fifty thousand rows never need one. The right habit is to measure first.

Which index? For nearly everyone starting out, the answer is HNSW. It needs no training data, can be created on an empty table, and has the better recall at a given speed. Choose IVFFlat only when build time or memory is a hard constraint.

Which opclass? It must match the operator you query with:

You query with Create the index with
<-> (L2) vector_l2_ops
<=> (cosine) vector_cosine_ops
<#> (inner product) vector_ip_ops
<+> (L1) vector_l1_ops (HNSW only)

So for cosine search, the whole job is one line:

SQL
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);

Postgres names the index for you, typically items_embedding_idx. The index will accelerate queries of the form ORDER BY embedding <=> '[...]' LIMIT n, and nothing else. A query using <-> will ignore it, and a query using <=> with no LIMIT will ignore it too.

You can set two build options. They have sensible defaults, and a beginner should usually leave them alone:

SQL
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

m is the maximum number of connections each point keeps in the graph (default 16). ef_construction is the size of the candidate list used while building (default 64), and it must be at least twice m. Larger values give better recall and cost more time and memory to build. Only raise them after you have measured poor recall.

IVFFlat, if you do need it, has a different recipe. It must be created after you have loaded representative data, because the cluster centres are fixed when the index is built:

SQL
CREATE INDEX ON items USING ivfflat (embedding vector_l2_ops) WITH (lists = 100);

lists is the number of clusters (default 100). The documentation suggests starting around rows / 1000 for tables up to a million rows, and the square root of the row count beyond that. At query time, ivfflat.probes controls how many clusters are searched (default 1). A common starting point is the square root of lists. If you build an IVFFlat index on an empty or tiny table, Postgres warns you: NOTICE: ivfflat index created with little data, followed by a detail that this will cause low recall. Believe it: drop the index, load data, and build again.

Build time and memory. For HNSW, the build is much faster if the graph fits in maintenance_work_mem. If it does not, you see:

TEXT
NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.
HINT:  Increase maintenance_work_mem to speed up builds.

Raise it for your session before the build, within the memory your machine really has:

SQL
SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 4;
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);

In Docker, parallel builds use shared memory, so --shm-size must be at least as large as maintenance_work_mem; otherwise the build fails with a "could not resize shared memory segment" error. On a live production table, add CONCURRENTLY to the create statement (CREATE INDEX CONCURRENTLY ...) so that writes are not blocked during the build. Prefer to load data first and create the index afterwards, because bulk-loading into an already-indexed table is far slower.

Confirm the index is used. Put EXPLAIN in front of the query:

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

Look for a line such as Index Scan using items_embedding_idx on items. If you see Seq Scan instead, the planner chose to read the whole table. On a tiny table that is correct. On a large one, check the three usual suspects: a missing LIMIT, an operator that does not match the opclass, or an ORDER BY that is not the plain operator in ascending order. To test whether the index can be used at all, run SET enable_seqscan = off; in your session and EXPLAIN again.

An index changes your answers After you add an approximate index, the same query can return slightly different rows than before, and sometimes fewer rows than your LIMIT. That is the nature of approximate search, not a bug. The next section explains the main reason you get fewer rows, and how to measure recall so you know how good the answers are.
Try it
  1. Generate a bigger table to play with: INSERT INTO items (embedding) SELECT ARRAY[random(), random(), random()]::vector FROM generate_series(1, 100000); on a table with a vector(3) column.
  2. Time a nearest-five query with EXPLAIN ANALYZE and note whether it is a sequential scan.
  3. Create an HNSW index with the matching opclass, rerun the same EXPLAIN ANALYZE, and compare the plan and the time.
  4. Change the query to use a different operator from the index's opclass and see that the plan reverts to a sequential scan.

Making the index behave: recall, ef_search, and filters

An approximate index has a dial that exchanges speed for accuracy, and a typical beginner problem that is easy to fix once you understand it. This section covers both.

The recall dial for HNSW is hnsw.ef_search. It sets the size of the candidate list the search keeps while walking the graph. The default is 40, and it can range from 1 to 1,000. A higher value looks at more candidates, so it finds more of the true neighbours, at the cost of slower queries. You set it like any Postgres setting:

SQL
SET hnsw.ef_search = 100;

That lasts for your session. To change it for a single query, use a transaction and SET LOCAL:

SQL
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id FROM items ORDER BY embedding <=> '[3,1,2]' LIMIT 5;
COMMIT;

The SET LOCAL form matters more than it looks. If your application connects through a connection pooler that shares connections between clients, a plain session SET may not stick to your next query, whereas SET LOCAL inside the transaction always applies to that transaction's queries. For IVFFlat, the equivalent dial is ivfflat.probes. These names are reserved prefixes, so a typo such as SET hnsw.efsearch = 100; fails with an error rather than silently doing nothing.

There is one further consequence of ef_search that surprises beginners: it also caps the number of rows an index scan can return. With the default of 40, an index scan returns at most about 40 candidates, so asking for LIMIT 100 cannot give you 100 rows. If you need larger result sets, raise ef_search to at least the number you want.

The filtering surprise. Consider this query on an indexed table:

SQL
SELECT id FROM items WHERE category_id = 123 ORDER BY embedding <-> '[3,1,2]' LIMIT 5;

With an approximate index, the database first asks the index for its best candidates, then applies WHERE category_id = 123 to them. If only 10 per cent of rows are in category 123 and the index returned 40 candidates, then on average just four survive the filter, and you ask for five and get four, or fewer. No error appears. The index simply handed back too few candidates to start with.

You have three practical remedies, in the order a beginner should try them.

First, add an ordinary index on the filter column, for example CREATE INDEX ON items (category_id);. When the filter is selective, Postgres may then choose to fetch the few matching rows via the B-tree and compute exact distances over them, which is both fast and perfectly accurate.

Second, use iterative scans, added in pgvector 0.8.0. With this on, if the filter discards candidates, the index scan keeps going until it has found enough matching rows or reaches a limit:

SQL
SET hnsw.iterative_scan = relaxed_order;

relaxed_order gives the best recall, with the caveat that results can come back slightly out of order, so it fits queries where ordering by distance in the final list is not critical or can be sorted again afterwards. There is also strict_order for HNSW, which keeps exact ordering. IVFFlat has ivfflat.iterative_scan with only relaxed_order. The default for both is off. The cap on how far the scan goes is hnsw.max_scan_tuples (default 20,000).

Third, for a few well-known categories, a partial index (an index with a WHERE clause) gives each category its own small, fast index, and partitioning the table does the same at larger scale. Both belong to the mid-level guide.

Measuring recall. How do you know your index is giving good answers? Compare it to the exact answer on a sample query. Run the query normally and note the ids; then repeat it with index scans disabled so Postgres falls back to an exact scan, and compare:

SQL
BEGIN;
SET LOCAL enable_indexscan = off;
SELECT id FROM items ORDER BY embedding <=> '[3,1,2]' LIMIT 10;
COMMIT;

If nine of the ten ids match the indexed result, recall for that query is 0.9. Do this for a few dozen representative queries and you have a real number rather than a hunch. If it is too low, raise hnsw.ef_search first. Evaluating the quality of what you retrieve for an LLM application is its own topic, and tools such as RAGAS exist for that.

Silent failures to know about. Beyond filters, two other things hide rows from results without an error. Rows with a NULL embedding are not indexed. And for cosine, rows whose vector is all zeros are not indexed either, because the angle of a zero vector is undefined. If a particular row never shows up, check its embedding first.

A sensible starting recipe HNSW index with the opclass matching your operator, default m and ef_construction, hnsw.ef_search raised to somewhere between 100 and 200 if recall is short, and hnsw.iterative_scan = relaxed_order if you combine vector search with filters. Change one thing at a time, and measure recall after each change.
Try it
  1. On your 100,000-row table add an integer category_id column filled with (random() * 9)::int, and an HNSW index.
  2. Run a filtered nearest-ten query. Count the rows; do you get ten?
  3. Run SET hnsw.iterative_scan = relaxed_order; and repeat. Compare the row count.
  4. Compare an indexed result with the exact result using SET LOCAL enable_indexscan = off and estimate the recall.

Using pgvector from Python

SQL on its own is half the story. Your application creates the embeddings and sends them to the database. Python is the most common language for this, and pgvector has an official client library, pgvector-python, installed with pip install pgvector. At the time of writing it is at version 0.5.0 and requires Python 3.10 or newer.

Two warnings before the code, because this is where old tutorials go wrong. Version 0.5.0 made breaking changes. Importing now happens from the top-level package: from pgvector import Vector. The old pgvector.utils module was removed. And NumPy is no longer a dependency, so you can use plain Python lists. If you copy code from an older article and see ImportError, this is almost certainly why.

The example below uses Psycopg 3, the current Postgres driver for Python. Install both packages:

BASH
pip install "psycopg[binary]" pgvector

Then create a script. It connects, makes sure the extension exists, creates a table, inserts a few vectors, and searches.

search.py
import psycopg
from pgvector import Vector
from pgvector.psycopg import register_vector

conninfo = "postgresql://postgres:secret@localhost:5432/postgres"

# 1. Create the extension FIRST, on a plain connection.
with psycopg.connect(conninfo, autocommit=True) as setup:
    setup.execute("CREATE EXTENSION IF NOT EXISTS vector")

# 2. Now connect again and register the vector type.
with psycopg.connect(conninfo) as conn:
    register_vector(conn)

    conn.execute("DROP TABLE IF EXISTS fruits")
    conn.execute(
        "CREATE TABLE fruits (id bigserial PRIMARY KEY, name text, traits vector(3))"
    )

    rows = [
        ("apple", [6, 4, 9]),
        ("banana", [9, 1, 2]),
        ("lemon", [1, 10, 3]),
        ("mango", [10, 2, 2]),
    ]
    for name, traits in rows:
        conn.execute(
            "INSERT INTO fruits (name, traits) VALUES (%s, %s)",
            (name, Vector(traits)),
        )

    query = Vector([9, 3, 2])
    result = conn.execute(
        "SELECT name, traits <-> %s AS distance FROM fruits "
        "ORDER BY traits <-> %s LIMIT 3",
        (query, query),
    ).fetchall()

    for name, distance in result:
        print(f"{name:8s} {distance:.3f}")

Run it with python search.py. You should see mango first, then banana, matching the SQL you ran by hand.

Three details in that script are the ones beginners trip over. First, CREATE EXTENSION must run before register_vector, because registering looks up the database's type identifiers for vector, and those do not exist until the extension does; that is why the script uses two connections. Second, register_vector(conn) teaches the driver how to convert between Python and the vector type, and you call it on every new connection (with a pool, in the pool's configure hook). Third, since version 0.5.0, a vector column read through Psycopg comes back as a pgvector.Vector object; call .to_list() to get a plain Python list or .to_numpy() for a NumPy array if you have NumPy installed.

If you skip the registration, or if you pass a bare Python list where a vector is expected, you hit this error:

TEXT
psycopg.errors.UndefinedFunction: operator does not exist: vector <-> double precision[]

It means the driver sent a Postgres array and the database does not know how to compare an array with a vector. The fix is to register the type with register_vector, or to add an explicit cast in the SQL, %s::vector.

The same library supports other frameworks. With SQLAlchemy 2, you declare a column with VECTOR(3) from pgvector.sqlalchemy, and query with comparator methods such as Item.embedding.l2_distance([3, 1, 2]). With Django, you add VectorExtension() to a migration and use VectorField(dimensions=3) with functions such as L2Distance. Psycopg 2, asyncpg, SQLModel, Peewee and pg8000 are also supported. Official clients exist for more than thirty languages, among them Node.js, Go, Java, Rust, Ruby and PHP, so what you learn here carries over.

Where do the embeddings themselves come from? From a model of your choice. A hosted API, such as the one in the OpenAI API guide, returns a list of floats you can wrap in Vector(...), and a local model from the Hugging Face ecosystem does the same. What matters for pgvector is only that the length of the list matches the column's declared dimension. Frameworks such as LangChain and LlamaIndex offer ready-made pgvector-backed stores, which is convenient once you understand what they generate underneath. Look at the SQL they run, and you will recognise everything in this guide.

Never put keys or passwords in code The example hard-codes a throwaway password for a local container. In any real project, read the connection string and any embedding-API key from environment variables or a secret manager. pgvector itself has no secrets; the credentials belong to Postgres and to your embedding provider.
Try it
  1. Run the script against your Docker container and confirm the order of results.
  2. Change Vector([9, 3, 2]) to a plain list [9, 3, 2] and read the error. Then remove the cast problem by adding %s::vector in the SQL.
  3. Print the type of a vector you read back from the table. Convert it to a list with .to_list().

Common errors and how to read them

Error messages from pgvector are short and fairly literal. Learning to read them is the quickest way to become independent. Here are the ones beginners meet, grouped by when they appear.

Errors when you set things up.

ERROR: type "vector" does not exist means the extension is not enabled in the database you are connected to (or it is in a schema that is not on your search_path). Run CREATE EXTENSION vector; in this database. If you see it only from your application, check that the application is connecting to the database you think it is.

ERROR: could not open extension control file ".../extension/vector.control": No such file or directory means the files were never installed for this server, often because they were installed against a different Postgres version. Install the package for the correct major version, or rebuild from source with PG_CONFIG pointing at the right server.

ERROR: permission denied to create extension "vector" with the hint Must be superuser to create this extension means your role is not allowed. On a self-managed server, use a superuser. On a managed service, use the provider's admin role, and check whether the provider requires allow-listing (Azure does).

Errors when you store or query vectors.

ERROR: expected 3 dimensions, not 4 means you inserted a vector whose length does not match the column. Check the model's output size, then fix either the data or the column.

ERROR: different vector dimensions 3 and 4 means you compared two vectors of different lengths, almost always because the query embedding came from a different model from the stored ones.

ERROR: invalid input syntax for type vector: "{1,2,3}" with the detail Vector contents must start with "[" means you used curly-brace array syntax. Use '[1,2,3]' or ARRAY[1,2,3]::vector.

ERROR: operator does not exist: vector <-> double precision[] means your client sent the query parameter as an array. Register the vector type in the client library or cast %s::vector.

Errors when you build an index.

ERROR: column cannot have more than 2000 dimensions for hnsw index (the IVFFlat message is the same with ivfflat) means your vector column is too wide to index. A 3,072-dimension embedding hits this. The options are half-precision indexing, binary quantization, a model that can output fewer dimensions, or no index; the mid-level guide covers the first two.

ERROR: ef_construction must be greater than or equal to 2 * m is exactly what it says: raise ef_construction.

NOTICE: ivfflat index created with little data, with the detail This will cause low recall and the hint Drop the index until the table has more data, tells you to drop the index and rebuild after loading data.

ERROR: memory required is X MB, maintenance_work_mem is Y MB is an IVFFlat build that needs more memory than it was given. Raise maintenance_work_mem or lower lists.

ERROR: could not resize shared memory segment ... No space left on device appears in Docker when a parallel HNSW build outgrows the container's small /dev/shm. Start the container with a larger --shm-size.

Problems that give no error at all. These are the hardest, because the database is not complaining.

  • Fewer rows than your LIMIT. Usually a filter combined with an approximate index, or ef_search too low, or dead rows. Raise hnsw.ef_search, turn on iterative scans, or add a filter-column index.
  • The index is never used. Check for a missing LIMIT, DESC ordering, an expression such as 1 - (a <=> b) in the ORDER BY, an operator that does not match the opclass, or a table small enough that a sequential scan is cheaper. Use EXPLAIN to see the plan.
  • Different results after adding an index. Expected: that is approximate search.
  • A specific row never appears. Its embedding may be NULL, or an all-zero vector under cosine.
Try it
  1. Deliberately trigger three errors: insert a four-number vector into a vector(3) column, use '{1,2,3}' as a literal, and try to create an index with m = 48 and ef_construction = 64.
  2. For each, write one sentence explaining the cause in your own words, then fix it.
  3. Find a query on your 100,000-row table that does not use the index because of a missing LIMIT, and confirm it with EXPLAIN.

Putting it all together

Time to assemble everything into one small end-to-end project: a semantic search over a handful of help articles, using SQL and Python together. To keep it runnable without an API key, the "embedding" function below is a deliberately crude stand-in, a hashing trick that turns words into a fixed-length vector. It is not a real model, and the quality of the results shows that. In your own project, replace that single function with a call to a real embedding model; nothing else changes.

First, the database. Create a fresh table with room for 64 numbers, and a cosine HNSW index:

SQL
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE articles (
    id bigserial PRIMARY KEY,
    title text NOT NULL,
    body text NOT NULL,
    language text NOT NULL DEFAULT 'en',
    embedding vector(64)
);

CREATE INDEX ON articles USING hnsw (embedding vector_cosine_ops);

Because HNSW needs no training, creating the index before any data exists is fine. (If you loaded a large initial dataset in bulk, you would do the opposite and build the index after.)

Now the Python script that loads articles and searches them:

articles.py
import hashlib
import math
import psycopg
from pgvector import Vector
from pgvector.psycopg import register_vector

DIMS = 64
conninfo = "postgresql://postgres:secret@localhost:5432/postgres"


def embed(text: str) -> Vector:
    """A crude stand-in for a real embedding model.

    Each word is hashed into one of DIMS buckets. Replace this function
    with a call to a real model; the rest of the script stays the same.
    """
    values = [0.0] * DIMS
    for word in text.lower().split():
        bucket = int(hashlib.sha256(word.encode()).hexdigest(), 16) % DIMS
        values[bucket] += 1.0
    norm = math.sqrt(sum(v * v for v in values)) or 1.0
    return Vector([v / norm for v in values])


ARTICLES = [
    ("Reset your password", "How to reset a forgotten password and log in again"),
    ("Change your email", "Update the email address linked to your account"),
    ("Invoices and billing", "Download invoices and update your payment method"),
    ("Delete your account", "Close your account and remove your data permanently"),
    ("Two factor login", "Set up two factor authentication for safer login"),
]

with psycopg.connect(conninfo) as conn:
    register_vector(conn)
    for title, body in ARTICLES:
        conn.execute(
            "INSERT INTO articles (title, body, embedding) VALUES (%s, %s, %s)",
            (title, body, embed(title + " " + body)),
        )
    conn.commit()

    question = "I forgot my password and cannot log in"
    q = embed(question)
    rows = conn.execute(
        """
        SELECT title, 1 - (embedding <=> %s) AS similarity
        FROM articles
        WHERE language = 'en'
        ORDER BY embedding <=> %s
        LIMIT 3
        """,
        (q, q),
    ).fetchall()

    for title, similarity in rows:
        print(f"{similarity:.2f}  {title}")

Run it and the top result should be "Reset your password", because it shares the words "password" and "log" with the question. Notice what the stand-in embedding can and cannot do: it only matches shared words, so "I lost my credentials" would not find the password article. A real embedding model understands that those mean the same thing, which is the whole reason embeddings exist.

Study the structure, because it is the template for real applications:

  1. Ingestion turns each document into text, embeds it, and inserts the row with its vector. In production this runs as a batch job or in response to new content.
  2. Search embeds the user's question with the same function, then runs ORDER BY embedding <=> query LIMIT n.
  3. The filter language = 'en' shows the Postgres advantage: relational conditions and similarity in one statement.
  4. The select list converts distance into a friendly similarity, while the ORDER BY stays as the plain operator so the index remains usable.
Try it
  1. Run the project end to end and confirm the password article ranks first.
  2. Add three more articles of your own and a question that should match one of them. Does the crude embedding find it?
  3. Add a second language value to a row and test that the WHERE language = 'en' filter excludes it.
  4. Run EXPLAIN on the search query and describe what the planner chose for such a small table, and why that is correct.

What you can now do, and what comes next

You started this guide with the idea that "similar" cannot be expressed in SQL. You now know that it can, once your data is embeddings stored in a vector column. You can install pgvector four different ways and check which version you have, create tables with vector columns and fill them, write nearest-neighbour queries with the right operator, and tell exact search from approximate search. You can add an HNSW index with the matching opclass, confirm with EXPLAIN that it is used, tune hnsw.ef_search, and handle filtered queries with iterative scans. You can call all of it from Python, and you can read the commonest errors without guessing.

Equally important, you know the traps: the extension is named vector, not pgvector; the index only helps queries whose operator matches the opclass and that end in ORDER BY ... LIMIT; <#> is negative; approximate indexes change results; vectors from different models must never be compared; and old tutorials may describe versions that no longer match.

Here is what the next two levels add, so you know what to look for:

  • Mid-level goes under the hood of HNSW and IVFFlat so you can predict behaviour, and covers production patterns: half-precision storage and indexing for wide embeddings, partial indexes and partitioning for filters, hybrid search that combines keyword and vector ranking, build tuning, vacuum behaviour, and bulk loading.
  • Senior treats pgvector as a platform: capacity planning and memory, replication and upgrades, multi-tenant isolation with row-level security, monitoring recall as a metric, incident playbooks, and, importantly, when pgvector stops being the right tool and a dedicated vector database such as Qdrant or a library like FAISS is.

Sources