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 196 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/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics 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/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch 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/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData 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/17
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 TuningTraining Run OperationsReward 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/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel 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 & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/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 Agent System Design
🎤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

Skip most of a 100-million-vector runbook index with HNSW, IVF, or PQ, then release from a measured Recall@5 versus latency curve.

36 min read
Learning path
Step 120 of 196 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.

A support assistant has to find one rollback runbook in a corpus of 100 million trace summaries. Checking every stored representation can blow tail latency; skipping most candidates can miss the runbook an operator needs. The production question isn't "which index is fastest?" It's "how much work can we skip while still recovering the right neighbors?"

Use that query to make the trade-off visible. An embedding turns the query and each runbook into a vector, so related meaning appears as nearby geometry. Exact search checks every vector; Hierarchical Navigable Small World (HNSW) walks graph links; inverted file (IVF) opens selected partitions; and product quantization (PQ) compresses stored vectors. The release decision comes from recovered neighbors, latency, memory, and update behavior measured together.

The brute-force problem

Start with exact search, also called flat search. Given a query vector qqq, it computes a distance to every stored vector and returns the kkk nearest neighbors; k-NN is shorthand for kkk-nearest-neighbor search.

Mathematically, given a query qqq and a database of NNN vectors each with ddd dimensions, exact search has O(N⋅d)O(N \cdot d)O(N⋅d) complexity. That notation means the work grows in direct proportion to both the number of vectors and the number of dimensions.

Use the corpus as a concrete baseline: 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 a service-level agreement (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). That budget leaves little room for index and application state on many single-node deployments. Approximate nearest-neighbor (ANN) search reduces 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.

Recall@k answers one question: of the exact top-k nearest neighbors, how many did the 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.

Side-by-side ANN mechanisms: HNSW greedy descent from an upper-layer entry into a layer-0 beam, versus IVF scoring coarse centroids and scanning only the probed inverted lists.
HNSW walks a layered graph. IVF lights up a few centroid cells, then scores only those inverted lists. Both skips trade scored candidates for Recall@5 and query time on the same rollback-runbook corpus.

HNSW (Hierarchical Navigable Small World)

With exact search held as ground truth, start with HNSW when the working set can stay in memory. 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.

Layers skip, then search locally

Suppose the rollback query enters at a node that is far from its best match. A useful next hop should move closer to the query, but scanning every neighbor at every node would erase the speedup. HNSW makes that local decision on a sparse upper graph first, then repeats it on a denser neighborhood.

That structure is a multi-layer proximity graph. Upper layers hold long-range links; layer 0 contains every vector and does the local search.[2]Reference 2Efficient and Robust Approximate Nearest Neighbor Using Hierarchical Navigable Small World Graphs.https://arxiv.org/abs/1603.09320 M sets how many neighbors a new node keeps, ef_search sets the query beam, and the layer-0 adjacency cap (often 2M) keeps the base layer better connected. The mechanism is graph hops, not a partition map: an upper-layer hop gets search near the right region, and layer 0 spends the larger candidate budget there.

HNSW search path descending greedily from sparse layer 2 through layer 1, then running an ef_search beam on dense layer 0 around nodes v6, v8, and v3 before returning the top-k neighbors nearest the query.
Upper layers keep a single current node and hop to a closer neighbor. Layer 0 is where `ef_search` widens the beam and where most of the latency is spent.

A worked example: tracing one insertion

To see the layers work, insert one new runbook vector into a graph that already has a few nodes.

First, a random process samples the new node's top layer. Most nodes live only on layer 0; a few appear higher, which keeps upper layers sparse.

Next, start at the current entry point and greedily walk each layer above the new node's top layer. Stop when no neighbor is closer, then drop down. Those upper layers locate a useful neighborhood without wiring the new node into distant parts of the graph.

On each layer the new node actually occupies, run a beam search with ef_construction candidates. Keep up to M neighbors, add bidirectional edges, and prune an existing neighbor if its degree is now too large. The layer-0 cap is commonly 2M, so the base layer can offer more local routes than upper layers.

Finally, update the entry point only when the sampled level is above the graph's current maximum. Landing on an existing top layer changes local wiring, not the global starting point.

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

Build a tiny graph, then search it

Insert follows the paper's two phases: greedy descent through layers above the new node's top layer, then a wider ef_construction search while wiring bidirectional edges on the layers the node actually occupies.[2]Reference 2Efficient and Robust Approximate Nearest Neighbor Using Hierarchical Navigable Small World Graphs.https://arxiv.org/abs/1603.09320 Neighbor selection here is "keep the nearest M candidates." Production HNSW usually adds a diversity heuristic so the graph doesn't collapse into a clique of near-duplicates.

Level sampling is exponential. sample_level is the random process a real builder uses. The lab then inserts with explicit levels so the wiring stays reproducible. Before running it, predict the distinction: the returned neighbors should match the exact top-2 in this tiny graph, but that result doesn't establish large-scale recall.

hnsw-insert-and-search.py
1import heapq 2import math 3import random 4from dataclasses import dataclass, field 5 6def squared_l2(left: tuple[float, ...], right: tuple[float, ...]) -> float: 7 return sum((a - b) ** 2 for a, b in zip(left, right)) 8 9def sample_level(mL: float, rng: random.Random) -> int: 10 u = max(rng.random(), 1e-12) 11 return math.floor(-math.log(u) * mL) 12 13@dataclass 14class ToyHNSW: 15 M: int = 2 16 ef_construction: int = 4 17 vectors: dict[int, tuple[float, ...]] = field(default_factory=dict) 18 edges: dict[tuple[int, int], list[int]] = field(default_factory=dict) 19 entry_point: int | None = None 20 max_level: int = -1 21 22 def neighbors(self, node_id: int, level: int) -> list[int]: 23 return list(self.edges.get((node_id, level), [])) 24 25def greedy_search(graph: ToyHNSW, query: tuple[float, ...], entry_id: int, level: int) -> int: 26 current = entry_id 27 improved = True 28 while improved: 29 improved = False 30 for neighbor in graph.neighbors(current, level): 31 if squared_l2(query, graph.vectors[neighbor]) < squared_l2(query, graph.vectors[current]): 32 current = neighbor 33 improved = True 34 return current 35 36def search_layer( 37 graph: ToyHNSW, query: tuple[float, ...], entry_id: int, level: int, ef: int 38) -> list[int]: 39 visited = {entry_id} 40 entry_dist = squared_l2(query, graph.vectors[entry_id]) 41 candidates = [(entry_dist, entry_id)] 42 results = [(-entry_dist, entry_id)] 43 while candidates: 44 dist, current = heapq.heappop(candidates) 45 if len(results) >= ef and dist > -results[0][0]: 46 break 47 for neighbor in graph.neighbors(current, level): 48 if neighbor in visited: 49 continue 50 visited.add(neighbor) 51 neighbor_dist = squared_l2(query, graph.vectors[neighbor]) 52 if len(results) < ef or neighbor_dist < -results[0][0]: 53 heapq.heappush(candidates, (neighbor_dist, neighbor)) 54 heapq.heappush(results, (-neighbor_dist, neighbor)) 55 if len(results) > ef: 56 heapq.heappop(results) 57 return [node for _, node in sorted((-neg, node) for neg, node in results)] 58 59def prune(graph: ToyHNSW, node_id: int, level: int, cap: int) -> None: 60 linked = graph.edges[(node_id, level)] 61 if len(linked) <= cap: 62 return 63 keep = sorted( 64 linked, key=lambda other: squared_l2(graph.vectors[node_id], graph.vectors[other]) 65 )[:cap] 66 dropped = set(linked) - set(keep) 67 graph.edges[(node_id, level)] = keep 68 for other in dropped: 69 graph.edges[(other, level)] = [n for n in graph.edges.get((other, level), []) if n != node_id] 70 71def insert(graph: ToyHNSW, node_id: int, vector: tuple[float, ...], level: int) -> None: 72 graph.vectors[node_id] = vector 73 for layer in range(level + 1): 74 graph.edges.setdefault((node_id, layer), []) 75 if graph.entry_point is None: 76 graph.entry_point = node_id 77 graph.max_level = level 78 return 79 80 entry = graph.entry_point 81 for layer in range(graph.max_level, level, -1): 82 entry = greedy_search(graph, vector, entry, layer) 83 for layer in range(min(level, graph.max_level), -1, -1): 84 candidates = search_layer(graph, vector, entry, layer, graph.ef_construction) 85 chosen = sorted(candidates, key=lambda i: squared_l2(vector, graph.vectors[i]))[: graph.M] 86 cap = 2 * graph.M if layer == 0 else graph.M 87 for neighbor in chosen: 88 if neighbor == node_id: 89 continue 90 if neighbor not in graph.edges[(node_id, layer)]: 91 graph.edges[(node_id, layer)].append(neighbor) 92 graph.edges.setdefault((neighbor, layer), []) 93 if node_id not in graph.edges[(neighbor, layer)]: 94 graph.edges[(neighbor, layer)].append(node_id) 95 prune(graph, neighbor, layer, cap) 96 prune(graph, node_id, layer, cap) 97 entry = min(candidates, key=lambda i: squared_l2(vector, graph.vectors[i])) 98 if level > graph.max_level: 99 graph.entry_point = node_id 100 graph.max_level = level 101 102def search(graph: ToyHNSW, query: tuple[float, ...], k: int = 2, ef_search: int = 4) -> list[int]: 103 if graph.entry_point is None: 104 raise ValueError("empty graph") 105 if ef_search < k: 106 raise ValueError("ef_search must be at least k") 107 current = graph.entry_point 108 for layer in range(graph.max_level, 0, -1): 109 current = greedy_search(graph, query, current, layer) 110 candidates = search_layer(graph, query, current, 0, ef_search) 111 return sorted(candidates, key=lambda i: squared_l2(query, graph.vectors[i]))[:k] 112 113rng = random.Random(0) 114mL = 1 / math.log(16) 115draws = 0 116level = 0 117while level == 0: 118 level = sample_level(mL, rng) 119 draws += 1 120print(f"mL for M=16: {mL:.3f}") 121print(f"P(level >= 1) = {1 / 16:.1%}") 122print(f"first upper-layer draw: level {level} after {draws} samples") 123 124graph = ToyHNSW() 125for node_id, vector, level in [ 126 (0, (0.0, 0.0), 1), 127 (1, (0.2, 0.1), 0), 128 (2, (0.3, 0.0), 1), 129 (3, (5.0, 5.0), 0), 130 (4, (5.1, 4.8), 0), 131 (5, (0.1, 0.3), 0), 132]: 133 insert(graph, node_id, vector, level) 134 135query = (0.25, 0.05) 136nearest_ids = search(graph, query, k=2, ef_search=4) 137exact_ids = sorted(graph.vectors, key=lambda i: squared_l2(query, graph.vectors[i]))[:2] 138print(f"nearest ids: {nearest_ids}") 139print(f"exact top-2: {exact_ids}")
Output
1mL for M=16: 0.361 2P(level >= 1) = 6.2% 3first upper-layer draw: level 1 after 36 samples 4nearest ids: [1, 2] 5exact top-2: [1, 2]

The toy returns the exact top-2 because its graph and beam are generous for six vectors. That is a correctness check for the implementation, not a production recall result.

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

M, ef_construction, and ef_search are the knobs that actually move recall, RAM, build time, and latency. Use the table as a starting map, then measure on your queries before copying a default.

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.

M0 (often 2M2M2M) is why layer 0 is denser than the upper layers.[2]Reference 2Efficient and Robust Approximate Nearest Neighbor Using Hierarchical Navigable Small World Graphs.https://arxiv.org/abs/1603.09320 At 4 bytes per packed neighbor id, M=16M=16M=16 adds 128 bytes of layer-0 ids on top of a 1,536-d float32 vector (6,144 bytes). The embedding still dominates; the memory cliff at LLM sizes is uncompressed vectors, not the graph overlay. Compression (PQ, disk-backed graphs) is what changes the budget.

budget-hnsw-layer0-bytes.py
1def hnsw_layer0_bytes_per_vector(dimensions: int, M: int) -> dict[str, int]: 2 vector = dimensions * 4 3 neighbor_ids = (2 * M) * 4 4 return { 5 "vector": vector, 6 "layer0_ids": neighbor_ids, 7 "total": vector + neighbor_ids, 8 } 9 10budget = hnsw_layer0_bytes_per_vector(1536, 16) 11print(f"float32 vector: {budget['vector']:,} bytes") 12print(f"layer-0 ids at M=16: {budget['layer0_ids']:,} bytes") 13print(f"vector share: {budget['vector'] / budget['total']:.1%}")
Output
1float32 vector: 6,144 bytes 2layer-0 ids at M=16: 128 bytes 3vector share: 98.0%

ef_search is the query-time knob: raise it to spend latency for recall, without rebuilding. The lab below picks the cheapest measured point that clears a recall floor and a p95 budget.

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

Ask the same question as before: where can we avoid work without losing the rollback runbook? If a true neighbor sits just across a partition boundary, opening only the closest partition will miss it. IVF protects against that miss by choosing a few coarse regions rather than one.

Training happens before serving. Run K-means clustering on a representative sample to learn CCC cluster centers, or centroids. In Faiss terminology, CCC is usually nlist; those centroids become the coarse map.

Indexing assigns each incoming vector to its nearest centroid and stores its ID in that centroid's inverted list. At query time, score all centroids, keep the closest nprobe lists, then score only their candidates. IVF-Flat uses exact distances inside those lists; IVF-PQ can score compressed codes with asymmetric distance computation (ADC) lookup tables.[3]Reference 3Billion-scale similarity search with GPUs.https://arxiv.org/abs/1702.08734

IVF partition map with 1,024 list tiles in a 32 by 32 grid, five probed cells highlighted, and work counts of 1,024 centroid scores plus about 4,883 list scans versus a 1,000,000-vector brute-force scan.
Each tile is one inverted list. Five of 1,024 light up. For 1M vectors and balanced lists, `nprobe = 5` scores about 5,907 distances instead of 1,000,000.

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 performs 1,000,0001{,}000{,}0001,000,000 distance computations per query.

IVF first scores its 1,0241{,}0241,024 centroids. With balanced lists, each list averages 1,000,000/1,024=976.56251{,}000{,}000 / 1{,}024 = 976.56251,000,000/1,024=976.5625 vectors, so five probes add about 5×976.5625≈4,8835 \times 976.5625 \approx 4{,}8835×976.5625≈4,883 vector scores.

Together, centroid scoring and list scans cost roughly 5,9075{,}9075,907 distance computations. That's about a 169× reduction in distance computations. Real systems are slower than this model because lists are uneven and each comparison has overhead, but the principle holds: IVF refuses to scan most of the database. nlist = 1,024 is a round teaching number here, not a universal default.

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

A boundary miss: nprobe = 1 drops a true neighbor

Before running the lab, predict what nprobe = 1 keeps. Query (4.9, 0) is closer to centroid 0, so only list 0 opens. Vector 3 at (5.4, 0) is the true second neighbor, but it lives in list 1 and stays invisible until both lists are probed.

The example uses two hand-set centroids: payments-like vectors near the origin and GPU-like vectors near (10, 0). It isolates the failure mode without hiding it behind clustering noise.

Faiss's IndexIVFFlat is the production form of this loop: train centroids, assign inverted lists, then search with nprobe.[3]Reference 3Billion-scale similarity search with GPUs.https://arxiv.org/abs/1702.08734

ivf-nprobe-boundary-miss.py
1def squared_l2(left: tuple[float, ...], right: tuple[float, ...]) -> float: 2 return sum((a - b) ** 2 for a, b in zip(left, right)) 3 4centroids = {0: (0.0, 0.0), 1: (10.0, 0.0)} 5vectors = { 6 0: (0.2, 0.1), 7 1: (0.4, -0.1), 8 2: (4.6, 0.0), 9 3: (5.4, 0.0), 10 4: (9.8, 0.2), 11} 12 13def nearest_centroid(vector: tuple[float, ...]) -> int: 14 return min(centroids, key=lambda cid: squared_l2(vector, centroids[cid])) 15 16inverted_lists: dict[int, list[int]] = {0: [], 1: []} 17for vector_id, vector in vectors.items(): 18 inverted_lists[nearest_centroid(vector)].append(vector_id) 19 20def ivf_search(query: tuple[float, ...], nprobe: int, k: int = 2) -> tuple[list[int], list[int]]: 21 ranked_lists = sorted(centroids, key=lambda cid: squared_l2(query, centroids[cid]))[:nprobe] 22 scored: list[tuple[float, int]] = [] 23 for list_id in ranked_lists: 24 for vector_id in inverted_lists[list_id]: 25 scored.append((squared_l2(query, vectors[vector_id]), vector_id)) 26 scored.sort() 27 return [vector_id for _, vector_id in scored[:k]], ranked_lists 28 29query = (4.9, 0.0) 30exact = sorted(vectors, key=lambda vid: squared_l2(query, vectors[vid]))[:2] 31nprobe_1, lists_1 = ivf_search(query, nprobe=1) 32nprobe_2, lists_2 = ivf_search(query, nprobe=2) 33 34print(f"inverted lists: {inverted_lists}") 35print(f"exact top-2: {exact}") 36print(f"nprobe=1 lists {lists_1} -> {nprobe_1}") 37print(f"nprobe=2 lists {lists_2} -> {nprobe_2}")
Output
1inverted lists: {0: [0, 1, 2], 1: [3, 4]} 2exact top-2: [2, 3] 3nprobe=1 lists [0] -> [2, 1] 4nprobe=2 lists [0, 1] -> [2, 3]

nprobe is the main query-time knob. If lists were perfectly balanced, the scanned fraction would be about nprobe / nlist. Real lists 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 listsApproaches exhaustive search and often loses the 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

Before comparing index families, fix the ranking rule. The same stored vectors can produce a different neighbor order when magnitude matters, so changing the metric changes both search and any IVF centroids trained for it. 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.[3]Reference 3Billion-scale similarity search with GPUs.https://arxiv.org/abs/1702.08734

L2 distance is the standard Euclidean objective. Use it when vector magnitude carries signal or when the model was trained with an L2-style loss.

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

Cosine similarity ignores magnitude and keeps only direction. Normalize both database 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 math 2 3def normalize(vector: list[float]) -> list[float]: 4 norm = math.sqrt(sum(value * value for value in vector)) 5 return [value / norm for value in vector] 6 7vectors = [[3.0, 0.0], [1.0, 1.0], [0.0, 2.0]] 8query = [1.0, 0.8] 9normalized_vectors = [normalize(vector) for vector in vectors] 10normalized_query = normalize(query) 11inner_product = [ 12 sum(left * right for left, right in zip(vector, normalized_query)) 13 for vector in normalized_vectors 14] 15l2 = [ 16 sum((left - right) ** 2 for left, right in zip(vector, normalized_query)) 17 for vector in normalized_vectors 18] 19 20print(f"inner-product rank: {sorted(range(3), key=lambda i: -inner_product[i])}") 21print(f"L2 rank after normalization: {sorted(range(3), key=lambda i: l2[i])}")
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
MemoryRaw vectors plus about 2M2M2M layer-0 neighbor idsRaw vectors plus ids; no graph overlay
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, stale coarse centroids can unbalance lists or lower recall. HNSW doesn't have that centroid-training dependency. At typical LLM embedding sizes the extra graph ids are a small add-on to the same raw vectors IVF-Flat already stores; HNSW's usual extra costs are build time and delete behavior, not a second copy of the corpus.

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 lower-dimensional subspaces and quantizes each one 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.

First split the original ddd-dimensional vector into mmm sub-vectors of dimension d∗=d/md^* = d/md∗=d/m. A 768-dimension vector split into 96 sub-vectors yields chunks of dimension 8.

Then cluster each subspace separately. Run K-means on training data to find a codebook for each chunk, often with k=256k=256k=256 centroids. Finally, encode each sub-vector as the ID of its nearest centroid. The full vector becomes mmm small IDs instead of ddd floats.

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 of a 768-dimensional float32 vector into 96 chunks of 8 dimensions, each replaced by a one-byte centroid id, shrinking 3,072 bytes to a 96-byte code.
A 768-d float32 vector is 3,072 bytes. Split it into 96 chunks of 8 dimensions, store one byte per chunk, and the code is 96 bytes. That 32× shrink is why IVF-PQ can fit when raw HNSW vectors can't.

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 32× 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. Their codebooks spend centroids on uninformative directions.

Optimized Product Quantization (OPQ) learns an orthogonal rotation matrix RRR and applies it before the split, distributing 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 a factory string such as OPQ96,IVF4096,PQ96 (keeps the original dimension) or OPQ96_384,IVF4096,PQ96 (rotates and reduces to 384-d, so each of the 96 chunks is 4-d). 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. For each query sub-vector, calculate its distance to every centroid once and keep those values in a lookup table. A stored code then needs only one lookup per chunk and a sum.

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): the query stays uncompressed while the database uses codes. Instead of reconstructing an approximate full vector and computing distance against it, the query-time distance for any stored vector becomes a table lookup and sum.

In IVF-PQ, Faiss's default is to encode the residual after subtracting the coarse centroid.[3]Reference 3Billion-scale similarity search with GPUs.https://arxiv.org/abs/1702.08734 ADC then compares the query residual (q−clist)(q - c_{\text{list}})(q−clist​) to those residual codes, so the lookup tables are rebuilt (or selected) per probed list rather than once globally. The lab below shows the simpler non-residual case: one table per subspace, then a sum of lookups.

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
1def squared_l2(left: list[float], right: list[float]) -> float: 2 return sum((a - b) ** 2 for a, b in zip(left, right)) 3 4def adc_pq_search( 5 query: list[float], 6 pq_codes: list[list[int]], 7 codebooks: list[list[list[float]]], 8 k: int = 2, 9) -> list[int]: 10 subspaces = len(codebooks) 11 dim_per_chunk = len(query) // subspaces 12 tables = [] 13 for index, codebook in enumerate(codebooks): 14 query_chunk = query[index * dim_per_chunk : (index + 1) * dim_per_chunk] 15 tables.append([squared_l2(query_chunk, centroid) for centroid in codebook]) 16 17 distances = [] 18 for row in pq_codes: 19 distances.append(sum(tables[index][code] for index, code in enumerate(row))) 20 ranked = sorted(range(len(pq_codes)), key=lambda vector_id: (distances[vector_id], vector_id)) 21 return ranked[:k] 22 23query = [0.0, 0.0, 0.0, 0.0] 24codebooks = [ 25 [[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [2.0, 2.0]], 26 [[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [2.0, 2.0]], 27] 28pq_codes = [ 29 [0, 0], 30 [1, 0], 31 [0, 1], 32 [1, 1], 33] 34 35print(f"nearest ids: {adc_pq_search(query, pq_codes, codebooks, k=2)}")
Output
1nearest ids: [0, 1]

Filtered search

The index can behave well for an unfiltered query and still fail once access rules narrow the candidate set. A support tool asks for "runbooks like this one, but only from the last 30 days and only for the EU region." Before choosing a filter strategy, predict the failure: will the filter shrink work, empty the candidate buffer, or block graph routes needed to reach allowed neighbors? Three strategies make that trade-off concrete.

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. pgvector applies filters after an approximate index scan and can expand with iterative index scans (hnsw.iterative_scan / ivfflat.iterative_scan) until enough matches appear or a budget is exhausted.[1]Reference 1pgvectorhttps://github.com/pgvector/pgvector

Allow-list graph traversal: the walk can still traverse disallowed nodes for connectivity, but only allowed nodes count toward the result set. Weaviate's pre-filtered HNSW does this. Since v1.34, ACORN is the default filter strategy; a sweeping walk remains available. ACORN is most useful when a restrictive filter correlates poorly with the vector neighborhood, so the graph region nearest the query is mostly disallowed.[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 A tiny allowed set can still make a flat scan cheaper.

Those three placements:

Diagram showing Query + filter, Where to apply?, Before: exact scan, and After: ANN then drop.
Query + filter, Where to apply?, Before: exact scan, and After: ANN then drop.

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.

Four questions a vendor page can't answer

When you evaluate a vector database, ask four questions that survive vendor churn. Each one connects an index choice to an operating constraint:

  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. As a working heuristic, Postgres-style extensions 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. That makes filtered-search behavior part of the index evaluation, not a later integration detail.

Faiss and Faiss-derived stacks expose broad ANN menus, which shifts more tuning responsibility onto the engineer. The freedom is useful when you can own benchmarking and deployment decisions.

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.

These questions lead to a workload decision, not a vendor slogan: 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 the rollback corpus grows past RAM, two budgets compete: compress the vectors, or move most of the index body to storage. A billion-vector incident-search platform might compress each trace-summary vector with PQ, keep a sparse graph in memory, or compare both against a sharded design. The right branch depends on bytes, recall, and tail latency together.

1. Hybrid approaches (IVF-PQ)

Combining IVF (to reduce search scope) and PQ (to compress vectors) targets both budgets. 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. The savings buy capacity, but quantization error and extra knobs can cost recall compared with 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 keeps a compact in-memory structure and places the large graph body on SSD. The graph is still navigational, but its layout is optimized for storage access rather than assuming every edge traversal hits RAM. Search quality therefore depends on read amplification and layout as well as graph degree and beam width.

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. Those are benchmark-specific measurements, not a universal promise for every embedding workload.

3. Distributed sharding

If neither compression nor storage fits the single-node budget, 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.

Split 1B vectors into SSS shards, such as 10 shards of 100M each. A gateway broadcasts the query to all SSS nodes (scatter); each node returns its local top-kkk; then the gateway merges those packets into the global top-kkk (gather).

Full fan-out cuts local work but exposes the request to coordination overhead and the slowest shard. Throughput doesn't scale linearly, and tail latency (P99) is a property of the whole scatter-gather path, not any one shard.

When vector indexes break

Most failures announce themselves as one of three symptoms: recall drops after the data or metric changes, results underfill after filtering or deletes, or p99 rises while local work looks healthy. Use the table to connect each symptom to the budget that moved and the evidence needed before changing a knob.

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 (plus graph ids and allocator overhead) exceed budgetmeasure bytes per vector at production settings; evaluate PQ or IVF-PQ before blaming graph degree
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 behaviorBuild time, delete behavior, and raw-vector RAMLow latency matters, the working set's float32 vectors fit 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. That fixes what "correct" means while you compare candidates.

Then sweep each candidate's search budget and plot recall against p95 latency and bytes per vector under target concurrency. Add build time, update/delete behavior, and filtered-search completeness to the 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 collection holds 100 million vectors with 1,536 float32 dimensions. What does one unfiltered exact query and raw storage require before index overhead?

Correct answer: Roughly 572 GiB stored and 153.6 billion components scored, before hardware and index effects.

Storage is N * d * 4 bytes, about 614 GB or 572 GiB. Exact work is N * d, which is 153.6 billion scalar components per query. ANN reduces candidates; exact search still supplies recall ground truth and can suit tiny filtered sets.

2.Exact top-5 IDs are [10, 21, 34, 55, 89]. ANN-A returns [10, 21, 8, 13, 5] at 4 ms; ANN-B returns [10, 21, 34, 55, 3] at 20 ms. Which conclusion is valid?

Correct answer: ANN-A has 40% and ANN-B has 80% Recall@5; neither latency number is meaningful without a required operating point.

Recall counts recovered exact IDs: two of five for A and four of five for B. A faster weak result and a slower strong result represent different points on the frontier. Release requires explicit recall and latency floors, not either metric alone.

3.An HNSW graph has max_level = 2; a new node samples level 1 with M = 16 and ef_construction = 200. Which insertion sequence is correct?

Correct answer: Greedily descend layer 2, then search and connect on layers 1 and 0; use up to M new-node neighbors and a typical 2M layer-0 cap.

Layers above the node's sampled level are navigation only. The node is wired from its top layer down to layer 0 using the construction beam, with bounded bidirectional degree. Entry point changes only when a node exceeds the prior maximum level.

4.A service must return k = 10, but HNSW is configured with ef_search = 5 to reduce p95 latency. What should happen?

Correct answer: Reject or raise it to at least k, then tune larger beams from measured recall-latency curves.

The layer-0 candidate set must be able to hold at least the requested number of results. Setting ef_search barely above k can still miss better neighbors, so larger values need evaluation against exact top-k and tail latency.

5.An IVF-Flat index has 1 million vectors, nlist = 1,024, and balanced lists. What changes when nprobe rises from 5 to 20?

Correct answer: Centroid scoring stays at 1,024, while expected list scans grow from about 4,883 to 19,531 and may recover boundary neighbors.

IVF ranks all coarse centroids, then scans vectors in the chosen lists. Balanced lists average about 976.6 vectors, so quadrupling probes roughly quadruples candidate work. More cells protect recall near partition boundaries at added latency.

6.An IVF index trained on raw vectors under L2 is switched to cosine by normalizing only queries. Recall drops. Which migration matches cosine geometry?

Correct answer: Normalize training, database, and query vectors, then retrain IVF centroids before inner-product search.

Cosine ranking becomes inner-product ranking after both sides are normalized. IVF routing depends on the geometry used to train its coarse quantizer, so old raw-L2 centroids can send normalized queries to poor lists. Rebuilding restores geometric consistency.

7.A 768-dimensional float32 vector is split into 96 PQ sub-vectors with 256 centroids per subspace. Ignoring IDs and codebooks, what code size results?

Correct answer: 96 bytes, giving 32x compression from the original 3,072-byte float32 vector.

Each of 256 centroids fits in an 8-bit identifier, so every sub-vector contributes one byte. Ninety-six identifiers require 96 bytes, while 768 float32 values require 3,072 bytes. IDs, centroids, and codebooks add separate overhead.

8.During IVF-PQ search, a teammate reconstructs every approximate vector before computing L2. Which ADC design removes that repeated reconstruction?

Correct answer: Keep the query uncompressed, precompute query-to-centroid tables, and sum indexed distances for each stored code.

ADC spends query-time work once per subspace centroid, then scores every database code through cheap lookups and additions. In residual IVF-PQ, the same principle applies to query residuals for each probed coarse list.

9.An ANN query requests 100 candidates, then application code keeps region = EU. EU rows are 1% of data and poorly aligned with vector neighborhoods; final k is 10. What is the main failure?

Correct answer: The fixed buffer may contain too few EU rows, and unrestricted intermediate results may also cross the authorization boundary.

Selective post-filtering can underfill because most initial candidates fail the predicate. If region is an access rule, unrestricted search also exposes hidden evidence to intermediate state. Trusted pre-filtered exact search, iterative expansion, or allow-list traversal addresses the appropriate constraint.

10.An incident-search service needs high-recall in-memory search today, but projected raw vectors will later exceed RAM. Which benchmark sequence fits those constraints?

Correct answer: Benchmark HNSW while raw vectors fit; compare IVF-PQ or SSD-backed graphs once memory becomes binding.

HNSW is a strong candidate for low-latency in-RAM recall without centroid training. PQ trades accuracy for far fewer bytes, while DiskANN-style systems move index bodies to SSD. Final choice still comes from representative recall, p95, memory, filtering, and update measurements.

10 questions remaining.

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

HNSW and IVF decide how an index walks once a query vector exists, but they can't repair a vague or underspecified question. The next chapter moves upstream with <span data-glossary="query-rewriting">query rewriting</span>, HyDE, Self-<span data-glossary="rag">RAG</span>, and Corrective RAG, then separates those control failures from `ef_search` and `nprobe` tuning failures.

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.