local-ai·lab
Lesson 7

Rebuild RAG with LangChain

Rebuild the Lesson 1 RAG pipeline on LangChain over the same corpus with the same system prompt, watch where the two disagree, and price the swap: which hand-rolled file each component replaced, how many lines you still maintain, and how many packages moved into your dependency tree. Python and Node.js, with the three escape hatches - a chat model, an embeddings adapter and a retriever - you still have to write yourself.

Follow along in:
Overview

What you'll build

New to LangChain? It is an open-source framework for building on top of language models - not a model, not a database. Two things: a catalogue of components for steps you already have (load, split, embed, retrieve, prompt, call a model) and a way to compose them with |, called LCEL. It ships official Python and JavaScript SDKs, and no .NET one - which is why this lesson is Python and Node, and why .NET gets its own lesson (10 · Semantic Kernel) rather than a LangChain impersonation.

Lessons 1-6 built every RAG primitive by hand. LangChain ships all of them as components. This lesson rebuilds the same pipeline on top of it - same corpus, same system prompt, same citation contract - and then does the thing most framework write-ups skip: it counts the cost.

You'll watch the two pipelines ground the same questions, find the one question where they disagree (and understand why), and read a scorecard that maps every LangChain component onto the Lesson 1 file it replaced. Then the part that matters: LangChain replaces five of seven components outright, and for the other two - your provider and your retriever - you subclass its base classes and hand it your own.

Nothing here is mysterious, because you wrote the twin of every component. Pick a language above and press → to begin.

Rebuild RAG with LangChain, then count what the framework cost.

  the same corpus ─▶ LOAD ─▶ SPLIT ─▶ RETRIEVE ─▶ PROMPT ─▶ ANSWER ─▶ cited answer
                       │       │         │          │         │
        ┌──────────────┴───────┴─────────┴──────────┴─────────┴──────────────┐
        ▼                                                                     ▼
   hand-rolled (Lesson 1)                                        LangChain (Lesson 7)
   extract.py    45L                                             Document(...)
   chunk.py      46L                                             RecursiveCharacterTextSplitter
   store.py      66L                                             InMemoryVectorStore
   retriever.py 119L                                             BaseRetriever subclass  ◀── yours
   prompts.py    19L                                             ChatPromptTemplate
   providers/    37L                                             SimpleChatModel subclass ◀── yours
   engine.py     47L                                             LCEL  ( | )
        │                                                                     │
        ▼                                                                     ▼
   379 lines you can read                                        147 lines you still write
   49 packages installed                                         67 packages installed

Two things the framework could NOT replace: your provider and your retriever. You
subclass its base classes and hand it your own - which is why writing the
primitives first was worth it. Nothing above is mysterious; you wrote its twin.
Setup

What you need

This is the one lesson in the course that installs something, and that is deliberate: the dependency is the subject. ./run -l 7 puts langchain-core and langchain-text-splitters into the course venv on first use. If you skip the install, the demo still runs the hand-rolled side, marks the LangChain column not installed, and tells you the command - and ./run -l 7 test passes either way. @langchain/core declares node >= 20, so the dependency would have moved the runtime floor on its own - it happens not to bind, because Node 18 and 20 are both end-of-life and the whole course now targets Node 22. Run everything from the repo root:

run
$ ./run -l 7                 # Python: the playground (default)
./run -l 7 demo            # Python: the comparison + scorecard, print and exit
./run -l 7 --lang node demo  # Node.js: same pipeline, different dependency bill
Step 1

The corpus and the questions

Three questions, each aimed at a different document under data/corpus/: the factory reset lives in the manual, the amber status ring in the FAQ, the buffer export in the API reference. chunk_size and chunk_overlap are the settings both pipelines are handed, and top_k is how many chunks reach the prompt. One file drives the demo, the test, the playground, and the Node port.

data/questions.json
{
  "chunk_size": 700,
  "chunk_overlap": 120,
  "top_k": 3,
  "questions": [
    "What is the factory reset procedure?",
    "Why does the status ring stay amber?",
    "Which endpoint exports the logging buffer?"
  ]
}
Both pipelines read their settings from the same place for the same reason they share a system prompt: if the framework got a different chunk size or a better brief, the comparison would be theatre rather than a measurement.
Step 2

The hand-rolled side - imported, not rewritten

Every step here calls the module you wrote in Lesson 1. load is extract.py, split is chunk.py, retrieve is retriever.py, render_prompt is prompts.py, answer is providers/. Nothing is reimplemented for the comparison.

python/handrolled_pipeline.py

    The cache directory is deliberately a throwaway: running the lesson must never
    touch the index under your real `.localrag/`.
    """
    config = load_config()
    config.docs_dir = CORPUS_DIR
    config.cache_dir = Path(_SCRATCH.name)
    if provider:
        config.provider = provider
    return config


def load(corpus_dir: Path = CORPUS_DIR) -> List[Page]:
    """extract.py - every supported file under the corpus, in sorted order."""
    pages: List[Page] = []
    for path in discover_files(corpus_dir):
        pages.extend(extract_pages(path))
    return pages


def split(pages: List[Page], size: int, overlap: int) -> List[Chunk]:
    """chunk.py - overlapping chunks that each remember source and page."""
    return chunk_pages(pages, size=size, overlap=overlap)


def build_retriever(chunks: List[Chunk]) -> Bm25Retriever:
    """retriever.py - build the BM25 index once, then answer many queries from it.

    Lesson 1 builds BM25 in `Bm25Retriever.__init__` and reuses it, and the LangChain
    arm does the same, so this arm must too. Rebuilding per query would tokenize the
    whole corpus on every question and make the comparison a measurement of who
node/langchain_rag.mjs
function splitText(text, size, overlap) {
  const normalized = text.split(/\s+/).filter(Boolean).join(" ");
  if (normalized.length <= size) return normalized ? [normalized] : [];

  const chunks = [];
  let start = 0;
  const n = normalized.length;
  while (start < n) {
    let end = Math.min(start + size, n);
    if (end < n) {
      const window = normalized.slice(start, end);
      for (const sep of [". ", "! ", "? ", "\n", " "]) {
        const pos = window.lastIndexOf(sep);
        if (pos > Math.floor(size / 2)) {
          end = start + pos + sep.length;
          break;
        }
      }
    }
    const piece = normalized.slice(start, end).trim();
    if (piece) chunks.push(piece);
    if (end >= n) break;
    start = Math.max(end - overlap, start + 1);
  }
  return chunks;
}

function handSplit(pages, size, overlap) {
  const chunks = [];
  let index = 0;
  for (const page of pages) {
    for (const piece of splitText(page.text, size, overlap)) {
      chunks.push({ source: page.source, page_number: page.page_number, chunk_index: index, text: piece });
      index += 1;
    }
  }
  return chunks;
}

// rank_bm25.BM25Okapi, ported exactly - including the epsilon floor it applies to
A benchmark against a simplified straw-man version of your own code proves nothing. The only way the scorecard means anything is if the left-hand column is the real Lesson 1 pipeline, running unmodified.
Step 3

Load and split, the LangChain way

A Document is a string plus a metadata dict, so for markdown this builds them directly rather than pulling in a loader package - use the component when it earns its place, not because it exists. The splitter is the real swap: RecursiveCharacterTextSplitter keeps the text as written and walks a separator list, while chunk.py collapses whitespace first and breaks on sentence punctuation.

python/lc_pipeline.py
    """LangChain ships loaders (`PyPDFLoader`, `Docx2txtLoader`, ...), but for plain
    text a `Document` is just a string plus metadata - so build it directly and keep
    the dependency list short. `page` mirrors Lesson 1's page numbering for markdown."""
    docs: List[Document] = []
    for path in sorted(corpus_dir.rglob("*.md")):
        docs.append(
            Document(
                page_content=path.read_text(encoding="utf-8").strip(),
                metadata={"source": path.name, "page": 1},
            )
        )
    return docs


def split(docs: List[Document], size: int, overlap: int) -> List[Document]:
    """The component that replaces chunk.py - and the one that behaves differently.

    `RecursiveCharacterTextSplitter` walks separators in order ("\\n\\n", "\\n", " ", "")
    and preserves the text as written. Lesson 1's `_split_text` first collapses all
    whitespace, then breaks on sentence punctuation. Same job, different boundaries -
    which is exactly what the playground lets you watch."""
    splitter = RecursiveCharacterTextSplitter(chunk_size=size, chunk_overlap=overlap)
    return splitter.split_documents(docs)
node/langchain_rag.mjs

// localrag/engine.py:dedup_sources
function citations(pairs) {
  const seen = [];
  for (const [source, page] of pairs) {
    const tag = `${source}:${page}`;
    if (!seen.includes(tag)) seen.push(tag);
  }
  return seen;
}

// --------------------------------------------------------------- LangChain

function lcLoad() {
  return readdirSync(CORPUS_DIR)
    .filter((name) => name.endsWith(".md"))
    .sort()
Same job, different boundaries - and that difference is the entire reason Q3 grounds differently later. It is the cheapest possible demonstration that a component swap is not a no-op.
Step 4

The escape hatch - a chat model LangChain has never heard of

The course's default provider is the Claude Code CLI. LangChain has no adapter for it and never will. SimpleChatModel asks for exactly one method - messages in, string out - and in exchange every LCEL chain in the ecosystem can now drive Claude Code, Ollama, Gemini, or OpenAI, with streaming, batching, and callbacks you did not write.

python/lc_provider.py

def _truncate_at_stop(text: str, stop: Optional[List[str]]) -> str:
    """Enforce LangChain's `stop` contract on providers that have no stop parameter.

    A caller can pass stop sequences to any chat model and expect output to end at
    the first one. None of Lesson 1's providers take a stop argument, so honour it
    here by cutting the response - silently ignoring `stop` would hand the caller
    text they explicitly asked not to receive.
    """
    if not stop:
        return text
    cut = len(text)
    for sequence in stop:
        if not sequence:
            continue
        found = text.find(sequence)
        if found != -1:
            cut = min(cut, found)
    return text[:cut]


class LocalRagChatModel(SimpleChatModel):
    """Drive any localrag provider (claude / ollama / gemini / openai) from an LCEL chain.

    `SimpleChatModel` asks for exactly one method: turn a list of messages into a
    string. Everything else - streaming, batching, callbacks, `|` composition -
This is the shape of every framework: a wide catalogue, plus a few base classes for the things it cannot know about. The two components LangChain could not replace are exactly the two that had to know something about your system - which is the most useful sentence in this lesson.
Step 5

The second escape hatch - your own retriever

LangChain ships a BM25Retriever, in langchain-community - a package that is now sunset upstream and warns on import. Twenty lines against BaseRetriever removes the dependency, the warning, and next year's migration, and keeps the install at 67 packages instead of 80.

python/lc_provider.py

    Note what this cannot do: the Claude Code provider has no embedding endpoint, so
    asking it to embed raises `EmbeddingError`. That limit is a property of the
    provider, not of the wrapper - exactly as it was in Lesson 1.
    """

    def __init__(self, config: Any, provider_name: str = "ollama") -> None:
        self.config = config
        self.provider_name = provider_name

    def embed_documents(self, texts: List[str]) -> List[List[float]]:
        return embed_texts(self.provider_name, self.config, list(texts))

    def embed_query(self, text: str) -> List[float]:
        return self.embed_documents([text])[0]


def _tokenize(text: str) -> List[str]:
    """The same tokenizer Lesson 1 uses, so the two BM25 arms differ only in their input."""
    return re.findall(r"[a-z0-9]+", text.lower())


class LocalRagBM25Retriever(BaseRetriever):
    """BM25 over LangChain `Document`s, using rank_bm25 - already a course dependency.
node/langchain_rag.mjs
class BM25Okapi {
  constructor(corpus, k1 = 1.5, b = 0.75, epsilon = 0.25) {
    this.k1 = k1;
    this.b = b;
    this.corpusSize = corpus.length;
    this.docFreqs = [];
    this.docLen = [];
    this.idf = new Map();

    const nd = new Map();
    let numDoc = 0;
    for (const document of corpus) {
      this.docLen.push(document.length);
      numDoc += document.length;
      const frequencies = new Map();
      for (const word of document) frequencies.set(word, (frequencies.get(word) ?? 0) + 1);
      this.docFreqs.push(frequencies);
      for (const word of frequencies.keys()) nd.set(word, (nd.get(word) ?? 0) + 1);
    }
    this.avgdl = numDoc / this.corpusSize;

    let idfSum = 0;
    const negative = [];
    for (const [word, freq] of nd) {
      const idf = Math.log(this.corpusSize - freq + 0.5) - Math.log(freq + 0.5);
      this.idf.set(word, idf);
      idfSum += idf;
      if (idf < 0) negative.push(word);
    }
    const eps = epsilon * (idfSum / this.idf.size);
    for (const word of negative) this.idf.set(word, eps);
  }

  getScores(query) {
    const scores = new Array(this.corpusSize).fill(0);
    for (const q of query) {
      const idf = this.idf.get(q) ?? 0;
      for (let i = 0; i < this.corpusSize; i += 1) {
        const freq = this.docFreqs[i].get(q) ?? 0;
        const denom = freq + this.k1 * (1 - this.b + (this.b * this.docLen[i]) / this.avgdl);
        scores[i] += (idf * (freq * (this.k1 + 1))) / denom;
      }
    }
    return scores;
  }
}

// One index per corpus, built on first use and reused for every later query -
The version-churn cost is usually argued in the abstract. Here it is dated: the package this lesson was drafted against went end-of-life during the writing of it. Twenty lines of your own code did not.
Step 6

LCEL - the chain that replaced engine.answer_question()

Read the | left to right: fan the question into a context lookup and a passthrough, render the prompt, call the model, take the text out. Your engine.answer_question() did the same five things in imperative Python.

python/lc_pipeline.py
    """engine.answer_question(), as an LCEL chain.

    Read the `|` left to right: fan the question into a context lookup and a
    passthrough, render the prompt, call the model, take the text out."""
    return (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt_template()
        | llm
        | StrOutputParser()
    )
Every stage being a Runnable is what buys streaming, batching, and async for free. It is also what puts the framework's dispatch machinery between you and a stack trace when the chain misbehaves. Both are true, and the trade only looks obvious from one side.
Command

Run the comparison

demo · python
$ python -m pip install -q -r requirements.txt
demo · python
$ ./run -l 7 demo
demo · node
$ npm --prefix node install --silent --no-audit --no-fund
demo · node
$ ./run -l 7 --lang node demo
Run it

Read the output

Q1 and Q2 agree: both pipelines cite the same sources in the same order. Q3 differs - LangChain's splitter cut the corpus elsewhere, so retrieval pulled in installation.md too. Neither is wrong, and the 2/3 line is the honest result; engineering all three into agreement would have taught you nothing.

Then the bill. Five of seven components became an import. The two that did not - the retriever and the provider - are the two you subclassed. 379 lines you can read against 147 lines you still write, and 18 extra packages for two requirements lines. The line counts are measured off disk at run time, so they can never drift from the code.

Rebuild RAG with LangChain  -  7 documents, same corpus, same system prompt
chunked at size=700 overlap=120, retrieving top 3

  hand-rolled   15 chunks   (chunk.py: collapse whitespace, break on sentences)
  langchain     15 chunks   (RecursiveCharacterTextSplitter: keep text, split on separators)

Q1  What is the factory reset procedure?
    hand-rolled   sources: manual.md:1 . warranty.md:1
    langchain     sources: manual.md:1 . warranty.md:1
    GROUNDING AGREES  -  same sources, same order

Q2  Why does the status ring stay amber?
    hand-rolled   sources: faq.md:1 . manual.md:1 . warranty.md:1
    langchain     sources: faq.md:1 . manual.md:1 . warranty.md:1
    GROUNDING AGREES  -  same sources, same order

Q3  Which endpoint exports the logging buffer?
    hand-rolled   sources: api.md:1 . manual.md:1
    langchain     sources: api.md:1 . manual.md:1 . installation.md:1
    GROUNDING DIFFERS  -  langchain also cites installation.md:1

    2/3 questions grounded identically. Different chunk boundaries, mostly the same evidence.

Component by component
  step      hand-rolled (Lesson 1)               LangChain (Lesson 7)
  -------------------------------------------------------------------------------
  load      localrag/extract.py              45L  Document(...)
  split     localrag/chunk.py                46L  RecursiveCharacterTextSplitter
  index     localrag/store.py                66L  InMemoryVectorStore
  retrieve  localrag/retriever.py           119L  BaseRetriever subclass (yours)
  prompt    localrag/prompts.py              19L  ChatPromptTemplate
  provider  localrag/providers/__init__.py   37L  SimpleChatModel subclass (yours)
  chain     localrag/engine.py               47L  LCEL  ( | )
  -------------------------------------------------------------------------------

What it cost
                            hand-rolled      LangChain
  code you maintain           379 lines      147 lines
  requirements lines                  8             10
  packages installed                 49             67
  install size                   ~34 MB         ~43 MB

  Two requirements lines cost 18 packages and ~9 MB.
  Taking BM25 from the sunset langchain-community, rather than writing the
  twenty-line retriever, would have made it 80 packages and ~52 MB.
Try it

A question both pipelines agree on

Start where the two arms line up. Same sources, same order, from two completely different chunkings - which is the result you would hope for, and worth seeing before the one that breaks:

Why does the status ring stay amber?
Try it

The question where they diverge

Now the interesting one. Watch LangChain cite an extra file. Then push chunk size up to 1600 and watch the disagreement vanish, because at that size both splitters keep each document whole. The divergence was never about retrieval quality - it was about where the text got cut.

Which endpoint exports the logging buffer?
Try it

Ask for a real answer

The comparison is deliberately offline. This runs the whole LCEL chain through a model - retrieval, prompt, provider - using the chat model adapter you wrote, so the course's default Claude Code provider works inside LangChain with no LangChain adapter for it. Add --native to run the same chain through ChatOllama instead and compare the two paths, or --arm embed to retrieve with real local vectors rather than BM25.

ask · python
$ ./run -l 7 ask "What is the factory reset procedure?"
Try it

Confirm it with the test

An offline test pins the lesson's claims: the hand-rolled side grounds and cites every question; both pipelines are handed the same system prompt; both lead with the same top source; the chat model adapter forwards system and user text correctly (with a fake provider - no subprocess, no network); the scorecard reads real files; and the demo still exits 0 with a useful message when LangChain is not installed. No network, no model, no API key.

test · python
$ ./run -l 7 test
Command

Watch the two splitters disagree - no code editing

web · python
$ python -m pip install -q -r requirements.txt
web · python
$ ./run -l 7
Experiment

Try - drop the chunk size

Pull chunk size down to 300. Both pipelines now cut mid-procedure, and the citations start to scatter. Small chunks retrieve precisely and answer badly, because the evidence arrives without the sentence that gave it meaning.

What is the factory reset procedure?
Experiment

Try - show the rendered prompt

Turn on Show the rendered prompt and read both. The system prompt is byte-identical - it is imported from localrag.prompts, not retyped - and the template is the same. Only the retrieved text differs. That is what makes the comparison a measurement rather than an opinion.

Which endpoint exports the logging buffer?
Going further

From demo to production

This is a teaching artefact: in a real system you pick one pipeline, because two chunkers means two sets of citations to explain.

Pin your versions - langchain-core>=1.0 is fine for a lesson and reckless for a service; pin exactly and read the changelog before every bump. Keep the citation contract at the boundary - source:page survived this rewrite untouched, so make that the thing your tests assert and you can change frameworks without changing what you promise callers. Measure before you adopt - packages, install size, cold start, stack depth on a failure; four numbers, ten minutes, and they outlast any framework comparison online. Own your adapters - the sixty lines of LocalRagChatModel are the reason this pipeline is not locked to one vendor. Fold it into Lesson 5 - put both pipelines in the golden set and let the evaluation gate tell you whether the rewrite changed answer quality, instead of guessing from three questions.

Recap

What you learned

You rebuilt the Lesson 1 pipeline on LangChain and priced the swap. Five components became imports; two - the provider and the retriever - you had to write yourself against SimpleChatModel and BaseRetriever, which is the honest shape of every framework. You saw two of three questions ground identically and one diverge because the splitters cut differently, and you watched that divergence appear and vanish as you moved a slider.

The numbers: 379 lines you maintain against 147 you still write, and two requirements lines that cost 18 packages. A framework does not remove work; it moves it out of your files and into your dependency tree, where only one of the two shows up in a diff. You are now equipped to make that trade deliberately rather than by default - including the part where a package this lesson was drafted against was sunset while it was being written.

Next: Lesson 8 · LangGraph - turn this linear chain into a stateful agent graph with retries, tool routing, and memory.

Use arrow keys, the dots, or the buttons. Deep-link a step with #step-N.