PostgreSQL in Depth Module 9 – Search, vectors and maps
Vector similarity search with pgvector
Store embeddings in PostgreSQL 18 with pgvector, read exact and HNSW query plans, and choose HNSW, IVFFlat or exact search, from runs on 5,000 vectors.
What you will learn
- Store embeddings as vector, halfvec or sparsevec, and choose the distance operator that ranks them
- Read exact and index plans, and build an index whose operator class matches the query
- Choose between exact search, HNSW and IVFFlat, and size a vector index from measured bytes per row
- Measure recall@10 against exact search and set hnsw.ef_search or ivfflat.probes from the result
- Combine vector search with filters and full-text ranking without losing rows
Before you start
What you’ll learn · Before you start
What you will learn
- Store embeddings as vector, halfvec or sparsevec, and choose the distance operator that ranks them
- Read exact and index plans, and build an index whose operator class matches the query
- Choose between exact search, HNSW and IVFFlat, and size a vector index from measured bytes per row
- Measure recall@10 against exact search and set hnsw.ef_search or ivfflat.probes from the result
- Combine vector search with filters and full-text ranking without losing rows
Before you start
On this page
A query for the 10 documents nearest to one vector, in a table of 5,000, made PostgreSQL 18.6 read all 5,000 rows, compute a distance for each and sort them, as the plan in the second section records. That is exact search: always right, and slower with every row the table gains.
pgvector brings vector search into PostgreSQL: column types for
embedding: A list of numbers (a vector) that places similar items near each other in a high-dimensional space. Models produce embeddings for text, images, and other data so that distance between vectors reflects semantic similarity., operators that measure distance, and two approximate indexes, HNSW and
IVFFlat, that find close rows without reading the whole table. With a matching index, ORDER BY emb <=> query LIMIT 10
walks the index and returns rows that are close, though not always the closest. The vectors here come from seeded
SQL; a real system gets them from an embedding model outside the database.
A vector column, and three distances that pick different winners
pgvector stores an embedding in a column of type vector(n), n numbers of 4 bytes each, and refuses a value of any
other length; halfvec(n) keeps each number in 2 bytes, and sparsevec(n) only the elements that are not zero. The
operator you sort by decides which row is nearest: <-> is the L2 distance, the straight line between two points;
<=> is the cosine distance: One minus the cosine of the angle between two vectors. Vectors pointing the same way have distance 0; perpendicular vectors have distance 1. The pgvector operator is <=>., which compares directions and ignores length; <#> is
the inner product with its sign flipped, so an ascending sort puts the largest product first. The first run, on
pgvector 0.8.7, sorts five vectors by all three against the query [1, 1]:
types_session.py
"""pgvector 0.8.7 on PostgreSQL 18.6: the vector type, three distance operators that disagree, and the size of a value."""
from pg_extensions import extension_settings
from pg_session import Server
# pgvector may sit next to PostgreSQL or in a prefix of its own: pg_extensions.py finds such a prefix and returns
# the two settings PostgreSQL 18 needs for it (none when pgvector is installed with PostgreSQL).
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off", "timezone": "'UTC'"}
SETTINGS.update(extension_settings())
with Server(settings=SETTINGS) as pg:
pg.up()
pg.psql("types.sql", errors=1) Output
$ psql -X -a -f types.sql
-- pgvector on PostgreSQL 18.6: a vector column, three distances that rank five vectors differently,
-- what each type takes on disk, and a value of the wrong length.
CREATE EXTENSION vector;
CREATE EXTENSION
SELECT extversion FROM pg_extension WHERE extname = 'vector';
extversion
------------
0.8.7
(1 row)
-- Two numbers per vector so the distances can be checked by hand; an embedding has hundreds.
CREATE TABLE items (id int PRIMARY KEY, label text, emb vector(2));
CREATE TABLE
INSERT INTO items VALUES
(1, 'close', '[0.9, 1.1]'),
(2, 'same direction', '[2, 2]'),
(3, 'long east', '[5, 0]'),
(4, 'north', '[0.2, 1]'),
(5, 'opposite', '[-1, -1]');
INSERT 0 5
-- Each distance to the query vector [1, 1]; the smallest value is the nearest.
SELECT label,
round((emb <-> '[1,1]')::numeric, 3) AS l2,
round((emb <=> '[1,1]')::numeric, 3) AS cosine,
round((emb <#> '[1,1]')::numeric, 3) AS neg_inner
FROM items ORDER BY emb <-> '[1,1]';
label | l2 | cosine | neg_inner
----------------+-------+--------+-----------
close | 0.141 | 0.005 | -2.000
north | 0.800 | 0.168 | -1.200
same direction | 1.414 | 0.000 | -4.000
opposite | 2.828 | 2.000 | 2.000
long east | 4.123 | 0.293 | -5.000
(5 rows)
-- The nearest vector by each operator.
SELECT '<-> l2' AS operator, (SELECT label FROM items ORDER BY emb <-> '[1,1]' LIMIT 1) AS nearest
UNION ALL
SELECT '<=> cosine', (SELECT label FROM items ORDER BY emb <=> '[1,1]' LIMIT 1)
UNION ALL
SELECT '<#> inner product', (SELECT label FROM items ORDER BY emb <#> '[1,1]' LIMIT 1);
operator | nearest
-------------------+----------------
<-> l2 | close
<=> cosine | same direction
<#> inner product | long east
(3 rows)
-- Bytes per value: 4 per dimension plus 8, 2 plus 8, and 8 per non-zero element plus 16.
SELECT pg_column_size(array_fill(0.5, ARRAY[384])::vector(384)) AS vector_384,
pg_column_size(array_fill(0.5, ARRAY[384])::halfvec(384)) AS halfvec_384,
pg_column_size('{7:0.5, 9000:1.5}/30000'::sparsevec) AS sparsevec_2_of_30000;
vector_384 | halfvec_384 | sparsevec_2_of_30000
------------+-------------+----------------------
1544 | 776 | 32
(1 row)
-- A value with three numbers does not fit vector(2).
INSERT INTO items VALUES (6, 'too long', '[1, 2, 3]');
psql:types.sql:35: ERROR: expected 2 dimensions, not 3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 types_session.py
types.sql
-- pgvector on PostgreSQL 18.6: a vector column, three distances that rank five vectors differently,
-- what each type takes on disk, and a value of the wrong length.
CREATE EXTENSION vector;
SELECT extversion FROM pg_extension WHERE extname = 'vector';
-- Two numbers per vector so the distances can be checked by hand; an embedding has hundreds.
CREATE TABLE items (id int PRIMARY KEY, label text, emb vector(2));
INSERT INTO items VALUES
(1, 'close', '[0.9, 1.1]'),
(2, 'same direction', '[2, 2]'),
(3, 'long east', '[5, 0]'),
(4, 'north', '[0.2, 1]'),
(5, 'opposite', '[-1, -1]');
-- Each distance to the query vector [1, 1]; the smallest value is the nearest.
SELECT label,
round((emb <-> '[1,1]')::numeric, 3) AS l2,
round((emb <=> '[1,1]')::numeric, 3) AS cosine,
round((emb <#> '[1,1]')::numeric, 3) AS neg_inner
FROM items ORDER BY emb <-> '[1,1]';
-- The nearest vector by each operator.
SELECT '<-> l2' AS operator, (SELECT label FROM items ORDER BY emb <-> '[1,1]' LIMIT 1) AS nearest
UNION ALL
SELECT '<=> cosine', (SELECT label FROM items ORDER BY emb <=> '[1,1]' LIMIT 1)
UNION ALL
SELECT '<#> inner product', (SELECT label FROM items ORDER BY emb <#> '[1,1]' LIMIT 1);
-- Bytes per value: 4 per dimension plus 8, 2 plus 8, and 8 per non-zero element plus 16.
SELECT pg_column_size(array_fill(0.5, ARRAY[384])::vector(384)) AS vector_384,
pg_column_size(array_fill(0.5, ARRAY[384])::halfvec(384)) AS halfvec_384,
pg_column_size('{7:0.5, 9000:1.5}/30000'::sparsevec) AS sparsevec_2_of_30000;
-- A value with three numbers does not fit vector(2).
INSERT INTO items VALUES (6, 'too long', '[1, 2, 3]'); pg_session.py
"""Throwaway PostgreSQL 18.6 servers for the examples of this lesson.
Every session creates a cluster of its own with initdb in a new folder inside the examples folder, starts it, runs
psql scripts and server programs on it, prints what they printed, and stops and deletes it again, also when something
fails. The server is the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file.
It listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system reports as
unused, with no Unix socket, and it asks for a password (scram-sha-256) made up for the run and never printed.
Besides PostgreSQL's own programs, the sessions read only this folder (and an extension's own folder where a lesson
needs one), and they connect to nothing but 127.0.0.1.
The output is exactly what the programs printed, with four substitutions so that a record never shows the computer
it ran on and is the same on every run: the run's folder is shown as ".", the folder of PostgreSQL's programs is left
out of their paths, the operating-system user's name is shown as "learner", and the dots pg_ctl prints while it waits
(one about every tenth of a second) as three. A session may name more patterns (mask=) for numbers that change on
every run, such as the rates and times ANALYZE VERBOSE prints or the random key of pg_dump's \\restrict line; the
session's own file says which, and its record shows a fixed word in their place. The sessions never print the port.
A session stops with status 1 when a command fails that should have worked, or reports a different number of errors
than the session expects, so a run that went wrong is never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway cluster: initdb, settings, start; stopped and deleted when the with block ends."""
def __init__(self, settings=None, initdb=(), name="data"):
self.settings = dict(settings or {})
self.initdb_args = list(initdb)
self.name = name
self.running = []
def __enter__(self):
if not (BIN / "postgres").exists():
sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="pg", dir=".")).resolve()
self.port = free_port()
self.password = secrets.token_urlsafe(24)
(self.base / "pwfile").write_text(self.password + "\n")
self.env = {**os.environ, "PGHOST": "127.0.0.1", "PGPORT": str(self.port), "PGUSER": "postgres",
"PGPASSWORD": self.password, "PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "60"}
for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC"):
self.env.pop(key, None)
for script in Path(".").glob("*.sql"):
shutil.copy(script, self.base / script.name)
for data in Path(".").glob("*.csv"):
shutil.copy(data, self.base / data.name)
return self
def __exit__(self, *exc):
for folder, _port in reversed(self.running):
self._call(["pg_ctl", "-D", folder, "-m", "fast", "-w", "-t", "60", "stop"])
self.running = []
shutil.rmtree(self.base, ignore_errors=True)
# ---- the cluster ---------------------------------------------------------------------------------------------
def initdb(self, show=False):
args = ["initdb", "-D", self.name, "-U", "postgres", "--auth=scram-sha-256", "--pwfile=pwfile",
*self.initdb_args]
if not show:
args.append("--no-instructions")
self._expect(args, show=show)
with open(self.base / self.name / "postgresql.conf", "a") as conf:
conf.write("\n# Added by the session: 127.0.0.1 only, no Unix socket.\n")
conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.port}\nunix_socket_directories = ''\n")
for key, value in self.settings.items():
conf.write(f"{key} = {value}\n")
def start(self, show=False, folder=None, port=None):
folder = folder or self.name
self._expect(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"], show=show)
self.running.append((folder, port or self.port))
def up(self):
"""initdb and start, quietly."""
self.initdb()
self.start()
return self
def standby(self, folder="standby"):
"""A streaming standby of this server, made with pg_basebackup -R, started on a port of its own."""
self._expect(["pg_basebackup", "-D", folder, "-R", "-X", "stream", "-c", "fast"])
port = free_port()
with open(self.base / folder / "postgresql.conf", "a") as conf:
conf.write(f"port = {port}\n")
self.start(folder=folder, port=port)
for _ in range(300):
if self.query("SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") == "1":
return port
time.sleep(0.1)
sys.exit("the standby did not start streaming")
# ---- running things ------------------------------------------------------------------------------------------
def _call(self, args, env=None):
args = [str(a) for a in args]
done = subprocess.run([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=self.base, env=env or self.env)
return done.returncode, self.clean(done.stdout)
def _expect(self, args, show=False, status=0, mask=()):
code, out = self._call(args)
out = _masked(out, mask)
if show:
_show(args, out)
if code != status:
if not show:
print(out)
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The run's folder as ".", the user as learner, pg_ctl's waiting dots as three (see the docstring)."""
text = text.replace(str(BIN) + "/", "").replace(str(self.base) + "/", "").replace(str(self.base), ".")
text = re.sub(r"(waiting for server to (?:start|shut down))\.+", r"\1...", text)
return text.replace(f'"{OS_USER}"', '"learner"')
def wait_for(self, sql, value):
"""Waits (unprinted) until a query returns the value, for example after a reload."""
for _ in range(300):
if self.query(sql) == value:
return
time.sleep(0.1)
sys.exit(f"{sql} never returned {value}")
def show_file(self, path):
"""Prints a text file of the cluster, as `cat` would."""
print(f"$ cat {path}")
print((self.base / path).read_text().rstrip("\n"))
print()
def query(self, sql, port=None):
"""One value from one query, unprinted (the session uses it to wait or to decide)."""
env = {**self.env, "PGPORT": str(port or self.port)}
code, out = self._call(["psql", "-X", "-At", "-c", sql], env=env)
if code != 0:
sys.exit(f"the query failed: {out}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only to give a role the run's password)."""
self.query(sql)
def psql(self, script, errors=0, status=None, user=None, db=None, port=None, flags=("-a",), note=None, mask=()):
"""Runs a psql script with -X (no ~/.psqlrc) and the given flags, and prints the command and its output.
`errors` is how many ERROR lines the script must print; `status` the exit status it must end with."""
env = {**self.env, "PGPORT": str(port or self.port)}
if user:
env["PGUSER"] = user
if db:
env["PGDATABASE"] = db
args = ["psql", "-X", *flags, "-f", script]
code, out = self._call(args, env=env)
out = _masked(out, mask)
shown = ["psql", "-X", *flags, "-f", script]
if user:
shown[1:1] = ["-U", user]
if db:
shown[1:1] = ["-d", db]
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if "ERROR:" in line or ": error:" in line)
expected_status = 0 if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
def sessions(self, count):
"""Opens `count` idle psql connections (each one a client backend) until the with block ends."""
return _Idle(self, count)
def shell(self, *args, status=0, mask=()):
"""Runs one of PostgreSQL's programs and prints the command and its output."""
return self._expect(list(args), show=True, status=status, mask=mask)
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the cluster (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"-- in {folder}" + (f", the names that start with {prefix}" if prefix else "") + ":")
print(textwrap.fill(" ".join(names), width=78, initial_indent=" ", subsequent_indent=" ",
break_on_hyphens=False))
print()
return names
def _masked(text, mask):
"""Replaces what differs on every run (a rate, a time, a random key) with fixed text (see the docstring)."""
for pattern, replacement in mask:
text = re.sub(pattern, replacement, text, flags=re.M)
return text
def _arg(a):
"""A command-line argument as a reader would type it: double quotes around text with single quotes in it."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
class _Idle:
"""Idle psql connections, closed again when the with block ends."""
def __init__(self, server, count):
self.server, self.count, self.procs = server, count, []
def __enter__(self):
before = int(self.server.query("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"))
for _ in range(self.count):
self.procs.append(subprocess.Popen([str(BIN / "psql"), "-X", "-q"], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=self.server.env, cwd=self.server.base))
sql = "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"
for _ in range(300):
# the query's own connection is one more client backend
if int(self.server.query(sql)) == before + self.count:
return self
time.sleep(0.1)
sys.exit("the idle connections did not open")
def __exit__(self, *exc):
for p in self.procs:
p.stdin.close()
p.wait(timeout=30) pg_extensions.py
"""Where the sessions of this lesson find an extension that is not installed next to PostgreSQL.
An extension installed together with PostgreSQL (by a package manager, for example) needs nothing from this file:
extension_settings() then returns no settings. An extension built into a prefix of its own, a folder that holds
share/extension/ and lib/, is found by PostgreSQL 18 through two settings, extension_control_path and
dynamic_library_path. This file builds them from the prefixes it finds, in this order:
1. PG_EXTENSION_DIR, a colon-separated list of such prefixes, when it is set;
2. otherwise the entries of a folder named pg-extensions in the session's folder or in a folder above it: each entry
is a prefix, or a link to one.
The settings go into the throwaway cluster's configuration only. The sessions never print them, because they hold
paths of the computer that ran them.
"""
import os
from pathlib import Path
def extension_prefixes():
"""The prefixes to add, or [] when the extension sits next to PostgreSQL."""
listed = [p for p in os.environ.get("PG_EXTENSION_DIR", "").split(":") if p]
if listed:
return listed
here = Path.cwd()
for folder in (here, *here.parents):
holder = folder / "pg-extensions"
if holder.is_dir():
return sorted(str(p.resolve()) for p in holder.iterdir() if (p / "share" / "extension").is_dir())
return []
def extension_settings():
"""Settings for Server(settings=...): empty when no prefix is needed."""
prefixes = extension_prefixes()
if not prefixes:
return {}
return {
"extension_control_path": "'" + ":".join(f"{p}/share" for p in prefixes) + ":$system'",
"dynamic_library_path": "'" + ":".join(f"{p}/lib" for p in prefixes) + ":$libdir'",
} Each operator chose a different winner: L2 chose close, 0.141 away; cosine chose same direction, at distance 0
though twice as long; the inner product chose long east, with a product of 5. The sizes follow the README’s
formulas: 4 × 384 + 8 = 1,544 bytes for vector(384), 776 for halfvec(384), and 32 for the two non-zero elements
of a sparsevec. The last insert was refused for its third number.
Checkpoint
Check: Vector types and the three distance operators
Exact search reads every row, and an index serves one operator
Without a vector index pgvector performs exact search, which the README credits with perfect recall. The second run makes 5,000 documents of 64 numbers, plans the 10 nearest to query 1, then builds an HNSW index for cosine distance and plans three more queries:
plans_session.py
"""pgvector 0.8.7 on PostgreSQL 18.6: exact search reads every row, an HNSW index serves only its own operator."""
from pg_extensions import extension_settings
from pg_session import Server
# pg_extensions.py finds pgvector when it sits in a prefix of its own (see its docstring). Index builds run in the
# session's own process, with no parallel workers. They are random all the same (pgvector draws from PostgreSQL's
# per-process generator, which setseed() does not reach), so the script prints only what every build agrees on.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off", "timezone": "'UTC'",
"max_parallel_maintenance_workers": 0}
SETTINGS.update(extension_settings())
with Server(settings=SETTINGS) as pg:
pg.up()
pg.psql("docs.sql")
pg.psql("plans.sql") Output
$ psql -X -a -f docs.sql
-- 5,000 documents and 200 queries, each a vector of 64 numbers between -1 and 1 from random(); the seed makes every
-- run identical. A real system gets such vectors from an embedding model outside the database.
CREATE EXTENSION vector;
CREATE EXTENSION
SELECT setseed(0.42) AS seeded \gset
CREATE TABLE docs AS
SELECT i AS id, array_agg(random() * 2 - 1 ORDER BY d)::vector(64) AS emb
FROM generate_series(1, 5000) AS i, generate_series(1, 64) AS d
GROUP BY i;
SELECT 5000
CREATE TABLE queries AS
SELECT q AS qid, array_agg(random() * 2 - 1 ORDER BY d)::vector(64) AS emb
FROM generate_series(1, 200) AS q, generate_series(1, 64) AS d
GROUP BY q;
SELECT 200
ALTER TABLE docs ADD PRIMARY KEY (id);
ALTER TABLE
ALTER TABLE queries ADD PRIMARY KEY (qid);
ALTER TABLE
ANALYZE docs, queries;
ANALYZE
$ psql -X -a -f plans.sql
-- The 10 documents nearest to query 1 by cosine distance, before any vector index exists.
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT id FROM docs ORDER BY emb <=> (SELECT emb FROM queries WHERE qid = 1) LIMIT 10;
QUERY PLAN
-----------------------------------------------------------------------------
Limit (actual rows=10.00 loops=1)
InitPlan 1
-> Index Scan using queries_pkey on queries (actual rows=1.00 loops=1)
Index Cond: (qid = 1)
Index Searches: 1
-> Sort (actual rows=10.00 loops=1)
Sort Key: ((docs.emb <=> (InitPlan 1).col1))
Sort Method: top-N heapsort Memory: 25kB
-> Seq Scan on docs (actual rows=5000.00 loops=1)
(9 rows)
-- An HNSW index for cosine distance, with the default m = 16 and ef_construction = 64.
CREATE INDEX docs_emb_idx ON docs USING hnsw (emb vector_cosine_ops);
CREATE INDEX
EXPLAIN (COSTS OFF)
SELECT id FROM docs ORDER BY emb <=> (SELECT emb FROM queries WHERE qid = 1) LIMIT 10;
QUERY PLAN
--------------------------------------------------
Limit
InitPlan 1
-> Index Scan using queries_pkey on queries
Index Cond: (qid = 1)
-> Index Scan using docs_emb_idx on docs
Order By: (emb <=> (InitPlan 1).col1)
(6 rows)
-- The same question by L2 distance: the index was built for cosine distance.
EXPLAIN (COSTS OFF)
SELECT id FROM docs ORDER BY emb <-> (SELECT emb FROM queries WHERE qid = 1) LIMIT 10;
QUERY PLAN
------------------------------------------------------
Limit
InitPlan 1
-> Index Scan using queries_pkey on queries
Index Cond: (qid = 1)
-> Sort
Sort Key: ((docs.emb <-> (InitPlan 1).col1))
-> Seq Scan on docs
(7 rows)
-- Cosine similarity, highest first: an expression around the operator, in descending order.
EXPLAIN (COSTS OFF)
SELECT id FROM docs ORDER BY 1 - (emb <=> (SELECT emb FROM queries WHERE qid = 1)) DESC LIMIT 10;
QUERY PLAN
-------------------------------------------------------------------------------------
Limit
InitPlan 1
-> Index Scan using queries_pkey on queries
Index Cond: (qid = 1)
-> Sort
Sort Key: (('1'::double precision - (docs.emb <=> (InitPlan 1).col1))) DESC
-> Seq Scan on docs
(7 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 plans_session.py
docs.sql
-- 5,000 documents and 200 queries, each a vector of 64 numbers between -1 and 1 from random(); the seed makes every
-- run identical. A real system gets such vectors from an embedding model outside the database.
CREATE EXTENSION vector;
SELECT setseed(0.42) AS seeded \gset
CREATE TABLE docs AS
SELECT i AS id, array_agg(random() * 2 - 1 ORDER BY d)::vector(64) AS emb
FROM generate_series(1, 5000) AS i, generate_series(1, 64) AS d
GROUP BY i;
CREATE TABLE queries AS
SELECT q AS qid, array_agg(random() * 2 - 1 ORDER BY d)::vector(64) AS emb
FROM generate_series(1, 200) AS q, generate_series(1, 64) AS d
GROUP BY q;
ALTER TABLE docs ADD PRIMARY KEY (id);
ALTER TABLE queries ADD PRIMARY KEY (qid);
ANALYZE docs, queries; plans.sql
-- The 10 documents nearest to query 1 by cosine distance, before any vector index exists.
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT id FROM docs ORDER BY emb <=> (SELECT emb FROM queries WHERE qid = 1) LIMIT 10;
-- An HNSW index for cosine distance, with the default m = 16 and ef_construction = 64.
CREATE INDEX docs_emb_idx ON docs USING hnsw (emb vector_cosine_ops);
EXPLAIN (COSTS OFF)
SELECT id FROM docs ORDER BY emb <=> (SELECT emb FROM queries WHERE qid = 1) LIMIT 10;
-- The same question by L2 distance: the index was built for cosine distance.
EXPLAIN (COSTS OFF)
SELECT id FROM docs ORDER BY emb <-> (SELECT emb FROM queries WHERE qid = 1) LIMIT 10;
-- Cosine similarity, highest first: an expression around the operator, in descending order.
EXPLAIN (COSTS OFF)
SELECT id FROM docs ORDER BY 1 - (emb <=> (SELECT emb FROM queries WHERE qid = 1)) DESC LIMIT 10; The first plan is exact search: Seq Scan on docs returned all 5,000 rows, and a top-N heapsort in 25 kB kept the 10
smallest distances. With the index, the same query became Index Scan using docs_emb_idx, ordered by <=>. An index
is built with an operator class: The part of an index definition that says which operators the index can search with for a column's type, such as jsonb_path_ops for GIN. The catalogs pg_opclass and pg_amop list them. More in “GIN, GiST, SP-GiST, BRIN, hash and bloom” that names one distance, vector_cosine_ops here,
vector_l2_ops for <-> and vector_ip_ops for <#>, so the query sorted by <-> fell back to a scan and a sort.
So did the last one, sorted by similarity, highest first: an index serves its own operator, ascending, under a LIMIT.
Sorting by similarity
ORDER BY 1 - (emb <=> q) DESC LIMIT 10 returns the right rows but reads every row. Write
ORDER BY emb <=> q LIMIT 10, and compute 1 - (emb <=> q) in the select list if the application wants it.
Checkpoint
Check: Exact plans, index plans and operator classes
HNSW and IVFFlat trade a little recall for speed
An approximate index answers from a structure built in advance and gives up a little recall@k: The fraction of the true top-k nearest neighbours that an approximate search returns, measured by comparing with exact search. A recall of 0.9 means 9 of the 10 true neighbours were found., the share of the true nearest rows it returns. pgvector has two:
- An HNSW index: Hierarchical Navigable Small World: a graph-based approximate nearest-neighbour index in pgvector. It builds slowly and uses more memory than IVFFlat but gives better recall at a given query latency. links each vector to near neighbours in a graph of layers. It builds more slowly and uses more memory than IVFFlat, and gives better speed for the same recall.
- An IVFFlat index: Inverted File with Flat storage: an approximate nearest-neighbour index that clusters vectors into lists at build time and searches the closest lists at query time. It builds faster than HNSW but needs tuning of lists and probes. sorts the vectors into lists around centres found by A method that splits points into k groups: it places k centres, gives each point to its nearest centre, and moves each centre to the mean of its points until they settle.. It builds faster and smaller, and needs the table’s rows before it is created.
Step through one HNSW search over the 5,000 documents:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A graph built in layers. Each vector got a layer at random when it was indexed. With m = 16 a vector reaches layer 1 with a chance of 1 in 16 and layer 2 with 1 in 256, so of 5,000 vectors about 310 reach layer 1 and about 20 go higher.
- Start at the entry point. The search begins at the index's entry point, a vector on the highest layer this build drew, and measures its distance to the query vector.
- Greedy steps on thin layers. On each upper layer the search moves to whichever linked vector is closer to the query, until no neighbour is closer, then drops a layer. Few vectors live up there, so each link spans a long way.
- Down to layer 1. The closest vector found becomes the starting point on layer 1, where about 310 vectors each link to at most 16 others, so the steps get shorter.
- A list of 40 on layer 0. Layer 0 holds every vector, each linked to at most 32 others. Here the search keeps the 40 closest vectors it has met, hnsw.ef_search, and expands the closest one it has not expanded yet.
- When to stop. The search stops when the closest candidate it has not expanded is farther from the query than the 40th it keeps. Only vectors reached through links were ever compared.
- Rows in distance order. The scan hands its candidates to the executor nearest first, and LIMIT 10 keeps the first ten. They are close to the query, and usually, not always, the ten closest of the table.
The list kept on the bottom layer is the setting hnsw.ef_search, 40 by default; IVFFlat’s counterpart,
ivfflat.probes, is the number of lists a query reads, 1 by default. Both can be set per session, or per transaction
with SET LOCAL. Answer three questions about a table of yours:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Search | Finds the true nearest | Speed on a large table | Build time and memory | Index on an empty table |
|---|---|---|---|---|
| Exact search Works by: a distance for every row | Good always: the plan read all 5,000 rows and sorted their distances | Poor one distance per row on every query, so the cost grows with the table | Good nothing to build, store or keep in memory | Good nothing to learn from the data, so it works from the first row |
| HNSW index Works by: a layered graph of links | Mixed close rows, usually the closest; hnsw.ef_search sets how hard it looks | Good the better speed for a given recall, says the README | Poor slower to build and larger in memory than IVFFlat | Good no training step: it can be created before the rows arrive |
| IVFFlat index Works by: lists around k-means centres | Mixed reads only the lists that ivfflat.probes names, nearest centres first | Mixed fast, but slower than HNSW at the same recall | Good faster to build and smaller in memory than HNSW | Poor learns its lists from the rows present at CREATE INDEX |
How the answers decide
- “Must every answer be the true nearest rows, with nothing missed?” Yes, nothing may be missed: rules out HNSW index and IVFFlat index. Only exact search guarantees the true nearest rows; an approximate index returns close rows that are not always the closest.
- “How many rows will the table hold when it is busiest?” Millions, or growing fast and “Must every answer be the true nearest rows, with nothing missed?” No, very close is good enough: rules out Exact search. Exact search computes one distance per row on every query, so a table of millions makes every query read millions of rows.
- “How many rows will the table hold when it is busiest?” Thousands, and it grows slowly: adds a caveat to HNSW index and IVFFlat index. On a table of thousands a scan may already be quick enough; an index adds build time and memory, so time the query first.
- “Are the rows in the table before you create the index?” No, they arrive afterwards: rules out IVFFlat index. IVFFlat chooses its lists from the rows it sees at CREATE INDEX; HNSW has no such step and can be created on an empty table.
In short: exact search for small tables or exact answers, HNSW for large tables that keep changing, IVFFlat when the data comes first and memory is tight.
A WHERE clause is applied to the rows an approximate index scan returns. Iterative index scans, added in pgvector 0.8.0, keep reading the index while it still needs rows. Hybrid search fuses vector ranks with the full-text ranks of full-text and trigram search.
Checkpoint
Check: HNSW, IVFFlat and their search settings
Practise: operators, plans and the index to choose
Try these on the runs above; each answer explains itself.
Practice that adapts to you
Practise this lesson
9 questions about 9 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
What vector indexes cost on disk, and how IVFFlat lists are built Pro: opens with a Pro, Premium or Ultimate pass
Vector indexes are large, and one that does not fit in memory is slow however well it is tuned. This part measures the bytes per row of vector and halfvec tables and their HNSW indexes, scales them to ten million rows, and builds and probes an IVFFlat index.
In this part:
- Bytes per row for vector and halfvec, measured, then scaled to ten million rows
- IVFFlat learns its lists from the rows it is built on
1 diagram · 2 runnable examples (docs.sql, ivfflat.sql, ivfflat_session.py, sizes.sql, sizes_session.py) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Measuring recall@10, and the query that returns fewer rows than its LIMIT Pro: opens with a Pro, Premium or Ultimate pass
An approximate index never reports what it missed, so recall has to be measured against exact search on the same queries. This part measures it at four settings, explains why every build gives slightly different numbers, and reproduces a LIMIT that the index cannot fill.
In this part:
- Recall at four settings of the candidate list, measured against exact search
- A LIMIT 50 that comes back with 40 rows
2 runnable examples (docs.sql, recall.sql, recall_session.py, short.sql, short_session.py) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Filters that empty a result, and ranking with full-text search Pro: opens with a Pro, Premium or Ultimate pass
A WHERE clause is applied to the rows an index scan returns, not to the search that finds them. This part reproduces a category filter that removes every candidate, fixes it two ways, and fuses vector ranks with full-text ranks.
In this part:
- A category filter that removes every candidate
- Hybrid ranking: full-text ranks and vector ranks, fused
2 runnable examples (docs.sql, filter.sql, filter_session.py, hybrid.sql, hybrid_session.py) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
Warm-up (fresher to 2–5 years). Cosine distance: what does it measure, and when do the three operators agree?
Cosine distance is 1 minus the cosine of the angle between two vectors: it compares directions and ignores length, so in the lesson’s run [2, 2] stood at distance 0 from the query [1, 1]. The same direction scores 0, a right angle 1, opposite directions 2. L2 distance measures the straight line, and the negated inner product rewards direction and length together, so the three operators chose three different nearest vectors. For vectors of length 1 the three orders agree: the squared L2 distance is then 2 minus twice the inner product.
Three production questions build on these runs:
-
Mid to senior
A catalogue of 10 million products, each with a 384-number embedding, moves into PostgreSQL with an HNSW index, on a server with 32 GB of memory. Estimate the table and index sizes, say what the index build needs, and choose a layout that keeps searches fast.
-
Mid to senior
An HNSW index went live on a table of 2 million documents, and users report that searches miss items they expected to see. How do you measure recall@10, and what do you change if it is below 0.9?
-
Senior to staff
A query for the 10 nearest products in category 7 returns 4 rows, though the category holds 300 products. EXPLAIN shows the HNSW index scan with a Filter line. Explain what happened, and give two fixes with what each costs.
Flashcards · 3 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What vector types and distance operators does pgvector offer?
vector(n) stores n numbers of 4 bytes, halfvec(n) 2 bytes each, sparsevec(n) only non-zero elements. Operators: <-> is L2 (straight-line) distance, <=> cosine distance (direction, ignoring length), <#> negative inner product (ascending puts the largest product first).
-
What is exact search, and how must you write an indexed nearest-neighbour query?
Without a vector index pgvector does exact search (a Seq Scan, perfect recall, slower per row). An index is built with an operator class naming one distance (vector_cosine_ops, vector_l2_ops, vector_ip_ops) and serves only that operator, ascending, under a LIMIT — so write ORDER BY emb <=> q LIMIT 10, not 1 - (emb <=> q) DESC.
-
How do HNSW and IVFFlat indexes compare?
HNSW links each vector to near neighbours in a graph of layers — it builds more slowly and uses more memory, gives better speed for the same recall and suits large tables that keep changing. IVFFlat sorts vectors into lists around k-means centres — it builds faster and smaller, and needs the table's rows before it is created. hnsw.ef_search defaults to 40, ivfflat.probes to 1.
Key takeaways
- pgvector stores embeddings as
vector,halfvecorsparsevec, and the operator you sort by decides which row is nearest. - Without a vector index, a nearest-neighbour query is exact search: it read all 5,000 rows.
- An index scan needs the ORDER BY to use the index’s own operator, ascending, under a LIMIT.
- HNSW and IVFFlat return close rows fast at some cost in recall, and only HNSW can be built before the rows arrive.
Terms in this lesson
- cosine distance
- One minus the cosine of the angle between two vectors. Vectors pointing the same way have distance 0; perpendicular vectors have distance 1. The pgvector operator is <=>.
- embedding
- A list of numbers (a vector) that places similar items near each other in a high-dimensional space. Models produce embeddings for text, images, and other data so that distance between vectors reflects semantic similarity.
- HNSW index
- Hierarchical Navigable Small World: a graph-based approximate nearest-neighbour index in pgvector. It builds slowly and uses more memory than IVFFlat but gives better recall at a given query latency.
- IVFFlat index
- Inverted File with Flat storage: an approximate nearest-neighbour index that clusters vectors into lists at build time and searches the closest lists at query time. It builds faster than HNSW but needs tuning of lists and probes.
- recall@k
- The fraction of the true top-k nearest neighbours that an approximate search returns, measured by comparing with exact search. A recall of 0.9 means 9 of the 10 true neighbours were found.
References
- pgvector README (version 0.8.7) (The pgvector project)
- pgvector CHANGELOG (The pgvector project)
- pgvector 0.8.7 source: src/hnswutils.c (the random layer of each vector) (The pgvector project)
- pgvector 0.8.7 source: src/hnsw.h (links per layer, the random generator) (The pgvector project)
- pgvector 0.8.7 source: src/ivfbuild.c (the IVFFlat sample and k-means) (The pgvector project)
- pgvector 0.8.7 source: src/ivfkmeans.c (the random first centre of k-means) (The pgvector project)
- pgvector-python example: hybrid search with reciprocal rank fusion (The pgvector project)
- Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs (arXiv)
- PostgreSQL 18 documentation: CREATE INDEX (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 11.8 Partial Indexes (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.7 Query Planning (enable_indexscan) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.4 Resource Consumption (maintenance_work_mem) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 9.3 Mathematical Functions and Operators (random and setseed) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 12.3 Controlling Text Search (ranking) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 14.1 Using EXPLAIN (The PostgreSQL Global Development Group)
Report a problem with this lesson
Kept only in this browser. Your Learn progress