Area 02 // RAGLesson 46 / 14030 min read
Capstone: build a small RAG app from scratch
You build a complete, testable RAG app (ingestion, hybrid retrieval, grounded answers with citations, an HTTP API, tests and an eval gate) that answers questions about a set of company policies, and that later lessons containerise and deploy.
Think of it like this
Analogy
Building a first RAG app is like setting up a small library with a helpful librarian:
- Shelve the books (ingest): every policy goes on the shelf, split into labelled sections.
- Learn the catalogue (retrieve): a librarian who can find sections by exact words and by meaning.
- Answer at the desk (generate): read the right sections and answer, pointing at the page.
- Open the doors (API): people can ask from anywhere.
- Inspect the library (tests and evals): every week, check the librarian still finds the right sections.
The problem it solves
You've learned each RAG piece separately. Real value comes from wiring them into something that runs, can be tested, can be measured and can be deployed. Many tutorials stop at a notebook that calls an API with a key and prints an answer, which you can't test in CI, can't measure, and can't safely change.
This capstone builds a small app with the shape of a production system, so the Deployment area can containerise it and put it behind a CI/CD pipeline with an eval gate.
How it works, step by step
The full project lives in the course repository at examples/rag-app/. It answers employees' questions about four policy documents (leave, expenses, IT security, remote work).
- Project layout:
rag_app/
ingest.py # markdown -> chunks with stable IDs and "Doc > Section" titles
retrieve.py # hybrid search: BM25 + vector-style search, fused with RRF
generate.py # grounded, cited answer (mock | anthropic | openai | ollama)
app.py # FastAPI: GET /health, POST /ask
data/docs/ # the policy documents (markdown)
tests/ # fast tests, no API keys
evals/ # golden.jsonl + run_eval.py (the eval gate)
requirements.txt, requirements-dev.txt- Ingest (parsing, chunking): read every markdown file, make one chunk per
##section, and give it a stable ID (leave#2) plus a title (Leave policy > Carrying over leave) that's indexed with the text. - Retrieve (hybrid search): BM25 over words, plus a vector-style index (TF-IDF here so it runs anywhere; swap in real embeddings later), merged with reciprocal rank fusion. Return the top 3 chunks.
- Generate (grounding, citations): number the chunks as sources, tell the model to answer only from them, cite
[n]after every sentence, and say "I don't know" otherwise.LLM_PROVIDERchooses Claude, OpenAI, a local Ollama model, or an offline mock that quotes the best source. - Check citations: every
[n]must point at a real source; the API returnscitations_valid. - Serve (FastAPI, Pydantic):
POST /askvalidates the question (3–500 characters) and returns the answer, sources and the citation check;GET /healthreports status, chunk count and version. - Test: six fast tests cover chunking, retrieval, the API and citation checking, with no keys.
- Evaluate (retrieval metrics, golden datasets):
evals/golden.jsonlholds 12 real questions with the chunk IDs that answer them.run_eval.pycomputes Recall@3, MRR and citation validity, and exits with code 1 if any falls below its threshold, ready to become a CI eval gate.
- data/docs/*.md to ingest.py
- ingest.py to retrieve.py
- Client to app.py (FastAPI)
- app.py (FastAPI) to retrieve.py
- retrieve.py to generate.py
- generate.py to app.py (FastAPI)
- retrieve.py to evals/run_eval.py
- generate.py to evals/run_eval.py
See it in code
- Install
pip install -r examples/rag-app/requirements-dev.txt- Needs
- Python 3.11+. No API key: the default mock answers offline. Set LLM_PROVIDER and a key to use a real model.
The heart of the app: the API endpoint that ties retrieval, generation and the citation check together (from rag_app/app.py).
from fastapi import FastAPI
from pydantic import BaseModel, Field
from .generate import cited_ids, generate
from .ingest import load_corpus
from .retrieve import Retriever
CHUNKS = load_corpus(DOCS) # ingest once at start-up (fine for a small corpus)
RETRIEVER = Retriever(CHUNKS)
app = FastAPI(title="Policy RAG", version=VERSION)
class Question(BaseModel):
question: str = Field(min_length=3, max_length=500) # validated before we spend tokens
@app.get("/health")
def health() -> dict:
return {"status": "ok", "chunks": len(CHUNKS), "version": VERSION}
@app.post("/ask", response_model=Answer)
def ask(q: Question) -> Answer:
chunks = RETRIEVER.search(q.question, k=3) # hybrid retrieval
text = generate(q.question, chunks) # grounded + cited answer
_, valid = cited_ids(text, len(chunks)) # every [n] must be a real source
sources = [Source(n=i, id=c.id, title=c.title) for i, c in enumerate(chunks, start=1)]
return Answer(answer=text, sources=sources, citations_valid=valid)Run the tests, the eval gate and the server:
cd examples/rag-app
pytest -q
python -m evals.run_eval
uvicorn rag_app.app:app --reload # API docs at http://127.0.0.1:8000/docs...... [100%]
6 passed in 2.32s
PASS recall@3 1.00 (threshold 0.90)
PASS mrr 0.96 (threshold 0.75)
PASS citations_valid 1.00 (threshold 1.00)
12 questions, 12 chunks
{
"answer": "Report a lost or stolen laptop or phone to the IT helpdesk within 1 hour, by calling extension 4357. [1]",
"sources": [
{"n": 1, "id": "it-security#2", "title": "IT security rules > Lost or stolen devices"},
{"n": 2, "id": "it-security#3", "title": "IT security rules > Using AI tools"},
{"n": 3, "id": "expenses#1", "title": "Expenses policy > What you can claim"}
],
"citations_valid": true
}With LLM_PROVIDER=anthropic (or openai, ollama), the same endpoint returns a written answer from a real model, still grounded in the three sources and cited.
A real example
Example
From capstone to a real internal tool
A five-person startup used almost exactly this shape for its first internal assistant. They swapped TF-IDF for an embedding model, the markdown loader for a Notion export, and the mock for a small, cheap model, without touching the API or tests. The golden set grew from 12 to 120 questions taken from what people actually asked in Slack.
When someone later "improved" the chunking, the eval gate failed with Recall@3 dropping from 0.91 to 0.78, and the change never reached production. That one red CI run paid for all the setup.
The easy way
Start here
- Copy
examples/rag-app, replacedata/docswith your own markdown files. - Write 10 questions in
evals/golden.jsonlwith the chunk IDs that answer them. - Run
pytestandpython -m evals.run_evaluntil both pass. - Set
LLM_PROVIDER=ollama(free) or your provider, and try real answers.
When you grow: the production setup
The production version adds a real embedding model and a vector database (pgvector), an ingestion job with change detection, a reranker, query rewriting, permission filters, streaming answers (SSE), tracing (Langfuse), LLM-judged faithfulness evals, and the Docker image, CI/CD pipeline, health checks and rollback from the Deployment capstone.
Trade-offs
What you gain
- Every stage is small, testable and swappable behind its own module.
- Runs offline by default, so tests and CI are fast, free and reliable.
- Measured from day one: retrieval and citation quality are numbers, not feelings.
What it costs you
- TF-IDF misses paraphrases that real embeddings would catch.
- Ingesting at start-up doesn't scale to large or changing corpora.
- The mock generator only quotes; real answer quality needs a real model and LLM-judged evals.
When not to use it
- Don't copy this shape for corpora of millions of documents without moving ingestion to a separate job and the index to a real vector database.
Common mistakes
- Building the first version in a notebook with a hard-coded API key, so it can't be tested or deployed.
- No stable chunk IDs, so citations and evals can't refer to the same thing.
- Only testing "does it return 200?" instead of measuring retrieval quality.
- Skipping the "I don't know" path, so unanswerable questions get invented answers.
- Letting the golden set go stale as documents are added.
Interview questions
Try answering out loud first, then open the model answer.
Walk me through how you'd structure a production-ready RAG service.
Model answer
Separate modules for ingestion (parse, chunk with stable IDs and metadata, embed, upsert), retrieval (hybrid search, filters, reranking behind one interface), generation (prompt template with numbered sources, grounding and abstention rules, provider adapter, citation validation) and the API layer (validated requests, health endpoint, streaming). Configuration via environment variables, an offline mode for tests, unit and API tests, a golden eval set with a CI gate, tracing and metrics, and a container image deployed through CI/CD.
How do you test a RAG application?
Model answer
Unit tests for deterministic parts (chunking, ID stability, prompt building, citation parsing), API tests with a test client, retrieval evals on a golden set (Recall@k, MRR) that run in CI with thresholds, and end-to-end answer evals (faithfulness, relevance, abstention on unanswerable questions) with an LLM judge on a schedule or before release. Keep the model behind a mockable adapter so most tests need no keys.
How would you make this app handle a much larger and changing document set?
Model answer
Move ingestion out of start-up into a scheduled or event-driven job with change detection and deletes, store vectors in a vector database or pgvector with metadata and permission filters, switch to a real embedding model and add a reranker, add caching, and scale the stateless API horizontally behind a load balancer. Keep the eval set growing and run it on every change.
Quiz
Pick an answer to see whether it’s right and why.
0 of 6 answered
Key terms
- RAG (retrieval-augmented generation)
- Fetching relevant documents for a question and adding them to the prompt, so the model answers from your data.
- Chunk
- A small piece of a document (often a few hundred tokens) that is embedded, stored and retrieved as one unit.
- Hybrid search
- Running keyword search and vector search together and merging their results, to catch both exact terms and meaning.
- Citation
- A reference in an answer that points to the exact source passage supporting a claim.
- Eval
- A repeatable test of an AI system's quality on a set of examples with a scoring method.
- FastAPI
- A popular Python web framework for building APIs, using type hints and Pydantic for validation and automatic docs.
Before and after
Progress is saved in this browser only.