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

PieceWhat it doesThe lesson
Documentid + text + metadatathe retrievable unit (mirrors langchain_core.documents.Document)
KeywordRetrieverdeterministic top-k by distinct-token overlap, id tie-breakthe retriever contract (invoke(query) -> list[Document]) is what the chain depends on — the scorer is swappable
format_docsdocs → newline-joined [id] text context blockretrieved docs must become a string before a prompt can embed them
PromptTemplatedict → formatted prompt; missing variable raisesa prompt is a Runnable too; fail loud on a missing variable, never a blank prompt
quoting_modelinjected pure LLM: quotes the first context line, refuses on empty contextthe model is a pure function of the prompt — the seam that makes RAG testable
parse_with_sourcesfinal {question, answer, sources}citations = the doc IDs that grounded the answer, carried through the chain
build_rag_chainretrieve | augment | generate | parse via RunnableParallel + assignRAG in LangChain is Runnables wired with |, not a magic object

File map

FileRole
lab.pyyour implementation (fill the # TODOs)
solution.pyreference + a main() that answers grounded queries with citations and refuses on empty retrieval
test_lab.py25 tests: retrieval ranking/ties/empty, formatting, prompt validation, grounding, citations, question passthrough, determinism
requirements.txtpytest 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.
  • RunnableParallel fans 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 lab and solution.

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(). Your build_rag_chain is that expression with every component visible.
  • The retriever contract is real. vectorstore.as_retriever() returns a Runnable whose invoke(query) yields list[Document] — same contract as your KeywordRetriever. 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_chain returns {"input", "context", "answer"} the same way.
  • The injected model is the real testing seam. LangChain ships FakeListLLM/GenericFakeChatModel for precisely this: unit-test the chain's wiring with a deterministic model, then swap in ChatOpenAI/ChatAnthropic unchanged — 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 upgrade parse_with_sources to extract only the IDs the model actually cited.
  • Swap KeywordRetriever for Phase 05's hashing embedder + cosine index and prove the chain is untouched (the retriever contract holds).
  • Add a RunnableBranch in front: route "smalltalk" queries straight to the model, docs queries through retrieval — the classic routing pattern.
  • Rebuild the same chain in real LangChain with FakeListLLM and an InMemoryVectorStore, 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 (RunnableParallel fan-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."