Enable and check the extension
CREATE EXTENSION IF NOT EXISTS vector; -- the extension is called "vector", not "pgvector"
SELECT extversion FROM pg_extension WHERE extname = 'vector';
SELECT default_version FROM pg_available_extensions WHERE name = 'vector'; -- what an upgrade would give you
ALTER EXTENSION vector UPDATE; -- after the server package is upgraded
Note
On managed services the extension must usually be allow-listed first (Azure Flexible Server: add
VECTORto theazure.extensionsserver parameter). RDS, Aurora and Cloud SQL ship it as a supported extension; the available version depends on the engine minor version.
Column types
| Type | Stores | Max dims you can index (HNSW/IVFFlat) | Use for |
|---|---|---|---|
vector(n) | 4-byte floats | 2,000 | Default for embeddings |
halfvec(n) | 2-byte floats | 4,000 | Halves storage and index size; large models (3072 dims) |
bit(n) | Binary | 64,000 | Binary-quantized vectors, Hamming/Jaccard |
sparsevec(n) | Non-zero elements only | 1,000 non-zero elements | Sparse embeddings (SPLADE-style) |
Always declare the dimension (vector(1536), not vector). A column without a fixed dimension can't be indexed.
CREATE TABLE doc_chunks (
id bigserial PRIMARY KEY,
doc_id bigint NOT NULL,
tenant_id int NOT NULL,
content text NOT NULL,
embedding vector(1536) NOT NULL
);
Distance operators and matching operator classes
The operator in ORDER BY must match the operator class of the index, or the index is ignored.
| Distance | Operator | HNSW / IVFFlat opclass | Notes |
|---|---|---|---|
| L2 (Euclidean) | <-> | vector_l2_ops | |
| Negative inner product | <#> | vector_ip_ops | Returns the negative inner product so ascending order works; multiply by -1 to display |
| Cosine distance | <=> | vector_cosine_ops | Cosine similarity = 1 - (a <=> b) |
| L1 (taxicab) | <+> | vector_l1_ops | HNSW only |
| Hamming | <~> | bit_hamming_ops | bit columns |
| Jaccard | <%> | bit_jaccard_ops | bit columns |
For halfvec and sparsevec the opclasses are halfvec_cosine_ops, sparsevec_l2_ops and so on.
Tip
If your embedding model returns normalised vectors (OpenAI, most sentence-transformers models), inner product and cosine give the same ranking and inner product is slightly cheaper.
Indexes
-- HNSW: best recall/latency, no training step, can be built on an empty table
CREATE INDEX CONCURRENTLY doc_chunks_emb_hnsw
ON doc_chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64); -- these are the defaults
-- IVFFlat: faster build, less memory, build AFTER loading data (it trains on existing rows)
CREATE INDEX CONCURRENTLY doc_chunks_emb_ivf
ON doc_chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 1000); -- rule of thumb: rows/1000 up to 1M rows, sqrt(rows) above
-- Index a halfvec expression without changing the column
CREATE INDEX ON doc_chunks USING hnsw ((embedding::halfvec(1536)) halfvec_cosine_ops);
Build settings for big tables:
SET maintenance_work_mem = '8GB'; -- graph must fit, or the build slows sharply
SET max_parallel_maintenance_workers = 7; -- plus the leader process
Query patterns
-- Top 10 nearest; the parameter must be a vector of the same dimension
SELECT id, content, 1 - (embedding <=> $1) AS cosine_similarity
FROM doc_chunks
ORDER BY embedding <=> $1
LIMIT 10;
-- With a filter
SELECT id FROM doc_chunks
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 10;
-- Halfvec expression index: the ORDER BY must use the same expression
SELECT id FROM doc_chunks
ORDER BY embedding::halfvec(1536) <=> $1::halfvec(1536)
LIMIT 10;
Query-time settings
| Setting | Default | Effect |
|---|---|---|
hnsw.ef_search | 40 | Candidate list size; higher = better recall, slower. Also caps rows returned per index scan |
ivfflat.probes | 1 | Lists searched; raise to 10+ for usable recall |
hnsw.iterative_scan | off | 0.8+: relaxed_order or strict_order keeps scanning when filters remove rows |
hnsw.max_scan_tuples | 20000 | 0.8+: upper bound for iterative scans |
ivfflat.iterative_scan | off | 0.8+: relaxed_order |
Set them per transaction so one query doesn't change the whole session:
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT ... ORDER BY embedding <=> $1 LIMIT 20;
COMMIT;
Inspect what you have
-- Vector indexes, their size and definition
SELECT c.relname AS index, am.amname, pg_size_pretty(pg_relation_size(c.oid)) AS size,
pg_get_indexdef(c.oid) AS definition
FROM pg_class c JOIN pg_am am ON am.oid = c.relam
WHERE am.amname IN ('hnsw', 'ivfflat');
-- Is the index used? idx_scan should grow with traffic
SELECT indexrelname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes WHERE indexrelname LIKE '%emb%';
-- Build progress
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;
Useful functions
| Function | Returns |
|---|---|
vector_dims(embedding) | Number of dimensions |
vector_norm(embedding) | L2 norm (≈1.0 if normalised) |
l2_normalize(embedding) | Normalised vector (0.7+) |
binary_quantize(embedding) | bit value for binary quantization (0.7+) |
subvector(embedding, 1, 256) | First 256 dimensions (0.7+; Matryoshka-style models) |
avg(embedding) | Aggregate centroid |
Prevent mistakes next time
- Pin the dimension in the column type and check
vector_dims()in ingestion tests. - Keep one distance metric per column and write it down next to the index definition.
- Use
SET LOCALfor query tuning, not database-wide defaults you'll forget about. - Check
ALTER EXTENSION vector UPDATEafter every PostgreSQL minor upgrade on managed services.