AI EngineeringZero to ProductionHome·About·Contact
Appendix · Advanced AI Engineering · Part 7

Vector Databases, Embeddings & Frameworks

RAG at scale is a data-infrastructure problem. This part covers how embeddings become searchable — from exact brute force (fine for thousands) to approximate-nearest-neighbor indexes and vector databases (needed for millions) — plus hybrid search and re-ranking. Then it demystifies the frameworks (LangChain, LlamaIndex): what they actually do, their architecture, and when to use one vs build direct.

⏱️ ~2 hours🎯 Intermediate → Expert🔎 retrieval at scalerunnable

Learning objectives

  • Explain what an embedding is and how similarity search uses it.
  • Know why exact search doesn't scale and what ANN indexes trade for speed.
  • Understand HNSW and IVF at a conceptual level (built on D4/D5 structures).
  • Compare vector DB options and use metadata filtering.
  • Combine dense + sparse retrieval (hybrid) with RRF and re-ranking.
  • Describe LangChain/LlamaIndex architecture and decide build-vs-framework.

The retrieval problem motivation

Give an agent knowledge and you need to answer, for any query, "which of my N documents are most relevant?" — fast, over possibly millions of items, ideally with metadata filters ("only this customer's docs, only PDFs from 2026"). That's what embeddings + a vector index + a database solve. The Ch 3 RAG build does the small-scale version by hand; this page is how it scales.

1 · Embeddings — meaning as a vector intermediate

An embedding model maps text to a fixed-length vector (e.g. 384–3072 floats) such that similar meanings land close together in that space (A4's cosine similarity measures "close"). Retrieval = embed the query, find the nearest document vectors.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — produce & compare embeddings
python# sentence-transformers runs a small embedding model locally:
#   from sentence_transformers import SentenceTransformer
#   model = SentenceTransformer("all-MiniLM-L6-v2")   # 384-dim
#   vecs = model.encode(["how to scale a deployment", "kubectl scale docs"])

import numpy as np
def cosine(a, b):
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

# stand-in vectors; real ones come from the model above
q  = np.random.rand(384).astype(np.float32)
d1 = np.random.rand(384).astype(np.float32)
print(round(cosine(q, d1), 3))    # similarity in [-1, 1]; higher = more related
▶ How this works

An embedding is a list of numbers (a vector) that captures the meaning of a piece of text. An embedding model turns "how to scale a deployment" into ~384 numbers; two texts that mean similar things get vectors that point in similar directions. This block measures how similar two vectors are.

  1. The commented lines at the top show the real thing: SentenceTransformer loads a small model that runs on your own machine and .encode([...]) turns each sentence into a 384-number vector. They are commented out so the demo runs with no install.
  2. cosine(a, b) computes cosine similarity: it takes the dot product a @ b and divides by each vector's length (np.linalg.norm). That cancels out size and leaves only direction — how aligned the two meanings are.
  3. q and d1 are stand-in random 384-number vectors so you can run the code today; in a real system they'd come from the model above.
  4. print(round(cosine(q, d1), 3)) shows the similarity score for the query vector q against document vector d1.

What the output means: A single number between -1 and 1. Higher = more related (1 = same direction, 0 = unrelated, -1 = opposite). Because these are random vectors, you'll get something near 0; real related sentences score much higher.

Try this: Make d1 = q (identical) and re-run — the score jumps to 1.0, the maximum. That is what "most similar" looks like.

Choosing an embedding modelTrade-offs: dimension (higher = more expressive but more memory/compute), domain (code vs prose vs multilingual), local vs API. Keep query and document embeddings from the same model, and normalize (A4) so cosine = dot product. Re-embedding the whole corpus when you switch models is the main migration cost.

2 · Exact search — and why it stops scaling intermediate

Exact nearest-neighbor is the A4 pattern: score the query against every vector, take top-k. It's simple and perfectly accurate — but O(N·d) per query. At thousands of docs, great. At tens of millions, every query scans gigabytes: too slow.

Try it — exact search (the baseline)
pythonimport numpy as np

def exact_topk(corpus, query, k=5):     # corpus: (N, d) normalized
    scores = corpus @ query                # (N,) — O(N*d)
    idx = np.argpartition(-scores, k)[:k]  # top-k, O(N) (D4 heap idea)
    return idx[np.argsort(-scores[idx])]

# Perfect recall, but O(N*d) every query -> fine to ~100k, painful at 10M+
▶ How this works

This is the simplest possible search: compare the query to every document and keep the best few. It is 100% accurate, and it's the baseline every faster method is measured against. The catch is speed once you have millions of documents.

  1. corpus @ query is one matrix multiply that scores the query against all N documents at once. Because the vectors are normalized, a higher score means more similar (this is the cosine idea from step 1).
  2. np.argpartition(-scores, k)[:k] grabs the top-k highest-scoring documents without fully sorting the rest — a fast "find the k best" trick (the heap idea from D4). The minus sign flips it so the biggest scores come first.
  3. idx[np.argsort(-scores[idx])] then sorts just those k winners so the very best is first, and returns their positions in the corpus.

What the output means: The function returns the indexes (positions) of the k most similar documents, best first. Retrieval is exact — you always get the true nearest neighbors.

Try this: Note the comment: this is O(N*d) per query — fine up to ~100k documents, but at 10M it scans gigabytes every time. That pain is exactly why the next sections exist.

3 · Approximate nearest neighbor (ANN) — trade recall for speed expert advanced

ANN indexes accept slightly imperfect results in exchange for massive speedups (sub-linear query time). Two dominant families — both built from structures you already know:

IndexIdeaBuilt onTrade-off
HNSWa multi-layer navigable graph; greedily hop toward the querygraphs (D5)fast + high recall; more memory
IVFcluster vectors; search only the nearest few clustersk-means + inverted liststunable recall via #clusters probed
PQ (product quant.)compress vectors into codesquantization (A4)huge memory savings; some accuracy loss
HNSW in one paragraphHierarchical Navigable Small World builds a layered graph: sparse long-range links up top, dense local links at the bottom. A search enters at the top, greedily walks to the closest node, drops a layer, and repeats — like zooming in on a map. Query time is roughly O(log N) instead of O(N). It's the default index in most vector DBs. This is the D5 graph traversal (greedy best-first) applied to similarity.
Try it — FAISS (Facebook AI Similarity Search)
python# pip install faiss-cpu
#   import faiss, numpy as np
#   d = 384
#   index = faiss.IndexHNSWFlat(d, 32)      # HNSW, 32 links/node
#   index.add(corpus)                        # corpus: (N, d) float32
#   scores, ids = index.search(query[None], k=5)   # ANN top-5 — sub-linear
#
# For exact search as a baseline: faiss.IndexFlatIP(d)  (inner product)
# Tune recall vs speed via HNSW efSearch / IVF nprobe.
print("FAISS: local, in-process ANN — great for embedding into an app")
▶ How this works

When exact search gets too slow, you use an ANN (approximate nearest neighbor) index: a clever data structure that finds almost the best matches far faster by not looking at every vector. FAISS is a popular library for this. The real calls are shown as comments; the runnable line just prints a summary.

  1. faiss.IndexHNSWFlat(d, 32) builds an HNSW index — a layered graph you hop through toward the query (like zooming in on a map), so query time is roughly O(log N) instead of scanning all N. The 32 is how many links each node keeps.
  2. index.add(corpus) loads your document vectors into the index (this is the one-time "build" step). index.search(query, k=5) then returns the top-5 approximate matches.
  3. The comments note you can tune the recall-vs-speed dial (HNSW's efSearch or IVF's nprobe): search harder for more-accurate results, or less for more speed.

What the output means: "Approximate" means you might occasionally miss a true top result, but you get answers dramatically faster. FAISS runs in-process (inside your app, no separate server), which is why it's great for embedding search directly into a program.

Try this: Compare mentally to section 2: exact search is always right but O(N); HNSW trades a tiny bit of accuracy for a huge speedup. That trade is the whole point of ANN.

4 · Vector databases advanced

A vector database wraps an ANN index with the things a real system needs: persistence, metadata filtering, CRUD/upserts, scaling/sharding, and often hybrid search. FAISS is a library (you manage storage); a vector DB is a service.

OptionShapeGood for
FAISSlibrary, in-processembedding in an app, full control, no server
Chromalightweight, local/embeddedprototypes, small-to-mid, dev ergonomics
Qdrant / Weaviate / Milvusself-hosted serviceproduction scale, filtering, on-prem
Pineconemanaged cloudno-ops scale, pay per usage
pgvectorPostgres extensionalready on Postgres; keep vectors with your data
Try it — Chroma with metadata filtering
python# pip install chromadb
#   import chromadb
#   client = chromadb.Client()
#   col = client.create_collection("runbooks")
#   col.add(
#       ids=["r1", "r2"],
#       documents=["how to scale prod", "rollback a bad deploy"],
#       metadatas=[{"env": "prod"}, {"env": "prod"}],   # <-- filterable
#   )
#   res = col.query(query_texts=["scale up"], n_results=3,
#                   where={"env": "prod"})   # metadata filter + vector search
print("Chroma auto-embeds docs and combines vector search with metadata filters")
▶ How this works

A vector database is an ANN index plus everything a real system needs: it stores your data on disk, lets you add/update/delete, and — crucially — lets you filter by metadata (tags like which customer, environment, or date). Chroma is a beginner-friendly one. The real calls are commented; the last line prints a summary.

  1. client.create_collection("runbooks") makes a named bucket to hold documents (like a table). col.add(...) inserts them — notice you pass plain documents=[...] text and Chroma embeds them for you automatically.
  2. Each document also gets metadatas=[{"env": "prod"}, ...] — extra labels attached to the vector. These are the fields you can later filter on.
  3. col.query(query_texts=["scale up"], n_results=3, where={"env": "prod"}) does the search: it finds the nearest vectors and keeps only those whose env is "prod". The where clause is the metadata filter.

What the output means: You get back the top matching documents that also satisfy the filter — combining meaning-based search with hard scoping rules in one call.

Try this: Read the tip below the code: a lot of "bad retrieval" is really "searched the wrong scope." Filtering by tenant/source/date often helps more than a fancier index.

Metadata filtering is underratedHalf of "bad retrieval" is really "retrieved from the wrong scope." Filtering by tenant, source, date, or doc-type before (or during) the vector search is often more impactful than a fancier index. Design your metadata schema deliberately — it's as important as the embeddings.

5 · Hybrid search & re-ranking expert expert

Dense (embedding) search captures meaning but can miss exact keywords, IDs, and rare terms — which sparse lexical search (BM25) nails. Hybrid search runs both and fuses the rankings (commonly with Reciprocal Rank Fusion). A re-ranker (a cross-encoder) then re-scores the top candidates for precision.

Try it — Reciprocal Rank Fusion
pythondef rrf(rankings, k=60):
    """Fuse several ranked ID lists into one. rankings: list of [id, id, ...]."""
    scores = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking):
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
    return sorted(scores, key=scores.get, reverse=True)   # fused order

dense = ["d3", "d1", "d7"]        # from vector search
sparse = ["d1", "d9", "d3"]       # from BM25
print(rrf([dense, sparse]))       # d1 & d3 rise (they appear in both)
# Then rerank the top ~20 with a cross-encoder for final precision.
▶ How this works

Meaning-based (dense) search and keyword-based (sparse/BM25) search each miss different things. Hybrid search runs both and merges the two ranked lists into one. Reciprocal Rank Fusion (RRF) is a simple, robust way to merge: a document scores higher when it ranks near the top of either list, and highest when it ranks well in both.

  1. For each ranked list, enumerate(ranking) gives every document its position (rank 0 = first). RRF adds 1 / (k + rank + 1) to that document's score — top items get a big boost, lower items get a smaller one. The constant k=60 just softens the difference between adjacent ranks.
  2. scores.get(doc_id, 0) + ... accumulates across lists, so a document that appears in several lists collects points from each one.
  3. sorted(scores, key=scores.get, reverse=True) returns the document IDs ordered by total fused score, best first.
  4. dense comes from vector search and sparse from BM25 keyword search — two different opinions about relevance that RRF blends.

What the output means: The printed order puts d1 and d3 near the top because they appear in both lists — agreement across methods is a strong signal of relevance.

Try this: Notice the final comment: after fusing, you'd re-rank the top ~20 with a cross-encoder (a slower, more accurate model) for the final order. Fuse broadly, then sharpen the top.

🔗 Used in the courseThis is exactly the hybrid-search + RRF + re-ranking pipeline described in Ch 3 and the starter's llmkit. Here you see how it scales: swap the in-memory scan for an ANN index / vector DB, keep the same fusion and re-rank stages on top.

6 · LangChain vs LlamaIndex — what they are intermediate

Both are Python frameworks that provide pre-built pieces so you don't wire everything by hand. They overlap, with different centers of gravity:

LangChainLlamaIndex
Focusgeneral orchestration: chains, agents, tools, memorydata/RAG: ingestion, indexing, retrieval
Strengthgluing many components & providers; agent workflowsdocument pipelines, advanced retrievers/indexes
Core abstractionsRunnables/LCEL, tools, agents, memoryDocuments, Nodes, Indexes, Retrievers, Query Engines
Use whenyou want composable multi-step/agent plumbingRAG over lots of heterogeneous documents is the core

7 · How the frameworks are built advanced

Under the hood they're the abstractions this course already taught you, formalized. LlamaIndex: Document → split into Nodes (chunks, A5) → embed → Index (vector/keyword/graph) → RetrieverQuery Engine (retrieve + synthesize). LangChain: composable Runnables piped with | (LCEL), Tools (A1's registry pattern), Agents (the Ch 4 loop), and Memory (the message buffer).

The shape (illustrative)
python# LlamaIndex — RAG in a few lines:
#   from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
#   docs = SimpleDirectoryReader("runbooks/").load_data()
#   index = VectorStoreIndex.from_documents(docs)   # chunk+embed+index
#   answer = index.as_query_engine().query("how do we roll back?")
#
# LangChain LCEL — compose steps with | (each is a "Runnable"):
#   chain = prompt | model | output_parser
#   chain.invoke({"question": "..."})
#
# Both wrap: chunking (A5) + embeddings + vector index (this page) +
# the agent loop (Ch 4) + structured output (A6). No magic — just glue.
print("Frameworks = the course's concepts, pre-assembled")
▶ How this works

Frameworks like LlamaIndex and LangChain are pre-built toolkits that bundle the steps you've seen (chunk → embed → index → retrieve → prompt) so you write far less glue code. This block shows the typical shape of each — as comments, because the point is to read the pattern, not run it.

  1. LlamaIndex: SimpleDirectoryReader("runbooks/").load_data() loads your files, VectorStoreIndex.from_documents(docs) does chunk + embed + index in one call, and .as_query_engine().query("...") retrieves relevant chunks and asks the model — a whole RAG pipeline in three lines.
  2. LangChain (LCEL): prompt | model | output_parser pipes steps left-to-right with the | symbol. Each piece is a "Runnable"; the data flows through them like a Unix pipeline. chain.invoke({...}) runs it.
  3. The closing comments make the key point: both frameworks wrap the same primitives this course builds by hand — chunking, embeddings, the vector index, the agent loop, structured output.

What the output means: "Frameworks = the course's concepts, pre-assembled." There's no magic; they just name and package the building blocks you already understand.

Try this: Map each framework call back to a concept: from_documents = chunk+embed+index; as_query_engine = retrieve+prompt. If you can name the primitive, you can debug the framework.

8 · Build direct vs use a framework advanced

The honest guidanceBuild direct (SDK + your own code, as this course does) when you want to understand the system, need tight control over prompts/latency/cost, or the app is focused — you'll ship less magic and debug faster. Use a framework when you need breadth quickly (many loaders, many vector stores, many providers), want battle-tested retrievers/parsers, or your team already uses one. Many teams start with a framework to prototype, then drop to direct SDK for the hot paths. Either way, this track's fundamentals — chunking, embeddings, indexes, the loop, structured output, resiliency — are what you're really using; the framework just names them.
A pragmatic ruleIf you can't explain what a framework call does in terms of the primitives (embed → index → retrieve → prompt → parse), you'll struggle to debug it in production. That's the whole point of building RAG and the agent by hand in Ch 3/Ch 4 first: the framework becomes a convenience, not a black box.

Exercises expert

Practice
  1. Benchmark exact search vs FAISS HNSW on 100k random vectors; measure query time and recall@10.
  2. Add metadata filtering to a small Chroma collection and confirm out-of-scope docs are excluded.
  3. Implement BM25 (or use rank_bm25), then fuse it with dense results via your rrf and compare to each alone.
  4. Sketch (in comments) the same RAG pipeline three ways: hand-built (Ch 3), LlamaIndex, LangChain — and note what each hides.
  5. Write a decision note: for a 5M-doc, multi-tenant knowledge base, which index + DB would you pick and why?

🎯 Interview practice interview

The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.

System design: RAG over 10M documents

Talk the two paths and trade-offs; interviewers grade the reasoning, not code.

python# OFFLINE:  docs -> chunk (overlap) -> embed -> ANN index (HNSW/IVF) + metadata
# ONLINE:   query -> embed -> retrieve top-k -> rerank -> prompt w/ context -> cite
# SCALE:    shard the index; cache hot queries; hybrid dense+BM25 (RRF)
# FAILURE:  stale index, retrieval miss -> hallucination; measure recall@k + answer evals
▶ How this works

This isn't runnable code — it's an interview answer sketch in comments. For "design RAG over 10M documents," interviewers grade how you split the system into an offline (prep) stage and an online (per-query) stage, and how you handle scale and failure.

  1. OFFLINE (done ahead of time): take documents, split them into overlapping chunks, embed each chunk into a vector, and load them into an ANN index (HNSW/IVF) alongside metadata. This is the expensive build you do once and reuse.
  2. ONLINE (per user query): embed the query, retrieve the top-k nearest chunks, re-rank them, stuff them into the prompt as context, and cite the sources in the answer.
  3. SCALE: shard (split) the index across machines when it's too big for one, cache answers to popular queries, and combine dense + BM25 with RRF (the hybrid idea from section 5).
  4. FAILURE: a stale index or a missed retrieval leads to hallucination — so you measure recall@k (did we fetch the right docs?) and run answer-quality evals.

Try this: In an interview, narrate these four lines out loud. Separating offline-build from online-serving, and naming a failure mode with how you'd measure it, is what earns the marks.

Cosine top-k retrieval (the core operation)

Normalize, score all docs with one matmul, take the k best.

pythonimport numpy as np
def top_k(corpus, query, k=5):
    c = corpus / np.linalg.norm(corpus, axis=1, keepdims=True)
    q = query / np.linalg.norm(query)
    scores = c @ q
    idx = np.argpartition(-scores, k)[:k]
    return idx[np.argsort(-scores[idx])]
▶ How this works

This is the single operation at the heart of every retrieval system, written cleanly for an interview: given a corpus of document vectors and one query vector, return the k most similar documents. It folds the normalization step directly into the function.

  1. c = corpus / np.linalg.norm(corpus, axis=1, keepdims=True) normalizes every document vector to length 1. axis=1 means "per row (per document)"; keepdims=True keeps the shape so the division lines up. After this, cosine similarity is just a dot product.
  2. q = query / np.linalg.norm(query) normalizes the query the same way.
  3. scores = c @ q is one matrix multiply that scores all documents against the query at once — the whole search in a single line.
  4. np.argpartition(-scores, k)[:k] pulls the top-k, then argsort orders just those k so the best is first (same top-k pattern as section 2).

What the output means: Returns the positions of the k most similar documents, best first — the exact-search core that an ANN index or vector DB optimizes when N gets huge.

Try this: Be ready to state the cost: O(N*d) for the matmul. If asked to scale it, that's your cue to bring up HNSW/IVF and vector databases.

Checkpoint expert

  • Explain embeddings + similarity search and why exact search stops scaling.
  • Describe HNSW/IVF/PQ trade-offs and connect them to graph/quantization concepts.
  • Choose a vector DB and use metadata filtering; build hybrid search with RRF + rerank.
  • Describe LangChain/LlamaIndex architecture and make an informed build-vs-framework call.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Cosine similarity from scratchBeginner

Context: Embeddings turn meaning into vectors, and similarity is the angle between them. Cosine similarity is the measure every vector search is built on because it scores direction, not length — magnitude-invariant semantic closeness.

Your task: Implement cosine(a, b) in pure Python and use it to rank three documents against a query vector.

Requirements:

  • Compute dot product over the product of the two norms — stdlib only, no numpy
  • Return 0.0 rather than dividing by zero when a vector is all zeros
  • Rank the docs best-first and show an orthogonal vector lands last
  • Score is unaffected by scaling a vector's length (direction only)
  • Print the ranking and one rounded similarity value to sanity-check

💡 Hint: sum(x*y for x, y in zip(a, b)) gives the dot product; divide by math.sqrt of each vector's sum-of-squares.

Show solution

Cosine = dot product over the product of norms — pure stdlib:

import math

def cosine(a, b):
    dot  = sum(x*y for x, y in zip(a, b))
    na   = math.sqrt(sum(x*x for x in a))
    nb   = math.sqrt(sum(y*y for y in b))
    return dot / (na * nb) if na and nb else 0.0

query = [1.0, 0.0, 1.0]
docs  = {"d1": [1.0, 0.0, 0.9], "d2": [0.0, 1.0, 0.0], "d3": [0.5, 0.1, 0.6]}
ranked = sorted(docs, key=lambda k: cosine(query, docs[k]), reverse=True)
print(ranked)                          # ['d1', 'd3', 'd2']
print(round(cosine(query, docs["d1"]), 3))   # 0.986

Cosine ignores vector length and measures direction, so it scores semantic closeness regardless of magnitude. d1 points almost the same way as the query; d2 is orthogonal (unrelated), so it ranks last.

Exercise 2 · Exact top-k search — and why it stops scalingIntermediate

Context: Exact search compares the query to every stored vector — correct, but its cost is linear in corpus size. Understanding that wall is why approximate-nearest-neighbor indexes like HNSW and IVF exist.

Your task: Implement topk(query, corpus, k) as a brute-force full scan and reason explicitly about its cost as the corpus grows.

Requirements:

  • Score every vector in the corpus against the query with cosine
  • Return the k highest-scoring ids, best-first
  • Handle k larger than the corpus without crashing
  • State the per-query cost as O(N·dim) and where it becomes unusable
  • Note that this linear scan is exactly what ANN indexes trade recall to avoid

💡 Hint: Score into a list of (id, similarity) pairs, sort descending, and slice the first k.

Show solution

Brute-force top-k is a full scan — correct, but linear in corpus size:

import math

def cosine(a, b):
    dot = sum(x*y for x, y in zip(a, b))
    na, nb = math.sqrt(sum(x*x for x in a)), math.sqrt(sum(y*y for y in b))
    return dot / (na*nb) if na and nb else 0.0

def topk(query, corpus, k=2):
    scored = [(cid, cosine(query, v)) for cid, v in corpus.items()]
    return sorted(scored, key=lambda t: t[1], reverse=True)[:k]

corpus = {f"d{i}": [i%3, (i+1)%3, (i+2)%3] for i in range(6)}
print(topk([1,0,1], corpus, k=2))

# cost: O(N * dim) per query. At N=10M vectors this is ~10M dot products
# EVERY query -> too slow. That's the wall exact search hits.

Exact search is O(N·dim) per query: fine at thousands of vectors, unusable at tens of millions. That linear cost is exactly why approximate nearest-neighbor (ANN) indexes like HNSW/IVF exist — trade a little recall for a huge speedup.

Exercise 3 · ANN recall — trade accuracy for speed, then measure itAdvanced

Context: ANN indexes return approximate neighbors: fast because they scan a shortlist instead of the whole corpus, but they can miss true neighbors. You should never adopt an ANN config without measuring the recall you traded away.

Your task: Model a cheap approximate retriever alongside an exact one, then compute recall@k of the approximate results against the exact answer.

Requirements:

  • An exact retriever returns the true top-k over the full corpus
  • An approximate retriever scans only a subset (a stand-in for an index shortlist)
  • recall@k = fraction of the exact top-k that the approximate set recovers
  • Show that a bigger shortlist raises recall at the cost of a slower query
  • Print exact vs approximate top-k and the resulting recall percentage

💡 Hint: Recall is a set overlap: len(truth & got) / len(truth) where both are sets of ids from the two retrievers at the same k.

Show solution

Model exact vs approximate, then measure the gap with recall@k:

import math
def cosine(a,b):
    d=sum(x*y for x,y in zip(a,b)); na=math.sqrt(sum(x*x for x in a)); nb=math.sqrt(sum(y*y for y in b))
    return d/(na*nb) if na and nb else 0.0

corpus = {f"d{i}": [(i*7)%5, (i*3)%5, (i)%5] for i in range(12)}
q = [2,1,0]

def exact_topk(k):
    return [c for c,_ in sorted(corpus.items(), key=lambda t: cosine(q,t[1]), reverse=True)[:k]]

def ann_topk(k, probe=6):                       # only scan a subset of candidates
    subset = list(corpus.items())[:probe]       # stand-in for an index's shortlist
    return [c for c,_ in sorted(subset, key=lambda t: cosine(q,t[1]), reverse=True)[:k]]

def recall_at_k(k):
    truth = set(exact_topk(k)); got = set(ann_topk(k))
    return len(truth & got) / len(truth)

print("exact:", exact_topk(3))
print("ann:  ", ann_topk(3))
print(f"recall@3: {recall_at_k(3):.0%}")

ANN scans a shortlist instead of the whole corpus, so it's fast but can miss true neighbors. recall@k turns that tradeoff into a number you can tune (bigger shortlist → higher recall, slower query). Never adopt an ANN config without measuring its recall.

Exercise 4 · Hybrid search + reciprocal rank fusionExpert

Context: Dense (meaning) and sparse (keyword/BM25) retrievers fail in different ways, so fusing them beats either alone — especially on exact-term queries dense search misses. Reciprocal Rank Fusion combines them using ranks only, with no score calibration between the two systems.

Your task: Implement rrf(rankings, k=60) that fuses two ranked lists of doc ids into one and show a doc winning by appearing in both.

Requirements:

  • Score each id as Σ 1/(k + rank) across the lists it appears in
  • Use rank position (state 0- or 1-based), never the retrievers' raw scores
  • An id present in only one list still contributes its term
  • Return ids sorted by fused score, highest first
  • Construct dense and sparse lists so a consensus doc fuses to the top

💡 Hint: Enumerate each ranking for positions and accumulate into a dict keyed by id; the constant k (≈60) damps how much the very top ranks dominate.

Show solution

RRF fuses by rank alone — no score calibration between retrievers:

def rrf(rankings, k=60):
    scores = {}
    for ranked in rankings:
        for rank, doc in enumerate(ranked):     # 0-based rank
            scores[doc] = scores.get(doc, 0.0) + 1.0 / (k + rank)
    return sorted(scores, key=lambda d: scores[d], reverse=True)

dense  = ["d2", "d1", "d5"]   # by semantic similarity
sparse = ["d5", "d3", "d2"]   # by keyword/BM25 match
print(rrf([dense, sparse]))
# d2 first: ranks high in dense AND appears in sparse -> fused to the top

A doc that either ranks high in one list or appears in both floats up under RRF, and it needs no score normalization between the two retrievers — only ranks. That robustness is why hybrid dense+sparse+RRF beats either retriever alone, especially on exact-term queries dense search misses.

Exercise 5 · Re-rank the wide recall set with a cross-encoderProfessional

Context: The production retrieval pattern is retrieve-wide then re-rank-narrow: a cheap first stage over-rewards surface cues like term repetition, so a richer re-ranker that reads query and chunk together promotes the doc that actually answers.

Your task: Model a cheap first-stage retriever (keyword frequency) and a richer cross-encoder re-ranker (query-term coverage), and show the top result change after re-ranking.

Requirements:

  • First stage ranks by a cheap signal that a repetitive doc can game
  • The re-ranker scores by how much of the query the chunk actually covers
  • Re-rank only the wide first-stage set, not the whole corpus
  • Include a doc that spams a keyword and one that genuinely answers
  • Print both orderings and show the genuine answer promoted after re-ranking

💡 Hint: Coverage as len(query_terms & doc_terms) / len(query_terms) is enough to model a cross-encoder; sort the first-stage list by that score.

Show solution

Wide recall, then a precise re-rank — both modeled offline:

def first_stage(query, corpus):                 # cheap: raw keyword frequency
    q = query.lower().split()
    scored = [(d, sum(t.lower().split().count(w) for w in q)) for d,t in corpus]
    return [d for d,_ in sorted(scored, key=lambda x:x[1], reverse=True)]

def cross_encoder(query, text):                 # richer: fraction of query terms covered
    q, t = set(query.lower().split()), set(text.lower().split())
    return len(q & t) / len(q)

corpus = [
    ("d1", "refund refund refund refund policy"),      # spams 'refund', misses 'window'
    ("d2", "how to enable two factor authentication"),
    ("d3", "the refund window is five business days"),  # actually answers the query
]
wide = first_stage("refund window", corpus)
texts = dict(corpus)
reranked = sorted(wide, key=lambda d: cross_encoder("refund window", texts[d]), reverse=True)
print("first-stage:", wide)      # d1 inflated by repetition
print("reranked   :", reranked)  # d3 promoted — covers both query terms

The cheap first stage over-rewards repetition (d1), so you retrieve wide for recall and then let a cross-encoder that reads query+chunk together promote the doc that actually covers the query (d3). Two stages: fast breadth, then precise top-k.

Exercise 6 · Build direct vs use a framework (LangChain / LlamaIndex)Industry scenario

Context: Frameworks like LangChain and LlamaIndex buy speed-to-ship on standard pipelines, but add an abstraction tax and some lock-in. Deciding when to adopt one vs hand-roll the retriever is a real engineering call you'll defend on the job.

Your task: Write a choose(...) selector that recommends build-direct vs framework from control needs, pipeline standardness, team size, and lock-in tolerance.

Requirements:

  • Return BUILD-DIRECT when you need fine control and want to avoid lock-in
  • Return FRAMEWORK for a standard pipeline with a small team (LlamaIndex for doc-QA, LangChain for chains/agents)
  • Recommend prototyping on a framework then peeling back hot paths when sensible
  • Return BUILD-DIRECT for a non-standard pipeline a framework would fight
  • Show at least two input combinations producing different recommendations

💡 Hint: This is pure branching logic — encode the tradeoff the lesson draws; there's no single right answer, only a defensible mapping from inputs to advice.

Show solution

Encode the build-vs-framework tradeoff the lesson draws (pure logic):

def choose(need_fine_control, standard_pipeline, team_small, care_about_lockin):
    if need_fine_control and care_about_lockin:
        return "BUILD DIRECT — you own the retriever/index/rerank; no abstraction tax"
    if standard_pipeline and team_small:
        return "FRAMEWORK — LlamaIndex for doc-QA/ingestion; LangChain for chains/agents"
    if standard_pipeline:
        return "FRAMEWORK to prototype, then peel back hot paths you need to control"
    return "BUILD DIRECT — non-standard pipeline; a framework fights you"

print(choose(False, True,  True,  False))   # FRAMEWORK
print(choose(True,  False, False, True))    # BUILD DIRECT

Frameworks buy speed-to-ship on standard pipelines (LlamaIndex leans doc-ingestion/QA, LangChain leans chains/agents) at the cost of an abstraction layer and some lock-in. Build direct when you need fine control over chunking/index/rerank or the pipeline is non-standard — and keep the retriever + prompts framework-agnostic so you can switch.

Knowledge check check yourself

✓ Knowledge check

Exact nearest-neighbor search is perfectly accurate, yet the lesson says ANN indexes like HNSW are needed at scale. What exactly do you trade, and how does HNSW achieve its speedup?

Show answer
You trade a little recall (occasionally missing a true nearest neighbor) for roughly O(log N) instead of O(N·d) query time; HNSW is a layered navigable graph you enter at the top and greedily hop toward the query, dropping layers — graph best-first traversal applied to similarity.
✓ Knowledge check

The lesson's "pragmatic rule" says if you can't explain a framework call in terms of embed -> index -> retrieve -> prompt -> parse, you'll struggle in production. Why does it recommend building RAG by hand first?

Show answer
Frameworks just pre-assemble the same primitives; understanding those primitives means the framework becomes a convenience you can debug, rather than a black box you can't reason about when retrieval quality, latency, or cost goes wrong.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in