LangChain: orchestrating a RAG pipeline 🔗

LangChain is glue. It wires prompts, models, retrievers, parsers, and memory into one pipeline so you stop hand-writing every call. This page teaches it to interview depth, and stays honest about when it earns its keep and when a plain SDK call is clearer.

You already hand-rolled this once. Your rag-docs-chatbot loaded docs, chunked them, embedded them, searched, stuffed context into a prompt, and called a model. LangChain is that same pipeline, named and composable. We will map your manual steps onto its building blocks, then watch a RAG query flow stage by stage.
Opened 0/10 · tap any section to expand
1

What LangChain is actually forgluing prompts, models, retrievers, parsers, memory

🤔 The problem: a real LLM feature is never one call. You format a prompt, call the model, parse its output, maybe fetch documents first, maybe remember the last three turns. Hand-wire that and every app becomes a tangle of string formatting, HTTP calls, and retry logic glued together by hand.
🔧 Think of it like
Plumbing. Each stage is a pipe segment. Water (your data) flows in one end, gets shaped at each joint, and comes out the other end. LangChain gives you standard pipe fittings so segments snap together the same way every time, instead of you soldering custom joints for every project.

LangChain is an orchestration framework. It defines a common interface (a Runnable) for every stage, prompt, model, retriever, parser, so you compose them into a chain and call .invoke() once. The data passes through the stages in order, each transforming it.

input prompt model parser out
Be honest in the interview: for a single prompt-and-response call, LangChain is over-abstraction. A direct bedrock.converse(...) or client.messages.create(...) is shorter and clearer, and you can read exactly what goes to the model. LangChain pays off when you have multiple stages, swappable components, and retrieval or memory. If the app is one call, skip it.

Now the words: the core abstraction is the Runnable (anything with .invoke, .stream, .batch). Chains are Runnables composed together. The company splits into langchain-core (interfaces), provider packages like langchain-aws / langchain-openai, and langchain-community (integrations). LangGraph is the newer library for stateful, branching, agentic flows.

2

The building blocksthe parts you snap together

🤔 The problem: "LangChain has a hundred classes" is what scares people off. It really has about eight parts that matter. Learn those and everything else is a variation.
BlockOne line
PromptTemplateA string with {slots} that fills in variables to build the final prompt.
ChatModelThe wrapper around the LLM (ChatBedrock, ChatOpenAI); takes messages, returns an AIMessage.
OutputParserTurns the raw model reply into what you want: a string, JSON, or a typed object.
DocumentLoaderReads a source (PDF, web page, Notion, S3) into Document objects.
TextSplitterCuts long docs into chunks that fit the context window and embed cleanly.
EmbeddingsTurns each chunk (and the query) into a vector of numbers representing meaning.
VectorStoreStores those vectors and does similarity search (FAISS, Pinecone, pgvector).
RetrieverThe query-time interface: give it a question, it returns the most relevant chunks.
MemoryHolds chat history across turns so the model remembers the conversation.
Map it to your project: in rag-docs-chatbot you wrote all nine by hand. Your PDF reader was the DocumentLoader, your chunking loop was the TextSplitter, your embed call was Embeddings, your similarity search was the VectorStore + Retriever, your f-string was the PromptTemplate. LangChain does not add capability here, it standardizes the seams so you can swap FAISS for Pinecone by changing one line.

Now the words: a Document is text plus metadata. Embeddings and VectorStore are covered in depth in the companion Embeddings tutorial and Pinecone tutorial in this folder. A Retriever is any object with get_relevant_documents(query); a vector store exposes one via .as_retriever().

3

LCEL: composing with the pipeprompt | model | parser

🤔 The problem: older LangChain used deeply nested chain classes that were hard to read and debug. You could not tell what flowed where.
🔧 Think of it like
A Unix pipe. cat file | grep err | wc -l reads left to right, each command's output feeds the next. LCEL uses the exact same | idea: prompt | model | parser means fill the prompt, send to the model, parse the reply.

LCEL (LangChain Expression Language) overloads the | operator so any Runnable composes with the next. The composed object is itself a Runnable, so it gets .invoke(), .stream(), .batch(), and async for free.

a minimal LCEL chain · Python
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_aws import ChatBedrock

prompt = ChatPromptTemplate.from_template("Explain {topic} in one sentence.")
model  = ChatBedrock(model_id="anthropic.claude-sonnet-4-5-20250929-v1:0")
parser = StrOutputParser()

chain = prompt | model | parser          # the pipe composes one Runnable

print(chain.invoke({"topic": "vector databases"}))
# -> "A vector database stores embeddings and finds nearest neighbors by similarity."
build the chain yourself · click stages, then invoke
chain = (empty)
Why LCEL wins: streaming, batching, retries, fallbacks, and parallelism are built into the interface. Write the chain once and chain.stream(...) just works. That is the real reason to reach for LangChain over raw calls.

Now the words: LCEL = LangChain Expression Language. The | is Python's __or__ overloaded on Runnables. A dict of Runnables (see next section) becomes a RunnableParallel that runs branches concurrently; RunnablePassthrough forwards input unchanged.

4

Watch a RAG query flowthe centerpiece · press Run

🤔 The problem: "RAG" sounds abstract until you see the data move. Six stages turn a question into a grounded answer. Toggle retrieval off and watch the model make something up instead.
🔧 Think of it like
An open-book exam. Without retrieval the model answers from memory and bluffs. With retrieval you hand it the exact pages first, so it answers from the book, not its gut.
animated RAG pipeline · your rag-docs-chatbot, stage by stage
Question
🔢Embed
🔎Retrieve
🧩Build prompt
🧠LLM
Answer
Read the log: with retrieval the answer cites the retrieved chunks and lands on the real number. With retrieval off the model still answers confidently, but the number is invented. That gap is the whole argument for RAG.

Now the words: RAG = Retrieval-Augmented Generation. "Stuffing" means concatenating retrieved chunks into the prompt (the stuff strategy); alternatives for many chunks are map_reduce and refine. The made-up answer is a hallucination: fluent, confident, wrong.

5

Building a real RAG chainload, split, embed, retrieve, answer

🤔 The problem: the demo was cartoon boxes. Here is the actual code that wires a retriever, prompt, chat model, and parser into one LCEL chain, plus the one-time indexing step.

Two phases. Indexing (once, offline): load docs, split, embed, store. Querying (per request): embed the question, retrieve, build the prompt, call the model, parse.

index the docs · once, offline
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_aws import BedrockEmbeddings
from langchain_community.vectorstores import FAISS

# 1. load  -> your rag-docs-chatbot read PDFs by hand; this is that step
docs = PyPDFLoader("handbook.pdf").load()

# 2. split into overlapping chunks that fit the window
chunks = RecursiveCharacterTextSplitter(
    chunk_size=1000, chunk_overlap=150).split_documents(docs)

# 3. embed + store (swap FAISS for Pinecone by changing this one line)
emb = BedrockEmbeddings(model_id="amazon.titan-embed-text-v2:0")
vectorstore = FAISS.from_documents(chunks, emb)
vectorstore.save_local("faiss_index")
the query chain · LCEL, per request
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_aws import ChatBedrock

retriever = vectorstore.as_retriever(search_kwargs={"k": 3})

prompt = ChatPromptTemplate.from_template(
    "Answer using ONLY the context. If it is not there, say you don't know.\n\n"
    "Context:\n{context}\n\nQuestion: {question}")

def format_docs(ds):
    return "\n\n".join(d.page_content for d in ds)

# the dict runs both branches; {question} passes through untouched
chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | ChatBedrock(model_id="anthropic.claude-sonnet-4-5-20250929-v1:0")
    | StrOutputParser()
)

print(chain.invoke("How many PTO days carry over?"))

Prefer the higher-level helper? The library ships create_retrieval_chain + create_stuff_documents_chain, which do the same wiring with less code. They return the answer plus the source documents, handy for showing citations.

same thing, the helper way
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain

combine = create_stuff_documents_chain(llm, prompt)   # prompt uses {context}
rag = create_retrieval_chain(retriever, combine)

out = rag.invoke({"input": "How many PTO days carry over?"})
print(out["answer"], out["context"])   # answer + the source chunks
The LCEL version is worth writing by hand once. It shows exactly what LangChain hides inside the helpers. In an interview, being able to draw {context: retriever, question: passthrough} | prompt | model | parser from memory proves you understand the framework, not just its shortcuts.

Now the words: chunk_overlap keeps context from being cut mid-sentence at chunk boundaries. RecursiveCharacterTextSplitter splits on paragraphs, then sentences, then words, preserving structure. k is how many chunks to retrieve. The dict literal in LCEL is sugar for RunnableParallel.

6

Retrievers & vector store integrationPinecone, FAISS, pgvector under one interface

🤔 The problem: the vectors have to live somewhere. In-memory FAISS for a prototype, managed Pinecone for production, pgvector when you already run Postgres. You do not want to rewrite your chain each time you switch.
🔧 Think of it like
A power adapter. The Retriever interface is the standard plug. FAISS, Pinecone, and pgvector are different wall sockets in different countries. LangChain is the adapter, so your chain plugs in unchanged.

Every vector store exposes .as_retriever(), so downstream code never knows which backend it hit. Swapping is one line:

same chain, three backends
# prototype: in-memory, zero infra
from langchain_community.vectorstores import FAISS
store = FAISS.from_documents(chunks, emb)

# production: managed, scales to millions of vectors
from langchain_pinecone import PineconeVectorStore
store = PineconeVectorStore.from_documents(chunks, emb, index_name="handbook")

# already on Postgres? reuse it
from langchain_postgres import PGVector
store = PGVector.from_documents(chunks, emb, connection=DB_URL)

retriever = store.as_retriever(search_type="mmr", search_kwargs={"k": 4})
KnobWhat it does
Similarity (top-k)Return the k nearest vectors by cosine distance. The default, fast and simple.
MMRMaximal Marginal Relevance. Trades some similarity for diversity so you do not get four near-duplicate chunks.
Metadata filterRestrict search to chunks matching a tag (e.g. {"dept": "HR"}) before ranking.
Score thresholdDrop weak matches so an off-topic question returns nothing instead of noise.

How the vectors get created (the embedding models, dimensionality, cosine vs dot product) is its own topic. See the Embeddings tutorial for what a 1536-dimensional vector means, and the Pinecone tutorial for how a managed vector index actually stores and searches them at scale.

Retrieval quality is where RAG lives or dies. A perfect chain over bad retrieval still answers from garbage. Tune chunk size, try MMR, add metadata filters, and evaluate with real questions before blaming the model.

Now the words: ANN (approximate nearest neighbor) is the fast search algorithm vector stores use (HNSW, IVF). MMR re-ranks for diversity. Hybrid search blends vector similarity with keyword (BM25) matching. LangChain wraps all of these behind the same Retriever call.

7

Memory & multi-turn chatbuffer vs summary · the token tradeoff

🤔 The problem: the model is stateless. Every call is amnesiac. If a user asks "and what about part-timers?" the model has no idea what "that" refers to unless you resend the prior turns. Your hoa-chatbot-gmail-login chatbot needs this: the second question depends on the first.
🔧 Think of it like
Catching someone up on a phone call. Every time you speak you re-summarize the conversation so far. Buffer memory reads back the whole transcript; summary memory reads back a running one-paragraph recap.

Memory collects prior turns and injects them into the next prompt. Two strategies, and the choice is a cost tradeoff:

TypeHowCost tradeoff
BufferKeeps the full verbatim history and resends it every turn.Perfect recall, but tokens grow every turn until you blow the window.
Windowed bufferKeeps only the last N turns.Bounded cost, but forgets anything older than N.
SummaryAn LLM compresses old turns into a running summary.Cheap and unbounded history, but lossy and adds a summarization call.
modern memory · RunnableWithMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory

store = {}
def get_history(session_id):
    return store.setdefault(session_id, ChatMessageHistory())

chat = RunnableWithMessageHistory(
    chain, get_history,
    input_messages_key="question",
    history_messages_key="history")

# history is keyed by session, so users don't cross streams
chat.invoke({"question": "What is the PTO policy?"},
           config={"configurable": {"session_id": "user-42"}})
chat.invoke({"question": "And for part-timers?"},   # remembers turn 1
           config={"configurable": {"session_id": "user-42"}})
The classic ConversationBufferMemory classes are deprecated. Modern LangChain does history with RunnableWithMessageHistory (or LangGraph's persistence). Also: memory + RAG both eat the context window. A long chat history plus four retrieved chunks plus the system prompt can silently overflow the limit. Budget your tokens.

Now the words: the context window is the max tokens per call. session_id partitions history per user. Summary memory adds an extra LLM call, so it trades money-per-turn for a smaller prompt. For a chatbot with logins like yours, persist history in a store (Redis, DynamoDB), not process memory.

8

Agents & toolsthe ReAct loop, and why it is flaky

🤔 The problem: a fixed chain always runs the same steps. Sometimes the model needs to decide: should I search the web, query the DB, or just answer? An agent lets the model pick tools and loop until it is done. Powerful, and where most demos quietly break.
🔧 Think of it like
A new intern with a phone. Given a goal, they think, make a call (use a tool), read the reply, think again, and repeat until solved. Capable, but they can loop forever, call the wrong number, or confidently do the wrong thing.

ReAct = Reason + Act. The model emits a thought, chooses an action (a tool + inputs), reads the observation, and loops. Modern LangChain leans on native tool calling: you bind tools to the model and it returns structured calls you execute.

Thought Action (tool) Observation loop until the model decides it can answer Answer
bind tools to a model · modern tool calling
from langchain_core.tools import tool

@tool
def get_balance(account_id: str) -> str:
    """Look up an account balance by id."""
    return db.lookup(account_id)

llm_with_tools = llm.bind_tools([get_balance])
resp = llm_with_tools.invoke("What's the balance on account A-19?")
print(resp.tool_calls)   # [{'name':'get_balance','args':{'account_id':'A-19'}}]
# you run the tool, feed the result back, model composes the final answer
The honest take, say this in interviews: agents are powerful but flaky and expensive. Each loop is another LLM call, so cost and latency stack up, and a wrong turn cascades. If you can express the task as a fixed chain (you know the steps in advance), do that. Reach for an agent only when the control flow genuinely depends on the model's decision. For durable multi-step agents, use LangGraph, which makes the state and branches explicit instead of a hidden while-loop.

Now the words: ReAct = Reason+Act prompting pattern. Tool calling (a.k.a. function calling) is the model returning structured tool invocations natively. AgentExecutor is the older loop runner; LangGraph is the current answer for stateful agents. Guard every agent with a max-iterations cap so it cannot loop forever.

9

Gotchas that bite in productionthe honest failure modes

🤔 The problem: LangChain makes the happy path short and the failure modes quiet. These are the ones that cost money, break in prod, or fail an interview if you cannot name them.
GotchaWhat happensFix
Context bloatRetrieved chunks + chat history + system prompt silently blow the context window; calls truncate or error.Cap k, trim history, count tokens before sending.
Silent costA "chatty" chain fires several LLM calls per request (rephrase, retrieve, summarize, answer). The bill balloons.Log every call, prefer fewer stages, cache where you can.
Over-abstractionThe framework hides the actual prompt sent to the model, so you cannot tell why output is wrong.Print the rendered prompt; do not debug blind.
Version churnFast breaking releases; the tutorial you found imports paths that no longer exist.Pin versions; use langchain-core + provider packages.
Opaque chainsA deep chain fails and the stack trace tells you nothing useful.Use LangSmith tracing or add plain logging between stages.
You didn't need itA one-shot prompt wrapped in three LangChain classes is harder to read than a direct SDK call.Use the raw SDK for simple calls; reserve LangChain for real pipelines.
the two habits that save you
# 1. see exactly what the model receives
print(prompt.invoke({"context": ctx, "question": q}).to_string())

# 2. trace the whole chain in LangSmith (set two env vars)
import os
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"]  = "ls-..."
# now every .invoke() shows each stage's input/output, latency, and token cost
Interview-grade nuance: the strongest answer is not "LangChain is bad." It is "LangChain is great glue for multi-stage, swappable pipelines with retrieval and memory, and it is over-abstraction for a single call. I reach for it when the seams matter and skip it when they don't." That shows judgment, which is what they are testing.

Now the words: LangSmith is LangChain's tracing and eval product. Token budget = tracking how many tokens each part of the prompt consumes. Pinning to langchain-core plus a provider package (langchain-aws) shields you from most churn in the big meta-package.

10

Interview rapid-firetap a card to flip

The eight questions that come up most. Tap to reveal a tight answer, then say it in your own words.