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.)
- 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.
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: 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 (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-largecut to 256 still beats the oldertext-embedding-ada-002at 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 aninput_type:search_queryorsearch_documentfor 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.
%% 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 client1.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.
# 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])], scoresIt's correct by construction, and for small corpora it's the right answer. The question is when it stops being one.
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?
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: 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".
%% 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".
# 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
nprobetrades 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":
- Split each 1,024-dim vector into m sub-vectors (say m = 64 sub-vectors of 16 dims).
- For each sub-space, learn 256 centroids with k-means. A sub-vector is replaced by the id of its nearest centroid: one byte.
- 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.
# 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 L25.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".
%% 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 store5.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.
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?
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: 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:
# 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: 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.
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.
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+), raiseef_search, use a partial index per language, or partition by language.
8.1 Three Strategies#
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 thankresults, 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#
%% 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"]):::okpgvector'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).
Chapter 9: Hybrid Search#
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).
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.
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.
# 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 theseTune 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#
%% 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 rerankIn PostgreSQL both retrievers can run in one database, which is what Instacart moved to:
-- 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: 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?
| 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_candidatesis "the number of nearest neighbor candidates to consider per shard", and Elasticsearch "collectsk... results from each shard, then merges them to find the global topkresults" (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).
%% 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 storeWhat 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?
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: 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.
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?
One strong answer
- Search becomes noise. The two models' spaces aren't aligned, so "nearest" between a
v2query andv1documents 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
v1ground truth is unmissable. - Fix: query with
v1until a completev2index 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: 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#
- Vector search alone. Order codes, SKUs and error strings need exact tokens. Run BM25 alongside and fuse with RRF.
- 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.
- Never measuring recall. Defaults are a starting point. Build the recall-vs-latency curve on real, filtered queries, sliced by language.
- 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.
- 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: 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.
16.2 Key Phrases#
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
Problem 1: Multi-Tenant Document Search#
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 + 16bytes 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#
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#
- Paper
Malkov and Yashunin — Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs (arXiv 1603.09320, v4 2018): https://arxiv.org/abs/1603.09320
- Read
Jégou, Douze and Schmid — Product Quantization for Nearest Neighbor Search (IEEE TPAMI 2011): https://inria.hal.science/inria-00514462
- Paper
Cormack, Clarke and Büttcher — Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (SIGIR 2009): https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf
- Paper
Subramanya et al. — DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node (NeurIPS 2019): https://papers.nips.cc/paper/2019/hash/09853c7fb1d3f8ee67a61b6bf4a7f8e6-Abstract.html
Documentation#
- Wiki
FAISS wiki — Guidelines to choose an index · Faiss indexes · The index factory: https://github.com/facebookresearch/faiss/wiki
- Read
pgvector README (v0.8.7): https://github.com/pgvector/pgvector
- Docs
Elastic — Tune approximate kNN search · Filtered kNN search · dense_vector · Reciprocal rank fusion: https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/approximate-knn-search · https://www.elastic.co/docs/solutions/search/vector/knn/filtered-knn-search · https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector · https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion
- Docs
Weaviate — Filtering: https://docs.weaviate.io/weaviate/concepts/filtering
- Docs
Pinecone — Indexing overview: https://docs.pinecone.io/guides/index-data/indexing-overview
- Docs
OpenAI — Vector embeddings: https://developers.openai.com/api/docs/guides/embeddings
- Docs
Voyage — Text embeddings · Pricing: https://docs.voyageai.com/docs/embeddings · https://docs.voyageai.com/docs/pricing
- Docs
Cohere — Embed API reference: https://docs.cohere.com/reference/embed
- Read
ann-benchmarks: https://github.com/erikbern/ann-benchmarks
Engineering Posts#
- Blog
Spotify — Introducing Voyager (25 October 2023): https://engineering.atspotify.com/2023/10/introducing-voyager-spotifys-new-nearest-neighbor-search-library
- Blog
Instacart — How Instacart Built a Modern Search Infrastructure on Postgres (29 May 2025): https://company.instacart.com/tech-innovation/how-instacart-built-a-modern-search-infrastructure-on-postgres
- Read
Airbnb — Embedding-Based Retrieval for Airbnb Search: https://airbnb.tech/ai-ml/embedding-based-retrieval-for-airbnb-search/
this lesson in shorts · 4
play them all →One idea from this lesson in about a minute each, made for your phone.
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.
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.
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.
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.
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.



