Learning LangChain by Building: 7 Tiny Examples from Chat Models to RAG A data engineer with about 12 years of AWS pipeline experience, most recently in financial-crimes compliance, published a repository of seven standalone LangChain scripts that each demonstrate a single concept, from swapping chat model providers to chaining prompts with LCEL and answering questions over a PDF. Most scripts run under 40 lines, and the writeup closes with a plan for moving the examples onto Amazon Bedrock. I've spent about 12 years building data pipelines on AWS, most recently for financial-crimes compliance in banking. When I started working with LLMs, I kept hitting the same wall. Every tutorial was either a one-line "hello world" or a 500-line agent framework I couldn't take apart. So I did what data engineers do with any new tool: I broke it into the smallest pieces I could run, inspect, and reason about. The result is a repo of seven standalone LangChain scripts , each teaching exactly one concept. Every file runs on its own, and most are under 40 lines. This post walks through them in order, from "call a model" to "answer questions over a PDF," and ends with how I'd move the whole thing onto Amazon Bedrock. LangChain is an open-source framework that puts standard, swappable interfaces around everything an LLM application needs besides the model itself. A raw LLM is a function: text in, text out. A real application needs prompts built from variables, conversation state, output your code can parse, access to private data, and the ability to call functions. Without a framework, you write that glue yourself, once per provider. | Problem without a framework | What LangChain gives you | |---|---| | Each provider has its own SDK and response format | One chat-model interface: model.invoke ... | | Prompts are f-strings scattered everywhere | Reusable prompt templates | | Parsing free-form text with regex | Output parsers and Pydantic structured output | | The model forgets everything between calls | Explicit message objects for history | | The model can't see your documents | Loaders, splitters, embeddings, vector stores | | The model can't take actions | Tools with auto-generated schemas | | Multi-step logic is custom glue | LCEL: prompt \ | As a data engineer, the last row is what sold me. LCEL treats every component as a pipeline stage with the same interface, which is exactly how I already think about ETL. The first script calls Llama 3.3 on Hugging Face. The OpenAI and Gemini versions sit right above it, commented out. {% raw %} model = ChatOpenAI model="gpt-5-mini" model = ChatGoogleGenerativeAI model="gemini-2.5-flash" llm = HuggingFaceEndpoint repo id="meta-llama/Llama-3.3-70B-Instruct", task="text-generation", model = ChatHuggingFace llm=llm response = model.invoke "tell me about ai agents in 10 words?" print response.content Swap the comment and nothing else changes. That one property is the whole value proposition of the framework in miniature. LLMs are stateless. Each call only knows what you send it, so "memory" means resending the conversation every turn. conversation = SystemMessage content="You are a senior data engineer. Answer in 3 to 4 sentences." while True: query = input "Ask a question: " if query.lower == "exit": break conversation.append HumanMessage content=query response = model.invoke conversation conversation.append AIMessage content=response.content print response.content The lesson: history has a cost. The list grows every turn, so tokens and latency grow with it. Production apps trim or summarize older messages. This is where LangChain clicked for me. The pipe operator connects components so each output feeds the next input: prompt = PromptTemplate template="tell me about {topic} in 100 words" prompt1 = PromptTemplate template="based on below article, create 5 quiz questions\n\n{article}" parser = StrOutputParser chain = prompt | model | parser | prompt1 | model | parser print chain.invoke {"topic": "ai agents"} One input produces a 100-word article, which becomes the input for five quiz questions. Two details worth noticing: StrOutputParser turns the model's message object into a plain string. prompt1 has exactly one variable, so LangChain maps that string into {article} automatically. No glue code. Every stage is a Runnable, so the same chain also supports .batch and .stream without changes. If code consumes the answer, you want data, not prose. Define a Pydantic model and let LangChain enforce it: class SQLOutput BaseModel : query: str = Field description="The generated SQL query." explanation: str = Field description="A brief explanation of the SQL query." model structured = model.with structured output SQLOutput response = model structured.invoke "give me sql to get top 5 products by sales from orders table" print response.query print response.explanation You get a typed object back. The Field descriptions are sent to the model too, so writing them well improves the output. A tool is a Python function the model can ask to call. The @tool decorator reads the name, type hints, and docstring to build a schema the model understands. I built tools over a local SQL Server retail database, plus a stock-price lookup: | Tool | What it does | |---|---| | get tables | Lists base tables in dbo | | get schema | Returns columns and data types for a table | | validate select query | Confirms a query is one read-only SELECT | | run query | Runs a validated query, returns rows as dicts | | get engine | Reconnects to a different server or database | | get stock price | Fetches a quote from the Finnhub API | Coming from regulated financial services, I wasn't going to let a model near a database without a gate. Every SQL tool goes through one validator: php def validate select query: str - str: normalized = query or "" .strip if not normalized: raise ValueError "The query is empty." if normalized.endswith ";" : normalized = normalized :-1 .rstrip if any tok in normalized for tok in ";", "--", "/ ", " /" : raise ValueError "Only one SELECT statement is allowed." if not re.match r"^ SELECT|WITH \b", normalized, flags=re.IGNORECASE : raise ValueError "Only SELECT queries are allowed." if re.search r"\b INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|GRANT|REVOKE|INTO \b", normalized, flags=re.IGNORECASE : raise ValueError "Write/DDL keywords are not allowed." return normalized It blocks stacked statements, comments a classic injection trick , anything that isn't SELECT or a CTE, and every write or DDL keyword, including SELECT ... INTO . It's a first line of defense, not the only one. In production, pair it with a read-only database login. Then I bind the tools to the model: model tools = model.bind tools get engine, get stock price, get tables, get schema result = model tools.invoke "show me all the tables in the database" for tool call in result.tool calls: print f"Calling tool '{tool call 'name' }' with arguments {tool call 'args' }" The most important thing I learned here: the model never runs your code. It returns a request, a tool name plus arguments. Your program decides whether to execute it. That boundary is where security and auditability live, and it's why tool calling is far less scary than it sounds. An LLM doesn't know your internal documents, and retraining it is expensive. Retrieval-Augmented Generation RAG retrieves the relevant passages at question time and hands them to the model as context. I used a sample bank HR policy as the source. The pipeline has two halves: embeddings = OpenAIEmbeddings model="text-embedding-3-small", dimensions=300 loader = PyMuPDF4LLMLoader "docs/Bank HR Policy.pdf", mode="single" docs = loader.load splitter = MarkdownHeaderTextSplitter headers to split on= " ", "Header 1" , " ", "Header 2" , " ", "Header 3" , chunks = splitter.split text docs 0 .page content vector store = Chroma collection name="hr policies", embedding function=embeddings, persist directory="./chroma db" vector store.add documents chunks The choice that mattered most was how to split . Splitting by a fixed character count is the common default, and it can cut a policy rule in half. My earlier experiment is still in the repo as chunking.py for comparison. Converting the PDF to Markdown first and splitting on headings means each chunk is one complete policy section. The heading text is stored as metadata, so every result can say where it came from. I also set the embedding size to 300 dimensions. That's smaller and cheaper to store and search, and plenty for a single policy document. embeddings = OpenAIEmbeddings model="text-embedding-3-small", dimensions=300 must match ingest vector store = Chroma collection name="hr policies", embedding function=embeddings, persist directory="./chroma db" similar docs = vector store.similarity search "What is the policy on remote work?", k=2 The question must be embedded with the same model and dimensions used at ingest, or the vectors aren't comparable. The search returns the two chunks closest in meaning, even when they share no exact words with the question. One gotcha: add documents appends. Run ingestion twice and you get duplicate chunks. Delete ./chroma db before a rebuild. The repo uses OpenAI, but because LangChain abstracts the provider, moving to Amazon Bedrock is mostly a two-line change with the langchain-aws package: python from langchain aws import ChatBedrockConverse, BedrockEmbeddings model = ChatBedrockConverse model="