Lab 10
Dense Retrieval, Semantic Search, Vector Indexes and Vector Databases
Overview of the lab
Lab 08 and Lab 09 answered: where does a dense text vector come from? This lab asks: how do we use those vectors to retrieve documents efficiently?
It deliberately separates representation quality from search / index quality: they are different problems.
- From embeddings to retrieval, and dense retrieval on Cranfield.
- Bi-encoders and Dense Passage Retrieval: passages, training, hard negatives.
- Similarity functions: cosine, dot product, Euclidean distance.
- Exact nearest-neighbor search: cost and memory at scale.
- Approximate nearest-neighbor search: partitions and graphs, two kinds of recall.
- FAISS: exact and approximate indexes.
- What is a vector database? Metadata and filtering.
- Exact vs ANN on Cranfield.
- Three levels of quality, the summary and an optional exercise.
Learning objectives
By the end you should be able to explain:
- how bi-encoder retrieval works, and how Dense Passage Retrieval fits the bi-encoder pattern;
- why passages are often indexed instead of entire long documents;
- cosine similarity and dot-product scoring;
- what exact nearest-neighbor search means, and why brute-force dense search becomes expensive at scale;
- the role of a vector index; what approximate nearest-neighbor search means, and why ANN trades some exactness for speed;
- the difference between IR Recall@k and ANN recall;
- the role of FAISS, what a vector database adds, and how vector search fits into a RAG system.
From embeddings to retrieval
From Labs 08 and 09:
Now suppose every document has a vector. Retrieval becomes: find the document vectors that are closest to the query vector.
| Work | When | |
|---|---|---|
| Offline | documents → document encoder → document vectors → store / index | once |
| Online | query → query encoder → query vector → search the index → top-k documents | for every query |
The document side can be precomputed: that is what makes bi-encoder retrieval practical. And a dense retriever contains two separate problems, easily mixed together:
1. Representation
How do we map text to vectors?
SentenceTransformer, the DPR encoders, E5, BGE: representation models.
2. Search
How do we efficiently find the nearest vectors?
FAISS, HNSW, vector database indexes: search / indexing technologies.
A better embedding model does not make nearest-neighbor search faster; a better index does not make the embeddings semantically better.
Dense retrieval on Cranfield
The Cranfield collection again, encoded with the model of Lab 09, so that the retrieval methods stay comparable across labs.
encode_cranfield.py Python
# pip install sentence-transformers ir_datasets numpy faiss-cpu
import ir_datasets
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
dataset = ir_datasets.load("cranfield")
docs = list(dataset.docs_iter())
queries = list(dataset.queries_iter())
doc_ids = [doc.doc_id for doc in docs]
titles = [doc.title for doc in docs]
texts = [f"{doc.title}. {doc.text}" for doc in docs]
doc_embeddings = model.encode(texts, batch_size=32, normalize_embeddings=True,
show_progress_bar=True) # the expensive offline step
doc_embeddings.shape
# (1400, 384)
search_dense.py Python
import numpy as np
def search_dense(query, k=5):
q = model.encode([query], normalize_embeddings=True)[0]
scores = doc_embeddings @ q # one dot product per document
top = np.argsort(scores)[::-1][:k]
return [(doc_ids[i], titles[i], float(scores[i])) for i in top]
q = queries[0].text
print(q)
for result in search_dense(q):
print(result)
# what similarity laws must be obeyed when constructing aeroelastic models
# of heated high speed aircraft .
# ('486', 'similarity laws for aerothermoelastic testing .', 0.718813419342041)
# ('184', 'scale models for thermo-aeroelastic research .', 0.6493932008743286)
# ('13', 'similarity laws for stressing heated wings .', 0.6335977911949158)
# ('51', 'theory of aircraft structural models subjected to aerodynamic\nheating and external loads .', 0.6116987466812134)
# ('12', 'some structural and aerelastic considerations of high\nspeed flight .', 0.5942484140396118)
- Cost: encoding the 1,400 abstracts took 4.5 seconds here on a laptop. For a large collection, the document vectors are computed once, stored and indexed.
- ❗ Truncation: 418 of the 1,400 abstracts (30%) are longer than the model's 256 word pieces (the longest has 808). Everything after the limit is silently ignored. Real systems split long documents into passages (Part II).
See it: Cranfield in embedding space
All 1,400 abstracts and 225 queries, projected to 2-D with t-SNE. Choose a query: its dense top 10 are ringed, its relevant documents are green. Click a dot to read its title.
query dense top 10 relevant (grade ≥ 1)
Bi-encoders and Dense Passage Retrieval
A bi-encoder uses independent representations for the query and the passage, then a vector similarity:
Dense Passage Retrieval (DPR) (Karpukhin et al., EMNLP 2020) is an influential example. Its core idea is simple: question → question encoder → question vector; passage → passage encoder → passage vector; relevant question–passage pairs should receive higher similarity than irrelevant pairs.
- Both encoders are BERT-base networks; the vector is the output at the [CLS] token, \(d = 768\).
- Wikipedia is split into disjoint blocks of 100 words: 21,015,324 passages.
- DPR beat a strong Lucene-BM25 system by 9–19 points (absolute) of top-20 passage retrieval accuracy across open-domain QA datasets.
- Search runs on a FAISS HNSW index (Part V): 995 questions per second, top 100 passages each, against 23.7 per second per CPU thread for BM25/Lucene, as reported in the paper.
Why passages?
Take a 50-page report where only one paragraph answers the query. One vector for the whole document must compress many topics. Instead, document → passages → one vector per passage gives finer retrieval granularity. This will matter again in RAG.
passages_cranfield.py Python
def passages(doc, size=100, stride=50): # overlapping 100-word windows
words = f"{doc.title}. {doc.text}".split()
if len(words) <= size:
return [" ".join(words)]
return [" ".join(words[s:s + size]) for s in range(0, len(words) - size + stride, stride)]
passage_texts, passage_doc = [], []
for i, doc in enumerate(docs):
for p in passages(doc):
passage_texts.append(p)
passage_doc.append(i)
passage_doc = np.array(passage_doc)
passage_emb = model.encode(passage_texts, batch_size=32, normalize_embeddings=True)
len(passage_texts)
# 4238
query_embeddings = model.encode([q.text for q in queries], normalize_embeddings=True)
scores = query_embeddings @ passage_emb.T # 225 x 4238
doc_scores = np.full((len(queries), len(docs)), -np.inf)
for qi in range(len(queries)): # a document scores as its best passage (MaxP)
np.maximum.at(doc_scores[qi], passage_doc, scores[qi])
# evaluated with ir_measures: MAP 0.3094, nDCG@10 0.3440 (whole abstracts: 0.3207, 0.3555)
💡 On Cranfield, passages do not help: the abstracts are short (median 156 words; the model reads 256 word pieces), and a query is about the topic of the whole abstract. Splitting pays off when documents are long and the answer is local, the DPR setting of Wikipedia articles and 50-page reports.
Training intuition
A dense retriever often sees a query, a positive passage and a negative passage. The objective encourages
DPR's best model used the positives of the other questions in the same mini-batch as negatives ("in-batch negatives") plus one BM25 passage that matches the question but does not contain the answer.
See it: easy and hard negatives
The query symptoms of influenza and three passages, scored by all-MiniLM-L6-v2 (real cosines):
The easy negative (medieval architecture) is so far below the positive that the model has nothing left to learn from it. The hard negative shares the symptoms and differs only in the disease: it is the one that forces the model to learn finer distinctions. This is why negative mining matters in dense retrieval.
Similarity functions
Common choices: cosine similarity, dot product, Euclidean distance.
If the vectors are unit-normalized, \(q^\top d = \cos(q, d)\) (and \(\|q - d\|^2 = 2 - 2\cos(q, d)\)): all three give the same ranking. This lets us use fast inner-product search while preserving the cosine ranking.
| Measure | Formula | Range | More similar when |
|---|---|---|---|
| Cosine similarity | \(q \cdot d \,/\, (\|q\|\,\|d\|)\) | \([-1, 1]\) | higher: the angle between the vectors is smaller |
| Dot product | \(\sum_{i=1}^{n} q_i d_i\) | \((-\infty, \infty)\) | higher, generally; it grows with vector length too, so it is meaningful when the model gives length a meaning |
| Euclidean distance | \(\sqrt{\sum_{i=1}^{n} (q_i - d_i)^2}\) | \([0, \infty)\) | lower: it is a distance, not a similarity |
For unit vectors the ranges shrink: the dot product equals the cosine, in \([-1, 1]\), and the distance lies in \([0, 2]\).
Try it: when the three disagree
A query \(q\) and three documents in 2-D (invented). Stretch document A without turning it: its cosine does not change, but its dot product grows and its distance changes. Tick "normalize" to put every vector on the unit circle.
❗ Match the model's training setup. Do not choose a metric arbitrarily: a retrieval model may have been trained for dot product or for cosine similarity, and the search metric should match the representation model's intended use. (all-MiniLM-L6-v2: cosine, so we normalize and use the inner product.)
Exact nearest-neighbor search
For a small collection, retrieval can be brute force:
brute_force.py Python
scores = doc_embeddings @ query_embedding # normalized vectors: inner product = cosine
top = scores.argsort()[::-1][:10]
- Why exact? Every vector is considered, every candidate is scored. Ignoring numerical precision, the true top vectors cannot be missed by the search procedure.
- Complexity: for \(N\) document vectors of \(d\) dimensions, brute-force work grows as \(O(Nd)\) per query. Doubling the collection roughly doubles the vector comparison work.
- Small collection: \(N = 1{,}400\); Cranfield is trivial. Large collection: \(N = 100{,}000{,}000\); repeatedly scanning the whole collection becomes expensive: latency, throughput, memory bandwidth, compute cost.
Try it: the cost of brute force
One query against \(N\) stored vectors of dimension \(d\): \(N \cdot d\) multiply-adds, and the vectors must be in memory (4 bytes per number). The time assumes \(10^{10}\) multiply-adds per second, a rough order of magnitude for one fast CPU.
Memory calculation
1,000,000 vectors × 384 dimensions × 4 bytes (float32):
At 100 million vectors, raw storage becomes a major systems concern. Production systems reduce it with float16, quantization, compressed indexes and sharding across machines.
The search-engine analogy: lexical retrieval does not scan every document either; it builds an inverted index. Dense retrieval also needs data structures that avoid unnecessary comparisons; the index is different because the objects are continuous vectors rather than discrete terms.
First-half recap
Do we always need the mathematically exact nearest neighbors? Often, no. That leads to approximate nearest-neighbor search.
Approximate nearest-neighbor search
Find very close vectors without scanning the whole collection. The trade-off is explicit: faster search in exchange for possibly missed true neighbors.
| What is compared | Search accuracy | Cost | |
|---|---|---|---|
| Exact | every vector | maximum | higher |
| Approximate | only promising parts of the index | may miss some exact neighbors | much lower |
Two broad ideas
Many ANN methods can be understood through two high-level families: partition-based (divide the vector space into regions; for a query, identify the promising regions and search only the vectors in them) and graph-based (connect nearby vectors in a graph; start somewhere, move to a better neighbor, again, until you arrive near the query). Both in 2-D below, on 240 invented points (distance = Euclidean, so the picture is honest). No algorithmic derivation is needed.
Try it: partitions (the IVF idea)
Regions are k-means cells; the promising regions are those with the nearest centroids. Click the plot to move the query. Rings: the exact 5 nearest; filled: what the index returns.
Try it: a proximity graph (the HNSW idea)
Each point is connected to its nearest neighbors. Search starts at an entry point and greedily moves to the neighbor closest to the query until no neighbor is closer. Click the plot to move the query.
💡 With few edges, greedy search can stop at a local minimum: a point none of whose neighbors is closer, though it is not the nearest one. HNSW (Hierarchical Navigable Small World, Malkov & Yashunin) is a well-known graph-based approach: it avoids this with long-range links on sparse upper layers for quick navigation, and by keeping a list of several candidates (efSearch) instead of only the best one. More edges: better search, more memory.
- Product quantization (PQ) (Jégou, Douze & Schmid, 2011) is a compression technique rather than a search strategy: each vector is cut into sub-vectors and each sub-vector is replaced by the ID of its nearest centroid, so a 384-dimensional float32 vector (1,536 bytes) can be stored in a few dozen bytes and distances are estimated from the codes. It is usually combined with a partition index (FAISS
IndexIVFPQ). - ScaNN (Google; Guo et al., ICML 2020) combines partitioning with a quantization tuned for inner-product search, then rescores the best candidates exactly.
Do not confuse two kinds of recall
IR Recall@k (Lab 05)
Measures usefulness relative to relevance judgments.
ANN recall
Did approximate search recover the same nearest neighbors as exact vector search? \(E_k\): the exact top \(k\), \(A_k\): the ANN top \(k\). No human judgments involved.
Exact top 10 A B C D E F G H I J, ANN top 10 A B C D E F G H X Y: overlap 8 / 10, ANN recall@10 = 0.8. These are different metrics.
Two different failure sources
- Representation failure: the relevant item has a poor embedding score.
- Index failure: a relevant, high-scoring vector exists, but ANN search misses it.
- This distinction is important for debugging.
FAISS
FAISS (GitHub) is a library for efficient dense-vector similarity search. It supports both exact and approximate search: FAISS does not automatically imply approximation.
Exact flat index
faiss_flat.py Python
# pip install faiss-cpu
import faiss
import numpy as np
vectors = np.asarray(doc_embeddings, dtype="float32") # normalized, from Part I
d = vectors.shape[1]
flat = faiss.IndexFlatIP(d) # exact inner-product search
flat.add(vectors)
query_vector = model.encode([query], normalize_embeddings=True).astype("float32")
scores, ids = flat.search(query_vector, 10) # scores and document-vector positions
❗ IndexFlatIP still checks every vector. It is exact.
IVF: an approximate index
An inverted-file-style vector index partitions the vectors into coarse regions: all vectors → coarse clusters → the query searches only selected clusters (the partitions widget above).
faiss_ivf.py Python
nlist = 32
quantizer = faiss.IndexFlatIP(d) # compares the query with the 32 centroids
ivf = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT)
ivf.train(vectors) # k-means: learn the 32 regions
ivf.add(vectors) # put each vector in its region's list
ivf.nprobe = 4
scores, ids = ivf.search(query_vector, 10)
- nlist: the number of coarse partitions. nprobe: the number of partitions searched per query. More nprobe usually means higher ANN recall and higher query cost.
- ❗ Index training is not model training. Training the encoder learns text representations; training an IVF index learns a partitioning of the vector collection (here k-means for the 32 centroids). They solve different problems.
- HNSW is also available:
faiss.IndexHNSWFlat(d, 32, faiss.METRIC_INNER_PRODUCT), no training step (Part IX measures it).
What is a vector database?
A vector index and a vector database are related, but they are not the same thing.
Vector index
Primarily a data structure for efficient similarity search. It answers: given this query vector, which stored vectors are nearest?
Index ideas: flat exact search, IVF, HNSW, product quantization. FAISS provides vector-indexing and similarity-search algorithms.
Vector database
Wraps vector search inside a broader data-management system. It stores, per record: an ID, the embedding vector, metadata, the original text or a pointer to it.
Supports similarity search, metadata filtering, persistence, updates, deletion, index management, distributed storage.
What goes into a vector database
[−0.079, 0.008, 0.002, …]
\(n\) numbers (here 384) → vector database
+ ID, text, metadata
The vector database stores and searches these vectors; it does not care what they came from. But the embedding model must suit the data: all-MiniLM-L6-v2 reads text only; images need a multimodal model such as CLIP (Radford et al., 2021), which maps images and texts into one shared space, so that a text query can find a picture. Typical dimensions: 384, 768, 1,536.
record.py Python
{
"id": "chunk_0011",
"vector": [-0.079, 0.008, 0.002, ...], # its all-MiniLM-L6-v2 embedding, 384 numbers
"text": "The master's thesis is worth 30 ECTS credits.",
"metadata": {
"document": "programme_rules.pdf",
"page": 14,
"section": "Thesis"
}
}
See it: the vector database workflow, step by step
The twelve chunks of the widget below (invented texts and metadata, real all-MiniLM-L6-v2 vectors) going through the six steps of a vector database. Pick a query, then step through.
Why metadata matters
The query What is the vacation policy? against a collection that mixes HR documents, engineering manuals, legal contracts and student regulations. We may want semantic similarity + a metadata filter such as department = "HR", year = 2026, document_type = "policy":
This is extremely common in practical RAG systems.
Try it: filtered semantic search
Twelve chunks with invented metadata; the scores are real all-MiniLM-L6-v2 cosines. Choose a query and filters; the top \(k = 3\) are returned. Compare the two ways of combining the filter with the vector search.
💡 For How many vacation days do employees get? the outdated 2024 handbook scores 0.803 and the current one 0.804: the embedding cannot tell which year you need, a metadata filter can. With search, then filter (take the top 3, then drop what fails the filter), What is the vacation policy? filtered to the Student office returns nothing, although a matching chunk exists: the filter must be applied during the search, which is what vector databases do.
Vector database vs traditional database
| Aspect | Vector database | Traditional (relational) database |
|---|---|---|
| Data | embedding vectors, plus metadata and text | structured data: tables, rows, columns |
| Query | similarity search: the nearest neighbors of a query vector | exact equality, ranges, joins (SQL) |
| Main use | semantic search over unstructured content (text, images, audio) | structured records: accounts, orders, applications |
| Retrieval | usually approximate (ANN indexes) | exact lookups through B-tree and hash indexes |
| Transactions | support varies; often not the focus | strong ACID guarantees |
| Examples | Pinecone, Milvus, Weaviate, Qdrant, Chroma | PostgreSQL, MySQL, SQLite, SQL Server |
- The line is blurring: many real-world systems combine both (a vector database for semantic search, a relational database for structured data and transactions), and relational systems add vector search themselves (PostgreSQL's
pgvectorextension). A vector database does not necessarily replace relational storage. - Vector database vs FAISS: FAISS is a similarity-search library, a vector-index toolkit; a vector database is a persistent data system that may use ANN indexes internally, and adds storage, metadata, filtering, APIs, index lifecycle, replication / sharding.
Semantic search with a vector database
In a RAG system, those nearest chunks are what the language model gets to read.
- ❗ What the vector database does NOT do: create semantic meaning. It does not replace the embedding model; bad embeddings stored in an excellent vector database are still bad embeddings. Keep the layers separate: encoder → representation quality; vector index / database → storage + retrieval efficiency; reranker → final ordering quality.
- Common operations: insert / upsert vectors, search nearest neighbors, filter by metadata, delete stale vectors, update embeddings, persist the index, partition or shard the collection.
- When do we actually need one? For a teaching dataset such as Cranfield, NumPy + FAISS is enough. A vector database becomes useful with persistent collections, frequent updates, metadata filters, multiple users, large-scale serving, operational APIs, distributed storage. Do not introduce one merely because RAG tutorials use one: the underlying retrieval concepts come first.
Exact vs ANN on Cranfield
The Cranfield embeddings of Part I. Cranfield is small enough that ANN is unnecessary; that is pedagogically useful, because exact search can act as the reference.
exact_vs_ivf.py Python
import time
query_vectors = model.encode([q.text for q in queries[:20]],
normalize_embeddings=True).astype("float32")
exact_scores, exact_ids = flat.search(query_vectors, 10)
def ann_recall_at_k(exact_ids, approx_ids, k=10):
values = []
for exact, approx in zip(exact_ids, approx_ids):
values.append(len(set(exact[:k]) & set(approx[:k])) / k)
return sum(values) / len(values)
faiss.omp_set_num_threads(1) # time one CPU thread
def us_per_query(index, qv, reps=200):
t = time.perf_counter()
for _ in range(reps):
index.search(qv, 10)
return (time.perf_counter() - t) / reps / len(qv) * 1e6
print(f"flat {us_per_query(flat, query_vectors):.1f} µs")
for nprobe in [1, 2, 4, 8, 16, 32]:
ivf.nprobe = nprobe
_, ann_ids = ivf.search(query_vectors, 10)
print(f"{nprobe:>2} {ann_recall_at_k(exact_ids, ann_ids):.3f} {us_per_query(ivf, query_vectors):.1f} µs")
# flat 27.5 µs
# 1 0.530 1.8 µs nprobe, ANN recall@10 on 20 queries, time per query
# 2 0.685 3.1 µs
# 4 0.800 5.0 µs
# 8 0.925 9.1 µs
# 16 0.990 17.5 µs
# 32 1.000 33.4 µs
Measured on a laptop (Apple M-series), one thread; exact numbers depend on data and hardware. The pattern is the typical one: nprobe ↑ → more search work → ANN recall ↑ → latency ↑. And one more lesson: with all 32 lists (33.4 µs) the IVF index is slower than the flat index (27.5 µs). At 1,400 vectors an index is pure overhead.
Try it: nprobe on all 225 queries
The same FAISS index (nlist = 32), reproduced in your browser from its lists; checked to return exactly FAISS's results for every query. Move nprobe: the plot shows ANN recall@10 against the fraction of vectors compared, the table the relevance metrics against Cranfield's judgments.
ANN recall@10 nDCG@10 ÷ exact nDCG@10
See it: one query, its lists and its neighbors
The t-SNE map of Part I, each abstract colored by its IVF list. With the current nprobe, the probed lists are bright, the rest faded. Rings: the exact top 10; green: found by IVF; red: missed.
1Does lower ANN recall always reduce nDCG?
No. From nprobe 1 to 4 it does (nDCG@10 0.3006 → 0.3506), but at nprobe = 8 the index misses 3.2% of the exact top 10 (ANN recall 0.968) and still gets nDCG@10 0.3561, higher than exact search (0.3555). Recall@10 (0.4193 vs 0.4190) and MRR (0.5494 vs 0.5482) also go up.
2Can ANN miss an exact neighbor without changing the relevance metric? Why?
Yes. A missed neighbor that is not relevant costs nothing, and the document that replaces it may even be relevant. The exact neighbors are only the nearest vectors; nearest in vector space and relevant to the user are not identical concepts. ANN recall measures the index against exact search, IR metrics measure the whole system against people.
Three levels of quality
| Level | Question | Measure |
|---|---|---|
| 1. Representation quality | Are relevant documents close to the query in embedding space? | Recall@k, MRR, MAP, nDCG, against relevance judgments |
| 2. Index quality | Does ANN recover what exact vector search would have returned? | ANN recall@k |
| 3. Systems quality | How expensive is retrieval? | latency, queries per second, memory, index size, build time |
Diagnostic table
| Symptom | Likely layer |
|---|---|
| Exact dense search has poor nDCG | representation / retriever |
| Exact dense search good, ANN worse | vector index |
| Quality good, latency too high | systems / index settings |
| Retrieval good, final answer bad | downstream RAG / generation |
The recall–latency–memory trade-off
A retrieval system rarely optimizes one variable in isolation: more probes → higher recall → higher latency; more graph edges → potentially better search → more memory; compressed vectors → lower memory → possible quality loss. FAISS's HNSW on the same Cranfield vectors (32 edges per node, one thread):
faiss_hnsw.py Python
hnsw = faiss.IndexHNSWFlat(d, 32, faiss.METRIC_INNER_PRODUCT) # 32 edges per node
hnsw.add(vectors) # no training step; 42 ms here
all_queries = model.encode([q.text for q in queries], normalize_embeddings=True).astype("float32")
_, exact_all = flat.search(all_queries, 10) # all 225 queries
for ef in [16, 32, 64]:
hnsw.hnsw.efSearch = ef # candidates kept during the graph walk
_, ids = hnsw.search(all_queries, 10)
print(f"{ef:>2} {ann_recall_at_k(exact_all, ids):.4f} {us_per_query(hnsw, all_queries, reps=50):.1f} µs")
# 16 0.9924 10.8 µs efSearch, ANN recall@10 on 225 queries, time per query
# 32 0.9982 17.9 µs
# 64 0.9996 29.1 µs
The engineering question is not "which index is best?" but "which operating point satisfies our quality and resource requirements?" And vector-index design affects not only latency: it can change which evidence reaches the later RAG stages.
Summary: key takeaways
- A dense retriever has both a representation model and a search / index layer.
- Bi-encoders make retrieval efficient because document vectors can be precomputed.
- Exact search evaluates every vector; ANN avoids much of that work.
- ANN recall and IR Recall@k measure different things.
- A vector database adds persistence, metadata, filtering and operational data management around vector search.
- Vector-index design affects latency and can affect which evidence reaches later RAG stages.
Optional exercise
Using the Cranfield embeddings:
- build an exact
IndexFlatIP; - build an
IndexIVFFlat; - vary
nprobe; - measure ANN recall@10;
- measure the mean query latency;
- evaluate the retrieved documents with nDCG@10.
Compare vector-search fidelity with user-facing IR quality: they are related, but not identical. (exact_vs_ivf.py has steps 1–5; the nprobe widget does 4 and 6 for all 225 queries, and exercises 1–2 of Part VIII discuss the result.)