Vector Databases
How to find the nearest vectors among millions without comparing against all of them: exact search and its cost, recall, HNSW graphs and their knobs, IVF lists, filtering pitfalls, and when plain PostgreSQL with pgvector is all you need.
The 30-second version
You embed your documents once and store the vectors. When a user searches, you embed their query and ask for the 10 nearest stored vectors: those are the documents most similar in meaning. That's the core of semantic search and of retrieval-augmented generation.
The catch is scale. Checking every stored vector is simple and always correct, but with millions of vectors it is too slow to do on every request (§02). So vector databases build an approximate index that checks only a small fraction of them.
The most popular approximate index, HNSW, works exactly like asking for directions: it is a network of points where each one links to a few nearby points, and a search hops from point to point, always moving closer. Here is the difference on a tiny map of 38 real glossary terms:
Careful: positions are a rough 2-D squash of 384 numbers, so on this map the nearest dot isn't always the most related term, and real searches measure distance in all 384 dimensions. On 38 points the saving is small. On our 3,173 real vectors HNSW measured about 500 per query instead of 3,173, and on millions of vectors the gap is far larger: the path grows slowly while exact search grows with every vector you add.
How this data was made
Both found the same nearest point, but the approximate search measured only about a third of the points. That gap grows with every vector you add. The price: once in a while the hops lead to a point that is close, but not the closest. You will step through a search yourself in §06, including one that gets it wrong.
Exact is simple, then too slow
Approximate trades recall for speed
Then the database parts
Exact search: compare with everything
The problem, stated precisely
You have N stored vectors, each with d numbers (its dimensions). A query arrives as one more vector with d numbers. You want the k stored vectors that are most similar to it: the k nearest neighbors, or "k-NN" for short. Typical values: N = 1 million documents, d = 384 or 1,536, k = 10.
"Most similar" means highest Cosine similarity. When every vector has length 1 (most embedding models normalize their output, and MiniLM's are), cosine similarity is simply the Dot product: multiply the matching numbers and add them up. Databases usually talk about distance instead, where smaller is closer: cosine distance = 1 − cosine similarity.
What one comparison costs
A dot product of two 384-number vectors is 384 multiplications, each added to a running total. Each of those steps is one Multiply-add:
for i := range a {
s += a[i] * b[i] // one multiply-add; for 384 dimensions this line runs 384 times
}Exact search does one such comparison per stored vector. So the work per query is simply:
work per query = N × d multiply-adds
10,000 vectors × 384 dims = 3,840,000 (3.8 million)
1,000,000 vectors × 384 dims = 384,000,000 (384 million)
1,000,000 vectors × 1,536 dims = 1,536,000,000 (1.5 billion)Turning that into time
We measured a plain Go loop (like the one above) on our test machine, a 4-core cloud container: it scanned 1,000,000 vectors × 384 numbers in about 0.5 seconds, roughly 1.3 nanoseconds per multiply-add, on one CPU core. Your numbers will differ, but the shape won't:
- 0.5 s per query is far too slow for a search box. Users notice anything over about 100 ms.
- At 50 queries per second, you'd need 50 × 0.5 = 25 CPU cores busy doing nothing but this.
- Every query also reads all the vectors from memory: 1,000,000 × 384 × 4 bytes = 1.5 GB per query. At 50 QPS that's 77 GB every second. One CPU core can pull only tens of GB per second from memory (its Memory bandwidth), so the scan is limited by memory as much as by arithmetic, and you'd need many cores just to read the data fast enough.
Optimized code helps a lot. SIMD instructions do 8–16 multiply-adds at once, and libraries built for this are roughly ten times faster than our plain loop. But a 10× faster scan of 10× more data is right back where you started. The cost still grows linearly: double the vectors, double the work.
How this data was made
When exact search is the right answer
Don't skip past this. For up to tens of thousands of vectors, exact search is often the best choice:
- 10,000 × 384 multiply-adds takes about 5 ms even with our plain loop.
- It has perfect recall (it never misses, §05), no index to build, tune or keep in memory, and no surprises with filters (§09).
- pgvector does exactly this when a table has no vector index (§10).
The rest of this page is about what to do when N gets too big for that.
Brute-force k-NN in Go and JavaScript
Our test data for the whole page: the 3,173 glossary definitions on this site (like "Race condition: A bug where the result depends on the timing…"), each turned into a 384-number vector by the all-MiniLM-L6-v2 embedding model, run offline. We saved them to docs.json as [{ "key", "text", "vec": [384 numbers] }, …], and embedded one question into query.json.
// package main and imports (encoding/json, fmt, os, slices) omitted
type Doc struct {
Key string `json:"key"`
Text string `json:"text"`
Vec []float32 `json:"vec"`
}
type Hit struct {
Key string
Score float32
}
// dot multiplies matching numbers and adds them up. For unit-length
// vectors (MiniLM's are) this IS the cosine similarity.
func dot(a, b []float32) float32 {
var s float32
for i := range a {
s += a[i] * b[i] // ← one multiply-add per dimension
}
return s
}
// exactKNN compares the query with every vector: N × d multiply-adds.
func exactKNN(docs []Doc, q []float32, k int) []Hit {
hits := make([]Hit, 0, len(docs))
for _, d := range docs {
hits = append(hits, Hit{d.Key, dot(q, d.Vec)})
}
slices.SortFunc(hits, func(a, b Hit) int { // highest similarity first
if a.Score > b.Score {
return -1
}
if a.Score < b.Score {
return 1
}
return 0
})
return hits[:k]
}
func main() {
var docs []Doc
var q Doc
load("docs.json", &docs) // 3,173 glossary entries, 384 numbers each
load("query.json", &q) // load = os.ReadFile + json.Unmarshal
fmt.Printf("query: %q\n", q.Text)
for i, h := range exactKNN(docs, q.Vec, 3) {
fmt.Printf("%d. %-15s %.3f\n", i+1, h.Key, h.Score)
}
fmt.Printf("work: %d vectors × %d dims = %d multiply-adds\n",
len(docs), len(q.Vec), len(docs)*len(q.Vec))
}Both print exactly this (real output, Go 1.24 and Node 22):
query: "my program crashes because two threads write the same variable"
1. race-condition 0.544
2. data-race 0.471
3. thread 0.449
work: 3173 vectors × 384 dims = 1218432 multiply-addsNotice the scores: the best match is only 0.544, and the third is 0.449. Similarity scores for real text are rarely close to 1, and the gap between "right" and "pretty close" is small. Keep that in mind; it's why approximate search can slip (§04).
Two easy improvements
- Don't sort everything. Sorting all N scores to keep 10 is wasteful. Keep a small Top-K min-heap of size k instead: Heaps §08 shows how. It saves the sort, but not the N × d multiply-adds, which are the real cost.
- Store vectors in one flat array. A single
[]float32of N × d numbers (or aFloat32Arrayin JavaScript) keeps the data contiguous in memory, which the CPU reads much faster than thousands of separate small arrays. Our 0.5-second benchmark in §02 used this layout.
Neither changes the basic fact: every query touches every vector. To do less work, you need an index.
Why a B-tree can't help
What a B-tree is good at
A B-tree index keeps values sorted, so finding email = 'ada@example.com' or price BETWEEN 10 AND 20 takes 3–4 page reads even among millions of rows. Indexes & B-Trees §03 shows how. Everything it does relies on one fact: values that are close in the sort order sit next to each other in the index.
Why sorting fails for vectors
Try it in your head with 2-D points first. Sort them by their x coordinate and look up the query's x. The points right next to it in the sort order have a similar x, but their y could be anything: one is around the corner, one is across town. Sorting by one coordinate ignores all the others.
A 384-dimension embedding has 384 coordinates, and the meaning is spread across all of them. No single number, and no fixed combination like "sort by coordinate 1, then 2" (that's what a composite index does), keeps similar vectors next to each other. So a B-tree can't narrow the search, and the database falls back to a Full table scan: exact search.
The curse of dimensionality
For 2-D or 3-D data there are good tricks. Maps and geographic databases split space into boxes (k-d trees, R-trees, Postgres's GiST indexes) and only search the boxes near the query. Why not do that in 384 dimensions?
Two effects, together called the Curse of dimensionality, get in the way:
- Too many boxes. Cut each axis in half once and 2-D space has 4 boxes. 384-D space has 2³⁸⁴, a number with 116 digits. Nearly every box is empty, and a query's true neighbors are scattered across many of them.
- Everything is about equally far away. In high dimensions, the nearest and the farthest point end up at almost the same distance, so "only search nearby boxes" stops ruling much out.
Here is the second effect, measured. On the left, random points; on the right, our 3,173 real MiniLM vectors:
How this data was made
Read the right-hand chart carefully. The best match for a typical query scores about 0.58, the 10th-best about 0.43, and the 100th-best about 0.30, while half of all vectors score above about 0.1. The true top 10 are only slightly ahead of hundreds of runners-up. Any shortcut that skips vectors has a real chance of skipping one of the winners.
Approximate search and recall@k
The deal
An ANN index is built ahead of time, when vectors are inserted. At query time it uses that structure to jump to the promising region and measure only a few hundred or few thousand vectors, instead of all N. On our 3,173-vector test set, a typical HNSW query made about 500 distance computations and still found 98% of the true top 10 (§07 lets you explore this).
The price is that sometimes a true neighbor is never measured, so it can't be returned. The results still look perfectly plausible: you get 10 rows, sorted by distance. Nothing tells you a better match existed. So you have to measure how often this happens.
Recall@k, defined
Recall@k compares the approximate answer with the exact one (from brute-force search, §03):
recall@k = (how many of the true top k the index returned) ÷ kA worked example. The true top 5 are real exact-search results from our data; the "approximate" list is hypothetical, made up to show the arithmetic:
How this data was made
One query tells you little. You measure recall on many queries (a few hundred is typical) and look at two numbers:
- Average recall, e.g. 0.98: "on average, 98% of the true top 10 came back."
- Worst-case recall: the average can hide queries that fail badly. In our test, at a low setting HNSW averaged 0.91, but the worst query got only 5 of its true top 10: the search got stuck among the wrong group of vectors (§06 shows how this happens, §07 shows the query).
What recall doesn't tell you
- It's measured against your embeddings, not against the truth. If the embedding model thinks "chargeback" is the best match for "stop a payment being charged twice" (it did, in our data; the better answer, "idempotency key", ranked 25th), exact search returns that too, with recall 1.0. Recall only measures the index. Whether the results help the user is a separate question about embeddings, chunking and ranking; see Embeddings and RAG.
- A miss usually costs little. If the index returns the true #11 instead of the true #9, the user rarely notices. Losing the true #1 matters more. That's why "recall ≈ 0.95–0.99" is a common target, not 1.0.
HNSW: a graph you can walk
We'll build the idea in three steps, each fixing a problem with the one before.
Step 1: a graph of neighbors, searched greedily
Picture every stored vector as a dot, and draw a line from each dot to a handful of its nearest dots. That's a Graph: the dots are nodes, the lines are edges (or "links").
To search, start at any node and repeat: measure the distance from the query to each of the current node's neighbors, and move to the closest one, if it's closer than where you are. When no neighbor is closer, stop. That's Greedy search, and it measures only the nodes along the way, not all N.
Two things go wrong:
- Long trips. If every link is short, crossing from one side of the data to the other takes hundreds of tiny hops.
- Dead ends. You can stop at a node that is closer than all of its neighbors but still isn't the closest overall, like reaching the top of a small hill in fog and thinking it's the summit. This is called a local minimum.
Step 2: layers, for long trips
HNSW stands for Hierarchical Navigable Small World. "Small world" is the "six degrees of separation" idea: a Small-world graph has mostly short links plus a few long ones, so any node can reach any other in a few hops. HNSW gets its long links by stacking layers:
The yellow point is the entry point, where every search starts. Dashed lines join copies of the same point on different layers. Upper layers have few points and long links (fast, rough travel); layer 0 has every point and short links (careful, local search).
- Layer 0 contains every vector, each linked to its close neighbors.
- Layer 1 contains a random small fraction of them, linked to each other. Because there are fewer nodes, "nearest neighbor on this layer" is often far away: these are the long links.
- Layer 2 has fewer still, and so on. Real indexes over millions of vectors have 4–6 layers.
A search starts at the Entry point on the top layer and searches greedily there. When it can't get closer on that layer, it drops down to the same node on the layer below and continues. Think of driving: motorway to the right region, main road to the right neighbourhood, side streets to the door.
Step 3: a candidate list, for dead ends
On layer 0, where the final answer is found, HNSW doesn't keep just one "current best" node. It keeps a sorted Candidate list of the best few nodes found so far, and keeps exploring the neighbors of the closest one it hasn't explored yet. It stops when the closest unexplored candidate is farther than the worst node on the list: from there, nothing nearer can be reached.
The list's size is called ef_search. A longer list explores more side roads, so it escapes more dead ends (higher recall), but it measures more vectors (slower). It's the main knob you'll turn (§07).
Try it
The same 38-point map as in §01. Step through the race question, then the tricky spot with ef_search 3 (it fails), then the tricky spot with ef_search 6. You can also click anywhere on the map to drop your own query.
How this data was made
What to notice:
- Each layer narrows the region. On the top layer the search jumps across the map; on layer 0 it only takes short steps.
- The tricky spot fails at ef_search 3. The query sits in a gap between two groups. The walk arrives from the top, reaches the group on one side of the gap first, and the short list fills up with points from that group. The true nearest points are in the other group, and nothing on the list links there. With a list of 6 the search explores enough extra neighbors to cross over.
- Distance computations count the real cost. On this toy the saving over 38 is small; on our 3,173 real vectors it's about 6×, and on millions it's hundreds of times.
How the graph gets built
Vectors are inserted one at a time, and each insert uses the search you just watched:
- Pick the new vector's top layer at random, like rolling dice: most vectors get only layer 0, roughly 1 in M also get layer 1, roughly 1 in M² also get layer 2. (M is the links-per-node setting, §07.) This is what keeps upper layers sparse.
- Search for its nearest neighbors on each of its layers, with a candidate list of size ef_construction.
- Link it to the closest M of them, in both directions. If a neighbor now has too many links, it drops its farthest ones.
Because inserting is "search, then link", there's no separate training step: you can create an HNSW index on an empty table and insert as you go. That's a real advantage over IVF (§08). The cost is that every insert does a search, so building an index over millions of vectors takes minutes to hours (§12).
M, ef_construction, ef_search: the three knobs
| Knob | When it's set | Bigger value means | Costs | pgvector default |
|---|---|---|---|---|
| M | build | more links per node → easier to navigate, higher recall | more memory, slower build, slower inserts | 16 |
| ef_construction | build | better choice of each node's neighbors → higher recall | slower build and inserts | 64 |
| ef_search | query | longer candidate list → higher recall | more distance computations per query (latency) | 40 |
Measured on real data
We built an HNSW over our 3,173 real MiniLM vectors and ran 199 real test queries (section summaries from other topics), comparing each result with exact search. Every number below is measured, not estimated. Move the slider, then switch to M and to IVF (explained in §08):
Each step up in ef_search buys a little recall for more work. Notice the curve flattens: going from 98% to 100% costs four times the computations.
How this data was made
data-gen.mjs.txt, with a fixed random seed. Real libraries are better tuned: pgvector 0.8.7's own HNSW on the same vectors and queries gave recall@10 of 93.6%, 97.7%, 99.1% and 100% at ef_search 10, 20, 40 and 100.What the measurements say
- ef_search has diminishing returns. Going from 10 to 40 raised average recall from 91% to 98% for about twice the work. Going from 40 to 400 bought the last 2% for four times more. Most teams settle somewhere in the "knee" of that curve.
- Watch the worst query, not just the average. At ef_search = 10, the worst query found only 5 of its true top 10, and it stayed at 5 at ef_search = 16 before jumping to 9 at 24. It was a vague cheat-sheet summary, "The whole topic on one screen." Vague queries are the hardest: nothing matches them strongly, so their true neighbors are scattered across the graph instead of sitting in one tight group the walk can find.
- M costs memory and build time. M = 32 reached 99.5% recall at ef_search = 40, but each vector stored about twice as many links as with M = 16, and building took 2.6 times more distance computations. M = 4 was cheap and found only 79%.
The same thing in pgvector
On the same vectors and queries, pgvector 0.8.7's HNSW index (m = 16, ef_construction = 64) measured:
hnsw.ef_search | 10 | 20 | 40 (default) | 100 |
|---|---|---|---|---|
| recall@10 | 93.6% | 97.7% | 99.1% | 100% |
It's a little better than our teaching version at every setting because it chooses neighbors more carefully (§06), but the shape is the same. Changing ef_search is a plain setting, so you can raise it for one query that needs high recall:
BEGIN;
SET LOCAL hnsw.ef_search = 100; -- only for this transaction
SELECT id FROM docs ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;A tuning recipe
- Build with the defaults (M = 16, ef_construction = 64).
- Measure recall@10 on a few hundred real queries (your golden set, §05).
- If recall is too low, raise ef_search until it's high enough, and check latency at your expected load.
- Only if ef_search gets uncomfortably high, rebuild with a larger M or ef_construction. That costs memory and build time, so measure again.
IVF lists and quantization
How IVF works
Build (once, needs data):
- Choose a number of lists, say 1,000.
- Run k-means on (a sample of) your vectors to find 1,000 centroids: centre points that each represent one region of the data.
- Put every vector in the list of its nearest centroid.
Search:
- Compare the query with all 1,000 centroids (cheap: 1,000 comparisons).
- Pick the nearest few lists. How many is the probes / nprobe setting.
- Compare the query exactly with every vector in those lists, and keep the best k.
The name comes from search engines: an "inverted file" maps each bucket to the items in it, like a book index maps each word to its pages.
Try it
Our 38-point map split into 4 lists. The transaction question is selected, with probes 1: one of its 3 nearest points sits in the neighbouring list.
How this data was made
With 1 probe, IVF scanned only one list and missed neighbors just across the boundary. With 2 probes it found them, at the cost of scanning more points. On our real 3,173 vectors split into 56 lists, the explorer in §07 (switch it to IVF) shows the same pattern: 1 probe found only 50% of the true top 10; 8 probes found 90%; 32 probes found 99.4% but scanned more than half the data.
HNSW or IVF?
| HNSW | IVF | |
|---|---|---|
| Recall for the same work | usually better | usually lower |
| Build | slower: every insert is a search | faster: k-means plus one assignment per vector |
| Memory | vectors + links (more) | vectors + centroids (less) |
| Empty table / no data yet | fine: insert as you go | needs representative data to train the centroids first |
| Data that changes a lot | inserts keep the graph good | new data drifts away from old centroids; rebuild from time to time |
pgvector's own README sums it up the same way: HNSW has the better speed–recall trade-off but slower builds and more memory; IVFFlat builds faster and uses less memory. For most applications starting today, HNSW is the default choice. IVF shines when memory or build time is the constraint, and very large systems often combine IVF with quantization (next): an IVF to pick the region, compressed vectors inside each list.
Quantization: make each vector smaller
Both indexes are fastest when everything fits in RAM, and a 384-number float32 vector is 1,536 bytes. Quantization stores each number with fewer bits (Embeddings covers the accuracy side in detail). Measured on our 3,173 vectors and 199 queries, using exact search over the compressed vectors:
| Format | Bytes per 384-d vector | recall@10 vs float32 |
|---|---|---|
| float32 (normal) | 1,536 | 100% (by definition) |
| half precision (2 bytes per number) | 768 | 100% |
| 1 bit per number, compared by Hamming distance | 48 | 59.2% |
| 1 bit to fetch 40 candidates, then re-rank them with float32 | 48 in the index (+ full vectors on disk) | 88.7% |
Half precision is close to free: half the memory, the same neighbors. One bit per number is 32× smaller but, used alone, misses 4 of every 10 true neighbors on our data. The common pattern is the last row: use the tiny codes to shortlist candidates quickly, then compute exact distances on just the shortlist. Fetch a longer shortlist to get closer to 100%. pgvector supports both (halfvec columns and indexes, and binary_quantize() for expression indexes, which index a computed value instead of a plain column).
Filtering: when WHERE meets ANN
The query everybody writes
SELECT id, body
FROM docs
WHERE tenant_id = 7 -- only this customer's documents
ORDER BY embedding <=> $1 -- nearest to the query vector
LIMIT 10;The intent is obvious: "the 10 nearest documents that belong to tenant 7". (In a Multi-tenant app, every query looks like this.) There are two basic ways a database can run it.
Post-filtering: index first, then WHERE
Post-filtering: the HNSW index walks the graph as usual and produces its candidate list of ef_search nearest vectors (40 by default in pgvector). Then the WHERE condition throws away every candidate that isn't tenant 7.
If 10% of rows belong to tenant 7, then about 10% of the 40 candidates match: about 4 rows. You asked for 10. We ran exactly that on our real data in pgvector 0.8.7 (3,173 rows, tenant_id spread evenly over 0–9):
key | distance
-----------------+----------
lock-contention | 0.576
reentrant | 0.659
use-after-free | 0.690
gc-mutator | 0.695
(4 rows)No error, no warning. In a RAG app, the model would simply get 4 passages instead of 10. With a 2% filter, our JavaScript HNSW (the widget below) returned nothing at all for 42% of queries.
Pre-filtering: WHERE first, then distances
Pre-filtering: find the matching rows first (an ordinary B-tree index on tenant_id does that in a few page reads), then compute exact distances to just those rows. It's exact, and it's cheap when few rows match: for a 2% filter on our data that's 64 distance computations instead of 3,173. When most rows match, it degrades into exact search over almost everything.
Explore it
- lock-contention
- reentrant
- use-after-free
- gc-mutator
- (missing)
- (missing)
- (missing)
- (missing)
- (missing)
- (missing)
How this data was made
tenant_id is a made-up column we assigned round-robin (row number modulo 2, 10 or 50), so matching rows are spread evenly. The HNSW is our JavaScript one (M = 16, ef_construction = 64). Rows in yellow are not in the exact filtered top 10. Iterative scan is simulated by re-running the search with a doubled candidate list; pgvector's real iterative scan continues the same scan instead, which is cheaper. In pgvector 0.8.7 on the same data, the 10% query returned the same 4 rows.The pattern in the averages:
- 50% of rows match: post-filtering is fine (10 rows, 97% recall).
- 10% match: post-filtering returns 3.9 rows on average; recall against the true filtered top 10 drops to 39%.
- 2% match: post-filtering is useless, and even the "keep scanning" fix costs more than exact search over the whole table. Pre-filtering wins easily.
The fixes, in pgvector terms
-
Iterative index scans (pgvector 0.8.0+). The index keeps walking the graph until enough rows pass the filter, up to a limit:
SET hnsw.iterative_scan = strict_order; -- or relaxed_order: faster, results may be slightly out of orderOn our data this turned the 4-row answer into the correct 10 rows. Scanning stops at
hnsw.max_scan_tuples(20,000 by default), so a very selective filter can still come up short. -
An ordinary index on the filter column. With
CREATE INDEX ON docs (tenant_id), the planner can choose to filter first and compute exact distances on the matching rows. pgvector's README recommends starting here. Check which plan you got withEXPLAIN ANALYZE. -
Partial indexes or partitions. For a few fixed filter values, one HNSW index per value:
CREATE INDEX ... USING hnsw (embedding vector_cosine_ops) WHERE tenant_id = 7. For many values (like thousands of tenants), partition the table by tenant so each partition has its own small index.
Your tests won't catch this
Filter bugs hide in development: test databases are often tiny or have no vector index yet, so the query runs as an exact scan, which always returns 10 rows. The 4-row answer only appears in production, once the table is big enough for the index to be used. Test filtered queries on production-sized data, and alert when a query returns fewer rows than its LIMIT.
Dedicated vector databases handle this inside the index, typically by checking the filter while walking the graph so non-matching nodes are skipped but still used as stepping stones. It's one of their real advantages (§11), but it isn't magic: very selective filters are hard for every graph index, and many of them fall back to exact search over the matching rows, which is pre-filtering again.
pgvector: vector search inside Postgres
vector column type, distance operators and HNSW/IVF indexes to PostgreSQL. Your vectors become one more column in an ordinary table.Set up a table
CREATE EXTENSION IF NOT EXISTS vector; -- once per database
CREATE TABLE docs (
id bigserial PRIMARY KEY,
key text,
body text,
tenant_id int NOT NULL,
embedding vector(384) -- ← 384 numbers, must match your embedding model
);A Postgres extension has to be installed on the database server first (managed Postgres services list which extensions they offer; pgvector is widely available). vector(384) rejects vectors of any other length, which catches a common bug: switching embedding models without re-embedding. Vectors are written as text like '[0.12,-0.03,…]'.
Query: ORDER BY distance, LIMIT k
pgvector's operators return a distance (smaller = closer):
| Operator | Distance | Use when |
|---|---|---|
<=> | cosine distance (1 − cosine similarity) | the usual choice for text embeddings |
<-> | Euclidean (L2) distance | the model's docs say so |
<#> | negative inner product | vectors are normalized and you want the fastest option |
SELECT key, embedding <=> $1 AS distance
FROM docs
ORDER BY embedding <=> $1 -- nearest first
LIMIT 5;With our "two threads write the same variable" question as $1, both with and without an index:
key | distance
-----------------+----------
race-condition | 0.456
data-race | 0.529
thread | 0.551
false-sharing | 0.576
lock-contention | 0.576
(5 rows)These are the same neighbors as our Go and JS programs in §03: 0.456 is 1 − 0.544.
Add an HNSW index
CREATE INDEX docs_embedding_hnsw ON docs
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64); -- these are the defaults; shown for clarityOn our 3,173 rows the build took about 1.3 seconds on our machine and the index was 6,360 kB. Now the plan uses it:
In psql we kept our saved question vectors in a small asks table, so the query vector comes from a subquery (that's the InitPlan lines; $0 is its result):
EXPLAIN (ANALYZE, COSTS OFF)
SELECT key FROM docs ORDER BY embedding <=> (SELECT embedding FROM asks WHERE id = 3) LIMIT 5;
Limit (actual time=0.747..0.757 rows=5 loops=1)
InitPlan 1 (returns $0)
-> Index Scan using asks_pkey on asks (actual time=0.012..0.012 rows=1 loops=1)
Index Cond: (id = 3)
-> Index Scan using docs_embedding_hnsw on docs (actual time=0.746..0.754 rows=5 loops=1)
Order By: (embedding <=> $0)
Planning Time: 0.149 ms
Execution Time: 0.783 msThe line to look for is Index Scan using docs_embedding_hnsw. Without the index the same query was a Seq Scan over all 3,173 rows followed by a Sort (exact search). Indexes & B-Trees §07 explains how to read plans like this.
ef_search also caps the number of rows
An HNSW index scan can return at most hnsw.ef_search rows (40 by default). On our data, ORDER BY embedding <=> $1 LIMIT 100 returned 40 rows. After SET hnsw.ef_search = 200 it returned 100. If you need more than 40 results, raise ef_search to at least your LIMIT (or turn on iterative scans, §09).
From Go and JavaScript
You need the embedding of the user's question first (from your embedding model's API or a local model; see Embeddings). Then it's an ordinary parameterized query. Here we read a saved query vector from query.json and send it in pgvector's text format, so no extra library is needed:
// package main and imports (context, encoding/json, fmt, log, os, strconv, strings,
// github.com/jackc/pgx/v5) omitted
// toVectorLiteral turns []float32{0.1, 0.2} into "[0.1,0.2]", pgvector's text format.
func toVectorLiteral(v []float32) string {
parts := make([]string, len(v))
for i, x := range v {
parts[i] = strconv.FormatFloat(float64(x), 'f', -1, 32)
}
return "[" + strings.Join(parts, ",") + "]"
}
func main() {
ctx := context.Background()
conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL")) // github.com/jackc/pgx/v5
if err != nil {
log.Fatal(err)
}
defer conn.Close(ctx)
var q struct {
Vec []float32 `json:"vec"`
}
b, err := os.ReadFile("query.json") // in a real app: the embedding API's answer
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(b, &q); err != nil {
log.Fatal(err)
}
rows, err := conn.Query(ctx, `
SELECT key, embedding <=> $1 AS distance
FROM docs
ORDER BY embedding <=> $1 -- ← uses the HNSW index
LIMIT 3`, toVectorLiteral(q.Vec))
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var key string
var dist float64
if err := rows.Scan(&key, &dist); err != nil {
log.Fatal(err)
}
fmt.Printf("%-15s %.3f\n", key, dist)
}
if err := rows.Err(); err != nil {
log.Fatal(err)
}
}Both print (real output; pgx v5.12.0 with Go 1.24, pg 8.23.1 with Node 22):
race-condition 0.456
data-race 0.529
thread 0.551For production code, the official helper packages (pgvector-go and pgvector for Node) add a proper vector type and binary encoding, which is faster for large batches. The SQL stays the same.
Just use Postgres, or a dedicated vector database?
Both kinds of system use the same ideas from this page: HNSW or IVF, quantization, recall-vs-speed knobs. The difference is everything around the index.
When "just use Postgres" wins
- One system, not two. No second database to deploy, secure, monitor, back up and pay for. No sync job copying rows from Postgres to the vector store, and no bugs when that job falls behind or fails.
- Transactions. The document row and its embedding are written in the same Database transaction. When it commits, the new document is searchable; when it rolls back, it never was. With two systems, you have to handle "saved in one, not the other" yourself.
- Joins and filters are just SQL. "Nearest 10 products that are in stock, under €50, in the user's country, joined with their reviews" is one query on data that is always consistent.
- Your existing operations work. Backups, point-in-time recovery, read replicas, access control, and the monitoring you already have all cover the vectors too.
- Scale is bigger than people expect. A few million 384-d vectors is a few GB of vectors plus a similar-sized index (§12): ordinary territory for one Postgres server.
When a dedicated vector database helps
- Very large collections. Hundreds of millions to billions of vectors don't fit one machine's RAM. Dedicated systems split the index across many machines (shards) for you and run each query on all of them (§12), where with Postgres you'd build that yourself.
- Heavy write and rebuild load. Constant high-volume inserts, deletes and re-embedding can compete with your main application's queries on a shared Postgres. A separate service isolates that load.
- Search features built in. Filtering inside the graph walk (§09), built-in keyword plus vector (hybrid) search, multiple vectors per item, aggressive quantization tuned for their storage engine, tiered storage on disk.
- Managed scaling. A Managed service that adds capacity, replicas and shards as you grow, if you'd rather not tune Postgres at that size.
The trade-offs flip too: a separate service means a sync pipeline, eventual consistency between the two stores, another bill, and another thing on call.
A way to decide
| Question | Points to pgvector | Points to a dedicated database |
|---|---|---|
| Where does the source data live? | already in Postgres | spread across many systems, or none yet |
| How many vectors in 2 years? | up to tens of millions | hundreds of millions or more |
| Do results need joins, transactions, row-level permissions? | yes | rarely |
| Write volume of vectors? | moderate | very high, or frequent full re-embeds |
| Who runs it? | your team already runs Postgres | you want a managed search service |
Staff-level: memory, builds, updates, sharding, recall in production
1. Memory: vectors plus links
HNSW is fast only while the vectors and the graph sit in RAM; once it has to read pages from disk, every hop can become a disk read. So size it:
vectors: N × d × bytes per number
links: N × (about 2·M neighbor ids on layer 0) × bytes per id (upper layers add a little)
10 million × 768 dims × 4 bytes = 30.7 GB of vectors
10 million × 32 links × ~8 bytes ≈ 2.6 GB of links
≈ 33 GB, plus per-row overhead, plus the table itselfOur measurement keeps that honest: pgvector's index over our 3,173 vectors of 384 dims was 6,360 kB, about 2.0 kB per vector, where the vector itself is 1.5 kB. The rest is links and page/row overhead. Note too that Postgres stores the vector twice: once in the table and once in the index.
The cheapest lever is the number format (§08): halfvec halves both copies with no measurable recall loss on our data. Binary quantization with re-ranking shrinks the index 32×, at the cost of a re-rank step and some recall. Fewer dimensions (some embedding models let you truncate their vectors) is another lever, but changes what "similar" means, so re-measure recall.
2. Building the index takes a while
Every insert is a search (§06), so building costs roughly "N searches with a candidate list of ef_construction". Our simple HNSW needed about 1,100 distance computations per vector to build with M = 16, and 2.6× more with M = 32. pgvector built our 3,173 vectors in about 1.3 s on our machine; at millions of rows, builds run from minutes to hours.
What helps, per the pgvector README (0.8.7):
- Give the build enough memory. Builds are much faster while the graph fits in
maintenance_work_mem; pgvector prints a notice when it no longer does. Don't set it so high that the server runs out of memory. - Use parallel workers:
SET max_parallel_maintenance_workers = 7;(plus the leader process). - Load first, index after. Bulk-loading rows into a table without the index and then running
CREATE INDEXis faster than inserting into an existing index row by row. UseCREATE INDEX CONCURRENTLYon a live table so writes aren't blocked during the build.
3. Updates and deletes in a graph
- Insert: search + link. Fine, and the main advantage over IVF.
- Update: Postgres's MVCC turns an UPDATE into "new row version + old row version left behind", so changing an embedding is effectively an insert plus a delete.
- Delete: you can't just remove a node: other nodes route through it. The deleted row stays in the graph as a Dead row (dead tuple) / bloat until VACUUM removes it and repairs its neighbors' links, which is expensive. pgvector's README notes that vacuuming HNSW indexes can take a while and suggests
REINDEX INDEX CONCURRENTLYfirst. Until then, dead entries still occupy candidate-list slots, so a table with heavy churn can see fewer results and lower recall. - Re-embedding with a new model is the big one: every vector changes, and old and new vectors can't be compared. Add a new column (
embedding_v2), backfill it in batches, build its index, switch reads behind a flag after checking recall and quality, then drop the old column. Store the model name with every vector so you always know which is which.
4. Sharding vectors
When one machine isn't enough, split the vectors across shards. The question is how queries find their neighbors:
- Shard by something every query filters on, typically the tenant. Then a query goes to exactly one shard, and each shard's index is small. This is the best case, and it works with plain Postgres partitioning too.
- Otherwise, every query visits every shard. That's Scatter-gather: send the query to all shards, ask each for its own top k, merge the lists, keep the overall top k. Each shard must return the full k (the overall best 10 could all live on one shard). The query is only as fast as the slowest shard, so tail latency (p99) grows with the shard count, and one slow machine slows every query.
Sharding covers shard keys and rebalancing in general; the vector-specific twist is that a nearest-neighbor query almost never has a natural shard key unless you give it one.
5. Measure recall in production
Recall can drop silently: more data, a churned graph, a changed setting, a new embedding model. Build it into your routine:
-
Keep a Golden set of a few hundred real queries (sampled from logs, with personal data removed).
-
On a schedule, compute their exact top k with the index turned off, and their approximate top k as production runs them. pgvector's README shows the exact-search trick:
BEGIN; SET LOCAL enable_indexscan = off; -- force exact search for this transaction SELECT id FROM docs ORDER BY embedding <=> $1 LIMIT 10; COMMIT; -
Chart average and worst-case recall@10, and alert when they drop. Also track how many rows filtered queries return (§09).
This is the index-level half of quality. Whether the retrieved documents actually help the user, and whether the final answer is good, is measured with the evaluation techniques in Evaluating LLM Apps.
6. Hybrid search: don't throw away keywords
Vectors are good at meaning and weak at exact strings: product codes, error messages like ERR_CONN_RESET, people's names, version numbers. A query that contains one of those should match documents containing exactly that string, and an embedding may rank a vaguely related document higher. Keyword (full-text) search handles those well. (It's no cure-all: for our "stop a payment being charged twice" question, the right entry, idempotency key, ranked only 25th by vectors, but its definition shares almost no words with the question either. Fixing that is about better chunks and queries, covered in RAG.) Hybrid search runs both searches and merges the ranked lists; a common merge is reciprocal rank fusion (RRF): each result scores 1 / (60 + its rank) in each list, and the scores are added up, so items ranked well by both searches rise to the top. Postgres can do both in one database (full-text search plus pgvector). How to combine, re-rank and evaluate them is covered in RAG.
Check yourself
Predict the answer before you click. Q1 is covered in §02, Q2 in §05, Q3 in §07, Q4 in §09, Q5 in §08 and Q6 in §11.
You store 2,000,000 vectors of 768 numbers each and run one exact (brute-force) query. Roughly how many multiply-adds is that?
The exact top 10 for a query is A–J. Your HNSW index returns A, B, C, D, E, F, G, H, X, Y. What is recall@10?
Recall is too low at hnsw.ef_search = 40. You set it to 200. What do you expect?
pgvector, HNSW index, default hnsw.ef_search = 40, iterative scans off. 5% of rows have lang = 'de'. How many rows does this usually return?
SELECT id FROM docs
WHERE lang = 'de'
ORDER BY embedding <=> $1
LIMIT 10;Which statement about IVF with probes = 1 is true?
A team has 3 million product descriptions in PostgreSQL, needs results filtered by stock and price, and wants new products searchable as soon as the transaction commits. What's the sensible first step?
Cheat sheet
probes lists. Faster build, less memory, lower recall per unit of work.| I want to… | pgvector 0.8.7 (as of October 2026) |
|---|---|
| Enable it | CREATE EXTENSION vector; |
| Store 384-d embeddings | embedding vector(384) (or halfvec(384) for half the space) |
| Nearest 10 by cosine | ORDER BY embedding <=> $1 LIMIT 10 |
| Similarity instead of distance | 1 - (embedding <=> $1) |
| HNSW index for cosine | CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); |
| Higher recall for one query | BEGIN; SET LOCAL hnsw.ef_search = 100; … COMMIT; |
| More than 40 rows from HNSW | raise hnsw.ef_search to at least your LIMIT |
| Filtered queries that return enough rows | SET hnsw.iterative_scan = strict_order; plus an index on the filter column |
| IVF instead | USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); and SET ivfflat.probes = 10; |
| Check recall | compare with SET LOCAL enable_indexscan = off; (exact) on a golden set |