LangChain RAG Tutorial 2026: Rewriting the Pipeline After the chains Module Disappeared
Every RAG walkthrough still opens with an import from langchain.chains, and on langchain 1.4.6 that line raises ModuleNotFoundError. Here is the rebuilt pipeline, with chunk counts measured at three settings and a hit@4 loop that tells you whether retrieval or generation is broken.
💡 What You Will Learn
Every RAG walkthrough still opens with an import from langchain.chains, and on langchain 1.4.6 that line raises ModuleNotFoundError. Here is the rebuilt pipeline, with chunk counts measured at three s
📜 Table of Contents
- What a clean install contains now
- The legacy import, and why it still fails
- Step 1: split, and actually measure it
- Step 2: the retrieval chain, in four runnables
- Step 3: score retrieval before you touch the prompt
- Step 4: moving to Postgres without changing the chain
- Step 5: when the job is agentic, not retrieval
- Failure modes, mapped
- An honest note on whether you need the framework
- FAQ
- Why did the chains module disappear instead of being deprecated?
- Does installing langchain-classic fix my old script completely?
- What chunk size should I start with?
- Is InMemoryVectorStore usable in production?
- Do I need a reranker?
- Related reading
Paste almost any RAG tutorial into a fresh terminal and it dies on line one: ModuleNotFoundError: No module named 'langchain.chains'. That is not a broken tutorial. That is what happens when you install langchain==1.4.6 and run code written for the 0.x line, where retrieval chains lived in langchain.chains. The module is gone. Every walkthrough that opens with from langchain.chains import create_retrieval_chain is now a dead end.
So here is the pipeline rewritten against what is actually on PyPI today, with the parts most tutorials skip: how splitting behaves when you measure it, how to check retrieval before you blame the model, and where the legacy import hides if you genuinely need it.
What a clean install contains now
These are the versions a fresh virtualenv pulled when this page was written (verified 22 October 2026, Python 3.11):
| Package | Version | What it gives you |
|---|---|---|
| langchain | 1.4.6 | Agent API (create_agent), middleware, structured output. No chains module. |
| langchain-core | 1.6.12 | Runnables, prompts, parsers, InMemoryVectorStore |
| langchain-text-splitters | 1.1.3 | RecursiveCharacterTextSplitter and friends |
| langchain-openai | 1.7.2 | ChatOpenAI, OpenAIEmbeddings |
| langchain-postgres | 0.0.19 | PGVector store for production |
| langchain-classic | 1.0.8 | Optional. Where the old chains now live. |
Two things worth noticing. First, langchain requires Python >=3.10,<4.0, so a 3.9 environment cannot run it at all. Second, the splitter library is a separate distribution now: if pip install langchain succeeded but RecursiveCharacterTextSplitter is missing, you skipped langchain-text-splitters.
The legacy import, and why it still fails
If you have old code and no time to rewrite it, install langchain-classic. But the import path moved one level down, which catches people twice:
# fails on langchain-classic 1.0.8
from langchain_classic.chains import create_stuff_documents_chain
# works
from langchain_classic.chains.combine_documents import create_stuff_documents_chain
from langchain_classic.chains.retrieval import create_retrieval_chain
The top-level langchain_classic.chains package only exposes an importer shim, so the familiar one-line import raises ImportError even after you install the right package. Import from the submodule and the old chains behave as before.
Better option: skip chains entirely. In the 1.x line the whole retrieval graph is four runnables, and you can read all four.
Step 1: split, and actually measure it
Chunk settings are the biggest single cause of "my RAG answers are garbage", and the usual advice (1000 characters, 200 overlap) gets repeated without numbers attached. So here are numbers. The test document was 5,600 characters of plain prose, split with RecursiveCharacterTextSplitter at three settings:
| chunk_size / overlap | chunks | avg chars | min | max |
|---|---|---|---|---|
| 1000 / 200 | 7 | 969 | 829 | 999 |
| 500 / 50 | 13 | 473 | 210 | 499 |
| 300 / 30 | 21 | 288 | 108 | 299 |
The max column is the useful one. Chunks never exceed chunk_size, but they can be far smaller: at 500/50 the smallest chunk was 210 characters, less than half the target. That happens because the splitter prefers to break at a paragraph boundary rather than fill the budget. The default separator order is blank line, newline, space, then a hard character split. A stray short section in your document therefore produces a stub chunk that embeds as noise.
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
add_start_index=True, # keeps the char offset so you can cite a location
)
chunks = splitter.create_documents([raw_text], metadatas=[{"source": "manual.pdf"}])
print(len(chunks), sum(len(c.page_content) for c in chunks) / len(chunks))
Two settings matter more than the exact size. add_start_index=True gives every chunk its offset in the source, which is what lets you show a citation like "page 12, character 4,180" instead of just a filename. And drop chunks under roughly 100 characters before embedding: they are almost always headings or table fragments that retrieve badly and push better chunks out of the top-k.
Step 2: the retrieval chain, in four runnables
No chains module, no legacy objects. A store, a formatter, a prompt, a model:
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
store = InMemoryVectorStore.from_documents(chunks, OpenAIEmbeddings(model="text-embedding-3-small"))
retriever = store.as_retriever(search_kwargs={"k": 4})
def format_docs(docs):
return "\n\n".join(
f"[{i+1}] ({d.metadata.get('source','?')} @ {d.metadata.get('start_index','?')})\n{d.page_content}"
for i, d in enumerate(docs)
)
prompt = ChatPromptTemplate.from_messages([
("system",
"Answer only from the numbered context. Cite the bracket number after each claim. "
"If the context does not contain the answer, say so and stop."),
("human", "Context:\n{context}\n\nQuestion: {question}"),
])
chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| ChatOpenAI(model="gpt-4.1-mini", temperature=0)
| StrOutputParser()
)
print(chain.invoke("How many stages does the pipeline have?"))
InMemoryVectorStore lives in langchain_core, so this runs with no extra service. It also exposes similarity_search_with_score and max_marginal_relevance_search, which you will want in the next step. Past a few thousand chunks, swap it for PGVector from langchain-postgres; the retriever interface does not change.
Step 3: score retrieval before you touch the prompt
Most people debug RAG by rewriting the system prompt. That assumes the right passage was in the context, which is exactly the assumption worth testing first. Twenty labelled questions are enough to tell you whether the problem is retrieval or generation:
import json
# questions.json: [{"q": "...", "must_contain": "phrase that only the right chunk has"}, ...]
cases = json.load(open("questions.json"))
hits, mrr = 0, 0.0
for case in cases:
docs = retriever.invoke(case["q"])
rank = next((i + 1 for i, d in enumerate(docs)
if case["must_contain"].lower() in d.page_content.lower()), None)
if rank:
hits += 1
mrr += 1 / rank
print("hit@4 =", hits, "/", len(cases))
print("MRR =", round(mrr / len(cases), 2))
Interpretation, and this is the part that saves days:
- hit@4 below 0.7 means the answer text never reached the model. Rewriting the prompt cannot fix it. Change chunk size, add metadata filters, or add a reranker.
- hit@4 high, answers still wrong means generation. Then the prompt, the model, or
kis the problem, in that order. - MRR low while hit@4 looks fine means the right chunk arrives at position 4 instead of position 1. With a four-chunk context you are spending three quarters of the window on distractors.
Step 4: moving to Postgres without changing the chain
The in-memory store loses everything on restart, so the production swap is one constructor:
from langchain_postgres import PGVector
store = PGVector(
embeddings=OpenAIEmbeddings(model="text-embedding-3-small"),
collection_name="handbook",
connection="postgresql+psycopg://app:secret@localhost:5432/knowledge",
use_jsonb=True,
)
store.add_documents(chunks)
retriever = store.as_retriever(search_kwargs={"k": 4})
PGVector in langchain-postgres 0.0.19 gives you from_documents, from_texts, add_documents and as_retriever, so the rest of the script is untouched. Keep collection_name stable across deploys; if you change the embedding model, change the collection name too, because mixing two embedding spaces inside one collection silently ruins similarity scores.
Step 5: when the job is agentic, not retrieval
If a question needs two or three lookups instead of one, the 1.x answer is the agent API rather than a retrieval chain:
from langchain.agents import create_agent
agent = create_agent(
model="openai:gpt-4.1-mini",
tools=[search_docs], # a plain function wrapped with the tool decorator
system_prompt="Search before answering. Cite the source name for every claim.",
)
result = agent.invoke({"messages": [{"role": "user", "content": "Compare the refund rules in both PDFs."}]})
create_agent is exported from langchain.agents in 1.4.6, alongside middleware and structured_output. Everything previously expressed as a multi-vector or router chain is now just more tools, which is easier to debug: you see the tool call, the arguments and the returned text in one trace.
Failure modes, mapped
| Symptom | Most likely cause | First thing to change |
|---|---|---|
| ModuleNotFoundError: langchain.chains | 0.x code on a 1.x install | Rewrite as runnables, or install langchain-classic and import from submodules |
| Right answer exists in the docs, never cited | Chunk boundary cut the passage, or a 100-character stub won a slot | Increase overlap, filter short chunks, raise k to 6 |
| Confident answer with no numbers | Model filling gaps; context empty or off-topic | Run the hit@4 loop; add the "if the context does not contain it, say so" line |
| The same two sources cited for every question | Overlap too high, near-duplicate chunks cluster at the top | Lower overlap, enable MMR search for diversity |
| Answers degrade after a reindex | Embedding model changed inside the same collection | New collection_name, re-embed everything |
| Fast locally, slow in the container | Embedding calls re-run per request | Cache embeddings by content hash; embed once at ingest |
An honest note on whether you need the framework
The pipeline above is roughly 40 lines of LangChain. The same pipeline with httpx, a Postgres client and your own SQL is roughly 120 lines you fully own, with no version churn. LangChain earns its place when you want the ecosystem: document loaders for a dozen file types, retrievers like parent-document and multi-query, and the agent middleware in 1.x. If your system is one index over one document set, the framework is a dependency you will keep re-reading release notes for. Pick deliberately, not by default.
FAQ
Why did the chains module disappear instead of being deprecated?
The 1.x rewrite rebuilt the package around runnables and agents. The old chain classes moved to a separate distribution, langchain-classic, so installs stay small and legacy code keeps working without pinning the main package back to 0.x.
Does installing langchain-classic fix my old script completely?
Only after you fix the import path. In 1.0.8 the chains are importable from submodules such as langchain_classic.chains.retrieval, but not from the package root. Change those imports and old code runs on Python 3.10 or newer.
What chunk size should I start with?
Start at 1000 characters with 200 overlap for prose, 300 to 500 for API docs and tables, then measure. In the run above the same 5,600-character input produced 7 to 21 chunks depending on settings, and that count decides how many near-duplicates compete for your k slots.
Is InMemoryVectorStore usable in production?
For a demo or a test suite, yes. It lives in process memory, so it disappears on restart and is not shared between workers. Switch to PGVector, Qdrant or pgvector directly once the corpus passes a few thousand chunks.
Do I need a reranker?
Add one after measuring. If hit@4 is already above 0.9 but MRR is under 0.5, a cross-encoder reranker is the cheapest fix available. If hit@4 is under 0.6, a reranker cannot rescue passages the retriever never returned.
Related reading
Written by our editorial team; tools listed here are tested or verified against public sources. Links point to official sites or GitHub repos for reference only — no paid placements.
