The trilemma — pick two

Recall, latency, and memory form a trilemma: Flat maxes recall, HNSW maxes speed-at-recall but eats RAM, IVFPQ maxes memory savings but sacrifices recall — no index wins all three, so every production decision is just choosing a point on this Pareto frontier.

Previously

Four indexes, three axes, one chart — and the picture is unambiguous: no index is best at recall AND latency AND memory at once. Choosing an index is choosing which axis to sacrifice. With the index-choice arc complete, we now leave the clean geometry behind and hit where production actually gets hard: real queries aren't just 'similar to X'.

Scene 10

The trilemma — pick two

  1. Watch
  2. Try it
  3. Predict
  4. Capture
↖ ideal: high recall · low latency0.000.000.250.250.500.500.750.751.001.00recall@10 (up = better)latency (right = slower)FlatIVFHNSWPQIVFPQYour configINDEXFlatIVFHNSWPQIVFPQcurrent configbubble size = memoryThe frontier is the edge of what's achievable. …One chart, every index the arc built. Drag the knobs and watch one axis pay for another.
Flat: perfect recall, huge & slow
IVFPQ: tiny & fast, lower recall
What to watch for

Every dot the arc dropped, finally on one chart. Flat sits top-left: recall 1.0 — but it's slow and its bubble is huge (it keeps every full vector). HNSW is fast and high-recall, but its bubble is just as big — it stores full vectors PLUS a graph of edges. IVFPQ has a tiny bubble (compressed) and is fast, but it sits lower — it gave up recall. IVF lands in the middle. Look across all three axes at once: no dot is high AND far-left AND small. The curve through the best dots is the frontier — the boundary of what any index can reach. Nothing lives above-and-left-of it.

Implementation

Highlighted lines are the ones running in the diagram right now.

buyRecall (Flat / re-rank path)
exact distances on full vectors — pays in latency + memory
def rank_exact(query, candidates):
scored = []
for v in candidates: # full float32 vectors
d = l2(query, v.full) # no approximation
scored.append((d, v.id))
return topk(scored) # recall ceiling = Flat
# re-rank: rescue recall an approx index conceded
approx = index.search(query, k * 10)
return rank_exact(query, approx) # exact, but slower + holds vectors
buySpeed (IVF probe / HNSW walk)
skip most of the data — pays in recall (boundary misses)
def search_fast(query, nprobe, ef_search):
cells = nearest_centroids(query, nprobe) # IVF: scan few cells
cand = scan(cells)
# OR HNSW greedy walk: enter top layer, hop down
cand = greedy_walk(query, ef_search)
return topk(cand)
# tighter nprobe / ef_search = faster, lossier:
# a true neighbor across an unprobed boundary is missed
buyMemory (Product Quantization)
store tiny codes, not vectors — pays in recall (lossy codes)
def encode(v, m, codebooks): # split into m subvectors
return bytes(
nearest_centroid_id(sub, codebooks[i]) # 1 byte each
for i, sub in enumerate(split(v, m))
) # 6144 bytes -> m bytes
def adc(query, code): # query stays full-precision
return sum(dist_table[i][code[i]] for i in range(m))
# lossy: estimated distance, so recall sags vs Flat

Where this sits in Build a vector database (Pinecone / Weaviate / pgvector style)

Scene 10 of 15, in the Compress & pick act — PQ shrinks 6 KB to 96 bytes, IVFPQ combines both, and the trilemma forces a choice.. Recall, latency, memory: Flat maxes recall, HNSW speed-at-recall, IVFPQ memory — no index wins all three. Every deployment is a chosen point on the Pareto frontier.

Up next. Every query so far was pure similarity. But real users ask 'similar to this song AND released after 2020 AND under 3 minutes'. Bolting a metadata filter onto vector search sounds trivial — and it quietly breaks both obvious ways of doing it. Let's see how.

All 15 scenes in Build a vector database (Pinecone / Weaviate / pgvector style) · Every curriculum

Built with Arqly
Every scene in Build a vector database (Pinecone / Weaviate / pgvector style) builds on the one before it.All 15 Build a vector database (Pinecone / Weaviate / pgvector style) scenes