Search Tech Journey

Find topics, journeys and posts

6-month learning plan119 / 130
back to blog
llmadvanced 50m read

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

LLMsM14 · LLMs & Applications· Session 119 of 130 90 min

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

You will be able to
  • 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

Finding the nearest coffee shop without walking every street
🌍 Real world

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.

💻 Code world

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.

The three-way choice: HNSW vs IVF vs brute force
  • 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

  1. 2010
    FAISS · Facebook AI Research
    Open-source library for large-scale similarity search. Ships IVF, PQ, HNSW. Foundation for most vector DBs.
  2. 2016
    HNSW paper · Malkov & Yashunin
    'Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs.' Now the standard ANN.
  3. 2019
    Pinecone founded
    First managed vector DB. Turns HNSW into a hosted API. Kicks off the commercial category.
  4. 2021
    pgvector 0.1
    Andrew Kane ships vector similarity as a Postgres extension. Boring, brilliant, changes the game for teams with existing Postgres.
  5. 2023
    pgvector 0.5 · HNSW
    pgvector adds HNSW alongside IVF-Flat. Suddenly Postgres is a serious vector DB.
  6. 2024
    pgvector 0.7 + pgvectorscale
    Streaming 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

11
Enter at top layer

Start at a fixed entry node in the sparsest layer. Greedy-walk to the neighbour closest to the query.

22
Descend when you can't get closer

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.

33
Repeat until layer 0

At layer 0 (all N vectors), keep a priority queue of ef_search candidates. Explore neighbours of neighbours until the queue stops improving.

44
Return top K

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

HNSW knobs

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
IVF knobs

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)

You have 100M+ vectors
At 1024 dims × 4B = 4KB each, 100M vectors is 400GB uncompressed. PQ shrinks to ~1.6GB per hundred million. Suddenly fits in RAM.
scale
You can accept 1–3 points recall loss
PQ is lossy — sub-vector centroid quantisation loses some fidelity. Rerank a broader candidate set at full precision to recover most of it.
quality
You're using FAISS or Milvus (native)
pgvector and Pinecone HNSW don't ship PQ; use IVFPQ in FAISS or a Milvus IVFPQ index.
impl

The vector-DB decision matrix

pgvector (Postgres)

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
Pinecone

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
Qdrant / Weaviate

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
Milvus / Zilliz

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

Common misconception
✗ What most people think

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

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.
From first principles
Start with the question

Why is exact nearest-neighbour search in high dimensions no faster than a brute-force scan? Why must we accept approximate search?

  1. 1
    Low-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
  2. 2
    Pruning 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
  3. 3
    But 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
  4. 4
    With 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
  5. 5
    A 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

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.

Mental modelA navigable small world

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.
🔔 Fires when you see

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.

The tradeoff

Where do embeddings live — an extension on your existing database, a dedicated vector database, or a library-level index you manage yourself?

pgvector / Elasticsearch (existing store)
+ you gain one system to operate, back up and secure; and decisively, vectors sit in the same transaction as the rows they describe, so there is no sync pipeline and no window where the index disagrees with the source of truth; SQL joins and permission filters work natively
− you pay index build and query performance lag specialised engines; scaling is vertical until you shard the whole database; and ANN memory competes with your OLTP working set
pick when under roughly ten million vectors, or whenever filtering by relational attributes and permissions is a first-class requirement
Dedicated vector database
+ you gain purpose-built sharding, replication and memory layout for ANN; mature filtered-search support; managed options remove the operational burden of tuning HNSW yourself
− you pay a second source of truth requiring a sync pipeline that will drift; a separate consistency, backup and access-control model; and per-vector pricing that grows with the corpus regardless of query volume
pick when hundreds of millions of vectors, sustained high QPS, or a genuine need for independent scaling of search from your primary workload
FAISS / library index in-process
+ you gain maximum control and the lowest possible latency (no network hop); zero infrastructure; ideal for a fixed index rebuilt on a schedule
− you pay you own persistence, replication, updates and deletes — all the things a database gives you; incremental updates are painful and typically mean a full rebuild
pick when a static or batch-refreshed corpus, offline evaluation, or an embedded/edge deployment where a network hop is unacceptable
What a senior engineer actually does

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

CREATE INDEX ... USING hnsw ... WITH (m, ef_construction)
Build-time knobs. Larger M = denser graph = better recall + more memory. Default 16 is fine for most workloads.
build
SET hnsw.ef_search = X
Query-time knob (session setting in Postgres). Larger = visit more candidates = higher recall + latency. This is the dial you tune per workload.
query
USING ivfflat ... WITH (lists = 316)
IVF partition count ≈ √N. Too few = big cells (slow). Too many = tiny cells + centroid-search overhead.
ivf
SET ivfflat.probes = X
How many cells to search per query. Analogous to ef_search. 8–32 typical.
probes
embed <=> %s (cosine distance operator)
pgvector defines three: <-> L2, <=> cosine, <#> inner product. Match to your embedding model's training distance.
op
brute_force_gold + recall calc
The 'true' top-K via full dot product. Recall@K = |ANN ∩ gold| / K. Without this, you're guessing at recall.
eval
Try itWatch the metadata-filter cliff

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.

💡 Hint · Add a WHERE cat='a' filter to the query. On a naive setup, HNSW may fall off a cliff — the graph doesn't know about the filter and has to skip most nodes. pgvector 0.7+ handles filtered HNSW smartly; older versions have a known 'filter blow-up' pathology. Measure and see.

(d) Production reality · 15 min

War story Notion · scaling AI Q&Amillions of workspaces
🔥 What broke

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.

🧯 The fix

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

🎓 Lesson to steal
The right vector DB is usually the database you already run. pgvector is boring, transactional, and integrates permissions/joins for free. Reach for a dedicated vector DB when you outgrow it, not before.
War story Spotify · music recommendationbillions of tracks + user vectors
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
At billion scale, memory is the bottleneck, not compute. PQ trades some recall for a 30-100× memory reduction, making the whole thing possible.
Post-mortem
War story Common ANN failure modeevery quarter, on stackoverflow
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
ANN indexes have two lives: build and query. Don't judge quality on default settings — always measure recall vs latency at multiple knob values and pick your operating point.

Where this shows up next

Vector DBs are the foundation of every dense-retrieval system
S117 · Chunking + indexing
The vectors you're indexing come from the chunks you built in S117.
S118 · Hybrid retrieval
The dense leg of hybrid runs on top of one of these indexes.
S120 · LLM Agents
Long-term memory for agents = vectors + retrieval; same infra as RAG.
S122 · Evaluation
Recall@K, NDCG@K — the metrics you'll use to tune HNSW/IVF knobs.
S125 · Multimodal
CLIP embeddings sit in the same vector DBs; image similarity is a MATCH ... USING hnsw call.
S127 · Streaming Analytics
Incremental index updates are still a young field; understanding HNSW build cost matters for real-time ingest.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

  1. How does HNSW find neighbours without scanning every vector?
  2. When would you pick IVF over HNSW?
  3. 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.