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.
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.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 -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 billThree 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.
{
"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?"
]
}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.
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 whofunction 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 toA 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.
"""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)
// 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()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.
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 -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.
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.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 -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.
"""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()
)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.$ python -m pip install -q -r requirements.txt$ ./run -l 7 demo$ npm --prefix node install --silent --no-audit --no-fund$ ./run -l 7 --lang node demoQ1 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.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?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?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.
$ ./run -l 7 ask "What is the factory reset procedure?"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.
$ ./run -l 7 test$ python -m pip install -q -r requirements.txt$ ./run -l 7Pull 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?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?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.
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.
#step-N.