cd /news/large-language-models/learning-langchain-by-building-7-tin… · home › topics › large-language-models › article
[ARTICLE · art-160443] src=dev.to ↗ pub= topic=large-language-models verified=true sentiment=↑ positive

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.

by read7 min views3 publishedOct 29, 2026

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 s, 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:

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)

 = PyMuPDF4LLM("docs/Bank_HR_Policy.pdf", mode="single")
docs = .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:

from langchain_aws import ChatBedrockConverse, BedrockEmbeddings

model = ChatBedrockConverse(model="<a Bedrock model ID enabled in your account>")
embeddings = BedrockEmbeddings(model_id="amazon.titan-embed-text-v2:0")

The chains, structured output, tool binding, and Chroma code stay the same. For my world, the bigger wins are operational:

.env. One 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.

tools.py reads environment variables at import time, so calling load_dotenv() after import tools silently used defaults. Small bug, real lesson. Each script stops at a clear boundary, and each next step is a post of its own:

get_tables → get_schema → validate_select_query → run_query so plain-English questions return real data. The full repo, with setup steps and a concept-by-concept README, is here: https://github.com/KiranGunturu/langchain/tree/main

Clone 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.

── more in #large-language-models 4 stories · sorted by recency
── more on @langchain 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/learning-langchain-b…] indexed:0 read:7min 2026-10-29 · —