Week 22: Retrieval-Augmented Generation & Embeddings

Fine-tuning (Week 19-20) bakes knowledge into weights; prompting (Week 21) works with whatever fits in the context window. Neither lets a model reliably answer from your private, changing documents. RAG solves that: retrieve the relevant pieces of your own data at query time, and hand them to the model as context — grounding its answer in something real instead of its pretrained memory alone.

Phase 6 of 8 Week 22 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain how embeddings and cosine similarity power semantic search
  • Chunk documents and store them in a vector database for retrieval
  • Build a full RAG pipeline and check whether its answers are actually grounded

1. Embeddings & Vector Similarity

An embedding model converts a piece of text into a fixed-length vector such that semantically similar text ends up nearby in that vector space — a sentence about "returning a damaged product" lands close to "refund for a broken item," even though they share almost no exact words.

embeddings.py
from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer("all-MiniLM-L6-v2")

query = "How do I get a refund for a broken item?"
doc = "To return a damaged product for a refund, visit the returns portal within 30 days."

query_vec = model.encode(query)
doc_vec = model.encode(doc)

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))   # exactly Week 2's formula

similarity = cosine_similarity(query_vec, doc_vec)

This is precisely Week 2's cosine similarity, applied to embeddings instead of the small hand-built word-count vectors from that lesson's exercise. RAG's entire retrieval step is built on this one idea: embed a query, embed a collection of documents, and rank documents by how close their vectors are to the query's — a real-world direct application of Week 2's dot-product-and-norm formula.

2. Chunking Strategies

A whole document is usually too long to embed as one meaningful vector (its meaning gets diluted across many topics), and too long to fit in a context window (Week 17) alongside a question and an answer. Chunking splits documents into smaller pieces before embedding and storing them.

chunking.py
def chunk_text(text, chunk_size=500, overlap=50):
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        start += chunk_size - overlap   # overlap keeps context from being cut mid-thought
    return chunks

Chunk size is a real tradeoff: too small, and a chunk loses surrounding context needed to make sense of it in isolation; too large, and its embedding blurs together multiple distinct ideas, hurting retrieval precision (Section 4). Overlap between consecutive chunks helps avoid awkwardly severing a sentence or idea exactly at a chunk boundary. More sophisticated approaches chunk along natural document structure (paragraphs, sections, headers) rather than a fixed character count — generally preferable when the document's own structure is meaningful, at the cost of more complex preprocessing.

3. Vector Databases & Approximate Nearest-Neighbor Search

Computing cosine similarity between a query and every single chunk works fine for a thousand chunks, but doesn't scale to millions — exactly the same kind of computational wall Week 15 hit with attention's quadratic cost. A vector database stores embeddings alongside an index structure built for fast approximate nearest-neighbor (ANN) search, trading a small amount of retrieval accuracy for dramatically faster lookups at scale.

vector_db_sketch.py
import chromadb

client = chromadb.Client()
collection = client.create_collection("support_docs")

collection.add(
    embeddings=[doc_vec_1, doc_vec_2, doc_vec_3],
    documents=[chunk_1, chunk_2, chunk_3],
    ids=["doc1", "doc2", "doc3"],
)

results = collection.query(query_embeddings=[query_vec], n_results=3)   # top-3 nearest chunks

"Approximate" is the key word: rather than guaranteeing the mathematically exact nearest neighbors, ANN indexes (like HNSW, a common one under the hood) find very likely nearest neighbors far faster than an exhaustive search — a deliberate, usually worthwhile accuracy/speed tradeoff, directly reminiscent of Week 9's tradeoff between a simpler and a more accurate model when speed matters.

4. Retrieval Quality: Recall vs. Precision Tradeoffs

Retrieval has its own version of Week 6's precision/recall tension: retrieving more chunks (a higher n_results) increases the chance the truly relevant information is somewhere in there (recall), but also increases the chance of including irrelevant or distracting chunks that push up the token count (Week 17's context budget) and can confuse the model's final answer (precision).

the RAG generation step
retrieved_chunks = collection.query(query_embeddings=[query_vec], n_results=5)["documents"][0]

context = "\n\n".join(retrieved_chunks)
prompt = f"""
Answer the question using ONLY the context below. If the answer isn't in the context, say so.

Context:
{context}

Question: {query}
"""

"Answer using ONLY the context" is doing real work in that prompt — without it, the model may fall back on its pretrained knowledge (Week 17) even when the retrieved context doesn't actually contain the answer, defeating the entire purpose of grounding the response in your own data. Tuning n_results, chunk size, and this instruction together is the practical work of building a good RAG system.

5. Evaluating a RAG System's Answers for Groundedness

A RAG answer can fail in two distinct ways, and telling them apart matters: the retrieval step might fail to find the relevant chunk at all, or retrieval might succeed while the model still hallucinates — generating an answer not actually supported by the retrieved context, despite the instruction from Section 4.

a simple groundedness check
# Ask a second LLM call to verify: is the answer actually supported by the retrieved context?
verification_prompt = f"""
Context: {context}
Answer: {generated_answer}

Does the answer above rely ONLY on information present in the context? Reply YES or NO, with a brief reason.
"""

This kind of check — separately verifying retrieval quality ("was the right chunk found?") and generation groundedness ("did the model actually use it correctly?") — is the foundation Week 24 builds into a proper evaluation suite, including hallucination detection as a first-class metric rather than an afterthought.

6. Hands-on Exercise

Hands-on

Build an end-to-end RAG pipeline over a small document set

Assemble a small knowledge base (5–10 short documents on a topic of your choice — product docs, a FAQ, or article excerpts all work).

Requirements:

  1. Chunk your documents (Section 2), embed each chunk (Section 1), and store them in a vector database (Section 3).
  2. Write a retrieval function that embeds a query and returns the top-k most similar chunks.
  3. Write a generation step that answers a question using only the retrieved context, explicitly instructing the model not to use outside knowledge (Section 4).
  4. Ask 5 questions clearly answerable from your documents, and 2 questions clearly not covered by them — confirm the model correctly says it doesn't know for the latter.
  5. Implement Section 5's groundedness check as a second LLM call, and run it against all 7 answers — does it catch any answer that wasn't actually well-supported by the retrieved context?
Hint

If the model answers an "unanswerable" question anyway, check your retrieved chunks first — a weak query embedding can retrieve something even when nothing relevant exists, and the model may then (incorrectly) try to answer from that irrelevant context rather than declining.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why can two pieces of text with almost no words in common still have highly similar embeddings?

Embedding models are trained to place text with similar meaning close together in vector space, not similar surface wording — a well-trained embedding model captures semantic similarity (synonyms, paraphrases, related concepts) rather than relying on exact word overlap the way a simple keyword search would.

Q2

What's the tradeoff involved in choosing a smaller vs. larger chunk size?

Smaller chunks risk losing surrounding context needed to interpret them correctly in isolation. Larger chunks blur multiple distinct ideas into one embedding, which can hurt how precisely retrieval can pinpoint the specific relevant piece of information — there's no universally correct size, only a tradeoff to tune for a given document type.

Q3

What does "approximate" mean in approximate nearest-neighbor search, and why accept it?

ANN search finds very likely nearest neighbors rather than guaranteeing the mathematically exact closest vectors, in exchange for dramatically faster search over large collections. It's an accuracy/speed tradeoff accepted because an exhaustive exact search becomes computationally infeasible once the number of stored vectors grows into the millions.

Q4

A RAG system gives a wrong answer. How would you determine whether the problem was retrieval or generation?

Inspect the retrieved chunks: if the actually-relevant information wasn't retrieved at all, it's a retrieval failure (chunking, embedding, or query-matching issue). If the relevant information was retrieved but the generated answer doesn't match or use it correctly, it's a generation/groundedness failure — a hallucination despite having the right context available, which a groundedness check like Section 5's is specifically designed to catch.