Lab 10

Dense Retrieval, Semantic Search, Vector Indexes and Vector Databases

DPR Exact search ANN and FAISS Vector databases
Work in progress These notes are still being written and reviewed: content may change, and some parts may still contain mistakes.

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?

documents → encoder → document vectors → vector index  |  query → encoder → query vector → nearest-neighbor search → top-k documents

It deliberately separates representation quality from search / index quality: they are different problems.

  1. From embeddings to retrieval, and dense retrieval on Cranfield.
  2. Bi-encoders and Dense Passage Retrieval: passages, training, hard negatives.
  3. Similarity functions: cosine, dot product, Euclidean distance.
  4. Exact nearest-neighbor search: cost and memory at scale.
  5. Approximate nearest-neighbor search: partitions and graphs, two kinds of recall.
  6. FAISS: exact and approximate indexes.
  7. What is a vector database? Metadata and filtering.
  8. Exact vs ANN on Cranfield.
  9. 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.
Part I

From embeddings to retrieval

From Labs 08 and 09:

text → tokenizer → Transformer encoder → contextual token vectors → pooling → text embedding

Now suppose every document has a vector. Retrieval becomes: find the document vectors that are closest to the query vector.

\[ \operatorname{top-k}_{d \in D}\; sim\big(E_q(q), E_d(d)\big) \]
WorkWhen
Offlinedocuments → document encoder → document vectors → store / indexonce
Onlinequery → query encoder → query vector → search the index → top-k documentsfor 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)

Part II

Bi-encoders and Dense Passage Retrieval

A bi-encoder uses independent representations for the query and the passage, then a vector similarity:

\[ \vec{q} = E_q(q) \qquad \vec{p} = E_p(p) \qquad score(q, p) = \vec{q}^{\top}\vec{p} \]

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

\[ score(q, p^+) > score(q, p^-) \]

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.

Part III

Similarity functions

Common choices: cosine similarity, dot product, Euclidean distance.

📐 Three similarity functions
\[ \cos(q, d) = \frac{q \cdot d}{\|q\|\,\|d\|} \qquad score(q, d) = q^\top d \qquad \|q - d\|_2 \ \text{(smaller = closer)} \]

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.

MeasureFormulaRangeMore 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.)

Part IV

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):

\[ 1{,}000{,}000 \times 384 \times 4 \approx 1.5\ \text{GB} \]

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

good encoder → good vectors   but   many vectors → brute-force search expensive → need a vector index

Do we always need the mathematically exact nearest neighbors? Often, no. That leads to approximate nearest-neighbor search.

Part V

Approximate nearest-neighbor search

📐 Approximate nearest-neighbor (ANN) 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 comparedSearch accuracyCost
Exactevery vectormaximumhigher
Approximateonly promising parts of the indexmay miss some exact neighborsmuch 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.

12
1

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.

3

💡 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)

\[ Recall@k = \frac{\text{relevant items retrieved}}{\text{all relevant items}} \]

Measures usefulness relative to relevance judgments.

ANN recall

\[ \text{ANN recall}@k = \frac{|E_k \cap A_k|}{k} \]

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.
Part VI

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).
Part VII

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

text: "The master's thesis is worth 30 ECTS credits."an image
→ embedding model → vector
[−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":

\[ \text{similarity}(q, d) \quad \text{subject to metadata constraints} \]

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

AspectVector databaseTraditional (relational) database
Dataembedding vectors, plus metadata and textstructured data: tables, rows, columns
Querysimilarity search: the nearest neighbors of a query vectorexact equality, ranges, joins (SQL)
Main usesemantic search over unstructured content (text, images, audio)structured records: accounts, orders, applications
Retrievalusually approximate (ANN indexes)exact lookups through B-tree and hash indexes
Transactionssupport varies; often not the focusstrong ACID guarantees
ExamplesPinecone, Milvus, Weaviate, Qdrant, ChromaPostgreSQL, 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 pgvector extension). 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

documents → encoder → embeddings → vector database  |  query → encoder → query embedding (+ metadata conditions) → filtered similarity search → nearest chunks

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.
Part VIII

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.

2

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.

Part IX

Three levels of quality

LevelQuestionMeasure
1. Representation qualityAre relevant documents close to the query in embedding space?Recall@k, MRR, MAP, nDCG, against relevance judgments
2. Index qualityDoes ANN recover what exact vector search would have returned?ANN recall@k
3. Systems qualityHow expensive is retrieval?latency, queries per second, memory, index size, build time

Diagnostic table

SymptomLikely layer
Exact dense search has poor nDCGrepresentation / retriever
Exact dense search good, ANN worsevector index
Quality good, latency too highsystems / index settings
Retrieval good, final answer baddownstream 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

  1. A dense retriever has both a representation model and a search / index layer.
  2. Bi-encoders make retrieval efficient because document vectors can be precomputed.
  3. Exact search evaluates every vector; ANN avoids much of that work.
  4. ANN recall and IR Recall@k measure different things.
  5. A vector database adds persistence, metadata, filtering and operational data management around vector search.
  6. Vector-index design affects latency and can affect which evidence reaches later RAG stages.

Optional exercise

Using the Cranfield embeddings:

  1. build an exact IndexFlatIP;
  2. build an IndexIVFFlat;
  3. vary nprobe;
  4. measure ANN recall@10;
  5. measure the mean query latency;
  6. 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.)