Lab 02 — A RAG Chain Built From LCEL Runnables
Phase 29 · Lab 02 · Phase README · Warmup
The problem
"Build a RAG app with LangChain" is the single most common LangChain task in the wild — and the JD line this phase answers ("LangChain for building LLM-powered applications") almost always means exactly this. The canonical LangChain RAG chain is one LCEL expression:
chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| model
| StrOutputParser()
)
Most engineers can paste that. Far fewer can explain why it works: why the dict fans the one query out to the retriever and a passthrough, how the formatted documents become a prompt variable, why the question survives to the end untouched, and where source citations come from. This lab builds every piece — retriever, doc formatter, prompt template, injected model, citation-aware parser — and wires them into that exact shape, so the answer is muscle memory.
What you build
| Piece | What it does | The lesson |
|---|---|---|
Document | id + text + metadata | the retrievable unit (mirrors langchain_core.documents.Document) |
KeywordRetriever | deterministic top-k by distinct-token overlap, id tie-break | the retriever contract (invoke(query) -> list[Document]) is what the chain depends on — the scorer is swappable |
format_docs | docs → newline-joined [id] text context block | retrieved docs must become a string before a prompt can embed them |
PromptTemplate | dict → formatted prompt; missing variable raises | a prompt is a Runnable too; fail loud on a missing variable, never a blank prompt |
quoting_model | injected pure LLM: quotes the first context line, refuses on empty context | the model is a pure function of the prompt — the seam that makes RAG testable |
parse_with_sources | final {question, answer, sources} | citations = the doc IDs that grounded the answer, carried through the chain |
build_rag_chain | retrieve | augment | generate | parse via RunnableParallel + assign | RAG in LangChain is Runnables wired with |, not a magic object |
File map
| File | Role |
|---|---|
lab.py | your implementation (fill the # TODOs) |
solution.py | reference + a main() that answers grounded queries with citations and refuses on empty retrieval |
test_lab.py | 25 tests: retrieval ranking/ties/empty, formatting, prompt validation, grounding, citations, question passthrough, determinism |
requirements.txt | pytest only |
Run it
pytest test_lab.py -v # your lab.py (red until you implement)
LAB_MODULE=solution pytest test_lab.py -v # the reference (green)
python solution.py # worked example
Success criteria
-
The retriever ranks by distinct-token overlap, breaks ties by id, respects
k, and returns[]on no match — fully deterministic. -
RunnableParallelfans one query out to{"docs": retriever, "question": passthrough}and the question survives to the final output unchanged. - The chain's answer provably quotes the retrieved document (retrieval fed generation), and changing the query changes the retrieved sources.
- The output pairs the answer with source citations (the grounding doc IDs).
-
Empty retrieval produces a grounded refusal with
sources == []— not a hallucination, not a crash. -
All 25 tests pass under both
labandsolution.
How this maps to the real stack
- The chain shape is the real one. LangChain's own RAG tutorial builds literally
{"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser(). Yourbuild_rag_chainis that expression with every component visible. - The retriever contract is real.
vectorstore.as_retriever()returns a Runnable whoseinvoke(query)yieldslist[Document]— same contract as yourKeywordRetriever. Swapping keyword overlap for dense embeddings + a vector index (FAISS, pgvector, Chroma) changes the scorer, not the chain — see Phase 05 for the hybrid retriever this stands in for, and Phase 06 for the advanced variants. - Citations are real. LangChain's "returning sources" pattern uses exactly this trick — carry
the retrieved docs alongside the generation (via
RunnableParallel/assign) so the final output can include them;create_retrieval_chainreturns{"input", "context", "answer"}the same way. - The injected model is the real testing seam. LangChain ships
FakeListLLM/GenericFakeChatModelfor precisely this: unit-test the chain's wiring with a deterministic model, then swap inChatOpenAI/ChatAnthropicunchanged — both are just Runnables.
Limits of the miniature. Real retrieval is embeddings + ANN (plus hybrid BM25 fusion and
reranking — Phase 05), not keyword overlap; real prompts are chat-message lists with roles, not one
string; a real parser might extract inline [id] citations the model chose to cite rather than
listing all retrieved docs; and real chains stream tokens (Lab 01's stream machinery composes in
unchanged).
LangChain vs LangGraph. This chain retrieves once, unconditionally, then generates — pure
linear data flow, LCEL's sweet spot. The moment you want agentic RAG — the model decides
whether to retrieve, grades the retrieved docs, rewrites the query and loops on a bad result —
you need cycles and state, and that is LangGraph, Phase 18
(the agent ↔ tools graph with a retriever tool). One-shot RAG: LCEL. Self-correcting RAG: LangGraph.
Extensions (your own machine)
- Make the model emit inline
[id]markers and upgradeparse_with_sourcesto extract only the IDs the model actually cited. - Swap
KeywordRetrieverfor Phase 05's hashing embedder + cosine index and prove the chain is untouched (the retriever contract holds). - Add a
RunnableBranchin front: route "smalltalk" queries straight to the model, docs queries through retrieval — the classic routing pattern. - Rebuild the same chain in real LangChain with
FakeListLLMand anInMemoryVectorStore, and diff the outputs.
Interview / resume signal
"Built LangChain's canonical RAG chain from first principles — retriever, doc formatter, prompt template, model, and citation-aware parser composed with LCEL (
RunnableParallelfan-out +Passthrough.assign) — with tests proving retrieval feeds generation, sources are cited, the question threads through, and empty retrieval degrades to a grounded refusal instead of a hallucination."