Vector Search in PostgreSQL with pgvector

Kobi Lemberg Kobi Lemberg
October 6, 2026
9 min read

pgvector turns PostgreSQL into a vector database. What it does, and what's changed since 2023: halfvec, sparsevec, iterative scans, and quantization.

Vector Search in PostgreSQL with pgvector

Most of what a relational database compares is naturally orderable: numbers, dates, strings you can sort alphabetically. Embeddings break that assumption. A movie, a support ticket, a product photo, or a paragraph of documentation has no natural ordering — the only useful question is "what else is like this one?" Answering that requires turning each object into a vector (a list of floats produced by an embedding model) and then finding the vectors that sit close to a query vector in that high-dimensional space.

pgvector is the extension that lets PostgreSQL do that comparison natively. It adds a vector column type, a handful of distance operators, and approximate nearest-neighbor indexes, all inside the database you're already running. No separate service, no ETL job shuttling embeddings between two systems, no second set of backups to worry about. For a deeper reference on the operators, index tuning knobs, and query patterns, see our PostgreSQL vector search guide — this post covers what pgvector is, how it works today, and what's changed since it was the new thing everyone was trying out.

How pgvector traverses an HNSW graph to find similar rows

What pgvector Actually Adds

The extension gives you a vector type and three distance operators: <-> for Euclidean (L2) distance, <=> for cosine distance, and <#> for negative inner product. Which one you use depends on your embedding model — most sentence and text embedding models are trained for cosine similarity, image models vary, and if your model already normalizes vectors, inner product and cosine distance rank results identically but inner product is cheaper to compute.

Installing it and using it is close to any other extension:

CREATE EXTENSION vector;

CREATE TABLE documents (
    id bigserial PRIMARY KEY,
    content text NOT NULL,
    embedding vector(768)
);

INSERT INTO documents (content, embedding)
VALUES ('PostgreSQL supports partitioning, replication, and row-level security.',
        '[0.012, -0.034, 0.101, ...]');

SELECT id, content
FROM documents
ORDER BY embedding <=> '[0.010, -0.030, 0.098, ...]'
LIMIT 5;

That last query is an exact nearest-neighbor scan — it checks every row. Fine for a few thousand documents, painfully slow for a few million, which is where indexing comes in.

IVFFlat and HNSW

pgvector ships two approximate index types, and they trade off differently.

IVFFlat clusters your vectors into lists during index build, then at query time only searches the lists closest to the query vector. The clustering is a training step, so build it after the table has representative data — an index built on an empty or tiny table ends up with bad list boundaries. It's cheaper to build and smaller on disk than HNSW. It's a reasonable choice when memory is tight or the dataset is still small enough that build time matters more than query latency.

HNSW (Hierarchical Navigable Small World) builds a multi-layer graph where each vector is linked to a handful of nearby and distant neighbors, and a query walks the graph greedily from an entry point until it can't get any closer. It builds slower and uses more memory than IVFFlat, but it consistently gives better recall at a given latency, and unlike IVFFlat it doesn't need a training pass — you can build it against an empty table and let it grow. For most new projects, HNSW is the default worth reaching for first:

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

SET hnsw.ef_search = 100;

m controls how many connections each graph node keeps (more connections, better recall, more memory); ef_construction controls how thorough the build-time search is. hnsw.ef_search is the query-time equivalent — turn it up when recall matters more than milliseconds, turn it down when it doesn't.

What's Changed Since the Early Releases

pgvector moved fast. If the last time you looked at it was 2023, several things are different now that change how you'd actually design a schema.

halfvec. A halfvec stores each dimension as a 2-byte float instead of 4, roughly halving both storage and index memory with a small, usually negligible, hit to recall. It also raises the ceiling: you can index up to 4,000 dimensions with halfvec, double the 2,000-dimension indexable limit for regular vector columns, which matters as embedding models keep growing (a lot of current text embedding models sit at 1,024 or 1,536 dimensions already). You can store halfvec natively:

CREATE TABLE documents (
    id bigserial PRIMARY KEY,
    content text NOT NULL,
    embedding halfvec(1536)
);

CREATE INDEX ON documents USING hnsw (embedding halfvec_cosine_ops);

or keep the column as vector and quantize only at index time with an expression index, which is a nice middle ground when other parts of your application still expect full precision:

CREATE INDEX ON documents
USING hnsw ((embedding::halfvec(1536)) halfvec_cosine_ops);

Query against that expression index by casting the query vector the same way: ORDER BY embedding::halfvec(1536) <=> '[...]'::halfvec(1536).

sparsevec. Sparse vectors — mostly zeros, indexable up to 1,000 nonzero elements — got a native type instead of forcing you to fake them with a dense array. This matters for learned sparse retrieval models (SPLADE and similar), which produce exactly this shape of vector and used to have nowhere natural to live in pgvector.

Quantization beyond halfvec. binary_quantize() collapses a vector down to one bit per dimension, which is aggressive but extremely cheap to store and scan, and works well as a first-pass filter you then re-rank with full precision:

CREATE INDEX ON documents
USING hnsw ((binary_quantize(embedding)::bit(1536)) bit_hamming_ops);

Parallel HNSW builds. Index construction used to be single-threaded, which made building an HNSW index over tens of millions of rows a multi-hour affair. Builds now use max_parallel_maintenance_workers, and on a multi-core box that turns a build that used to run overnight into one you can run during a maintenance window. Bump maintenance_work_mem too — if the in-progress graph doesn't fit in that budget, the build falls back to on-disk construction and gets dramatically slower.

Iterative index scans. This one fixed a real production headache. Before it existed, an ANN query with a WHERE clause could silently return fewer rows than you asked for, or none — the index scan checks a bounded number of graph candidates, and if most of them get filtered out by the WHERE clause, you're left with an under-full result set instead of an error. Iterative scans expand the search automatically when that happens:

SET hnsw.iterative_scan = relaxed_order;
SET hnsw.max_scan_tuples = 20000;

SELECT id, content
FROM documents
WHERE status = 'published'
ORDER BY embedding <=> '[...]'
LIMIT 10;

strict_order expands the search while preserving exact distance ordering; relaxed_order expands faster by relaxing that guarantee slightly. If you've ever shipped a filtered semantic search endpoint that mysteriously returned three results instead of ten, this is the setting that was missing.

Building a Retrieval Pipeline

The most common reason people reach for pgvector is retrieval-augmented generation: give an LLM relevant context instead of relying on what it memorized during training. The shape of the pipeline hasn't changed even as the tooling around it has:

  1. Chunk your source documents and generate an embedding for each chunk.
  2. Store the chunk text, its embedding, and whatever metadata you'll want to filter on later (source, date, permission level).
  3. At query time, embed the user's question with the same model you used for indexing.
  4. Run a nearest-neighbor query — filtered by metadata if needed — to pull back the top chunks.
  5. Pass those chunks to the LLM as context alongside the question.
CREATE TABLE knowledge_base (
    id bigserial PRIMARY KEY,
    source text NOT NULL,
    chunk_text text NOT NULL,
    embedding halfvec(1536),
    published_at date
);

CREATE INDEX ON knowledge_base USING hnsw (embedding halfvec_cosine_ops);

SELECT chunk_text, source,
       1 - (embedding <=> '[...]'::halfvec(1536)) AS similarity
FROM knowledge_base
WHERE published_at > CURRENT_DATE - INTERVAL '2 years'
ORDER BY embedding <=> '[...]'::halfvec(1536)
LIMIT 5;

Worth being precise about terms here: this is vector search, not automatically "semantic" search. It becomes semantic search when the embeddings you feed it actually capture meaning — swap in a bad or mismatched embedding model and you get mathematically valid but useless nearest neighbors. Our semantic search vs. vector search breakdown goes into where that line sits and when hybrid search (combining this with full-text search) beats either approach alone.

Postgres Isn't the Only Place This Works

If your stack is already PostgreSQL, pgvector is the obvious choice precisely because it avoids standing up a second system. But vector search isn't a Postgres-exclusive concept — Elasticsearch and OpenSearch have had their own dense_vector and knn_vector field types for years, with the same HNSW indexing underneath, and if you're already running one of those for full-text search, adding vectors there instead of introducing Postgres for that reason alone is often the simpler path. The right database for vector search is usually whichever one you're already operating well, not whichever one has the newest vector feature.

Where That Leaves a New Project

For a new table today: use halfvec unless you have a specific reason not to, default to HNSW with m = 16, ef_construction = 64 and tune from there, and turn on iterative scans the moment you add a WHERE clause to a vector query — the default behavior of silently truncating results is the kind of bug that only shows up once real users start filtering. Run on PostgreSQL 18 if you can; PostgreSQL 19 is in beta (Beta 3 shipped in mid-August 2026) with GA planned for September 2026, and it's worth testing against once it ships rather than running a beta under a production vector workload.

The parts that are still on you: picking the right distance operator for your embedding model, sizing ef_construction and m against your actual recall requirements instead of the defaults, and noticing when an index that performed fine at 100K rows starts degrading at 10M. That last one is where a lot of pgvector deployments quietly rot — nobody's watching the HNSW index size against available memory until queries that used to take 20ms start taking 400. NeverBlink watches PostgreSQL alongside Elasticsearch, OpenSearch, and ClickHouse for exactly this kind of drift, and surfaces the recommendation — resize the index, adjust ef_search, add the missing metadata filter to a partial index — rather than applying it blind. Vector search is still new enough that most of the tuning knobs are unfamiliar; having something flag when they're wrong is worth more than having something guess on your behalf.

Vector Search in PostgreSQL with pgvector

Get AI-Powered Cluster Maintenance

Try it Free

Subscribe to the NeverBlink Newsletter

Get early access to new NeverBlink features, insightful blogs & exclusive events , webinars, and workshops.

We use cookies to provide an optimized user experience and understand our traffic. To learn more, read our use of cookies; otherwise, please choose 'Accept Cookies' to continue using our website.