AI EngineeringZero to ProductionHome·About·What’s new·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

Your RAG demo dazzled everyone on 200 documents. Then someone loaded the real corpus — 8 million support tickets — and now every question takes nine seconds, half the answers cite the wrong customer, and the bill for re-scanning everything on each query keeps climbing. Nothing about your logic broke. What broke is that "compare the question to every document" is a promise you can only keep at small scale. This whole page is about keeping that promise when the pile gets huge.

Here's the shape of the problem in one sentence: for any incoming query, you must answer "which of my N documents are most relevant?" — and you must answer it fast, over possibly millions of items, ideally while respecting hard filters ("only this customer's docs, only PDFs from 2026"). Think of it like a library. On a tiny personal shelf you just read every spine. In a city library you cannot — you need a catalogue, an index, and a way to narrow to the right room before you start scanning titles. Retrieval at scale is that catalogue, built for meaning instead of alphabetical order.

The common mistake is treating this as a single knob ("get a vector database and it'll be fast"). It isn't — it's a stack of decisions, each covered below: how you turn text into vectors (embeddings), how you find near ones without scanning them all (ANN indexes), what infrastructure holds and filters them (vector DBs), how you combine meaning-search with keyword-search (hybrid + re-ranking), and whether to wire it yourself or lean on a framework. The Ch 3 RAG build does the small-scale version by hand so the pieces are familiar; this page is how each one scales — and, just as important, when you actually need the heavier machinery versus when a plain array scan is still the right, boring answer.

"get a vector database" is not one knob — it is a stack of decisions embeddings text → vectors ANN index near, not all vector DB hold + filter hybrid +rerank meaning + literal framework? build vs use each stage is a separate section below — and sometimes the answer is "a plain scan is fine" small corpus → skip the machinery;brute-force is correct + cheap Retrieval at scale is a stack, not a switch. "Which of my N documents are most relevant, fast?" decomposes into five sequenced decisions — embeddings, ANN index, vector DB, hybrid+rerank, framework-or-not — and at small scale the honest answer is often to skip most of them.
🗺️ How to read this diagram

Read the top strip left to right: it is the same five decisions this page covers, laid out in the order data flows through them.

  • Each box is one decision — turn text into embeddings, find near vectors with an ANN index, hold and filter them in a vector DB, blend meaning and keyword search with hybrid + rerank, and finally decide build vs framework.
  • The blue arrows show they are sequential, not interchangeable: each stage feeds the next, which is why "just add a vector database" skips four other choices.
  • The green bar at the bottom is the payoff caveat: for a small corpus you deliberately skip most of the stack — a plain brute-force scan is already correct and cheap.

In short: retrieval at scale is a pipeline of five decisions; know each one, and know when your corpus is small enough to skip nearly all of them.

1 · Embeddings — meaning as a vector intermediate

Computers can't compare meanings — they can only compare numbers. So how does a machine know that "how do I cancel my subscription?" and "steps to end my plan" are asking the same thing, when they share almost no words? A keyword search sees two unrelated strings. The trick that makes modern retrieval work is to first translate meaning itself into numbers, so that "close in meaning" becomes "close in space" — something arithmetic can actually measure.

That translation is an embedding. Picture a giant map where every possible sentence gets a pin. Sentences about refunds cluster in one region; sentences about passwords cluster in another; the two clusters sit far apart. An embedding model is the cartographer: you hand it text and it hands back that pin's coordinates — a fixed-length list of floats (typically 384 to 3072 of them). Two texts that mean similar things get pinned near each other, even if they used entirely different words. Retrieval then becomes almost embarrassingly simple: embed the query to get its pin, and find the document pins nearest to it.

"Nearest" needs a definition, and here's the one that matters: we measure the angle between two vectors (cosine similarity, from A4), not the straight-line distance. Why the angle? Because it captures direction — "what is this about" — while ignoring magnitude, which for text often just reflects length or word-count quirks you don't care about. Two vectors pointing the same way score near 1 (same meaning); at right angles, near 0 (unrelated); opposite, near -1.

The mistake that quietly poisons a retrieval system: embedding your queries with one model and your documents with another, or swapping models without re-embedding the whole corpus. The coordinates only mean anything relative to the same map — mix two cartographers and every distance you compute is garbage, yet nothing crashes, so the bug hides as "retrieval just feels a bit off." Keep query and document embeddings from the identical model, and normalize them so cosine reduces to a plain dot product.

same model maps every text to a point — close in meaning becomes close in space text "end my plan" embedding model the cartographer floats vector space (the map) refund cluster password cluster query Meaning becomes geometry. One embedding model turns any text into a point; texts that mean similar things land near each other, so retrieval reduces to "find the pins nearest the query" — which is why query and documents must share the same model.
🗺️ How to read this diagram

Read left to right: raw text enters the model, comes out as coordinates, and lands as a point inside the big box on the right — the vector space "map".

  • The blue box is a piece of text; the purple box is the embedding model (the "cartographer") that converts it into a list of floats. The blue arrows are that translation.
  • Inside the map, the blue dots are refund-related sentences and the grey dots are password-related ones — semantically similar texts form clusters, and the two clusters sit far apart.
  • The amber ringed dot is the query. The payoff: it lands inside the refund cluster because that's what it means — so the nearest neighbours are the right documents, found by smallest angle, not by shared words.

In short: the model places meaning on a map; retrieval is just finding the closest pins to the query pin — which only works if every pin came from the same map.

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

Before you reach for anything clever, know that the dumb way is often the right way. The simplest possible search is: compare the query to every single document, keep the best few. No index, no approximation, no infrastructure. It is trivially correct, it never misses a true match, and — this is the part people skip past — for most projects it is entirely good enough. The instinct to grab a vector database on day one is the most common over-engineering mistake in this whole topic.

This brute-force approach is called exact nearest-neighbor search, and it's the exact A4 pattern: score the query against every vector, take the top-k. Think of it like finding the closest star to Earth by measuring the distance to all of them and sorting — flawless, and fine when there are a few thousand stars. The reason it eventually breaks is pure arithmetic: it costs O(N·d) per query, meaning the work grows in lockstep with both the number of documents N and the vector dimension d. Double the corpus, double the time. That's a straight line, and lines look harmless until N gets large.

Here's where the line bites. At ten thousand documents, an exact scan is a blink. At ten million 768-dimension vectors, a single query touches billions of floating-point numbers — that's gigabytes read from memory on every query, and now you're at hundreds of milliseconds per lookup and a compute bill that scales with your traffic. The failure is gradual and sneaky: it works in the demo, works in staging, and only melts once real data and real query volume arrive together. The honest rule of thumb — good to roughly a hundred thousand vectors you can live with exact search; past that, the sections below (ANN indexes, then vector databases) exist precisely to break that linear wall by not looking at every vector.

per-query cost as the corpus grows — exact search is a straight line corpus size N → query cost → ~100k exact O(N·d) ANN ~O(log N) painful zone: gigabytes per query Exact search grows in a straight line. Cost is O(N·d), so doubling the corpus doubles the query time — fine to ~100k vectors, then it climbs into a zone where every query reads gigabytes. ANN indexes (green) stay near-flat by not scanning everything.
🗺️ How to read this diagram

Read it like a graph: the horizontal axis is how many documents you have (N), the vertical axis is how long one query takes. Follow each line left to right as the corpus grows.

  • The red line is exact search: perfectly straight, because cost is O(N·d) — every extra document adds the same fixed work, so twice the data means twice the time.
  • The dashed mark near ~100k and the shaded painful zone to its right show where that straight line becomes too expensive to serve: hundreds of milliseconds and gigabytes read per query.
  • The green curve is an ANN index (next section): it bends almost flat because it's roughly O(log N) — it deliberately avoids looking at every vector, so growth barely raises the cost.

In short: exact search is a line you can't escape; once N crosses the painful zone you switch to the flat green curve — that's the whole reason ANN and vector DBs exist.

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

Here's a bargain that sounds too good, and mostly isn't. What if you gave up being perfectly right — accepting that once in a while you'd miss the true best match — and in exchange your search got a hundred or a thousand times faster? For retrieval, that's an easy trade, because the query embedding is already a fuzzy approximation of intent, the top-10 results are all "pretty relevant" anyway, and a language model reading them rarely cares whether the genuinely-8th-best chunk sneaks in at rank 9. You were never chasing a provably optimal answer — so paying for one is waste.

That bargain is approximate nearest neighbor (ANN) search: indexes that find almost the best matches in sub-linear time — they stop scanning every vector. The key concept to hold onto is recall: the fraction of the true nearest neighbors an approximate search actually recovers. Recall is a dial, not a fixed cost — you can turn it toward "more accurate but slower" or "faster but sloppier," and the entire craft of running ANN is choosing where that dial sits and measuring what you gave up. The cardinal sin here is adopting an ANN config without ever measuring its recall against exact search; "it's fast now" tells you nothing about whether it quietly started returning junk.

The beautiful part, for a data-structures course, is that ANN indexes aren't magic — they're the tree and graph structures you already met, pointed at similarity. The two dominant families below both work by the same instinct a human uses: don't check everything, narrow to a promising neighborhood first. HNSW does it with a navigable graph you hop across; IVF does it by pre-clustering vectors and only searching the nearest clusters; product quantization shrinks each vector so more of them fit in memory. Each row of the table is a different answer to "how do I avoid looking at all N vectors?"

HNSW: enter at the top, greedily hop toward the query, drop a layer — like zooming in L2 · sparse L1 · denser L0 · dense enter nearest ✓ Narrow first, don't scan everything. HNSW is a layered graph: sparse long-range links up top, dense local links at the bottom. The search enters at the top, greedily hops toward the query, and drops a layer each time — reaching the nearest node in roughly O(log N) instead of touching all N.
🗺️ How to read this diagram

Read top to bottom and follow the blue arrows — that's the path a single query takes through the graph, one layer at a time.

  • Each horizontal row is a layer: the top (L2) is sparse with a few long-range links, the bottom (L0) is dense with many short local links. The dots are stored vectors; dashed lines are the links between them.
  • The search enters at the top-left node and greedily hops (blue arrows) to whichever neighbour is closest to the query, then drops down a layer to search more finely — the "zooming in on a map" idea.
  • The amber-ringed node at the bottom is the nearest match it lands on. Because the top layers let it leap across huge distances, it never had to visit most of the dots — that's the sub-linear speedup.

In short: coarse long jumps up top, fine steps at the bottom — greedy best-first graph traversal (D5) applied to similarity, so you find the neighbour without scanning the whole corpus.

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

An ANN index alone is like an engine with no car around it. FAISS will find you the nearest vectors blazingly fast — but where do those vectors live when your process restarts? How do you delete a document a customer asked you to forget? How do you say "search only this tenant's data"? How do you add a thousand new docs at 2am without rebuilding everything? A raw index answers none of those. In real systems, "find nearest vectors" is maybe 20% of the job; the other 80% is the plumbing around it.

A vector database is that plumbing, wrapped around an ANN index so you get a running service instead of a bare data structure. It adds the things a production system can't live without: persistence (vectors survive a restart), metadata filtering (attach tags like tenant/date/source and restrict search to a scope), CRUD and upserts (add, update, delete individual records without a full rebuild), and scaling and sharding (spread the index across machines when it outgrows one). The clean mental split: FAISS is a library — you call it in-process and you own storage, lifecycle, and ops; a vector DB is a service — it owns all of that for you, at the cost of running (or paying for) another system.

The mistake to sidestep is picking the heaviest option reflexively. There's a real spectrum, laid out in the table below — from a library you embed in one app, through a lightweight embedded store for prototypes, to self-hosted and fully-managed services for production scale, plus the "keep vectors next to your existing relational data" option (pgvector). The right choice falls out of honest questions: How many vectors? Do you need filtering and multi-tenancy? Are you willing to run a server, or do you want someone else to? Choosing a distributed cluster for a 50k-document internal tool is as much an error as bolting brute-force onto 50 million records.

a vector DB = the ANN index plus the plumbing a bare index lacks bare ANN index e.g. FAISS · a library find nearest ≈ 20% of the job wrap it vector database — a service nearest search the same index inside persistence survives restart metadata filter tenant · date · source CRUD · scale upsert · shard Finding nearest vectors is only ~20% of the job. A bare index (FAISS, a library you run in-process) does just that; a vector DB is a running service that wraps the same index with persistence, metadata filtering, CRUD/upserts, and scaling — the 80% a production system can't live without.
🗺️ How to read this diagram

Read left to right: the small blue box on the left is a bare index; the arrow "wraps it" into the large green box on the right, which is the full database.

  • The blue box is a bare ANN index like FAISS — a library you call in-process. All it offers is the white "find nearest" capability, labelled as only about 20% of what a real system needs.
  • The green box is a vector database: a running service. Inside it, the white box is the same nearest-search index — the DB doesn't replace it, it surrounds it.
  • The three amber boxes are the production plumbing the bare index lacks: persistence (vectors survive a restart), metadata filtering (restrict search to a tenant/date/source), and CRUD & scaling (upsert individual records, shard across machines). That extra 80% is what you pay for by running or renting a service.

In short: a library gives you the search; a vector DB gives you the search plus the persistence, filtering, and lifecycle a production system depends on.

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

Ask an embedding search for error code "SG-4471" and it may shrug. Semantic search is brilliant at meaning and terrible at literals — exact IDs, product SKUs, function names, rare surnames, that one acronym. It smooths everything into "close in meaning," which is exactly wrong when the user needs that precise token and nothing near it. Meanwhile old-fashioned keyword search nails the literal but has no idea that "cancel my plan" relates to "end subscription." Each is strong precisely where the other is weak — so the professional move is to stop choosing.

Hybrid search runs both at once: dense (embedding) retrieval for meaning and sparse lexical retrieval — classically BM25, which scores by term frequency — for exact matches. Now you have two ranked lists that disagree, and here's the trap: their scores aren't comparable. A cosine of 0.82 and a BM25 score of 14.3 live on different planets; averaging them is meaningless. The fix, Reciprocal Rank Fusion (RRF), sidesteps the whole problem by throwing the raw scores away and fusing on rank position alone — a document earns points for finishing near the top of either list, and the biggest points for finishing high in both. Agreement across two different methods is a strong relevance signal, and RRF needs zero calibration to exploit it.

One more stage sharpens the result. The retrieval so far is fast but coarse — it's good at recall (fetching a wide net that probably contains the right answers) and only okay at precision (putting the single best one first). So the pattern is retrieve wide, then re-rank narrow: pull the top ~20 candidates cheaply, then hand just those to a re-ranker — a cross-encoder that reads the query and each candidate together (not as separate pre-computed vectors) and scores true relevance far more accurately. It's too slow to run over millions, which is the whole point: you only ever run it on the shortlist. Fuse broadly to catch everything, then re-rank to sharpen the top.

query fans out to two retrievers → RRF fuses by rank → re-rank narrows the shortlist query embed once dense · vectors ranked by meaning sparse · BM25 ranked by literals RRF fuse by rank, wide re-rank top ~20 → best cheap + broad → recall sharp → precision Two retrievers in, one sharp list out. Dense catches meaning and sparse catches literals; RRF fuses their ranks (no score calibration) into a wide recall set, then a cross-encoder re-ranks just the shortlist for final precision.
🗺️ How to read this diagram

Follow the blue arrows left to right: the query on the left splits into two paths, they rejoin at RRF in the middle, and the flow ends at the re-ranker on the right.

  • The query box fans out to two retrievers: the upper white box is dense (embedding) search that scores by meaning; the lower is sparse BM25 that scores by exact keyword match. Each returns its own ranked list.
  • Both arrows converge on the green RRF box: it fuses the two lists using only rank position, so a document rewarded by either — and especially by both — rises. The label notes this stage is cheap and broad, optimizing recall.
  • The amber re-rank box is the payoff: it takes just the top ~20 fused candidates and runs a slower cross-encoder over them to sharpen precision. Because it only sees the shortlist, its cost stays affordable.

In short: fan out to catch both meaning and literals, fuse by rank to get a wide net, then re-rank the shortlist to put the truly-best answer first.

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

You've now hand-built every piece: embed, index, retrieve, fuse, re-rank. That's a lot of glue code — connectors for every file type, adapters for every vector store, plumbing for every model provider. Rewriting all of it for each project is tedious, and this is the itch frameworks scratch. LangChain and LlamaIndex are Python libraries that ship those pieces pre-assembled, so a RAG pipeline that took you a page of code becomes three lines. The catch — and the reason this section is worth reading carefully — is that they don't add new ideas; they package the ones you already own, and packaging always hides something.

Think of them like two kitchen setups. LlamaIndex is the specialist's data kitchen: its whole gravity is ingestion and retrieval — loading heterogeneous documents, chunking them, building indexes, and querying them well. LangChain is the generalist's orchestration kitchen: chains, agents, tools, and memory glued together, spanning many providers and multi-step workflows. They overlap heavily (both can do RAG; both can call tools), so the useful question is never "which is better" but "which one's center of gravity matches my problem." The table below lines up their focus, strengths, and core abstractions so you can match a project to the right tool rather than defaulting to whichever you heard of first.

both can do RAG — the question is which one's center of gravity fits your problem LlamaIndex the data / RAG kitchen ingestion · chunking indexing · retrieval LangChain the orchestration kitchen chains · agents · tools memory · workflows overlap RAG · tools Same ingredients, different center of gravity. LlamaIndex's gravity is data — ingestion, indexing, retrieval; LangChain's is orchestration — chains, agents, tools, memory. They overlap heavily (both do RAG and call tools), so the useful question is not "which is better" but "which center of gravity matches my problem."
🗺️ How to read this diagram

Read it as two panels side by side, with a shared middle: the left panel is LlamaIndex, the right is LangChain, and the amber box between them is what they both do.

  • The left panel is LlamaIndex — its two inner boxes (ingestion/chunking, indexing/retrieval) show a center of gravity in data and RAG.
  • The right panel is LangChain — its inner boxes (chains/agents/tools, memory/workflows) show a center of gravity in orchestration.
  • The amber overlap box in the middle is the payoff caveat: both can do RAG and call tools, so they are not mutually exclusive — you pick by which side's gravity your problem lives on, not by picking a winner.

In short: LlamaIndex leans data/retrieval, LangChain leans orchestration, and they overlap — match the tool to where your problem's weight sits.

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

A framework feels like magic right up until it breaks — and then it feels like a wall. Retrieval quality drops, or latency spikes, or the bill triples, and if the framework is a black box to you, you're stuck poking at config flags hoping one helps. The cure is to look under the hood once, and discover there's no engine you haven't seen. Every framework abstraction is just a formalized version of a primitive this course already built by hand. Learn the mapping and the black box turns into a labelled diagram you can debug.

Take LlamaIndex first, because its vocabulary is almost a mirror of the RAG pipeline. A Document is your raw source; it gets split into Nodes (the chunks from A5); each Node is embedded and loaded into an Index (vector, keyword, or graph — the structures from this page and D4/D5); a Retriever pulls the relevant Nodes; and a Query Engine wraps "retrieve + synthesize into an answer." Read that chain again: it is exactly embed → index → retrieve → prompt, just with proper nouns. Nothing new, only named.

LangChain organizes the same reality around composition. Its core idea is the Runnable — any step with an .invoke() — and its LCEL syntax pipes them with | like a Unix shell (prompt | model | parser), so data flows left to right through stages you can swap. Around that sit Tools (the registry pattern from A1), Agents (the reason-act loop you built in Ch 4), and Memory (the message buffer that keeps conversation state). So when you can name which primitive a call maps to — "this is the retriever," "this is the chunker" — you can reason about where a failure lives instead of guessing, which is the entire payoff of having built these by hand first.

LlamaIndex's proper nouns are the RAG primitives you already built — renamed framework name what you built by hand Document Nodes Index Retriever Query Engine raw source chunks (A5) embed +index retrieve retrieve +prompt No new ideas — only new names. Each LlamaIndex abstraction (purple, top) sits directly above the hand-built RAG primitive it wraps (blue, bottom): Document → source, Nodes → chunks, Index → embed+index, Retriever → retrieve, Query Engine → retrieve+prompt.
🗺️ How to read this diagram

Read it as two rows: the top row is LlamaIndex's vocabulary, the bottom row is the plain concept each one maps to. The dashed arrows connect each name straight down to its meaning.

  • The purple boxes across the top are the framework's proper nouns, in pipeline order left to right: Document → Nodes → Index → Retriever → Query Engine.
  • Each dashed arrow drops to the blue box directly below — the primitive this course built by hand: a Document is just a raw source, Nodes are the chunks from A5, an Index is embed+index, a Retriever does retrieval.
  • The rightmost pair is the payoff: the Query Engine sits above the green box "retrieve + prompt" — it's simply the last two RAG steps bundled together. Read the top row alone and it's exactly embed → index → retrieve → prompt.

In short: the framework's names line up one-to-one with primitives you already own — so when it breaks, you can point to the exact stage instead of guessing.

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

This is the decision that starts more team arguments than any other in RAG work — and both camps are usually arguing past each other. "Frameworks are bloated abstraction soup." "Hand-rolling everything is reinventing wheels." The reason it turns into a religious war is that people treat it as a permanent identity choice rather than what it actually is: a per-project engineering trade-off with honest costs on both sides, and often a sequence rather than a single pick.

The trade is straightforward once you name it. A framework buys you breadth and speed-to-ship — dozens of document loaders, vector-store adapters, and provider integrations already written and battle-tested — at the cost of an abstraction tax: indirection to debug, opinions you may not share, and some lock-in. Building direct (SDK plus your own code, the way this course does) buys you control and transparency — you see and tune every prompt, every millisecond of latency, every dollar of cost — at the cost of writing and maintaining the plumbing yourself. Neither is virtuous; they're suited to different situations.

So resist the two mirror-image mistakes: adopting a heavy framework for a small focused app you could hand-build in an afternoon, and hand-rolling connectors for ten data sources when a framework already ships all ten. A common, sane path is to prototype on a framework to move fast, then drop to the direct SDK on the hot paths where control actually pays off. The callouts below give the honest decision criteria — and, more importantly, the reminder that whichever you pick, the fundamentals from this whole track (chunking, embeddings, indexes, the loop, structured output, resiliency) are what you're really running; the framework just labels them.

neither is virtuous — a per-project trade, and often a sequence use a framework + breadth: many loaders/stores + fast to ship − abstraction tax · lock-in build direct (SDK) + control: prompts/latency/cost + transparent to debug − you maintain the plumbing common path prototype on a framework →drop to the direct SDK on the hot paths A trade with honest costs on both sides. A framework buys breadth and speed-to-ship but charges an abstraction tax and some lock-in; building direct buys control and transparency but you maintain the plumbing. The sane default is often a sequence — prototype on a framework, then drop to the direct SDK where control pays off.
🗺️ How to read this diagram

Read the two top boxes as the two options, then the bottom bar as the path that borrows from both.

  • The left green box is "use a framework": the plus lines are its wins (breadth of loaders/stores, speed-to-ship); the amber line is its cost (abstraction tax and lock-in).
  • The right blue box is "build direct": its wins are control over prompts/latency/cost and easy debugging; its amber cost is that you maintain all the plumbing yourself.
  • The amber bar at the bottom, fed by arrows from both options, is the payoff: the common sane path is not one-or-the-other but a sequence — prototype fast on a framework, then peel back to the direct SDK on the hot paths where control matters.

In short: framework = breadth + speed at the cost of a tax; direct = control at the cost of maintenance — and prototyping on one before dropping to the other often beats picking a side.

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