{"slug": "learning-langchain-by-building-7-tiny-examples-from-chat-models-to-rag", "title": "Learning LangChain by Building: 7 Tiny Examples from Chat Models to RAG", "summary": "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.", "body_md": "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.\n\nSo 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.\n\nThis 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.\n\nLangChain is an open-source framework that puts **standard, swappable interfaces** around everything an LLM application needs besides the model itself.\n\nA 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.\n\n| Problem without a framework | What LangChain gives you | \n|---|---|\n| Each provider has its own SDK and response format | One chat-model interface: `model.invoke(...)` | \n| Prompts are f-strings scattered everywhere | Reusable prompt templates | \n| Parsing free-form text with regex | Output parsers and Pydantic structured output | \n| The model forgets everything between calls | Explicit message objects for history | \n| The model can't see your documents | Loaders, splitters, embeddings, vector stores | \n| The model can't take actions | Tools with auto-generated schemas | \n| Multi-step logic is custom glue | LCEL: `prompt \\ | \n\nAs 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.\n\nThe first script calls Llama 3.3 on Hugging Face. The OpenAI and Gemini versions sit right above it, commented out.\n\n{% raw %}\n\n```\n#model = ChatOpenAI(model=\"gpt-5-mini\")\n#model = ChatGoogleGenerativeAI(model=\"gemini-2.5-flash\")\n\nllm = HuggingFaceEndpoint(\n    repo_id=\"meta-llama/Llama-3.3-70B-Instruct\",\n    task=\"text-generation\",\n)\nmodel = ChatHuggingFace(llm=llm)\n\nresponse = model.invoke(\"tell me about ai agents in 10 words?\")\nprint(response.content)\n```\n\nSwap the comment and nothing else changes. That one property is the whole value proposition of the framework in miniature.\n\nLLMs are stateless. Each call only knows what you send it, so \"memory\" means resending the conversation every turn.\n\n```\nconversation = [SystemMessage(content=\"You are a senior data engineer. Answer in 3 to 4 sentences.\")]\n\nwhile True:\n    query = input(\"Ask a question: \")\n    if query.lower() == \"exit\":\n        break\n    conversation.append(HumanMessage(content=query))\n    response = model.invoke(conversation)\n    conversation.append(AIMessage(content=response.content))\n    print(response.content)\n```\n\nThe lesson: history has a cost. The list grows every turn, so tokens and latency grow with it. Production apps trim or summarize older messages.\n\nThis is where LangChain clicked for me. The pipe operator connects components so each output feeds the next input:\n\n```\nprompt  = PromptTemplate(template=\"tell me about {topic} in 100 words\")\nprompt1 = PromptTemplate(template=\"based on below article, create 5 quiz questions\\n\\n{article}\")\nparser  = StrOutputParser()\n\nchain = prompt | model | parser | prompt1 | model | parser\nprint(chain.invoke({\"topic\": \"ai agents\"}))\n```\n\nOne input produces a 100-word article, which becomes the input for five quiz questions. Two details worth noticing:\n\n`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.\nEvery stage is a Runnable, so the same chain also supports `.batch()` and `.stream()` without changes.\n\nIf code consumes the answer, you want data, not prose. Define a Pydantic model and let LangChain enforce it:\n\n```\nclass SQLOutput(BaseModel):\n    query: str = Field(description=\"The generated SQL query.\")\n    explanation: str = Field(description=\"A brief explanation of the SQL query.\")\n\nmodel_structured = model.with_structured_output(SQLOutput)\nresponse = model_structured.invoke(\"give me sql to get top 5 products by sales from orders table\")\n\nprint(response.query)\nprint(response.explanation)\n```\n\nYou get a typed object back. The `Field` descriptions are sent to the model too, so writing them well improves the output.\n\nA 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.\n\nI built tools over a local SQL Server retail database, plus a stock-price lookup:\n\n| Tool | What it does | \n|---|---|\n| `get_tables` | Lists base tables in `dbo` | \n| `get_schema` | Returns columns and data types for a table | \n| `validate_select_query` | Confirms a query is one read-only `SELECT` | \n| `run_query` | Runs a validated query, returns rows as dicts | \n| `get_engine` | Reconnects to a different server or database | \n| `get_stock_price` | Fetches a quote from the Finnhub API | \n\nComing 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:\n\n``` php\ndef _validate_select(query: str) -> str:\n    normalized = (query or \"\").strip()\n    if not normalized:\n        raise ValueError(\"The query is empty.\")\n    if normalized.endswith(\";\"):\n        normalized = normalized[:-1].rstrip()\n    if any(tok in normalized for tok in (\";\", \"--\", \"/*\", \"*/\")):\n        raise ValueError(\"Only one SELECT statement is allowed.\")\n    if not re.match(r\"^(SELECT|WITH)\\b\", normalized, flags=re.IGNORECASE):\n        raise ValueError(\"Only SELECT queries are allowed.\")\n    if re.search(r\"\\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|GRANT|REVOKE|INTO)\\b\",\n                 normalized, flags=re.IGNORECASE):\n        raise ValueError(\"Write/DDL keywords are not allowed.\")\n    return normalized\n```\n\nIt 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.\n\nThen I bind the tools to the model:\n\n```\nmodel_tools = model.bind_tools([get_engine, get_stock_price, get_tables, get_schema])\nresult = model_tools.invoke(\"show me all the tables in the database\")\n\nfor tool_call in result.tool_calls:\n    print(f\"Calling tool '{tool_call['name']}' with arguments {tool_call['args']}\")\n```\n\nThe 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.\n\nAn 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.\n\nThe pipeline has two halves:\n\n```\nembeddings = OpenAIEmbeddings(model=\"text-embedding-3-small\", dimensions=300)\n\nloader = PyMuPDF4LLMLoader(\"docs/Bank_HR_Policy.pdf\", mode=\"single\")\ndocs = loader.load()\n\nsplitter = MarkdownHeaderTextSplitter(headers_to_split_on=[\n    (\"#\", \"Header 1\"), (\"##\", \"Header 2\"), (\"###\", \"Header 3\"),\n])\nchunks = splitter.split_text(docs[0].page_content)\n\nvector_store = Chroma(collection_name=\"hr_policies\",\n                      embedding_function=embeddings,\n                      persist_directory=\"./chroma_db\")\nvector_store.add_documents(chunks)\n```\n\nThe 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.\n\nI also set the embedding size to 300 dimensions. That's smaller and cheaper to store and search, and plenty for a single policy document.\n\n```\nembeddings = OpenAIEmbeddings(model=\"text-embedding-3-small\", dimensions=300)  # must match ingest\nvector_store = Chroma(collection_name=\"hr_policies\",\n                      embedding_function=embeddings,\n                      persist_directory=\"./chroma_db\")\n\nsimilar_docs = vector_store.similarity_search(\"What is the policy on remote work?\", k=2)\n```\n\nThe 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.\n\nOne gotcha: `add_documents()` appends. Run ingestion twice and you get duplicate chunks. Delete `./chroma_db` before a rebuild.\n\nThe repo uses OpenAI, but because LangChain abstracts the provider, moving to Amazon Bedrock is mostly a two-line change with the `langchain-aws` package:\n\n``` python\nfrom langchain_aws import ChatBedrockConverse, BedrockEmbeddings\n\nmodel = ChatBedrockConverse(model=\"<a Bedrock model ID enabled in your account>\")\nembeddings = BedrockEmbeddings(model_id=\"amazon.titan-embed-text-v2:0\")\n```\n\nThe chains, structured output, tool binding, and Chroma code stay the same. For my world, the bigger wins are operational:\n\n`.env`.\nOne thing to re-check when you switch: embedding dimensions. Titan Text Embeddings V2 supports a fixed set of sizes, not arbitrary values like the 300 I used with OpenAI, and you must re-ingest whenever the embedding model changes.\n\n`tools.py` reads environment variables at import time, so calling `load_dotenv()` after `import tools` silently used defaults. Small bug, real lesson.\nEach script stops at a clear boundary, and each next step is a post of its own:\n\n`get_tables` → `get_schema` → `validate_select_query` → `run_query` so plain-English questions return real data.\nThe full repo, with setup steps and a concept-by-concept README, is here: [https://github.com/KiranGunturu/langchain/tree/main](https://github.com/KiranGunturu/langchain/tree/main)\n\nClone it, add an `OPENAI_API_KEY` to a `.env` file, and run any script on its own. If you're a data engineer getting into GenAI, I'd love to hear which concept clicked for you, or what you'd want covered next.", "url": "https://wpnews.pro/news/learning-langchain-by-building-7-tiny-examples-from-chat-models-to-rag", "canonical_source": "https://dev.to/kiran_gunturu_022085015ec/learning-langchain-by-building-7-tiny-examples-from-chat-models-to-rag-jh", "published_at": "2026-10-29 17:42:56+00:00", "updated_at": "2026-10-29 17:49:05.230542+00:00", "lang": "en", "topics": ["large-language-models", "ai-tools", "developer-tools", "ai-agents"], "entities": ["LangChain", "Amazon Bedrock", "AWS", "Hugging Face", "Llama 3.3", "OpenAI", "Google Gemini"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/learning-langchain-by-building-7-tiny-examples-from-chat-models-to-rag", "markdown": "https://wpnews.pro/news/learning-langchain-by-building-7-tiny-examples-from-chat-models-to-rag.md", "text": "https://wpnews.pro/news/learning-langchain-by-building-7-tiny-examples-from-chat-models-to-rag.txt", "jsonld": "https://wpnews.pro/news/learning-langchain-by-building-7-tiny-examples-from-chat-models-to-rag.jsonld"}}