S119 · Vector Databases — pgvector, HNSW, IVF
How dense retrieval actually works at 10M+ vectors — HNSW graph traversal, IVF partitioning, product quantisation, and when pgvector beats Pinecone (and vice versa).
🎯 Pick a vector index (HNSW, IVF, or brute-force) and a vector database (pgvector, Pinecone, Qdrant, Weaviate) for a given scale + budget, with justification.
Why this session exists
Cosine similarity over 10M 1024-dim vectors is not a SELECT ... ORDER BY cosine LIMIT 10 — that would be 40 GB of dot products at query time. Every production vector search uses an approximate index: HNSW (a graph you traverse), IVF (partitions you shortlist), or product quantisation (compressed vectors). This session teaches you what each index is doing under the hood, the trade-off knobs (recall vs latency vs memory), and how to pick between pgvector-in-Postgres and a dedicated vector DB.
- Explain HNSW graph traversal and IVF partitioning at the level of 'why does M=16 matter'.
- Pick between HNSW, IVF-Flat, IVF-PQ, and brute-force for a given corpus size + hardware.
- Tune the two knobs that matter (M/ef for HNSW; nlist/nprobe for IVF) with the right vocabulary.
- Choose between pgvector, Pinecone, Qdrant, Weaviate, Milvus by scale, filtering needs, and operational cost.
- Diagnose 'my vector queries got slow' bugs — index cold-start, filter blow-up, or scale wall.
Prerequisites
- S117 · Chunking + indexing — you need embeddings and a corpus in mind.
- S118 · Hybrid retrieval — you know why vector search is only one leg.
(a) Intuition · 5 min
You just moved to a huge city. You want the nearest coffee shop. Walking every street to compare distances (brute force) would take a week.
Two smarter strategies exist. Partition the city into neighbourhoods (IVF) — figure out which neighbourhood you're in, then only check shops there. Follow a road-hub graph (HNSW) — start at a major highway junction, hop to a smaller junction closer to you, then to a street, then to the shop. Either way you visit ~50 shops instead of 50,000.
Vector search does exactly this. Brute-force = compare query to every vector (linear). IVF = partition vectors into nlist Voronoi cells at index time; at query time visit nprobe nearest cells (usually 8–32). HNSW = build a hierarchical graph where each layer is sparser than the one below; greedy-walk from the top down.
Both are approximate — you might miss the true nearest neighbour ~1 in 100 queries. In exchange, latency drops from seconds to milliseconds.
- Brute force — exact, O(N) per query. Fine up to ~100K vectors, or 10M vectors on a GPU. Skip everything else.
- HNSW — graph-based ANN. Best recall/latency trade-off across most benchmarks. Higher memory cost (stores the graph). Default in Pinecone, Qdrant, Weaviate, pgvector 0.5+.
- IVF (with PQ compression) — partition-based ANN. Lower memory (great for billion-scale), slightly worse recall/latency than HNSW at the same tuning. FAISS default; used at Meta, Spotify.
- Rule of thumb: <10M vectors → HNSW. >100M → IVF+PQ (memory is the bottleneck). In between → HNSW if you can afford the RAM, IVF otherwise.
Where these indexes came from
- 2010FAISS · Facebook AI ResearchOpen-source library for large-scale similarity search. Ships IVF, PQ, HNSW. Foundation for most vector DBs.
- 2016HNSW paper · Malkov & Yashunin'Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs.' Now the standard ANN.
- 2019Pinecone foundedFirst managed vector DB. Turns HNSW into a hosted API. Kicks off the commercial category.
- 2021pgvector 0.1Andrew Kane ships vector similarity as a Postgres extension. Boring, brilliant, changes the game for teams with existing Postgres.
- 2023pgvector 0.5 · HNSWpgvector adds HNSW alongside IVF-Flat. Suddenly Postgres is a serious vector DB.
- 2024pgvector 0.7 + pgvectorscaleStreaming index build, faster HNSW, TimescaleDB's DiskANN-inspired extension. 'Just use Postgres' becomes a serious option for tens of millions of vectors.
(b) Visual walkthrough · 15 min
HNSW at a glance — a hierarchical graph
Start at a fixed entry node in the sparsest layer. Greedy-walk to the neighbour closest to the query.
When no neighbour in the current layer is closer than you already are, drop to the next layer down. It has more nodes and finer edges.
At layer 0 (all N vectors), keep a priority queue of ef_search candidates. Explore neighbours of neighbours until the queue stops improving.
Report the K closest vectors seen. Total visits: O(log N × ef_search) instead of O(N).
IVF at a glance — Voronoi partitions
The two knobs each
M and ef
- M (graph degree) — build time. Higher = more edges = better recall + more memory. Default 16–32.
- ef_construction — build time. Higher = better graph = slower build. Default 200.
- ef_search — query time. Higher = visit more candidates = higher recall + higher latency. Tune this per workload.
- Rule: keep M∈[16,64], tune ef_search until you hit recall target
nlist and nprobe
- nlist — build time. Number of Voronoi cells. Rule: nlist ≈ √N. So 10M vectors → nlist ≈ 3162.
- nprobe — query time. Number of cells to search. Higher = better recall + higher latency.
- Cost per query ≈ (N / nlist) × nprobe distance computations.
- Rule: start nprobe at 8–32, tune up for recall
Product Quantisation — the memory-shrinking trick
When to reach for PQ (product quantisation)
The vector-DB decision matrix
Boring, brilliant, joins for free
- HNSW + IVF-Flat since 0.5
- Filters + joins = one SQL query
- Best up to ~10-50M vectors
- Operationally free if you already run Postgres
Managed service, zero ops
- HNSW under the hood
- Excellent filter performance
- Pay-per-vector-month — costly at scale
- Great when team has no infra to spare
Dedicated open-source vector DBs
- HNSW + payload filtering + hybrid
- Self-host or cloud
- Good sweet spot 10M–1B vectors
- Qdrant faster; Weaviate has richer schema
Billion-scale, GPU-accelerated
- Full FAISS index suite (IVF-PQ, HNSW, DiskANN)
- GPU indexes for extreme scale
- Complex ops — usually managed via Zilliz Cloud
- Choose when nothing else scales
"A vector database is a specialised store for embeddings. If I'm doing semantic search I need one — Postgres or Elasticsearch can't do this."
ANN search is an index type, not a database category. Postgres with pgvector, Elasticsearch, Redis and most cloud warehouses all ship HNSW or IVF indexes. A dedicated vector database buys you scale-out sharding, ANN-specific memory management and filtered-search integration — not the capability itself. Below the tens-of-millions range, adding a vector index to the database you already run is usually the better engineering decision.
Because vector search arrived as a product category rather than as a feature, so the framing was "which vector DB" before it was "do I need one". And the myth is locally true at the extreme: at billions of vectors with high QPS, general-purpose stores genuinely do fall over. Most teams inherit the architecture appropriate to that scale while operating three orders of magnitude below it — and pay for a second data store, a second consistency model, and a sync pipeline.
Estimate whether you are actually at vector-DB scale before choosing one:
n, d = 5_000_000, 768
raw = n * d * 4 / 1e9 # float32 vectors, GB
hnsw_graph = n * 32 * 2 * 4 / 1e9 # ~M=32 neighbours, both directions
print(f'vectors {raw:.1f} GB + graph {hnsw_graph:.1f} GB = {raw+hnsw_graph:.1f} GB RAM')
# Quantised alternative:
print(f'int8 vectors: {n*d/1e9:.1f} GB (4x smaller, small recall cost)')
# If the total fits comfortably in one machine's RAM, you do not
# need a distributed vector database -- you need an index.Why is exact nearest-neighbour search in high dimensions no faster than a brute-force scan? Why must we accept approximate search?
- 1Low-dimensional exact search works by space partitioning: a kd-tree splits on one axis at a time and prunes whole branches whose bounding region is farther than the current best.forced by · pruning is the entire source of the speedup; without it a tree is just a scan with pointer chasing
- 2Pruning a branch requires that the distance to the branch's bounding region be reliably larger than the current best distance.forced by · you may only discard a region if nothing inside it can beat what you have
- 3But in high dimensions, distances between random points concentrate: the ratio of the farthest to the nearest neighbour distance approaches 1 as d grows. Everything is roughly equidistant from everything.forced by · a distance is a sum of d independent coordinate differences, so by concentration its relative spread shrinks as roughly 1/√d
- 4With all candidate distances nearly equal, almost no bounding region can be excluded — the pruning condition essentially never fires.forced by · the current best is only marginally better than everything else, so nothing is safely worse
- 5A kd-tree that prunes nothing visits every leaf, at higher constant cost than a linear scan. This is the curse of dimensionality, and it applies to every exact partitioning scheme, not just kd-trees.forced by · the argument is about the geometry of the data, not about the specific index
Therefore exact high-dimensional search has no sub-linear algorithm in general. The only way out is to give up the exactness guarantee — accept finding the true neighbour with high probability rather than certainty, which is what ANN means.
And note what this predicts: since correctness is now probabilistic, recall becomes a tunable parameter traded against latency, and every ANN index will expose a knob for it (HNSW's efSearch, IVF's nprobe). It also predicts that if your embeddings occupy a low-dimensional manifold inside the ambient space — as real embeddings do — ANN will perform far better than the worst case suggests. That gap between theory and practice is exactly why HNSW works so well on real data and so poorly on random vectors.
HNSW builds a graph over your vectors where each node links to a few near neighbours, plus a hierarchy of sparser layers on top acting as express lanes. A search enters at the top layer, greedily walks toward the query taking long jumps, then drops a layer and refines with shorter hops.
It is a skip list in metric space. You never compare against most of the data — you follow a path, and the path is short because the top layers cover huge distances in one step.
- The core tradeoff is always recall vs latency vs memory. No index escapes it; they only offer different points on the surface.
- HNSW: best recall/latency, high memory (the graph itself is large), slow to build, and deletions are awkward. IVF-PQ: far less memory via quantisation, cheaper build, lower recall. Flat: exact, and correct up to roughly a hundred thousand vectors.
- Metadata filtering must be integrated with graph traversal. Post-filtering an ANN result silently reduces your k and can return nothing; pre-filtering can disconnect the graph.
- Cosine similarity requires normalised vectors — then it is equivalent to inner product and to Euclidean distance ranking. Mismatched metric between indexing and querying is a common and silent bug.
Fire this model the moment you see: a vector index being chosen without a scale estimate · filtered search returning fewer results than requested · recall degrading after a batch of deletes · memory growth far exceeding raw vector size · anyone proposing a second data store for embeddings alone.
Where do embeddings live — an extension on your existing database, a dedicated vector database, or a library-level index you manage yourself?
Start in the database you already run. The sync pipeline between a primary store and a separate vector store is the component that actually breaks in production — stale vectors after an update, orphaned vectors after a delete, permission drift — and it is a cost you pay every day for a scale problem you may never have.
Move to a dedicated system when you can name the specific limit you hit: index build time exceeding your refresh window, recall you cannot buy back with memory, or QPS the primary database cannot absorb without harming transactional traffic. "It felt like the right architecture" is not one of those.
(c) Hands-on · 25 min
Stand up pgvector locally, build both an HNSW and an IVF-Flat index, and benchmark the recall vs latency knobs.
"""pgvector_bench.py — build HNSW and IVF-Flat over synthetic vectors.
Requires: pip install psycopg[binary] numpy
Postgres 15+ with pgvector 0.5+ installed: CREATE EXTENSION vector;
Set DATABASE_URL, e.g. postgresql://user:pass@localhost:5432/rag
"""
from __future__ import annotations
import os
import time
import numpy as np
import psycopg
DB_URL = os.environ["DATABASE_URL"]
N, D = 100_000, 384 # 100K vectors of dim 384 (all-MiniLM-L6-v2 size)
K = 10 # top-K
def setup(conn):
with conn.cursor() as cur:
cur.execute("CREATE EXTENSION IF NOT EXISTS vector;")
cur.execute("DROP TABLE IF EXISTS items;")
cur.execute(f"""
CREATE TABLE items (
id SERIAL PRIMARY KEY,
cat TEXT,
embed vector({D})
);
""")
conn.commit()
def ingest(conn):
print(f"Generating {N} random vectors ...")
rng = np.random.default_rng(0)
vecs = rng.standard_normal((N, D)).astype(np.float32)
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True) # unit-normalise
cats = rng.choice(["a", "b", "c"], size=N)
print("Bulk insert ...")
with conn.cursor() as cur, conn.pipeline():
for i, (v, c) in enumerate(zip(vecs, cats)):
cur.execute(
"INSERT INTO items (cat, embed) VALUES (%s, %s);",
(c, v.tolist()),
)
conn.commit()
return vecs
def build_hnsw(conn, m=16, ef_construction=200):
print(f"Building HNSW (m={m}, ef_construction={ef_construction}) ...")
with conn.cursor() as cur:
cur.execute("DROP INDEX IF EXISTS items_hnsw;")
t0 = time.time()
cur.execute(f"""
CREATE INDEX items_hnsw ON items
USING hnsw (embed vector_cosine_ops)
WITH (m = {m}, ef_construction = {ef_construction});
""")
conn.commit()
print(f" built in {time.time()-t0:.1f}s")
def build_ivfflat(conn, lists=316): # ~ sqrt(N)
print(f"Building IVF-Flat (lists={lists}) ...")
with conn.cursor() as cur:
cur.execute("DROP INDEX IF EXISTS items_ivf;")
t0 = time.time()
cur.execute(f"""
CREATE INDEX items_ivf ON items
USING ivfflat (embed vector_cosine_ops)
WITH (lists = {lists});
""")
conn.commit()
print(f" built in {time.time()-t0:.1f}s")
def query(conn, q_vec, k=K, ef_search=None, nprobe=None):
with conn.cursor() as cur:
if ef_search is not None:
cur.execute(f"SET hnsw.ef_search = {ef_search};")
if nprobe is not None:
cur.execute(f"SET ivfflat.probes = {nprobe};")
t0 = time.perf_counter()
cur.execute(
"SELECT id FROM items ORDER BY embed <=> %s LIMIT %s;",
(q_vec.tolist(), k),
)
rows = cur.fetchall()
return [r[0] for r in rows], (time.perf_counter() - t0) * 1000
def brute_force_gold(vecs, q, k=K):
"""Ground truth: compare q to every vector."""
sims = vecs @ q # both unit-normalised → cosine == dot
return np.argpartition(-sims, k)[:k]
def bench(conn, vecs, index_kind: str):
rng = np.random.default_rng(42)
queries = rng.standard_normal((50, D)).astype(np.float32)
queries /= np.linalg.norm(queries, axis=1, keepdims=True)
for knob in (10, 50, 200):
recalls, lats = [], []
for q in queries:
gold = set(int(i) + 1 for i in brute_force_gold(vecs, q)) # +1: SERIAL
got, ms = query(conn, q,
ef_search=(knob if index_kind == "hnsw" else None),
nprobe=(knob if index_kind == "ivf" else None))
recalls.append(len(gold & set(got)) / K)
lats.append(ms)
print(f" {index_kind} knob={knob:>4} "
f"recall@{K}={np.mean(recalls):.3f} "
f"p50={np.percentile(lats,50):.1f}ms "
f"p95={np.percentile(lats,95):.1f}ms")
with psycopg.connect(DB_URL) as conn:
setup(conn)
vecs = ingest(conn)
print("\n--- HNSW ---")
build_hnsw(conn)
bench(conn, vecs, "hnsw")
print("\n--- IVF-Flat ---")
build_ivfflat(conn)
bench(conn, vecs, "ivf")Anatomy of the benchmark
What each part measures
Try:
cur.execute(
"SELECT id FROM items WHERE cat = 'a' ORDER BY embed <=> %s LIMIT %s;",
(q_vec.tolist(), k),
)Compare latency + recall against the unfiltered version. Then try with the filter on a category that's 1% of the corpus vs 33% — the shape of the slowdown teaches you when to pre-filter with SQL vs let the vector index handle it.
(d) Production reality · 15 min
Early Notion AI used Pinecone. As the corpus grew, cost per query became a real line item and cross-workspace filtering (permissions) got fragile — Pinecone's namespaces + metadata filters had scaling quirks at Notion's cardinality.
Migrated substantial portions of retrieval to pgvector-in-Postgres. Same Postgres cluster that held tenant metadata now held embeddings — filtering by workspace_id is a native SQL WHERE, always correct, always cheap.
General industry pattern in 2024–2025: 'if we already run Postgres, use pgvector until it hurts'.
At Spotify scale (hundreds of millions of tracks + user vectors), HNSW's memory footprint became infeasible. HNSW stores the graph — roughly M × N × 4 bytes = huge at N in the billions.
Adopted FAISS IVFPQ — Product Quantisation compresses vectors 32-100×, IVF partitions to only scan a fraction. Trade a few points of recall for enabling the workload to exist at all.
Rerank at full precision on the shortlist to recover most of the quality.
Team indexes 10M vectors with HNSW default settings; recall@10 is 0.72; they conclude 'vector search is broken'. Try switching indexes, embedding models, everything. Nothing helps.
They never tuned ef_search. Default is often 40. Bumping to 200 pushed recall to 0.98 at a 3× latency cost — still under 10ms.
Related: they applied a heavy metadata filter (cat='premium') on a category that's 0.1% of the corpus, which starves the HNSW graph traversal of candidates. Fix: pre-filter with SQL, then ANN over the filtered subset.
Where this shows up next
(e) Recall + stretch · 10 min
Explain-out-loud test
- How does HNSW find neighbours without scanning every vector?
- When would you pick IVF over HNSW?
- When would you pick pgvector over Pinecone/Qdrant/Milvus?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.