Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: A vector column, and three distances that pick different winners · Exact search reads every row, and an index serves one operator · HNSW and IVFFlat trade a little recall for speed · Practise: operators, plans and the index to choose. With a pass: 3 in-depth parts and 3 interview answers, about 10 minutes.

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]:

Three distance operators on five vectors, and the bytes each type takes

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

  1. Question 1 of 2 A table holds two vectors, [3, 3] and [1, 0.9]. Against the query [1, 1], which is nearer by <=> and which by <->?

    Choose one answer.

    A hint

    Cosine distance looks only at direction; L2 measures the straight line between the points.

    Show the answer: Question 1 of 2

    Answer: [3, 3] by <=>, and [1, 0.9] by <->

    Cosine distance compares directions, so the longer vector on the query's line wins, as same direction did at 0 in the run. L2 distance compares positions, so the vector closest in space wins, as close did at 0.141.

  2. Question 2 of 2 With the README formula the run confirmed for vector(384), how many bytes does one vector(768) value take?

    Type a number.

    A hint

    Four bytes per number, plus a fixed 8.

    Show the answer: Question 2 of 2

    Answer: 3080 bytes

    A vector takes 4 × dimensions + 8 bytes: 4 × 768 + 8 = 3,080. The run measured 4 × 384 + 8 = 1,544 for vector(384) and 776 for halfvec(384), which uses 2 bytes a number.

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:

Exact search, an HNSW index scan, and two queries the index cannot serve

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

  1. Question 1 of 2 Before any vector index existed, how did PostgreSQL find the 10 documents nearest to query 1 among 5,000?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It read all 5,000 rows, computed each distance and kept the 10 smallest with a top-N heapsort

    Exact search measures every row and sorts the distances. It is always right, and its cost grows with the table.

  2. Question 2 of 2 docs_emb_idx was built with vector_cosine_ops. Which of these queries can use it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ORDER BY emb <=> q LIMIT 10

    An index serves one distance, the one its operator class names, in ascending order under a LIMIT: <=> for vector_cosine_ops, <-> for vector_l2_ops, <#> for vector_ip_ops.

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:

Interactive · stage by stage One HNSW search, from the top layer down Step through one query over the 5,000 documents and watch the search narrow from a few long links to many short ones.
One HNSW search, from the top layer down, step 7 of 7. Showing Upper layers (closest found so far), Layer 1 (a closer start for layer 0), Layer 0 (40 candidates kept), Rows to the executor (LIMIT 10 keeps ten). In focus: Rows to the executor.Upper layersclosest found so farLayer 1a closer start for layer 0Layer 040 candidates keptRows to the executorLIMIT 10 keeps ten

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

Interactive · answer and compare Which nearest-neighbour search fits your table? Answer three questions about your table and the rows that will not fit are marked, with the reason.
How many rows will the table hold when it is busiest?
Must every answer be the true nearest rows, with nothing missed?
Are the rows in the table before you create the index?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Exact search and the two approximate indexes of pgvector against four things a table needs
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

  1. Question 1 of 2 A new table will receive its rows over the coming weeks, and its vector index must exist before the first row arrives. Which index fits?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: HNSW

    HNSW builds its graph row by row, so it can exist before the data. IVFFlat is trained on the data at CREATE INDEX, so create it after the rows are loaded.

  2. Question 2 of 2 What does hnsw.ef_search set?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: How many candidates the search keeps on the bottom layer of the graph, 40 by default

    hnsw.ef_search is the length of the candidate list on layer 0. It can be set per session, or per transaction with SET LOCAL.

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.

  1. Question 1 of 9 Which pgvector type stores only the elements that are not zero?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: sparsevec

    sparsevec suits vectors that are mostly zeros; vector and halfvec store every element, at 4 and 2 bytes each.

  2. Question 2 of 9 Why does <#> return the inner product with its sign flipped?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: So that an ascending sort, the order an index scan serves, puts the largest inner product first

    A larger inner product means nearer, so the sign is flipped to make ascending order mean nearest first, as for the other operators.

  3. Question 3 of 9 Every vector in a table, the query included, has length 1. Do ORDER BY emb <-> q and ORDER BY emb <#> q return different rows?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: No: at length 1 the squared L2 distance is 2 minus twice the inner product, so both orders are the same

    For unit vectors, |a - b|² = 2 - 2(a·b), and cosine distance is 1 - a·b, so all three operators rank rows the same way.

  4. Question 4 of 9 Which plan line shows that a nearest-neighbour query walked the HNSW index?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: Index Scan using docs_emb_idx, with Order By: (emb <=> …)

    Look for the vector index's name and an Order By on the distance operator; a Sort over a Seq Scan means exact search.

  5. Question 5 of 9 Some queries of an application sort by <=> and others by <->. What does the table need for both to use an index?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: Two indexes, one with vector_cosine_ops and one with vector_l2_ops

    One index per distance: vector_cosine_ops for <=>, vector_l2_ops for <->, vector_ip_ops for <#>, each costing its own disk space and write time.

  6. Question 6 of 9 An application shows a similarity score, 1 - (emb <=> q), and sorts by it, highest first, and every query reads the whole table. Which rewrite keeps the score and uses the cosine index?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: ORDER BY emb <=> q LIMIT 10, with 1 - (emb <=> q) computed in the select list

    Sorting by the highest similarity and by the lowest cosine distance give the same order, so sort by the distance the index keeps and compute the score for display.

  7. Question 7 of 9 What does recall mean for an approximate index?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: The share of the true nearest rows that the index returns

    Exact search has perfect recall by definition; HNSW and IVFFlat give up a little of it for speed.

  8. Question 8 of 9 On layer 0 the search keeps 40 candidates. When does it stop?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: When the closest candidate it has not expanded is farther than the 40th it keeps

    The search widens from the closest candidates until none of them can do better than the list it holds; a longer list makes it look further before it stops.

  9. Question 9 of 9 A table holds 2,000 product vectors, grows by a few rows a day, and every answer must be the true nearest. What do you choose?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: Exact search, with no vector index

    When answers must be exact, approximate indexes are ruled out; for a small table, exact search is also fast enough.

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.

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.

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.

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:

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 3 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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).

  2. 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.

  3. 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, halfvec or sparsevec, 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.