LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 177 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/8
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/11
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageClustering and PCACore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/24
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernanceData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/16
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/19
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning & Test-Time Compute
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnAdvanced Agents & RetrievalVector DB Internals: HNSW & IVF
📐HardEmbeddings & Vector Search

Vector DB Internals: HNSW & IVF

Learn how approximate nearest neighbor indexes use HNSW, IVF, and Product Quantization to balance speed, recall, and memory in production vector databases.

35 min read
Learning path
Step 113 of 177 in the full curriculum
Model Merging and Weight InterpolationAdvanced RAG: HyDE & Self-RAG

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

Exact vector scoring and production RAG established how retrieval feeds grounded generation. Now open the index itself: before an agent or RAG pipeline can reason over documents, the system has to find relevant candidate chunks quickly enough for an interactive product.

Finding one incident runbook in an archive with millions of traces, alerts, and postmortems is hard when you have only a vague description of the failure. Embeddings turn the query and documents into vectors so the system can compare meaning, but checking every stored vector is expensive. You'll see how Hierarchical Navigable Small World (HNSW) graphs and inverted file (IVF) partitions avoid most of that scan, what Product Quantization (PQ) compresses, and how to choose a recall-latency-memory operating point.

The brute-force problem

The simplest method, called Exact Search (or k-NN for k-Nearest Neighbors), calculates the distance from your query vector to every vector in the database.

Mathematically, given a query qqq and a database of NNN vectors each with ddd dimensions, exact search has <spandata−glossary="big−o−notation">O(N⋅d)</span><span data-glossary="big-o-notation">O(N \cdot d)</span><spandata−glossary="big−o−notation">O(N⋅d)</span> complexity. That notation means the work grows in direct proportion to both the number of vectors and the number of dimensions.

Make that concrete with 100 million trace-summary embeddings, each with 1,536 dimensions, stored as 4-byte floats. A flat query scores all 100 million vectors, or 153.6 billion scalar components before considering vectorization, batching, memory bandwidth, or accelerator hardware. Whether that fits an SLA is a benchmark question, not something dimensionality alone can answer.

The memory picture is just as stark:

100,000,000 vectors×1,536 dims×4 bytes≈614 GB≈572 GiB100{,}000{,}000 \text{ vectors} \times 1{,}536 \text{ dims} \times 4 \text{ bytes} \approx 614 \text{ GB} \approx 572 \text{ GiB}100,000,000 vectors×1,536 dims×4 bytes≈614 GB≈572 GiB

That's raw vector storage alone, before any index overhead (the lab below reports the same quantity in GiB, the binary units RAM is usually measured in). A single ordinary server won't keep that raw collection in RAM. Approximate Nearest Neighbor (ANN) indexes reduce scored candidates by accepting that some true neighbors may be missed.

estimate-flat-storage-and-work.py
1def gib_for_float32_vectors(count: int, dimensions: int) -> float: 2 return count * dimensions * 4 / (1024 ** 3) 3 4def scalar_components_scored(count: int, dimensions: int) -> int: 5 return count * dimensions 6 7n_vectors = 100_000_000 8dimensions = 1_536 9 10print(f"raw float32 storage: {gib_for_float32_vectors(n_vectors, dimensions):.1f} GiB") 11print(f"scalar components scored per flat query: {scalar_components_scored(n_vectors, dimensions):,}")
Output
1raw float32 storage: 572.2 GiB 2scalar components scored per flat query: 153,600,000,000

For large, weakly filtered collections, ANN is usually the practical serving path. Exact flat search still matters: it's the ground truth for measuring recall, and it can be the right production execution path when a metadata filter leaves only a small candidate set. For example, pgvector performs exact nearest-neighbor search by default and documents exact search as useful for selective filter conditions.[1]Reference 1pgvectorhttps://github.com/pgvector/pgvector

Why is unfiltered exact vector search difficult at 100 million vectors?

Answer

The system stores hundreds of GB of raw vectors and scores every vector for each unfiltered query. Both storage and query work grow with corpus size, so teams commonly evaluate ANN indexes while retaining exact search as their recall baseline.

The main quality metric is Recall@k: of the true top-k nearest neighbors, how many did your ANN index recover? Latency numbers without recall are misleading because an index can look fast by missing good candidates. A system that returns random vectors in 1 ms has terrible recall. A system that takes 20 ms and finds 99 of the true top 100 has excellent recall.

measure-recall-at-k.py
1def recall_at_k(exact_ids: list[int], approximate_ids: list[int]) -> float: 2 expected = set(exact_ids) 3 return len(expected.intersection(approximate_ids)) / len(expected) 4 5exact_top_5 = [10, 21, 34, 55, 89] 6fast_but_weak = [10, 21, 8, 13, 5] 7slower_but_better = [10, 21, 34, 55, 3] 8 9print(f"fast candidate recall@5: {recall_at_k(exact_top_5, fast_but_weak):.0%}") 10print(f"better candidate recall@5: {recall_at_k(exact_top_5, slower_but_better):.0%}")
Output
1fast candidate recall@5: 40% 2better candidate recall@5: 80%

Recall gate: Latency without recall is incomplete. Keep an exact-search evaluation set, measure ANN recall against it, and choose a latency-recall operating point that satisfies the product requirement.

Vector database overview comparing HNSW graph descent on one side with IVF routing into selected compressed lists on the other.
HNSW drops through sparse graph layers into dense local search. IVF scores coarse centroids first, then scans only selected lists with exact vectors or PQ codes.

HNSW (Hierarchical Navigable Small World)

HNSW is a widely used in-memory ANN index because it can provide a strong recall-latency trade-off without a separate centroid-training phase.[2]Reference 2Efficient and Robust Approximate Nearest Neighbor Using Hierarchical Navigable Small World Graphs.https://arxiv.org/abs/1603.09320 Whether it beats another index on your data still needs measurement under the same memory and recall constraints.

Core concept

HNSW is based on a multi-layer graph structure that mimics how people narrow a search. Map zoom is a useful analogy: start at the continent level, narrow down to a country, then a city, then a street. Each layer has more detail than the one above it.

  • Top Layers: Sparse graphs with long-range links. These are used for coarse navigation to quickly "zoom in" to the target neighborhood.
  • Bottom Layer (Layer 0): A dense graph containing all vectors. This is used for fine-grained local search.

This hierarchical structure is analogous to a skip list (a data structure that uses multiple layers of linked lists to allow fast search by skipping over intermediate nodes), but it's generalized for graphs.

HNSW search path descending from sparse upper graph layers into dense layer zero, where most local exploration happens before returning final nearest neighbors.
HNSW routes quickly through sparse upper layers, then spends most real search work in dense layer 0 before returning neighbors.

In the figure, Layer 2 has only a few nodes with long-range links, Layer 1 holds more nodes for regional refinement, and Layer 0 contains every node in a dense local graph. The parameters M, ef_search, and the Layer 0 adjacency cap (often 2M) control the recall-latency trade-off.

A worked example: tracing one insertion

To see how the layers work in practice, imagine you're building an HNSW index with just 8 vectors. The graph already has 7 vectors arranged across three layers. You now insert vector 8.

  1. Sample a level. A random process decides that vector 8 will live on Layers 0 and 1 (but not Layer 2).
  2. Zoom in. The entry point is on Layer 2. You greedily walk Layer 2 to find the node closest to vector 8, then drop to Layer 1.
  3. Connect on Layer 1. On Layer 1, you run a beam search with ef_construction candidates, pick the best neighbors (up to M), and add bidirectional edges. You also check if any existing neighbor now has too many edges and prune if needed.
  4. Drop to Layer 0. You again select up to M neighbors for the new node. Because connections are bidirectional, an existing Layer 0 node can commonly retain up to 2M total connections before pruning. That larger adjacency cap makes the base layer denser.
  5. Update entry point. If vector 8 had been assigned above the graph's current maximum level, it would become the new entry point. Landing on an existing top layer isn't enough.

This incremental construction is why HNSW doesn't need a training phase. Every new vector is wired into the existing graph using local search.

Insert algorithm

This example is simplified educational pseudocode. The key steps are: sample a level, zoom in through upper layers, connect on each layer, and update the entry point if the new node is taller than the current graph.

insert-algorithm.py
1import math 2import random 3from typing import Protocol, Sequence 4 5class HNSWGraph(Protocol): 6 entry_point: int | None 7 max_level: int 8 9 def neighbors(self, node_id: int, level: int) -> Sequence[int]: ... 10 11 def distance(self, query_vector: Sequence[float], node_id: int) -> float: ... 12 13 def add_node( 14 self, node_id: int, vector: Sequence[float], max_level: int 15 ) -> None: ... 16 17 def add_bidirectional_edge(self, left_id: int, right_id: int, level: int) -> None: ... 18 19 def prune_if_needed(self, node_id: int, level: int, max_degree: int) -> None: ... 20 21def greedy_search(graph, query_vector, entry_id, level) -> int: 22 """Walk this layer until no neighbor is closer to query_vector.""" 23 current = entry_id 24 improved = True 25 while improved: 26 improved = False 27 for neighbor in graph.neighbors(current, level): 28 if graph.distance(query_vector, neighbor) < graph.distance(query_vector, current): 29 current = neighbor 30 improved = True 31 return current 32 33def search_layer( 34 graph: HNSWGraph, 35 query_vector: Sequence[float], 36 entry_id: int, 37 level: int, 38 ef: int, 39) -> list[int]: 40 """Best-first search that returns up to ef candidate ids in one layer.""" 41 import heapq 42 visited = {entry_id} 43 entry_dist = graph.distance(query_vector, entry_id) 44 candidates = [(entry_dist, entry_id)] # min-heap by distance 45 results = [(-entry_dist, entry_id)] # max-heap of the best ef via negative distance 46 47 while candidates: 48 dist, current = heapq.heappop(candidates) 49 worst_result_dist = -results[0][0] 50 if len(results) >= ef and dist > worst_result_dist: 51 break 52 for neighbor in graph.neighbors(current, level): 53 if neighbor not in visited: 54 visited.add(neighbor) 55 d = graph.distance(query_vector, neighbor) 56 if len(results) < ef or d < -results[0][0]: 57 heapq.heappush(candidates, (d, neighbor)) 58 heapq.heappush(results, (-d, neighbor)) 59 if len(results) > ef: 60 heapq.heappop(results) 61 return [ 62 node 63 for distance, node in sorted((-neg_distance, node) for neg_distance, node in results) 64 ] 65 66def select_neighbors_placeholder( 67 graph, query_vector, candidate_ids, level, max_degree 68) -> list[int]: 69 """Teaching simplification: take nearest candidates by distance.""" 70 # Production HNSW applies its diversity heuristic here to improve connectivity. 71 candidates = sorted(candidate_ids, 72 key=lambda nid: graph.distance(query_vector, nid)) 73 return candidates[:max_degree] 74 75def sample_level(mL: float) -> int: 76 u = max(random.random(), 1e-12) 77 return math.floor(-math.log(u) * mL) 78 79def hnsw_insert( 80 graph: HNSWGraph, 81 new_id: int, 82 new_vector: Sequence[float], 83 M: int = 16, 84 ef_construction: int = 200, 85 mL: float = 0.36, 86) -> None: 87 """ 88 Insert one vector into an HNSW index. 89 90 Typical implementations select up to M neighbors for the new node. 91 Existing nodes commonly retain up to 2*M connections on layer 0 92 and up to M connections on upper layers before pruning. 93 """ 94 new_level = sample_level(mL) 95 96 if graph.entry_point is None: 97 graph.add_node(new_id, new_vector, max_level=new_level) 98 graph.entry_point = new_id 99 graph.max_level = new_level 100 return 101 102 graph.add_node(new_id, new_vector, max_level=new_level) 103 entry_id = graph.entry_point 104 105 # Phase 1: greedy descent through layers above the new node's top layer. 106 for level in range(graph.max_level, new_level, -1): 107 entry_id = greedy_search(graph, new_vector, entry_id, level) 108 109 # Phase 2: connect the new node from its top layer down to layer 0. 110 for level in range(min(new_level, graph.max_level), -1, -1): 111 candidate_ids = search_layer( 112 graph, new_vector, entry_id, level, ef=ef_construction 113 ) 114 max_degree = 2 * M if level == 0 else M 115 neighbors = select_neighbors_placeholder( 116 graph, new_vector, candidate_ids, level, M 117 ) 118 119 for neighbor_id in neighbors: 120 graph.add_bidirectional_edge(new_id, neighbor_id, level) 121 graph.prune_if_needed(neighbor_id, level, max_degree) 122 123 # Use the closest candidate as the entry point for the next lower layer. 124 entry_id = min(candidate_ids, 125 key=lambda nid: graph.distance(new_vector, nid)) 126 127 if new_level > graph.max_level: 128 graph.entry_point = new_id 129 graph.max_level = new_level

Search algorithm

Search mirrors insertion in reverse. The algorithm starts at the sparse top layers for fast coarse routing, then runs a bounded best-first search at layer 0. That last step isn't exhaustive over the full dataset. It keeps up to ef_search best result candidates while the walk may visit more nodes, which is why raising ef_search often improves recall at the cost of more work. On good graphs, search often behaves close to logarithmic, but HNSW doesn't provide a hard worst-case logarithmic guarantee.

search-algorithm.py
1import heapq 2from dataclasses import dataclass 3from typing import Sequence 4 5@dataclass 6class ToyHNSWGraph: 7 vectors: dict[int, tuple[float, ...]] 8 edges: dict[tuple[int, int], list[int]] 9 entry_point: int 10 max_level: int 11 12 def neighbors(self, node_id: int, level: int) -> Sequence[int]: 13 return self.edges.get((node_id, level), []) 14 15 def distance(self, query_vector: Sequence[float], node_id: int) -> float: 16 vector = self.vectors[node_id] 17 return sum((left - right) ** 2 for left, right in zip(query_vector, vector)) 18 19def greedy_search( 20 graph: ToyHNSWGraph, query_vector: Sequence[float], entry_id: int, level: int 21) -> int: 22 current = entry_id 23 improved = True 24 while improved: 25 improved = False 26 for neighbor in graph.neighbors(current, level): 27 if graph.distance(query_vector, neighbor) < graph.distance(query_vector, current): 28 current = neighbor 29 improved = True 30 return current 31 32def search_layer( 33 graph: ToyHNSWGraph, 34 query_vector: Sequence[float], 35 entry_id: int, 36 level: int, 37 ef: int, 38) -> list[int]: 39 visited = {entry_id} 40 entry_dist = graph.distance(query_vector, entry_id) 41 candidates = [(entry_dist, entry_id)] 42 results = [(-entry_dist, entry_id)] 43 44 while candidates: 45 dist, current = heapq.heappop(candidates) 46 worst_result_dist = -results[0][0] 47 if len(results) >= ef and dist > worst_result_dist: 48 break 49 for neighbor in graph.neighbors(current, level): 50 if neighbor in visited: 51 continue 52 visited.add(neighbor) 53 neighbor_dist = graph.distance(query_vector, neighbor) 54 if len(results) < ef or neighbor_dist < -results[0][0]: 55 heapq.heappush(candidates, (neighbor_dist, neighbor)) 56 heapq.heappush(results, (-neighbor_dist, neighbor)) 57 if len(results) > ef: 58 heapq.heappop(results) 59 60 return [ 61 node 62 for distance, node in sorted((-neg_distance, node) for neg_distance, node in results) 63 ] 64 65def hnsw_search( 66 graph: ToyHNSWGraph, query: Sequence[float], k: int = 10, ef_search: int = 100 67) -> list[int]: 68 """ 69 Performs approximate nearest neighbor search. 70 71 Args: 72 query: The query vector. 73 k: Number of results to return. 74 ef_search: Size of the dynamic candidate list during search. 75 """ 76 if ef_search < k: 77 raise ValueError("ef_search must be at least k") 78 79 current_node = graph.entry_point 80 81 # Phase 1: greedy descent from the top layer down to layer 1. 82 for level in range(graph.max_level, 0, -1): 83 current_node = greedy_search(graph, query, current_node, level) 84 85 # Phase 2: bounded best-first search on layer 0. 86 candidates = search_layer(graph, query, current_node, level=0, ef=ef_search) 87 88 return sorted(candidates, 89 key=lambda nid: graph.distance(query, nid))[:k] 90 91graph = ToyHNSWGraph( 92 vectors={ 93 0: (0.0, 0.0), 94 1: (1.0, 0.0), 95 2: (1.0, 1.0), 96 3: (5.0, 5.0), 97 }, 98 edges={ 99 (0, 1): [1], 100 (1, 1): [0, 2], 101 (2, 1): [1], 102 (0, 0): [1], 103 (1, 0): [0, 2], 104 (2, 0): [1, 3], 105 (3, 0): [2], 106 }, 107 entry_point=0, 108 max_level=1, 109) 110 111nearest_ids = hnsw_search(graph, query=(1.05, 1.0), k=2, ef_search=3) 112print(f"nearest ids: {nearest_ids}")
Output
1nearest ids: [2, 1]

If you set ef_search = 5 but ask for k = 10, most implementations enforce ef_search >= k because you need at least k candidates to return k results. The real risk is subtler: if ef_search is only slightly larger than k, you might miss better neighbors outside the explored candidate set. That's the recall-latency trade-off in action.

Common mistake: Setting ef_search from intuition rather than an evaluation curve. A small beam can miss true neighbors; an unnecessarily large beam spends latency without a useful recall gain. Measure ef_search values against exact top-k results on representative queries.

Key parameters

Tuning HNSW requires balancing the quality of the graph (which impacts recall) against the memory consumption and indexing speed. The parameters that matter most control the number of edges and the beam width used during graph traversal.

ParameterMeaningTypical ValueEffect
MMax neighbors per node on upper layers16-48Higher = better recall, higher memory usage and slower builds.
M0Max neighbors on layer 0 (often 2*M)2*MDenser base layer, better local connectivity, more memory.
ef_constructionCandidate list size during insert100-400Higher = better graph quality, slower indexing.
ef_searchCandidate list size during queryk to a few hundredHigher = better recall, higher latency. Many implementations require ef_search >= k.
mLControls the layer sampling distributionOften near 1/ln⁡(M)1/\ln(M)1/ln(M)Higher = more upper-layer nodes, shallower descents.

ef_search is a query-time knob. You can trade latency for recall dynamically without rebuilding the index.

select-hnsw-operating-point.py
1measurements = [ 2 {"ef_search": 24, "recall": 0.91, "p95_ms": 4.1}, 3 {"ef_search": 48, "recall": 0.97, "p95_ms": 6.8}, 4 {"ef_search": 96, "recall": 0.985, "p95_ms": 12.4}, 5] 6required_recall = 0.96 7latency_budget_ms = 10.0 8 9eligible = [ 10 point for point in measurements 11 if point["recall"] >= required_recall and point["p95_ms"] <= latency_budget_ms 12] 13choice = min(eligible, key=lambda point: point["p95_ms"]) 14print(f"release ef_search={choice['ef_search']} recall={choice['recall']:.3f} p95_ms={choice['p95_ms']}")
Output
1release ef_search=48 recall=0.970 p95_ms=6.8

IVF (Inverted File Index)

While HNSW is graph-based, IVF is a partition-based algorithm. It divides the vector space into distinct regions called Voronoi cells, where every point is closer to one centroid than to any other centroid. During search, the algorithm only looks inside a small subset of those cells.

Core concept: the centroid shard map

The easiest way to think about IVF is a centroid shard map. Instead of checking every vector in the collection, you find the shard whose centroid is closest to the query and only search that list. If a neighbor might sit across a boundary, you probe a few extra lists to protect recall.

The process is broken down into three phases:

  1. Train: Run K-means clustering on a representative sample of your vectors to identify a set of CCC cluster centers, known as centroids. In Faiss terminology, this count is usually called nlist.
  2. Index: During ingestion, assign each vector to its nearest centroid. That places the vector into one inverted list.
  3. Search: When a query vector arrives, first score the centroids, keep the closest nprobe lists, then score candidates only inside those selected lists. IVF-Flat calculates exact distances there; IVF-PQ can score compressed codes with ADC lookup tables.
IVF probe flow where a query scores coarse centroids, keeps only the closest lists, and then scores candidates inside those selected lists with either exact distances or PQ-based approximate distances.
IVF routes through coarse centroids first, probes only a few lists, then spends exact or approximate scoring work inside those selected lists.

A worked example: counting comparisons

Suppose you have 1 million embeddings with 128 dimensions. Compare brute force against IVF with nlist = 1,024 and nprobe = 5:

  • Brute force: 1,000,0001{,}000{,}0001,000,000 distance computations per query.
  • IVF centroid scoring: 1,0241{,}0241,024 distance computations.
  • IVF list scans: average list size 1,000,000/1,024=976.56251{,}000{,}000 / 1{,}024 = 976.56251,000,000/1,024=976.5625, so 5×976.5625≈4,8835 \times 976.5625 \approx 4{,}8835×976.5625≈4,883 vector scores inside probed lists.
  • IVF total: roughly 5,9075{,}9075,907 distance computations.

That's about a 169x reduction in distance computations. The actual speedup in a real system is smaller because of overhead, but the principle is clear: IVF avoids scanning most of the database.

estimate-ivf-probe-work.py
1def balanced_ivf_scores(total_vectors: int, nlist: int, nprobe: int) -> int: 2 return nlist + round(total_vectors / nlist * nprobe) 3 4total_vectors = 1_000_000 5nlist = 1_024 6nprobe = 5 7estimated_scores = balanced_ivf_scores(total_vectors, nlist, nprobe) 8 9print(f"flat vector scores: {total_vectors:,}") 10print(f"balanced IVF vector and centroid scores: {estimated_scores:,}") 11print(f"estimated reduction: {total_vectors / estimated_scores:.1f}x")
Output
1flat vector scores: 1,000,000 2balanced IVF vector and centroid scores: 5,907 3estimated reduction: 169.3x

Implementation with Faiss

Faiss is a widely used implementation of IVF-style ANN indexing and documents the IndexIVFFlat behavior used below.[3]Reference 3Billion-scale similarity search with GPUs.https://arxiv.org/abs/1702.08734 The example builds an IVF index, trains its coarse quantizer, assigns vectors to cells, and searches the selected lists.

implementation-with-faiss.py
1import faiss 2import numpy as np 3 4def build_ivf_index( 5 vectors: np.ndarray, nlist: int = 1024, nprobe: int = 10 6) -> faiss.IndexIVFFlat: 7 """ 8 Builds an IVF index using Faiss. 9 10 Args: 11 vectors: (N, d) float32 array of data. 12 nlist: Number of coarse centroids / inverted lists. 13 nprobe: Number of inverted lists to visit during search. 14 """ 15 vectors = np.ascontiguousarray(vectors, dtype=np.float32) 16 if vectors.ndim != 2: 17 raise ValueError("vectors must be a 2D array") 18 if vectors.shape[0] < nlist: 19 raise ValueError("nlist cannot exceed the number of training vectors") 20 21 d = vectors.shape[1] 22 23 # 1. Quantizer: index used to assign vectors to centroids. 24 # IndexFlatL2 uses brute-force L2 distance. 25 quantizer = faiss.IndexFlatL2(d) 26 27 # 2. IVF index. 28 index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_L2) 29 30 # 3. Train: run k-means to learn centroids from representative data. 31 index.train(vectors) 32 33 # 4. Add: assign all vectors to their nearest centroid. 34 index.add(vectors) 35 36 # 5. Configure search scope. 37 index.nprobe = min(nprobe, nlist) 38 39 return index 40 41left_cluster = np.column_stack((np.linspace(0.0, 0.39, 40), np.zeros(40))) 42right_cluster = np.column_stack((10.0 + np.linspace(0.0, 0.39, 40), np.full(40, 10.0))) 43vectors = np.vstack((left_cluster, right_cluster)).astype(np.float32) 44 45index = build_ivf_index(vectors, nlist=2, nprobe=2) 46distances, ids = index.search(np.array([[0.02, 0.0]], dtype=np.float32), k=3) 47 48print(f"trained: {index.is_trained}") 49print(f"stored vectors: {index.ntotal}") 50print(f"nearest ids: {ids[0].tolist()}")
Output
1trained: True 2stored vectors: 80 3nearest ids: [2, 1, 3]

Recall vs nprobe: The nprobe parameter is the most important knob for tuning an IVF index in production. It controls how many inverted lists are visited during search. If the inverted lists were perfectly balanced, the scanned fraction would be roughly nprobe / nlist. Real systems deviate from that because list sizes are uneven, but the direction still holds: more probes mean higher recall and more work.

nprobe regimeWhat gets scannedPractical effect
Very small (1 to a few lists)Only the closest coarse cellsLowest latency, highest miss rate.
ModerateA small fraction of the databaseUsually the best operating point.
Large (nprobe close to nlist)Most or all inverted listsIndexIVFFlat approaches exhaustive search and often loses its speed advantage.

Why does increasing nprobe usually improve IVF recall while increasing query work?

Answer

IVF scores vectors only inside selected lists. Probing more lists gives the search more chances to inspect a true neighbor that landed outside the closest centroid's cell, but each added list contributes more candidate vectors to score.

Distance metrics

The choice of distance metric affects both training and search. Faiss supports L2 and inner product directly across its main ANN indexes, and cosine similarity is usually implemented by normalizing vectors and then using inner product search.

L2 distance is the standard Euclidean objective. It's a good fit when vector magnitude carries signal or when your model was trained with an L2-style loss.

Inner product (IP) is the right objective for maximum dot-product ranking. Query norm doesn't affect ranking, but database vector norms do, so large-norm items can dominate if that's how your embeddings behave.

Cosine similarity ignores magnitude and keeps only direction. In practice, you normalize both database vectors and query vectors, then use inner product search. On normalized vectors, L2 and inner product give equivalent rankings because ∣∣x−y∣∣2=2−2⟨x,y⟩||x-y||^2 = 2 - 2\langle x, y \rangle∣∣x−y∣∣2=2−2⟨x,y⟩.

MetricWhat it measuresBest whenIVF retrain needed?
L2 (Euclidean)Straight-line distance in spaceMagnitude carries signalYes, if switching from another metric
Inner productx⋅yx \cdot yx⋅yYou want maximum dot-product rankingYes, if switching from another metric
CosineDirection only, no magnitudeYou only care about semantic similarityYes, on normalized vectors

Metric choice changes the geometry seen by the coarse quantizer. If you switch an IVF system from raw L2 search to cosine search, retrain the centroids on normalized vectors rather than reusing the old clustering.

verify-cosine-inner-product-ranking.py
1import numpy as np 2 3vectors = np.array([[3.0, 0.0], [1.0, 1.0], [0.0, 2.0]], dtype=np.float32) 4query = np.array([1.0, 0.8], dtype=np.float32) 5 6normalized_vectors = vectors / np.linalg.norm(vectors, axis=1, keepdims=True) 7normalized_query = query / np.linalg.norm(query) 8inner_product_scores = normalized_vectors @ normalized_query 9l2_scores = np.sum((normalized_vectors - normalized_query) ** 2, axis=1) 10 11print(f"inner-product rank: {np.argsort(-inner_product_scores).tolist()}") 12print(f"L2 rank after normalization: {np.argsort(l2_scores).tolist()}")
Output
1inner-product rank: [1, 0, 2] 2L2 rank after normalization: [1, 0, 2]

HNSW vs IVF

FeatureHNSWIVF-Flat
CategoryProximity graphClustering / inverted file
Search costEmpirically sublinear on suitable well-built graphs; benchmark on your dataCentroid scoring plus scanning roughly nprobe / nlist of the data if lists are balanced
MemoryHigh (raw vectors + graph edges)High (raw vectors + ids), but lower than HNSW
Training stepNoneRequired to learn centroids
Index updatesInserts are natural; deletes are implementation-specificInserts are easy, but centroid quality can drift over time
RecallOften strong at a fixed in-RAM latency budget; workload dependentTunable via nprobe; workload dependent
Build timeSlowFaster once centroids are trained
Best forLow-latency, high-recall search in RAMLarge datasets where you want a controllable scan fraction or a base for PQ

The biggest operational difference is that IVF requires a training step on representative data before indexing. If your vector distribution drifts over time, stale coarse centroids can unbalance lists or lower recall. HNSW doesn't have that centroid-training dependency, but commonly pays with more memory and slower construction.

Product Quantization (PQ)

For billion-scale datasets, storing full float32 vectors in RAM is usually impractical on a single RAM-resident node.

109 vectors×768 dims×4 bytes≈3 TB RAM10^9 \text{ vectors} \times 768 \text{ dims} \times 4 \text{ bytes} \approx 3 \text{ TB RAM}109 vectors×768 dims×4 bytes≈3 TB RAM

One billion vectors at 768 float32 dimensions need about 3 TB of raw vector storage alone. Add index overhead, operating-system buffers, and application memory on top of that.

You have 1B vectors, raw float32 storage needs terabytes of RAM, and one node only has 128 GB. What must product quantization buy you first before anything else matters?

Answer

It must buy memory reduction. By replacing sub-vectors with compact centroid IDs, PQ lets far more vectors stay resident, which is what makes any fast single-node search plan feasible in the first place.

Product Quantization (PQ) compresses vectors by breaking them into smaller chunks and quantizing those chunks independently.[4]Reference 4Product Quantization for Nearest Neighbor Search.https://dblp.org/rec/journals/pami/JegouDS11 Combined with IVF, PQ is one established way to make billion-point memory budgets tractable; whether a deployment also needs sharding depends on code size, metadata, throughput, and availability requirements.

The algorithm

Product Quantization divides the high-dimensional space into several lower-dimensional subspaces and quantizes each subspace independently. A single full-vector codebook would need an impractically large number of centroids to represent many combinations of values. PQ factorizes that codebook into smaller pieces that are cheaper to train and store.

The compression process follows three distinct steps:

  1. Split: Divide the original ddd-dimensional vector into mmm smaller sub-vectors, each having a dimension of d∗=d/md^* = d/md∗=d/m. For instance, a 768-dimension vector split into 96 sub-vectors would yield sub-vectors of dimension 8.
  2. Cluster: For each of the mmm independent subspaces, run K-Means clustering over your training data to find a set of centroids (often k=256k=256k=256). This collection of centroids for a given subspace is called its "codebook".
  3. Encode: For every vector in your database, map each of its sub-vectors to the nearest centroid in the corresponding codebook. Replace the actual sub-vector values with the ID of that centroid.

In an IVF-PQ system, those PQ codes are usually built on the residual vector after subtracting the assigned coarse centroid, not on the raw vector itself.[3]Reference 3Billion-scale similarity search with GPUs.https://arxiv.org/abs/1702.08734 Quantizing the smaller residual usually gives better accuracy for the same code size.

Product quantization pipeline splitting one vector into chunks, replacing each chunk with a centroid id, and shrinking storage from kilobytes to bytes.
PQ splits a large vector into many small sub-vectors, replaces each with a centroid ID, and stores bytes instead of float values. Compression is large because one code per chunk replaces many raw dimensions.

By using k=256k=256k=256 centroids, an ID requires just 1 byte of storage (since 28=2562^8 = 25628=256). A 768-dim float vector (3072 bytes) compressed with m=96m=96m=96 becomes an array of 96 single-byte IDs, taking up exactly 96 bytes. That's 32x compression for the vector code before ids, codebooks, centroids, and other index overhead.

budget-pq-code-storage.py
1def bytes_per_raw_vector(dimensions: int) -> int: 2 return dimensions * 4 3 4def bytes_per_pq_code(chunks: int, bits_per_chunk: int = 8) -> int: 5 return chunks * bits_per_chunk // 8 6 7raw_bytes = bytes_per_raw_vector(768) 8code_bytes = bytes_per_pq_code(96) 9ivf_pq_bytes_with_id = code_bytes + 8 10 11print(f"raw vector bytes: {raw_bytes}") 12print(f"PQ code bytes: {code_bytes}") 13print(f"IVF-PQ code plus 64-bit id bytes: {ivf_pq_bytes_with_id}") 14print(f"raw-to-code compression: {raw_bytes / code_bytes:.0f}x")
Output
1raw vector bytes: 3072 2PQ code bytes: 96 3IVF-PQ code plus 64-bit id bytes: 104 4raw-to-code compression: 32x

OPQ: rotating before you split

Plain PQ has a weakness: it splits dimensions into fixed contiguous chunks. If the variance in your embeddings is unevenly spread across dimensions, some subspaces carry most of the signal while others are nearly constant, and the per-chunk codebooks waste their 256 centroids on the wrong directions. Optimized Product Quantization (OPQ) addresses this by learning an orthogonal rotation matrix RRR and applying it before the split so the quantizer can distribute useful variation across chunks.[5]Reference 5Optimized Product Quantizationhttps://openaccess.thecvf.com/content_cvpr_2013/html/Ge_Optimized_Product_Quantization_2013_CVPR_paper.html An orthogonal rotation preserves L2 distances, so it costs one matrix multiply per query but loses no geometric information before quantization.

In Faiss you can request OPQ with the factory string OPQ96,IVF4096,PQ96: the OPQ96 prefix trains the rotation, then IVF and PQ run on the rotated vectors. OPQ is a candidate to benchmark at the same code size; its recall benefit depends on the embedding distribution and training sample.

Asymmetric distance computation (ADC)

We don't decompress vectors to search them. Instead, we calculate the distance from the uncompressed query to the compressed database vectors using lookup tables.

Mathematically, the squared Euclidean distance between a query vector qqq and a quantized vector yyy (approximated by codebook centroids C(y)C(y)C(y)) is:

d(q,y)2≈d(q,C(y))2=∑j=1md(qj,Cj(yj))2d(q, y)^2 \approx d(q, C(y))^2 = \sum_{j=1}^{m} d(q_j, C_j(y_j))^2d(q,y)2≈d(q,C(y))2=∑j=1m​d(qj​,Cj​(yj​))2

Where qjq_jqj​ is the jjj-th sub-vector of the query, and Cj(yj)C_j(y_j)Cj​(yj​) is the centroid for the jjj-th sub-vector of yyy. This is the core of Asymmetric Distance Computation (ADC). Instead of reconstructing an approximate full vector and computing distance against it, you precompute the distance from each query sub-vector to all centroids in each codebook. The query-time distance for any stored vector then becomes a table lookup and sum. The lab below builds those tables once per query, then ranks stored codes by summed partial distances only.

Why does ADC stay practical at large N while reconstructing every approximate vector doesn't?

Answer

ADC computes query-to-centroid distances once, then scores each database vector with cheap table lookups and additions. Reconstructing every approximate vector would reintroduce much more per-vector work at query time.

asymmetric-distance-computation-adc.py
1import numpy as np 2 3def adc_pq_search( 4 query: np.ndarray, 5 pq_codes: np.ndarray, 6 codebooks: np.ndarray, 7 k: int = 10 8) -> np.ndarray: 9 """ 10 Approximate nearest neighbor search using Asymmetric Distance Computation (ADC) 11 with Product Quantization. 12 13 Args: 14 query: (d,) float vector. 15 pq_codes: (N, m) uint8 array of stored codes. 16 codebooks: (m, 256, d/m) float array of centroids. 17 """ 18 m = pq_codes.shape[1] 19 if query.shape[0] % m != 0: 20 raise ValueError("query dimension must be divisible by number of PQ chunks") 21 d_sub = query.shape[0] // m 22 23 # 1. Precompute distance tables. 24 # For each subspace, measure query sub-vector distance to every centroid. 25 dist_tables = np.zeros((m, codebooks.shape[1]), dtype=np.float32) 26 for i in range(m): 27 query_sub = query[i*d_sub : (i+1)*d_sub] 28 dist_tables[i, :] = np.sum((codebooks[i] - query_sub) ** 2, axis=1) 29 30 # 2. Aggregate distances with table lookups. 31 # Sum partial distances for each vector based on stored code IDs. 32 dists = np.zeros(len(pq_codes)) 33 for i in range(m): 34 # Look up precomputed distance for code stored at this position. 35 dists += dist_tables[i, pq_codes[:, i]] 36 37 # Distance first, then vector ID for deterministic ties. 38 vector_ids = np.arange(len(dists)) 39 return np.lexsort((vector_ids, dists))[:k] 40 41query = np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float32) 42codebooks = np.zeros((2, 256, 2), dtype=np.float32) 43codebooks[0, 1] = np.array([2.0, 0.0]) 44codebooks[1, 1] = np.array([0.0, 2.0]) 45pq_codes = np.array( 46 [ 47 [0, 0], 48 [1, 0], 49 [0, 1], 50 [1, 1], 51 ], 52 dtype=np.uint8, 53) 54 55nearest_ids = adc_pq_search(query, pq_codes, codebooks, k=2).tolist() 56print(f"nearest ids: {nearest_ids}")
Output
1nearest ids: [0, 1]

Filtered search

Real queries almost never search the whole corpus. A support tool asks for "tickets like this one, but only from the last 30 days and only for the EU region." That metadata predicate is a filter, and where you apply it changes both recall and latency. Three strategies are useful to understand.

Filter then exact scan: first finds rows matching the predicate, then scores only that subset. If the allowed set is small, this is exact and can be efficient. It gets costly as the allowed set grows.

Post-filtering (search, then filter): runs normal ANN search for some candidate count, then discards anything that fails the predicate. This keeps the graph intact, but it has an overfiltering failure mode: if the predicate is selective (say only 1% of vectors match), the initial ANN candidate buffer may contain zero matches, so you return fewer than k results or none at all. Some engines address this by expanding the scan iteratively until enough matching results appear or a budget is exhausted.[1]Reference 1pgvectorhttps://github.com/pgvector/pgvector

Allow-list graph traversal: is another useful option. The graph walk only counts allowed nodes toward the result set while it can still traverse disallowed nodes for connectivity. Weaviate documents this pre-filtered HNSW behavior, a sweeping strategy, and an ACORN-inspired option; its documentation recommends ACORN particularly when restrictive filters correlate poorly with the vector neighborhood.[6]Reference 6ACORN: Performant and Predicate-Agnostic Search Over Vector Embeddings and Structured Datahttps://arxiv.org/abs/2403.04871[7]Reference 7Filteringhttps://docs.weaviate.io/weaviate/concepts/filtering This isn't a universal winner: a tiny allowed set can make a flat scan cheaper, while broader or poorly correlated filters can benefit from graph traversal.

Filter placement is a security boundary as well as a recall knob. Trusted filter-then-exact or in-traversal allow-lists keep unauthorized vectors out of the application path; app-side post-filtering after unrestricted ANN can leak privileged text through logs, traces, caches, or partial failures even when the final answer list is scrubbed. RAG Security & Access Control treats that trusted-plane contract in depth.

show-post-filter-underfill.py
1candidate_ids = ["eu-1", "us-1", "us-2", "us-3", "eu-2"] 2region = {"eu-1": "eu", "us-1": "us", "us-2": "us", "us-3": "us", "eu-2": "eu"} 3 4def post_filter(candidates: list[str], required_region: str, k: int) -> list[str]: 5 return [item for item in candidates if region[item] == required_region][:k] 6 7initial_candidates = candidate_ids[:3] 8expanded_candidates = candidate_ids 9 10print(f"initial result: {post_filter(initial_candidates, 'eu', k=2)}") 11print(f"expanded result: {post_filter(expanded_candidates, 'eu', k=2)}")
Output
1initial result: ['eu-1'] 2expanded result: ['eu-1', 'eu-2']

Why can a selective filter make post-filtering return fewer than k results?

Answer

Post-filtering ranks the top candidates first, then drops non-matches. If only a tiny fraction of vectors satisfy the predicate, the ANN candidate buffer may contain few or no matches, so the final list comes up short. Over-fetching or in-traversal filtering avoids this.

How products package these ideas

Vendor defaults drift faster than the underlying concepts, so skip memorizing a leaderboard table. In interviews and production reviews, ask four concrete questions instead:

  1. Which ANN families are exposed: HNSW, IVF, PQ, disk-backed graphs, or a managed index whose internals aren't exposed?
  2. How does filtered search work: before search, after search, or during traversal?
  3. Where does the index body live: RAM, SSD, or object storage?
  4. Which compression knobs are available, and what recall do they cost?

That framing travels better than product trivia:

  • Postgres-style extensions usually expose a small set of explicit index types such as HNSW or IVFFlat inside SQL workflows.
  • General-purpose vector databases often center HNSW for RAM-resident search, then layer in quantization and filter-aware traversal.
  • Faiss and Faiss-derived stacks expose broad ANN menus, which shifts more tuning responsibility onto the engineer.
  • Storage-first systems move more bytes out of RAM and onto SSD or object storage, which improves cost efficiency but makes IO behavior part of your latency budget.

The pattern that stays stable is conceptual, not vendor-specific: HNSW is a candidate when you want strong in-memory recall without centroid training, while IVF and PQ are candidates when scan fraction or bytes per vector become central constraints. Choose from measured recall, tail latency, build/update cost, and memory on your workload.

Scaling to billions

When data exceeds standard RAM limits, you usually need one of two moves: compress the vectors more aggressively, or move most of the index body out of RAM. A large incident-search platform tracking billions of trace-summary embeddings faces this exact choice: either compress each incident vector with PQ or keep a sparse graph in memory and store the full index on fast SSD.

1. Hybrid approaches (IVF-PQ)

Combining IVF (to reduce search scope) and PQ (to compress vectors) is a common billion-scale recipe.

  • Index: IndexIVFPQ in Faiss.
  • Memory math: In Faiss, IndexIVFPQ stores about ceil(m_pq * nbits / 8) + 8 bytes per vector for the PQ code plus the vector id, where m_pq is the number of PQ sub-vectors/codebooks. A 32-byte code therefore lands near 40 bytes per vector, so 1B vectors is roughly 40 GB before centroids and other overhead.
  • Trade-off: Much lower memory than raw float32 vectors, but more recall loss and more tuning knobs than HNSW.

2. Disk-based indexes (DiskANN)

Microsoft's DiskANN[8]Reference 8DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node.https://proceedings.neurips.cc/paper/2019/hash/09853c7fb1d3f8ee67a61b6bf4a7f8e6-Abstract.html shows that you can serve a billion-point graph index from a single node by keeping a compact in-memory structure and placing the large index body on SSD.

  • Mechanism: The system is graph-based, but optimized around SSD access patterns rather than assuming every edge traversal hits RAM.
  • IO-aware design: Search quality now depends on storage layout and read amplification, not graph degree and beam width alone.
  • Performance: The NeurIPS 2019 paper abstract reports >5000 queries per second, <3 ms mean latency, and 95%+ 1-recall@1 on SIFT1B on a 16-core machine with 64 GB RAM plus SSD. Treat those numbers as benchmark-specific, not a universal promise for every embedding workload.

3. Distributed sharding

If single-node vertical scaling fails, you can shard horizontally with scatter-gather.

Distributed vector search scatter-gather diagram showing query fan-out to shards, shard-local top-k packets, latency bars, and the broker waiting for the slowest shard before global merge.
Full fan-out sharding cuts local work, but a complete global top-k still waits for the slowest shard packet before the broker can merge results.
  • Strategy: Split 1B vectors into SSS shards (e.g., 10 shards of 100M each).
  • Query: The gateway node broadcasts the query to all SSS nodes (Scatter).
  • Merge: Each node returns its top-kkk. The gateway merges these lists to find the global top-kkk (Gather).
  • Bottlenecks: Under full fan-out, throughput doesn't scale linearly due to coordination overhead, and tail latency (P99) is exposed to the slowest shard.

When vector indexes break

SymptomLikely causeFix
IVF recall drops after a data shiftcentroids no longer represent new embeddingsretrain on representative data and gate on held-out recall@k
HNSW causes out-of-memory failuresraw vectors, graph edges, ids, and allocator overhead exceed budgetmeasure bytes per vector at production settings; evaluate compression or IVF-PQ
Search is fast but misses neighborsnprobe or ef_search is too smalltune family-specific search budgets against exact top-k
Cosine migration breaks IVF recallold coarse quantizer was trained in raw L2 geometrynormalize training, database, and query vectors; retrain centroids
Deletes create stale hits or recall holestombstones consume traversal budget until compactionset delete SLOs and rebuild when tombstone fraction or dead-node cost crosses budget
More shards worsen p99full fan-out waits for slowest shard and mergemeasure tail latency and routing overhead, not local shard latency alone

Index family trade-offs

Index familyCore ideaMain strengthMain costReach for it when
HNSWHierarchical proximity graphOften strong in-RAM recall/latency behaviorGraph memory and slower buildsLow latency matters, the working set fits in RAM, and evaluation supports it
IVF-FlatCoarse partitioning plus exact scan inside probed listsSimple and tunableNeeds training and still stores raw vectorsYou want controllable scan fraction without graph complexity
IVF-PQIVF plus compressed vector codesMuch lower bytes per vectorQuantization error and more tuningMemory cost dominates and some recall loss is acceptable
DiskANNSSD-backed graph indexSingle-node scale beyond RAMStorage-aware engineering complexityThe dataset is larger than RAM but one fast SSD-backed node is still attractive

Evaluate HNSW when you can afford RAM and need low-latency high-recall retrieval. Evaluate IVF-PQ when raw vector storage becomes the bottleneck. Consider DiskANN-style systems when a graph index remains attractive but the index body no longer fits in memory. In each case, release from a recall-and-latency curve measured on representative queries.

Release from a measured index frontier

Freeze the embedding model, distance normalization, representative query set, filters, and exact top-kkk baseline. Sweep each candidate's search budget, then plot recall against p95 latency and bytes per vector under target concurrency. Include build time, update/delete behavior, and filtered-search completeness in a release scorecard; choose the cheapest point that clears every constraint, not the index with the best isolated benchmark.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A team stores 100,000,000 embeddings, each with 1,536 float32 dimensions, and runs unfiltered exact top-k search. Which resource estimate explains why this is hard?

Correct answer: About 572 GiB of raw vector storage and 153.6 billion scalar components scored per flat query, before index overhead or hardware effects.

Exact flat search does O(Nd) distance work: 100,000,000 * 1,536 = 153.6 billion scalar components per query. The raw float32 storage is Nd*4 bytes, about 614 GB or 572 GiB. ANN reduces the number of scored candidates, while exact search remains useful as a recall baseline and for small filtered subsets.

2.An exact top-5 result set is [10, 21, 34, 55, 89]. Candidate set A returns [10, 21, 8, 13, 5] in 4 ms; candidate set B returns [10, 21, 34, 55, 3] in 20 ms. What should you conclude from Recall@5?

Correct answer: A has 40% recall and B has 80% recall, so latency must be judged against recall rather than by response time alone.

Recall@k counts how many true top-k ids appear in the approximate result. A recovers 2 of 5 exact ids, or 40%; B recovers 4 of 5, or 80%. The faster result is not automatically better because it may miss relevant neighbors, and the higher-recall result still has to fit the product latency budget.

3.An HNSW service must return k=10 neighbors. An engineer sets ef_search=5 to minimize p95 latency. What should the reviewer say?

Correct answer: Increase or reject the setting because the layer-0 candidate list must hold at least k results; tune larger values from a recall-latency curve.

HNSW uses greedy routing in upper layers, then bounded best-first search at layer 0. ef_search controls the size of the query-time candidate list, so it must be at least k to return k results. Values only slightly above k can still hurt recall, so the setting should be chosen from measured recall-latency curves.

4.While inserting a new vector into an HNSW graph, graph max_level=2, sampled new_level=1, M=16, and ef_construction=200. Which behavior matches the algorithm?

Correct answer: Greedily search layer 2, then search and connect on layers 1 and 0 using ef_construction candidates; select up to 16 neighbors for the new node and prune existing layer-0 nodes against a 32-edge cap.

For a new node whose top layer is 1, HNSW first greedily descends through layers above it, then connects the node from layer 1 down to layer 0. ef_construction controls the insertion search beam, the new node selects up to M neighbors, and existing layer-0 nodes commonly use a 2M cap before pruning.

5.An IVF-Flat index has 1,000,000 vectors, nlist=1,024, and roughly balanced inverted lists. If nprobe increases from 5 to 20, what changes at query time?

Correct answer: It still scores 1,024 centroids, but vector scans rise from about 4,885 to about 19,531; recall may improve because more lists are inspected.

IVF search first scores the coarse centroids, then scans vectors inside the selected nprobe lists. With balanced lists, each list has about 1,000,000 / 1,024 = 977 vectors, so quadrupling nprobe from 5 to 20 roughly quadruples the candidate-vector scan. More probed cells can recover neighbors that fell outside the closest cell, but they cost more work.

6.An IVF index was trained and populated for raw L2 search. A team switches to cosine search by normalizing only queries and reusing the old centroids; recall drops. What fix matches the failure?

Correct answer: Retrain the IVF centroids on normalized training vectors, and normalize both database vectors and query vectors before add and search.

Cosine search is typically implemented by normalizing both database and query vectors, then using inner product search. IVF centroids are learned under the training geometry, so reusing raw-L2 centroids after changing to normalized cosine geometry can route queries to poor lists. The coarse quantizer should be retrained on normalized data.

7.A 768-dimensional float32 embedding is encoded with PQ using m=96 chunks and 256 centroids per chunk. Ignoring ids and codebooks, what is the per-vector code size and raw-to-code compression?

Correct answer: 96 bytes per vector code, for 32x compression relative to the 3,072-byte raw vector.

A 768-dimensional float32 vector uses 768 * 4 = 3,072 bytes. With 256 centroids per PQ chunk, each centroid ID is 8 bits, or 1 byte. With 96 chunks, the code is 96 bytes, so the raw vector is 3,072 / 96 = 32 times larger than the PQ code.

8.In an IVF-PQ search, a teammate wants to reconstruct every candidate's approximate full vector, then compute an L2 distance to the query. What is the more efficient scoring pattern?

Correct answer: Use ADC: keep the query uncompressed, precompute query-subvector to centroid distances, and score each code by summing table lookups.

Asymmetric distance computation keeps the query in float form and the database in compressed-code form. For each query, it builds small lookup tables from query sub-vectors to codebook centroids, then each candidate distance is just a sum of indexed partial distances. Reconstructing every candidate would add much more per-vector work at query time.

9.A post-filtering system first asks ANN for 100 candidates and then keeps only rows with region='EU'. The EU predicate matches about 1% of the corpus and is poorly correlated with vector neighborhoods. What failure is likely for k=10?

Correct answer: The 100-candidate buffer may contain few or no EU rows, so filtering after ANN can return fewer than k matching results.

Post-filtering searches first and applies the predicate afterward. If only 1% of vectors match, a fixed ANN candidate buffer can easily contain too few allowed rows, even when many matching rows exist elsewhere. Over-fetching, iterative expansion, exact scan of a tiny allowed set, or in-traversal filtering can avoid this underfill.

10.A service has 10M 768-dimensional float32 embeddings, 16 GB RAM for the vector index, and can tolerate some recall loss if it is measured. Which design is the more plausible first benchmark?

Correct answer: IVF-PQ, because PQ reduces bytes per vector enough to fit memory budgets that raw vectors plus HNSW graph edges may exceed.

Raw 768-dimensional float32 vectors already require about 3,072 bytes each, so 10M vectors are roughly 30 GB before graph edges, ids, metadata, or overhead. HNSW commonly adds substantial graph memory. IVF-PQ trades some recall for much lower bytes per vector, making it the more plausible candidate when RAM is the hard constraint and recall loss can be measured.

10 questions remaining.

Next Step
Continue to Advanced RAG: HyDE & Self-RAG

There, you'll move one layer above the index and learn how <span data-glossary="query-rewriting">query rewriting</span>, HyDE, Self-RAG, and Corrective RAG change retrieval work before generation. The index trade-offs still matter: low recall or high latency can come from retrieval control, `ef_search`, or `nprobe`, and those causes need different fixes.

PreviousModel Merging and Weight Interpolation
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

pgvector

pgvector contributors · 2026 · GitHub

https://github.com/pgvector/pgvector

Efficient and Robust Approximate Nearest Neighbor Using Hierarchical Navigable Small World Graphs.

Malkov, Y. A., & Yashunin, D. A. · 2018 · IEEE Transactions on Pattern Analysis and Machine Intelligence

https://arxiv.org/abs/1603.09320

Billion-scale similarity search with GPUs.

Johnson, J., Douze, M., & Jégou, H. · 2017 · arXiv preprint

https://arxiv.org/abs/1702.08734

Product Quantization for Nearest Neighbor Search.

Jégou, H., Douze, M., & Schmid, C. · 2011 · IEEE TPAMI 2011

https://dblp.org/rec/journals/pami/JegouDS11

Optimized Product Quantization

Ge, T., He, K., Ke, Q., & Sun, J. · 2013

https://openaccess.thecvf.com/content_cvpr_2013/html/Ge_Optimized_Product_Quantization_2013_CVPR_paper.html

ACORN: Performant and Predicate-Agnostic Search Over Vector Embeddings and Structured Data

Patel, L., Kraft, P., Guestrin, C., & Zaharia, M. · 2024

https://arxiv.org/abs/2403.04871

Filtering

Weaviate · 2026

https://docs.weaviate.io/weaviate/concepts/filtering

DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node.

Subramanya, S. J., et al. · 2019 · NeurIPS

https://proceedings.neurips.cc/paper/2019/hash/09853c7fb1d3f8ee67a61b6bf4a7f8e6-Abstract.html

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.