~learn.

Day 0233 minPreface+60 XP

Embeddings and Vector Search.

MegaMart Assist has been live for a week. Its answers sound fluent, but the search behind them keeps missing.

8 stages6 code

watch it first · newThe whole lesson in a 24-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

MegaMart Assist has been live for a week. Its answers sound fluent, but the search behind them keeps missing. (An illustrative scene: the customers, articles and times are made up; each failure is a classic one.)

TWO SEARCHES THAT FAILED (ILLUSTRATIVE)
  • 10:02 —
    Kavya writes: "mera parcel toota hua aaya, paisa wapas kab milega?" The keyword index has no "toota", "paisa" or "wapas". The article "Refunds for items damaged in delivery" never comes back, and the assistant improvises a refund timeline
  • 10:05 —
    The team adds vector search. Kavya's message now lands on the right article: Hinglish and English that mean the same thing sit close together
  • 11:40 —
    Rohit pastes his return code: "RTN-4471 still not picked up". Vector search returns three articles about pickups in general. The ticket that mentions RTN-4471 by name ranks 40th: to an embedding, one return code looks much like another
  • 14:15 —
    An engineer adds "language = Hindi" as a filter. Queries that should return 10 results return 3, sometimes 0
  • Post-mortem —
    Keywords miss meaning. Vectors miss exact tokens. Filters bolted onto an approximate index silently drop results

None of these is a model bug. They're retrieval decisions: how text becomes vectors, how to find the nearest without scanning millions, how filters meet an approximate index, and how keyword and vector results combine.

Day 1 treated the model as a slow, metered dependency and sized its tokens. Today we build the search that decides which tokens it sees: semantic search over MegaMart's help articles and past resolved tickets. Tomorrow, Day 3 turns these results into a grounded answer.

ExplainerThe whole lesson: embeddings and vector search

Keyword search misses Kavya's Hinglish refund question, vector search misses Rohit's return code RTN-4471, and a Hindi filter returns 3 results instead of 10. Embeddings (voyage-4, 1,024 dimensions) put similar meanings close; exact search takes ~35 ms over 171,000 chunks but ~0.6 s over 3M tickets. HNSW descends sparse layers (~12,500 and ~200,000 of 3.2M vectors) with ef as the recall dial, IVF scans ~0.1% through 65,536 lists, and product quantisation stores 64 bytes instead of 4,096, rescored from SSD. Today ~3.2M vectors need ~13.5 GB, so full-precision HNSW on one node; at 100M the options run from 422 GB (float32 HNSW) to 7.2 GB (IVF-PQ), and hash shards scatter-gather. Recall@k is measured against exact search; a post-filter on ef_search 40 with 10% Hindi returns ~4 rows; reciprocal rank fusion (k = 60) combines BM25 and vectors. The index fails quietly, and a new embedding model needs a versioned index built beside the old and an alias flip.

Part I · Stage 1 of 8 · 5 min

Part I: Foundations#

Chapter 1: What Is an Embedding?#

1.1 The Simple Definition#

An embedding is a fixed-length list of numbers that a model produces from a piece of text (or an image), trained so that inputs with similar meaning get vectors that are close together. "My parcel arrived broken" and "item damaged in delivery" share no words, but their vectors are neighbours.

A vector search takes a query vector and returns the stored vectors nearest to it: the k nearest neighbours (kNN).

Keyword search vs vector search

Keyword (inverted index, BM25)

  • Matches tokens: "refund" finds documents containing "refund"
  • Exact on IDs, SKUs, error codes, names
  • Misses synonyms, paraphrase, other languages unless you add analysers and synonym lists
  • Explainable: you can see which terms matched

Vector (embeddings, kNN)

  • Matches meaning: "paisa wapas" can find "refund"
  • Weak on exact tokens: RTN-4471 and RTN-4472 look alike
  • Quality depends on the embedding model and your domain
  • Harder to explain: "these 1,024 numbers were close"

Week 7, Day 1 built the inverted index and Week 7, Day 3 scored it with BM25. We don't replace them today. Chapter 9 combines both.

1.2 What the Model Gives You#

Embedding models differ in dimensions, input length and price. As of October 2026:

Model (vendor docs) Default dimensions Notes
Voyage voyage-4 1,024 (also 256, 512, 2,048) 32,000-token context; "general-purpose and multilingual retrieval"; $0.06 per 1M tokens
OpenAI text-embedding-3-small 1,536 8,192 max input tokens; $0.02 per 1M tokens
OpenAI text-embedding-3-large 3,072 8,192 max input tokens; can be shortened with the dimensions parameter
Cohere embed-v4.0 1,536 (also 256, 512, 1,024) 128k context
Cohere embed-english-v3.0 / -light-v3.0 1,024 / 384 512-token context

MegaMart Assist uses voyage-4 at 1,024 dimensions, because its customers write in English, Hindi and Hinglish (Day 3's pipeline embeds with it). Three facts from these docs shape the design:

  • Dimensions are a cost knob. OpenAI says embeddings can be shortened "without the embedding losing its concept-representing properties": text-embedding-3-large cut to 256 still beats the older text-embedding-ada-002 at 1,536 (OpenAI docs). Fewer dimensions, less memory, faster maths.
  • Queries and documents may need different treatment. Voyage's input_type "prepends a prompt to your inputs before vectorizing them": "Represent the query for retrieving supporting documents:" for queries, "Represent the document for retrieval:" for documents (Voyage docs). Cohere's v3+ models require an input_type: search_query or search_document for search (Cohere API reference). Swap them and recall drops.
  • Vectors from different models aren't comparable. Chapter 13 handles the migration this forces.

1.3 Measuring "Close"#

Metric What it compares Typical use
Cosine similarity Angle only, ignores length Text embeddings
Dot product Angle and length Recommendation models trained for it
Euclidean (L2) distance Straight-line distance Image features, IVF clustering

OpenAI's embeddings "are normalized to length 1", so "cosine similarity can be computed slightly faster using just a dot product" and "cosine similarity and Euclidean distance will result in the identical rankings" (OpenAI docs). The rule: use the metric the model was trained for, normalise if the store's fast path needs it (Elasticsearch's dot_product similarity "requires vectors to be normalized", Elastic docs), and use the same metric at index and query time.

1.4 Bi-Encoders: Why Documents Can Be Embedded Ahead of Time#

An embedding model is a bi-encoder: it encodes query and document separately, so documents are embedded once at write time and a query costs one embedding call plus a lookup. Airbnb's search uses the same split: a listing tower that "could be computed offline on a daily basis" and a query tower run in real time (Airbnb Engineering). Cross-encoders, which read the pair together, rerank the shortlist in Day 3.

DiagramMegaMart Assist retrieval: write path and read path
%% title: MegaMart Assist retrieval: write path and read path
flowchart TD
  CMS@{ icon: "sd:file", label: "Help articles, policies,<br/>playbooks (CMS)" }
  TK@{ icon: "sd:database", label: "Ticket store<br/>resolved tickets" }
  ING["Ingest worker<br/>split (Day 3), embed, upsert"]:::service
  EMB@{ icon: "sd:ml", label: "Embedding model<br/>voyage-4, 1,024 dims" }
  PG@{ icon: "tech:postgresql", label: "PostgreSQL + pgvector<br/>~171K document chunks<br/>HNSW + full text" }
  VX@{ icon: "sd:index", label: "Ticket summaries<br/>~3M vectors, HNSW<br/>(same Postgres today)" }
  U@{ icon: "sd:users", label: "Customers<br/>English, Hindi, Hinglish" }
  AS["MegaMart Assist<br/>(Day 1 gateway)"]:::core
  RS["Retrieval service<br/>embed query, filter,<br/>keyword + vector, fuse"]:::core
  D3["Day 3: rerank,<br/>assemble context"]:::service
  CMS --> ING
  TK --> ING
  ING --> EMB
  ING --> PG
  ING --> VX
  U --> AS
  AS --> RS
  RS --> EMB
  RS --> PG
  RS --> VX
  RS --> D3
  class CMS,TK,PG,VX store
  class EMB external
  class U client

1.5 Key Terms#

Term Meaning
kNN / exact search Compare the query with every vector; always correct
ANN Approximate nearest neighbour: an index that checks a fraction of vectors and may miss some true neighbours
recall@k Of the true top k, the fraction the index returned
Selectivity Fraction of vectors that pass a filter (10% = "language is Hindi")
Quantisation Storing vectors in fewer bits (int8, 1-bit, PQ codes)
Rescoring Re-ranking a shortlist with full-precision vectors

Chapter 2: Exact Search and What It Costs#

The simplest vector search is a loop: compute the distance from the query to every stored vector, keep the best k.

pythonnormalise · 11 lines
# search/exact.py: exact top-k by cosine similarity (illustrative)
import numpy as np

def normalise(m: np.ndarray) -> np.ndarray:
    return m / np.linalg.norm(m, axis=-1, keepdims=True)

def exact_top_k(query: np.ndarray, docs: np.ndarray, k: int = 10):
    """docs: (N, d) float32, already normalised. One pass over every vector."""
    scores = docs @ normalise(query)            # N dot products = cosine similarities
    top = np.argpartition(-scores, k)[:k]       # O(N), no full sort
    return top[np.argsort(-scores[top])], scores

It's correct by construction, and for small corpora it's the right answer. The question is when it stops being one.

Your turn · checkpoint 1not answered

Before the index: can you just scan?

Day 3's corpus gives MegaMart ~171,000 document chunks (help articles, policies, playbooks) and ~3 million resolved-ticket summaries, all 1,024-dim float32. Peak load is ~15 retrievals a second (Day 1's numbers). Assume a server streams about 20 GB/s from memory across its cores (an assumption: measure yours). Roughly how long does one exact query take over each corpus, and can one server keep up at peak?

Think it through, or jot it down. Then compare.

One strong answer

  • One vector: 1,024 × 4 bytes = 4,096 bytes.
  • Documents: 171,000 × 4 KB ≈ 0.7 GB per query ÷ 20 GB/s ≈ 35 ms. At 15 a second that's 10.5 GB/s: tight but possible.
  • Tickets: 3M × 4 KB ≈ 12 GB per query ≈ 0.6 s. At 15 a second you'd need ~180 GB/s of memory reads: no single server does that.
  • So exact search is a memory-bandwidth problem. The documents could just about be scanned; the tickets need an index, and every growth step makes it worse.

Exact search stays useful at scale in two places: ground truth (Chapter 7 measures every index against it) and highly selective filters (Chapter 8): if only 2,000 tickets match "this customer's orders", scanning those 2,000 beats any index.

Part II · Stage 2 of 8 · 8 min

Part II: Approximate Indexes#

Every ANN index makes the same bargain: look at a small fraction of the vectors and sometimes miss a true neighbour. They differ in how they pick the fraction, and what that costs.

Chapter 3: HNSW, a Graph You Can Descend#

3.1 The Idea#

Hierarchical Navigable Small World graphs (Malkov and Yashunin, arXiv 2016, revised 2018) link each vector to some of its near neighbours, then stack sparser copies of the graph on top, like a skip list's "express lanes" (the paper notes the "similarity of the algorithm to the skip list structure"):

  • Every vector lives in layer 0. Its top layer is drawn at random "with an exponentially decaying probability distribution"; with M = 16 and the paper's suggested mL = 1/ln(M), each layer keeps about 1 in 16 of the vectors below it.
  • A search starts at a fixed entry point in the top layer, walks greedily to the neighbour closest to the query, drops a layer when it can't get closer, and repeats.
  • In layer 0 it keeps a candidate list of size ef and explores neighbours of the best candidates until the list stops improving. The best k in that list are the answer.

Top layers make long jumps; the bottom layer does the fine search. That's how the paper gets "a logarithmic complexity scaling".

DiagramAn HNSW search: long jumps on top, fine search at the bottom
%% title: An HNSW search: long jumps on top, fine search at the bottom
flowchart TD
  Q(["Query vector<br/>#quot;parcel arrived broken#quot;"]):::core
  L2["Layer 2: 1 in 256 vectors<br/>(~12,500 of 3.2M), greedy hops"]:::service
  L1["Layer 1: 1 in 16<br/>(~200,000), closer region"]:::service
  L0["Layer 0: all 3.2M vectors<br/>best-first search, list of size ef"]:::service
  R(["Top k from the ef candidates"]):::ok
  Q -->|"enter at the top layer"| L2
  L2 -->|"no closer neighbour: drop a layer"| L1
  L1 -->|"drop a layer"| L0
  L0 --> R

3.2 The Three Knobs#

Knob What it does Defaults (as of October 2026)
M Links per vector per layer (layer 0 gets 2M). More links = better recall, more memory pgvector 16, Elasticsearch 16
efConstruction Candidate list size while inserting. Larger = better graph, slower build pgvector 64, Elasticsearch 100
ef / ef_search Candidate list size while searching. The recall-vs-latency dial you turn at query time pgvector hnsw.ef_search 40

The paper's guidance: "A reasonable range of M is from 5 to 48", with bigger M "better for high recall and/or high dimensional data"; "2∙M is a good choice" for layer-0 links; and efConstruction should be large enough to reach construction recall near 1, "0.95 is enough for the most use-cases". FAISS's wiki gives 4 ≤ M ≤ 64 and says the speed-accuracy trade-off "is set via the efSearch parameter".

pythonsearch_layer · 24 lines
# search/hnsw_layer0.py: the best-first search inside one layer (illustrative)
import heapq

def search_layer(query, entry, ef, neighbours, dist):
    """neighbours(v) -> linked vertices; dist(q, v) -> distance. Returns the ef closest found."""
    visited = {entry}
    d0 = dist(query, entry)
    candidates = [(d0, entry)]                 # min-heap: closest unexplored first
    best = [(-d0, entry)]                      # max-heap of size ef: current results
    while candidates:
        d, v = heapq.heappop(candidates)
        if d > -best[0][0]:                    # closest candidate is worse than our worst result
            break                              # nothing left can improve the list
        for n in neighbours(v):
            if n in visited:
                continue
            visited.add(n)
            dn = dist(query, n)
            if len(best) < ef or dn < -best[0][0]:
                heapq.heappush(candidates, (dn, n))
                heapq.heappush(best, (-dn, n))
                if len(best) > ef:
                    heapq.heappop(best)        # drop the worst
    return sorted((-d, v) for d, v in best)

A larger ef keeps more candidates alive, visits more vectors, and finds more true neighbours. That's the whole recall-latency dial.

3.3 What HNSW Costs#

  • Memory. FAISS puts HNSW at "(d * 4 + M * 2 * 4) bytes per vector": with d = 1,024 and M = 16, 4,096 bytes of vector plus 128 of links (the paper: "about 60-450 bytes per object" for the graph). Vectors dominate, and both must be in RAM.
  • Build time. Each insert is a search plus link updates. Elastic calls building these structures "compute-intensive"; pgvector says "indexes build significantly faster when the graph fits into maintenance_work_mem". Give bulk builds memory and parallel workers.
  • Updates and deletes. Inserts work incrementally (pgvector: an HNSW index "can be created without any data in the table since there isn't a training step"). Deletes are harder: FAISS says "HNSW does not support removing vectors from the index. This would destroy the graph structure". Databases mark deleted rows and clean up later, so a delete-heavy index degrades until it's vacuumed or rebuilt.

Chapter 4: IVF, Clusters You Can Skip#

4.1 The Idea#

An inverted file (IVF) index runs k-means over a sample of vectors to find nlist centroids, then files every vector under its nearest centroid. It's Week 7's inverted index with clusters instead of terms. A query finds its nprobe nearest centroids and scans only those lists.

  • With 65,536 lists and nprobe = 64, a query scans about 0.1% of the vectors.
  • A true neighbour that fell into a list you didn't probe is missed. Raising nprobe trades latency for recall.

4.2 Sizing the Lists#

Two published rules of thumb:

Source Lists Probes
FAISS wiki 4*sqrt(N) to 16*sqrt(N); train on "between 30K and 256K vectors" tune
pgvector README rows / 1000 up to 1M rows, sqrt(rows) above start at sqrt(lists)

pgvector says to create the index "after the table has some data", because the centroids come from it. That's IVF's weakness: centroids are fixed at training time. When new tickets drift (a new product line, a festival sale), lists grow unevenly and recall at the same nprobe falls. Watch list sizes and retrain.

Its strength is cheap updates: adding a vector appends it to one list. Airbnb chose IVF over HNSW because "the high volume of real-time updates per second for Airbnb home listings... caused the memory footprint of the HNSW index to grow too large", and "parallel retrieval with HNSW alongside filters resulted in poor latency performance". IVF cluster assignments became "a standard search filter" in their existing search system, and Euclidean distance "produced much more balanced clusters" than dot product (Airbnb Engineering).

Chapter 5: Product Quantisation, Compressing the Vectors Themselves#

5.1 The Problem It Solves#

HNSW and IVF reduce how many vectors a query touches. Neither shrinks the vectors. MegaMart's 3.2M × 4 KB is 13 GB, which fits in RAM. But if it kept every conversation for four years, ~100M vectors, the raw floats alone would be 410 GB. Quantisation stores each vector in fewer bytes.

5.2 How PQ Works#

Product quantisation (Jégou, Douze and Schmid, IEEE TPAMI 2011) "decomposes the space into a Cartesian product of low dimensional subspaces and [quantizes] each subspace separately":

  1. Split each 1,024-dim vector into m sub-vectors (say m = 64 sub-vectors of 16 dims).
  2. For each sub-space, learn 256 centroids with k-means. A sub-vector is replaced by the id of its nearest centroid: one byte.
  3. The vector becomes m bytes: 64 bytes instead of 4,096, 64× smaller.

The paper, working on 128-dim SIFT descriptors, goes smaller still: "Using k* = 256 and m = 8 is often a reasonable choice", 64-bit codes, and it validates the approach "on a dataset of two billion vectors".

Distances are estimated without decompressing. With asymmetric distance computation (ADC), the query stays exact: build a table of distances from each query sub-vector to all 256 centroids of its sub-space (64 × 256 numbers), then a stored vector's distance is 64 table lookups and additions.

pythonpq_encode · 14 lines
# search/pq.py: product quantisation encode and ADC (illustrative, no training code)
import numpy as np

def pq_encode(x: np.ndarray, codebooks: np.ndarray) -> np.ndarray:
    """x: (d,), codebooks: (m, 256, d/m). Returns m uint8 codes."""
    m, _, sub = codebooks.shape
    parts = x.reshape(m, sub)
    return np.array([np.argmin(((codebooks[j] - parts[j]) ** 2).sum(1)) for j in range(m)], np.uint8)

def adc_distances(q: np.ndarray, codes: np.ndarray, codebooks: np.ndarray) -> np.ndarray:
    """codes: (N, m). Exact query, compressed database: one table, then lookups."""
    m, _, sub = codebooks.shape
    table = ((codebooks - q.reshape(m, 1, sub)) ** 2).sum(-1)    # (m, 256)
    return table[np.arange(m), codes].sum(1)                     # (N,) approximate squared L2

5.3 IVF-PQ and Rescoring#

PQ is usually combined with IVF: the paper's IVFADC uses a coarse quantiser (the IVF centroids) and PQ-encodes each vector's residual from its centroid. FAISS lists IVFx,PQy at "ceil(M * nbits/8)+8" bytes per vector: the code plus an 8-byte id.

Compressed distances are approximate, so close candidates come back in a noisy order. The fix is rescoring: take the top 100–200 by PQ distance, fetch their full vectors from SSD, recompute exact distances, return the top k. FAISS's RFlat "re-orders search results with exact distance computations"; Elastic recovers quantisation's lost recall "by oversampling and rescoring more vectors".

DiagramAn IVF-PQ query with rescoring
%% title: An IVF-PQ query with rescoring
flowchart TD
  Q(["Query vector"]):::core
  C["Compare with 65,536 centroids<br/>pick nprobe = 64 lists"]:::service
  T["Build ADC table<br/>64 sub-spaces × 256 centroids"]:::service
  S["Scan codes in 64 lists<br/>~0.1% of vectors, 72 B each"]:::service
  TOP["Top 200 by approximate distance"]:::service
  F@{ icon: "sd:database", label: "Full float32 vectors<br/>on SSD" }
  R(["Exact rescoring<br/>return top 10"]):::ok
  Q --> C --> T --> S --> TOP
  TOP -->|"fetch 200 × 4 KB"| F
  F --> R
  class F store

5.4 Simpler Quantisation#

PQ isn't the only option. Elastic's documentation, as of October 2026:

Encoding Bytes per 1,024-dim vector Elastic's description
float32 4,096 num_dimensions × 4
int8 (int8_hnsw) 1,040 "reduces the memory footprint by 75%"
int4 (int4_hnsw) 528 "reduces the memory footprint by 87%"
1-bit BBQ (bbq_hnsw) 142 "reduces the memory footprint by 96%"

Elastic now quantises by default: "float and bfloat16 vectors are quantized automatically, using BBQ where available and int8_hnsw for low-dimensional vectors". pgvector offers halfvec (2 bytes per dimension) and binary quantisation via expression indexes.

Chapter 6: Choosing an Index#

Flat (exact) HNSW IVF-Flat IVF-PQ
Recall 100% Very high, tuned by ef High, tuned by nprobe Lower; rescoring recovers most
Query latency Grows with N Low Low to medium Low
Memory per 1,024-d vector 4,096 B ~4,224 B (M=16) 4,104 B ~72 B + floats on disk
Build None Slow, memory hungry Train k-means, then fast Train k-means + codebooks
Inserts Free Incremental, costly Cheap append Cheap append
Deletes Free Tombstones, rebuild Easy Easy
Fits Small corpora, selective filters RAM-resident, high recall Frequent updates, filters Hundreds of millions on a budget

pgvector's README sums up the main pair: HNSW "has better query performance than IVFFlat (in terms of speed-recall tradeoff), but has slower build times and uses more memory". A newer family keeps the graph on SSD: Microsoft's DiskANN (NeurIPS 2019) can "index, store, and search a billion point database on a single workstation with just 64GB RAM and an inexpensive solid-state drive", serving "> 5000 queries a second with < 3ms mean latency and 95%+ 1-recall@1" on SIFT1B.

Your turn · checkpoint 2not answered

IVF-PQ because the big systems use it?

MegaMart's index holds ~3.2M vectors of 1,024 dims and serves ~15 queries a second at peak. One teammate wants IVF-PQ "because that's what billion-scale systems use". Another wants HNSW at full precision. Which would you pick today, what does it cost, and what would change your mind?

Think it through, or jot it down. Then compare.

One strong answer

  • Today: HNSW, full precision (or int8). 3.2M × ~4.2 KB ≈ 13.5 GB: one node's RAM, plus a replica. You get the best recall with no training, no retraining and no rescoring step. The cost is a slow build and RAM you can afford.
  • IVF-PQ today would save ~13 GB of RAM and pay for it with lower recall, a rescoring step and centroids to retrain as tickets drift.
  • Change your mind when the index outgrows a node (keep every conversation for four years and it's ~100M vectors, ~420 GB: Chapter 11), when update and delete churn degrades the graph (Airbnb's reason, Chapter 4), or when many replicas make RAM the main cost.

Part III · Stage 3 of 8 · 1 min

Part III: Measuring Recall#

Chapter 7: Recall vs Latency#

7.1 recall@k#

An ANN index can't tell you what it missed. You find out by comparing it with exact search:

recall@k = |ANN top-k ∩ exact top-k| ÷ k

Recall@10 of 0.95 means that, on average, 9.5 of the true 10 nearest neighbours came back. It measures the index, not the answer: if the embedding model puts the wrong article nearest, exact search returns it too. Answer quality is Day 4's evaluation problem; index recall is today's.

7.2 Measure on Your Data#

ann-benchmarks popularised the method: plot recall against queries per second per library and setting, on datasets such as SIFT (128 dims, 1M vectors) and GloVe (25–200 dims). Its latest results are "as of April 2025", and the project is "no longer actively maintained". Use it to learn the shape of the trade-off. Your vectors (1,024 dims, Hinglish, your filters) behave differently, so build the curve yourself:

pythonrecall_at_k · 15 lines
# eval/recall.py: recall@k vs ef_search, against exact ground truth (illustrative)
import random, time

def recall_at_k(ann_ids, exact_ids, k=10):
    return len(set(ann_ids[:k]) & set(exact_ids[:k])) / k

def sweep(index, exact, queries, efs=(20, 40, 80, 160, 320), k=10, sample=500):
    qs = random.sample(queries, sample)                 # real logged queries, not synthetic
    truth = {q.id: exact.search(q.vec, k) for q in qs}  # exact scan: slow, run offline
    for ef in efs:
        index.set_ef(ef)
        t0 = time.perf_counter()
        hits = [recall_at_k(index.search(q.vec, k, filters=q.filters), truth[q.id], k) for q in qs]
        ms = (time.perf_counter() - t0) * 1000 / len(qs)
        print(f"ef={ef:>4}  recall@{k}={sum(hits)/len(hits):.3f}  {ms:.1f} ms/query")

Use real queries with their filters (filtered recall is often much worse, Chapter 8), slice by language and selectivity (an average of 0.96 can hide Hindi at 0.80), and re-run after every model, parameter or data change.

Part IV · Stage 4 of 8 · 5 min

Part IV: Filters and Hybrid Search#

Chapter 8: Metadata Filtering#

Real queries come with conditions: only published articles, only the customer's language, only tickets from the last two years, only articles for the customer's region. Filtering is easy in a database and surprisingly hard in an ANN index.

Your turn · checkpoint 3not answered

The Hindi filter that returns three results

MegaMart's tickets live in PostgreSQL with an HNSW index, default settings. A query asks for the 10 nearest tickets WHERE language = 'hi', and 10% of tickets are Hindi. How many rows do you expect back, and why? Name one fix.

Think it through, or jot it down. Then compare.

One strong answer

  • About 4. In pgvector, "with approximate indexes, filtering is applied after the index is scanned". HNSW returns its ef_search = 40 candidates, and 10% of 40 is 4 (the README's own example).
  • Why it's silent: the query succeeds. It just returns fewer, and worse, results.
  • Fixes: turn on iterative index scans (SET hnsw.iterative_scan = relaxed_order, pgvector 0.8.0+), raise ef_search, use a partial index per language, or partition by language.

8.1 Three Strategies#

Where the filter runs

Post-filter

  • How: ANN search for the top N, then drop rows that fail the filter
  • Good: simple; works with any index
  • Bad: returns fewer than k when the filter is selective; can return zero
  • Example: pgvector without iterative scans; Elasticsearch post_filter, which "can return fewer than k results, even when enough relevant documents exist" (Elastic docs)

Pre-filter (filter first)

  • How: find matching ids first (B-tree, inverted index), then search only those
  • Good: exact and complete for selective filters
  • Bad: brute force over the matches gets slow when the filter matches millions
  • Example: pgvector with a B-tree on the filter column; Elasticsearch, when few documents in a segment match, "skips the HNSW graph and scores the filtered documents directly"

In-filter (filtered traversal)

  • How: walk the graph and only count nodes that pass the filter
  • Good: returns k results across a wide range of selectivity
  • Bad: very selective filters make the walk long; needs store support
  • Example: Elasticsearch's kNN filter, "applied during approximate kNN search"; Weaviate's ACORN strategy

Vendors name these differently. Weaviate calls its approach pre-filtering: "an inverted index... is used to create an allow-list of eligible candidates" for the graph search. Since v1.34 its default is ACORN, "especially useful when the filter has low correlation with the query vector", and it switches to a flat (brute-force) search "when a filter becomes too restrictive", below a configurable flatSearchCutoff (40,000 objects by default) (Weaviate docs). Ask any store: where does the filter run, what happens when it's selective, and can it return fewer than k?

8.2 Choosing by Selectivity#

DiagramChoosing a filter strategy for one query
%% title: Choosing a filter strategy for one query
flowchart TD
  S{"How many vectors<br/>pass the filter?"}:::core
  S -->|"a few thousand<br/>(one customer's tickets)"| EX["Pre-filter + exact scan<br/>B-tree on the column"]:::ok
  S -->|"a few known values<br/>(language, region)"| PI["Partial index or partition<br/>per value"]:::ok
  S -->|"anything from 1% to 100%<br/>(date ranges, tags)"| FT["In-filter traversal<br/>or iterative scan"]:::ok
  FT --> CK{"Recall still low<br/>at this selectivity?"}:::core
  CK -->|"yes"| MORE["Raise ef or candidates,<br/>or fall back to exact"]:::service
  CK -->|"no"| DONE(["Ship it, keep measuring"]):::ok

pgvector's README gives the same ladder: an index on the filter column for conditions "that match a low percentage of rows", partial indexes when "filtering by only a few distinct values", partitioning when "filtering by many different values", and iterative scans for the rest, bounded by hnsw.max_scan_tuples (20,000 by default).

Instacart shows what filtering inside the search is worth: with pgvector, "real-time inventory availability information is used as a pre-filter to reduce the search space", and "we saw a 6% drop in the number of searches with zero results due to better recall" (Instacart Engineering, 2025; more in Chapter 14).

9.1 Why Both#

Rohit's "RTN-4471" is a token: BM25 finds it, an embedding blurs it. Kavya's "paisa wapas" is a meaning: an embedding finds it, BM25 needs a synonym list. Hybrid search runs both retrievers and fuses their rankings; Pinecone's docs: "combine a keyword signal (BM25 or sparse) with a dense signal at query time, often with reranking".

9.2 Don't Add the Scores#

The tempting fusion, 0.5 × bm25 + 0.5 × cosine, breaks because the scales differ: BM25 is unbounded and depends on the query's terms (Week 7, Day 3), while cosine sits in a narrow band. One retriever dominates, differently per query.

Reciprocal rank fusion (RRF) ignores scores and uses only ranks (Cormack, Clarke and Büttcher, SIGIR 2009):

RRFscore(d) = Σ over rankings r of 1 ÷ (k + r(d))

The paper fixed k = 60 "during a pilot investigation", found it "near-optimal, but that the choice was not critical", and reported that RRF "consistently yields better results than any individual system". Elasticsearch's rrf retriever uses the same formula with rank_constant defaulting to 60 (Elastic docs).

Your turn · checkpoint 4not answered

Who wins the fusion?

With k = 60, article A ranks 1st in BM25 and 10th in vector search. Article B ranks 3rd in both. Article C ranks 1st in vector search and doesn't appear in BM25's list at all. Rank them by RRF before reading on.

Think it through, or jot it down. Then compare.

One strong answer

  • A: 1/61 + 1/70 = 0.01639 + 0.01429 = 0.0307
  • B: 1/63 + 1/63 = 0.0317
  • C: 1/61 = 0.0164
  • Order: B, A, C. Agreement beats one strong vote: the constant 60 flattens the gap between rank 1 and rank 10.
  • C is the cost. A document only one retriever can find (Kavya's Hinglish query, which BM25 can't match) scores about half. So pass Day 3's reranker a generous candidate list rather than trusting RRF's top 3.
pythonrrf · 14 lines
# search/fuse.py: reciprocal rank fusion (illustrative)
from collections import defaultdict

def rrf(rankings: list[list[str]], k: int = 60, weights: list[float] | None = None, top: int = 150):
    """rankings: one list of doc ids per retriever, best first."""
    weights = weights or [1.0] * len(rankings)
    scores = defaultdict(float)
    for w, ranking in zip(weights, rankings):
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] += w / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)[:top]

# lexical = bm25.search(q, 100); semantic = ann.search(embed(q), 100, filters=f)
# candidates = rrf([lexical, semantic])      # Day 3 reranks these

Tune weights later, and narrowly: if evals show exact tokens matter for queries containing an order code, weight the lexical list up for those queries only.

9.3 The Query Path#

DiagramOne hybrid query: Rohit's return code
%% title: One hybrid query: Rohit's return code
sequenceDiagram
  autonumber
  participant A as MegaMart Assist
  participant R as Retrieval service
  participant E as Embedding API
  participant K as Keyword index
  participant V as Vector index
  A->>R: RTN-4471 still not picked up, lang hi-en, region IN
  par lexical
    R->>K: BM25 top 100, filter published and region
    K-->>R: ticket T-55120 ranks 1st on RTN-4471
  and semantic
    R->>E: embed query, input_type query
    E-->>R: 1,024 floats
    R->>V: kNN top 100, same filter in the search
    V-->>R: pickup articles and similar tickets
  end
  R->>R: RRF with k = 60, keep top 150
  R-->>A: 150 candidates for Day 3 to rerank

In PostgreSQL both retrievers can run in one database, which is what Instacart moved to:

sql19 lines
-- MegaMart help articles: hybrid retrieval in one query (illustrative)
SET hnsw.ef_search = 100;                           -- HNSW returns at most ef_search rows (default 40)
SET hnsw.iterative_scan = relaxed_order;            -- keep filtered kNN from coming back short
WITH nearest AS MATERIALIZED (                      -- filters stay inside the CTE
  SELECT id, embedding <=> $1 AS dist
  FROM articles
  WHERE status = 'published' AND region = $3
  ORDER BY dist LIMIT 100
), semantic AS (
  SELECT id, row_number() OVER (ORDER BY dist + 0) AS r FROM nearest
), lexical AS (
  SELECT id, row_number() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS r
  FROM articles, websearch_to_tsquery('simple', $2) q
  WHERE tsv @@ q AND status = 'published' AND region = $3
  ORDER BY ts_rank_cd(tsv, q) DESC LIMIT 100
)
SELECT id, COALESCE(1.0 / (60 + s.r), 0) + COALESCE(1.0 / (60 + l.r), 0) AS rrf
FROM semantic s FULL OUTER JOIN lexical l USING (id)
ORDER BY rrf DESC LIMIT 150;

<=> is pgvector's cosine distance operator. A relaxed_order scan can return rows slightly out of order, so, as pgvector's README advises, the nearest rows go into a materialized CTE and are re-sorted by exact distance before fusion (the + 0 "is needed for Postgres 17+", so the planner doesn't assume they're already sorted).

Part V · Stage 5 of 8 · 5 min

Part V: Production Design#

Chapter 10: Choosing a Store#

Option What you get Watch out for Fits
pgvector (PostgreSQL extension) Vectors next to your rows; SQL filters and joins; WAL replication; HNSW and IVFFlat Index limit of 2,000 dims for vector (4,000 for halfvec); one machine's RAM unless you shard; vacuum and dead tuples Data already in Postgres; an index that fits one node's RAM
Search engine (Elasticsearch, OpenSearch) BM25 and kNN in one engine; filters during kNN; RRF built in; sharding and replicas Vector data must fit the page cache; segments and merges affect kNN latency Hybrid search, existing search cluster
Dedicated vector DB (Pinecone, Weaviate, Milvus, …) Managed scaling, quantisation, filtered ANN, multitenancy features Another system to sync and secure; filters limited to its metadata model Large or fast-growing corpora, many tenants
Library (FAISS, hnswlib, Voyager) Full control, fastest path, every index type An index file, not a database: no replication, transactions or rich metadata filtering unless you build them Batch jobs, offline similarity, embedded in your own service

As of October 2026: pgvector (v0.8.7) stores "4 * dimensions + 8 bytes" per vector and indexes vector "up to 2,000 dimensions", so 3,072-dim embeddings need halfvec or fewer dimensions. Elasticsearch's dense_vector takes up to 4,096 dims. Pinecone allows "40 KB of metadata per record" and suggests "one namespace per customer".

The default for MegaMart: start where the data already is. Documents, tickets and their metadata are already in Postgres, and Chapter 11 shows the whole index is ~13.5 GB, so pgvector holds both corpora today. A search engine or a dedicated store earns its place when the index outgrows a node or the team needs features Postgres lacks.

Chapter 11: Sizing and Sharding#

Week 0 taught us to estimate memory before buying it. For vectors the question is simple: what must be in RAM for a query to be fast?

WorksheetMegaMart's vector index, today and at 30× (assumed unless marked)
Value
Today: document chunks + ticket summaries (Day 3's corpus) 171,000 + 3M ≈ 3.2M vectors
Dimensions (voyage-4 default, Voyage docs) 1,024 → one float32 vector = 4,096 B
HNSW links, M = 16 (FAISS: M*2*4 bytes) 128 B per vector
Today, HNSW float32 (FAISS: d*4 + M*2*4) 4,224 B × 3.2M ≈ 13.5 GB of RAM
Peak retrievals (Day 1: 60,000 conversations × 6 turns, 15% in the peak hour) 54,000 an hour ≈ 15 a second
Growth case (illustrative): every conversation kept 4 years, 60,000 × 1,461 days ≈ 88M plan 100M vectors
A. HNSW, float32 at 100M 4,224 B × 100M ≈ 422 GB of RAM per replica
B. HNSW, int8 (Elastic: d + 16 bytes) + FAISS's 128 B of links (Elastic's own graph estimate, 4 × m, is 64 B) 1,168 B × 100M ≈ 117 GB (floats on disk)
C. HNSW, 1-bit BBQ (Elastic: ⌈d/64⌉ × 8 + 14 = 142 B) + links 270 B × 100M ≈ 27 GB (floats on disk for rescoring)
D. IVF-PQ, 64-byte codes + 8-byte id (FAISS: ceil(M*nbits/8)+8) 72 B × 100M ≈ 7.2 GB
IVF lists: 65,536, inside FAISS's 4√N–16√N (40,000–160,000) centroids 65,536 × 4 KB ≈ 0.27 GB; train on 2M–17M vectors
Rescoring for C or D: 200 candidates × 4 KB per query 0.8 MB × 15/s ≈ 12 MB/s from SSD
Alternative: 512 dims (a voyage-4 option) at full precision ~2.2 KB with links → ~220 GB at 100M; recall must be re-measured

Conclusion: today the whole index is ~13.5 GB: full-precision HNSW on one node plus a replica is the right, boring answer, and PQ would only cost recall. At the growth case, 15 queries a second means the index is bound by memory, not throughput: option A needs ~420 GB of RAM per replica to serve a load a few cores could handle, while C or D fits in 7–27 GB and rescoring from SSD costs almost nothing at this rate. Quantise when the index outgrows a node's RAM, not before, and re-measure recall when you do.

Build time belongs in the same estimate, but measure it: build an index of 1M of your own vectors, time it, and scale up. HNSW inserts slow down as the graph grows and once it outgrows build memory; IVF-PQ trains once on a sample, then encodes in one pass. If a rebuild takes a day, every model change (Chapter 13) costs a day.

11.1 Sharding a Vector Index#

Past one node's RAM, split the index (Week 1, Day 1 · Partitioning), in one of two ways:

  • Shard by id (hash). Each shard indexes a random slice. Every query goes to every shard and a coordinator merges their local results. Elasticsearch works this way: num_candidates is "the number of nearest neighbor candidates to consider per shard", and Elasticsearch "collects k... results from each shard, then merges them to find the global top k results" (Elastic docs). Latency is set by the slowest shard.
  • Shard by attribute (tenant, region, language). A query carrying it hits one shard, but shards are uneven (Week 1, Day 4 · Hot Keys and Skew) and other queries still fan out.

The HNSW paper flags the cost: partitioning the data across nodes works, but "the total parallel throughput of the system does not scale well with the number of computer nodes". So shard for memory, and add replicas for throughput and availability (Week 1, Day 2 · Replication).

DiagramScatter-gather over four hash-sharded shards (the 100M growth case)
%% title: Scatter-gather over four hash-sharded shards (the 100M growth case)
flowchart TD
  RS["Retrieval service<br/>k = 10, candidates = 50 per shard"]:::core
  S1@{ icon: "sd:index", label: "Shard 1<br/>25M vectors + replica" }
  S2@{ icon: "sd:index", label: "Shard 2<br/>25M vectors + replica" }
  S3@{ icon: "sd:index", label: "Shard 3<br/>25M vectors + replica" }
  S4@{ icon: "sd:index", label: "Shard 4<br/>25M vectors + replica" }
  M["Merge 200 candidates<br/>by distance, keep top 10"]:::service
  RS --> S1 & S2 & S3 & S4
  S1 & S2 & S3 & S4 --> M
  M --> OUT(["Top 10, slowest shard sets latency"]):::ok
  class S1,S2,S3,S4 store
Your turn · checkpoint 5not answered

What breaks if you shard by product category?

MegaMart reaches the 100M-vector growth case. A teammate proposes sharding the index by product category, "so each query only hits one shard". Electronics is 45% of tickets; groceries is 3%. Many customer messages don't name a category. What breaks, and what would you do instead?

Think it through, or jot it down. Then compare.

One strong answer

  • Skew: the electronics shard holds 45M vectors and takes almost half the queries. It needs the most memory and sets the latency, while the groceries shard idles.
  • Fan-out anyway: a message with no clear category has to go to every shard, so you get scatter-gather plus uneven shards.
  • Instead: hash by ticket id for even shards, and make category a filter applied during the search. Shard by an attribute only when almost every query carries it and the values are balanced, as with tenants in a multi-tenant SaaS (Week 9, Day 1).

11.2 Caching#

Results for an identical (query text, filters, model version, index version) can be cached (Week 4), and so can query embeddings, saving an API call per repeat. Caching answers by meaning, a semantic cache, is Day 5's topic.

Part VI · Stage 6 of 8 · 2 min

Part VI: Edge Cases and Failure Modes#

Chapter 12: When the Index Lies Quietly#

Vector search fails without errors: every query returns k results, relevant or not.

Failure What you see Guard
Filtered query comes back short 3 results instead of 10; worse answers for Hindi users In-filter search or iterative scans; alert on result count per filter
No good match, results anyway An article about pickups returned for a warranty question Similarity floor per model (calibrated on evals); pass "no strong match" to Day 3
Index outgrew RAM Latency climbs as queries hit disk: Elastic says "queries start reading from disk and latency climbs sharply" Alert on page-cache misses; quantise or shard before it happens
Deleted rows linger Fewer results: pgvector notes that results may be limited "due to dead tuples" Vacuum; rebuild HNSW after heavy deletes
Heavy indexing during peak kNN latency spikes: "actively indexing documents can have a negative impact on approximate kNN search performance" (Elastic) Batch writes; separate ingest from serving
IVF centroids drift Some lists balloon; recall falls at the same nprobe Watch list sizes; retrain on fresh samples
A deleted ticket still shapes answers Its vector stayed after the row was erased Delete vectors in the same workflow as the source (Week 9, Day 4)

Chapter 13: Changing the Embedding Model#

Sooner or later a better model arrives: better at Hinglish, cheaper, or shorter vectors.

Your turn · checkpoint 6not answered

The half-finished upgrade

A deploy switches the retrieval service to embed queries with a new model, v2. The 3.2M stored vectors were made with v1, and re-embedding them is "next sprint". Both models output 1,024 dims, so nothing errors. What happens to search, and how would you catch it in staging?

Think it through, or jot it down. Then compare.

One strong answer

  • Search becomes noise. The two models' spaces aren't aligned, so "nearest" between a v2 query and v1 documents means nothing. Every query still returns 10 results, with no error and no alert.
  • Catch it: store the model version with every vector and with the index, refuse a query whose model differs from the index's, and run the recall and relevance evals on every deploy. A sudden drop to near zero against v1 ground truth is unmissable.
  • Fix: query with v1 until a complete v2 index exists, then switch both together.

Treat a model change like a schema migration, with the alias pattern of Week 7's zero-downtime reindex. From the index's side: version every vector (a model_id column; pgvector can keep an expression index per model, WHERE (model_id = 123)); build the new index beside the old one (twice the memory for the overlap, ~27 GB today, plus the build time from Chapter 11); and flip the alias only after recall and relevance pass, keeping v1 for rollback. Voyage says "all embeddings created with the 4 series are compatible with each other", which avoids a rebuild only inside that family. Day 3 runs the whole migration through its ingestion pipeline.

Part VII · Stage 7 of 8 · 2 min

Part VII: Real-World Application#

Chapter 14: How Real Companies Do It#

Spotify: a library, chosen for speed and memory. Spotify built Voyager, "based on hnswlib" and "intended to succeed Annoy" (its own earlier library): "more than 10 times the speed of Annoy (at the same recall)" and "up to 4 times less memory usage than Annoy (thanks to E4M3 8-bit floating point)", serving production traffic "since 2022" (Spotify Engineering, 2023). A library fits when the team owns the serving system around it.

Instacart: retrieval moved into the database. Instacart moved search from Elasticsearch to PostgreSQL ("a 10x reduction in write workload"). Its semantic retrieval had run on FAISS, where "an excess number of documents had to be fetched first (overfetching) before being filtered". With pgvector, inventory became a pre-filter and zero-result searches fell 6%; "while pgvector was marginally slower than FAISS for larger retailers, it had better recall performance" (Instacart Engineering, 2025). Filtering, not raw ANN speed, decided it.

Airbnb: IVF because of updates and filters. Chapter 4's case: real-time listing updates bloated HNSW's memory and filters hurt its latency, so IVF clusters became one more filter in an existing search stack (Airbnb Engineering).

Chapter 15: Common Mistakes#

  1. Vector search alone. Order codes, SKUs and error strings need exact tokens. Run BM25 alongside and fuse with RRF.
  2. Filtering after the index. A selective filter on a post-filtered index returns short, silently. Know where your store filters, and alert on result counts.
  3. Never measuring recall. Defaults are a starting point. Build the recall-vs-latency curve on real, filtered queries, sliced by language.
  4. Sizing for RAM last, or quantising first. Full-precision HNSW at 100M × 1,024 dims is ~420 GB per replica; at MegaMart's 3.2M it's 13.5 GB. Do the arithmetic before choosing the store, and quantise (with rescoring) only when the index outgrows a node.
  5. Mixing embedding models. Vectors from different models or versions can't be compared. Version every vector and migrate with dual indexes and an alias.

Part VIII · Stage 8 of 8 · 4 min

Part VIII: Interview Preparation#

Chapter 16: Interview Tips and Phrases#

16.1 When to Bring It Up#

Any "search by meaning" prompt: support assistants, semantic product search, similar items, RAG. Raise hybrid search when the data has IDs, codes or names, and filtering when there are tenants, permissions or languages.

❯ Say this

16.2 Key Phrases#

Phrases that show you've run one of these
SIZING

"3 million 1,024-dim vectors is 13 GB, so full-precision HNSW on one node. At 100 million it's 410 GB of floats for 15 queries a second: a memory problem, not a throughput one, so I'd move to IVF-PQ or 1-bit quantisation with rescoring from SSD."

INDEX CHOICE

"I'd tune ef or nprobe against a recall@10 target measured on real, filtered queries, not defaults."

FILTERING

"Filters must run inside the search, or a 10% filter returns a handful of results. For a few known values I'd use partial indexes or partitions; for one customer's tickets, an exact scan."

HYBRID

"BM25 and vectors in parallel, fused with reciprocal rank fusion at k = 60, because the scores aren't on the same scale. The reranker on top is the next stage."

16.3 Questions to Ask the Interviewer#

  • How many documents, how fast do they grow, and how often are they updated or deleted?
  • Which filters are mandatory (tenant, permissions, language), and how selective are they?
  • Do users search with exact identifiers as well as natural language?
  • What recall or relevance bar must we meet, and how will we measure it?

16.4 Common Follow-Up Questions#

Question Good answer
"How do you know the index is good?" "Recall@k against exact search on a sample of real, filtered queries, sliced by language and selectivity, re-run after every change."
"The embedding model changes. Now what?" "New index alongside the old, dual-write, backfill within rate limits, shadow-evaluate, flip an alias, keep the old one for rollback."
"Why not just use HNSW everywhere?" "At 3M vectors I would. At 100M it needs ~420 GB of RAM per replica at full precision, and heavy updates and deletes degrade the graph, so quantise or move to IVF-PQ."
"How do you shard it?" "Hash by id for even shards and scatter-gather with per-shard candidates; shard by attribute only if almost every query carries it. Replicas for throughput."

Chapter 17: Practice Problems#

Practice · problem 1

Setup: A B2B SaaS lets 5,000 companies search their own documents by meaning. The biggest tenant has 20M chunks; the median has 5,000. No query may ever see another tenant's data.

Click for solution outline
  • Isolation: tenant id is a mandatory filter inside the search, or a namespace or partition per tenant; never a post-filter.
  • Small tenants: an exact scan over their few thousand vectors. Big tenants: their own index or shard (the Week 1 hot-key problem).
  • Test it: a cross-tenant leak test in CI, and recall per tenant size.

Practice · problem 2

Problem 2: Similar-Product Search for 500M Items#

Setup: An e-commerce site wants "more like this" over 500M product images, 768-dim embeddings, 2,000 queries a second, prices and stock changing all day.

Click for solution outline
  • Memory: 500M × 768 × 4 B ≈ 1.5 TB of floats. Quantise (int8 at d + 16 bytes is ~390 GB, PQ far less) and rescore.
  • Throughput: at 2,000/s replicas matter as much as shards; measure QPS per node at your target recall.
  • Freshness: stock and price are fast-changing filter fields, updated separately from the vectors.

Summary#

DAY 2 KEY TAKEAWAYS
EMBEDDINGS
  • Same model and version for queries and documents; the metric the model was trained for; dimensions are a cost knob
INDEXES
  • Exact for small corpora and selective filters; HNSW for RAM-resident high recall; IVF for cheap updates; PQ and quantisation with rescoring for scale
MEASURE
  • recall@k against exact search, on real filtered queries, sliced by language and selectivity
FILTERS AND HYBRID
  • Filter inside the search, not after it; BM25 plus vectors fused with RRF (k = 60)
OPERATE
  • Size RAM first; shard for memory, replicate for throughput; migrate models with dual indexes and an alias

Further Reading#

Papers#

Documentation#

Engineering Posts#

this lesson in shorts · 4

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 02 · Embeddings and Vector Search
Finish Day 2Stages clear as you read them (0/8 so far). Skipping ahead doesn't count: marking complete unlocks after ~6 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

Yu. A. Malkov and D. A. YashuninPaper · ~45 min

Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs

Yu. A. Malkov, D. A. Yashunin · 2016
What
The paper that introduced HNSW, the graph index behind pgvector, Elasticsearch and most vector stores.
Key idea
Layers drawn with an exponentially decaying probability work like a skip list: greedy long jumps on sparse top layers, then a fine search in the full bottom layer.
Why here
The HNSW chapter of this lesson at its source, including the guidance on M, efConstruction and memory per object.
taught here: HNSW
InstacartBlog post · ~17 min

How Instacart Built a Modern Search Infrastructure on Postgres

Ankit Mittal, Vinesh Gudla et al. · 2025
What
Why Instacart moved search from Elasticsearch to PostgreSQL, then its semantic retrieval from FAISS to pgvector.
Key idea
Keyword and embedding retrieval now run in one database, with live inventory as a pre-filter, and zero-result searches fell 6% thanks to better recall.
Why here
The metadata filtering and store-choice chapters of this lesson: filtering, not raw ANN speed, decided the design.
taught here: Hybrid search and pre-filtering
AirbnbBlog post · ~10 min

Embedding-Based Retrieval for Airbnb Search

What
How Airbnb built embedding-based candidate retrieval for search with a two-tower model.
Key idea
Listings are embedded offline, queries in real time; IVF beat HNSW because frequent listing updates bloated HNSW memory and filters hurt its latency.
Why here
The IVF chapter of this lesson: cluster ids became one more search filter, and Euclidean distance gave more balanced clusters.
taught here: IVF vs HNSW

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…