Elena' s AI Blog

Tiny Local RAG: LangChain + LangGraph + Ollama (Markdown Files)

22 Sep 2026 (updated: 08 Sep 2026) / 15 minutes to read

Elena Daehnhardt

Generated by ChatGPT-5. Prompt: Programmer in a bright modern office with multiple screens.


TL;DR:
  • A local RAG stack needs chunking, embeddings, retrieval tuning, and citation-first answers to stay accurate and auditable.

Previous: Part 18 — DeepSeek R1 With Ollama

Next: Part 32 — Production Hardening: Idempotency, Run Isolation, and Crash Safety

Tiny Local RAG: LangChain + LangGraph + Ollama (Markdown Files)

Let’s build a tiny Retrieval-Augmented Generation (RAG) system.

You point it at a folder of Markdown files (for example, your blog posts). It builds a vector index. You ask questions. It answers using your own content — with citations.

It runs locally. No cloud required.

And yes — it works on macOS, Linux, and Windows.


What Is RAG (in simple words)?

RAG means:

Retrieve relevant text first → then generate an answer.

Instead of hoping the model “remembers” your files (it doesn’t), we:

  1. Split your files into small chunks
  2. Convert each chunk into numbers (embeddings)
  3. Store those numbers in a vector database
  4. When you ask a question:

    • embed the question
    • retrieve the most relevant chunks
    • give them to the model as context
  5. The model answers using those chunks

That’s it.


Minimal Architecture

Markdown files
   ↓
Split into chunks
   ↓
Embeddings (meaning as numbers)
   ↓
Vector store (Chroma on disk)
   ↓
LangGraph workflow:
   retrieve → answer
   ↓
Answer with citations

LangChain handles:

  • Embeddings
  • Vector store
  • Prompt templating

LangGraph handles:

  • Clean workflow structure

Step 1 — Install Ollama (All Platforms)

You must have Ollama installed and running.

Then pull models:

ollama pull nomic-embed-text
ollama pull qwen2.5:7b

You can use a different chat model if you prefer.


Step 2 — Create a Project

macOS / Linux

mkdir tiny-rag
cd tiny-rag
python3 -m venv .venv
source .venv/bin/activate

Windows (PowerShell)

mkdir tiny-rag
cd tiny-rag
py -m venv .venv
.\.venv\Scripts\Activate.ps1

If activation fails:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

Step 3 — Install Python Packages

All platforms:

pip install -U pip
pip install langchain langchain-core langchain-community langchain-chroma langchain-ollama langgraph chromadb

Project Structure

tiny-rag/
  rag_app.py
  files/
    example1.md
    example2.md
  chroma_db/   (created automatically)

Put some Markdown files in files/.


The Working Code

Create rag_app.py:

from __future__ import annotations

import os
from pathlib import Path
from typing import List, TypedDict

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
from langchain_ollama import OllamaEmbeddings, ChatOllama
from langchain_core.documents import Document
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph.graph import StateGraph, END


class RAGState(TypedDict, total=False):
    question: str
    retrieved: List[Document]
    answer: str
    show_sources: bool


# -------- File loading --------

def load_markdown_files(folder: str) -> List[Document]:
    docs: List[Document] = []
    base = Path(folder)

    for path in base.rglob("*.md"):
        loader = TextLoader(str(path), encoding="utf-8")
        loaded = loader.load()
        for d in loaded:
            d.metadata["source"] = str(path)
        docs.extend(loaded)

    if not docs:
        raise ValueError(f"No .md files found in: {folder}")

    return docs


def split_docs(docs: List[Document]) -> List[Document]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=900,
        chunk_overlap=120,
    )
    return splitter.split_documents(docs)


def get_vectorstore(persist_dir: str) -> Chroma:
    embed_model = os.getenv("OLLAMA_EMBED_MODEL", "nomic-embed-text")
    embeddings = OllamaEmbeddings(model=embed_model)
    return Chroma(
        collection_name="md_rag",
        embedding_function=embeddings,
        persist_directory=persist_dir,
    )


def build_or_update_index(files_dir: str, persist_dir: str):
    docs = load_markdown_files(files_dir)
    chunks = split_docs(docs)

    vs = get_vectorstore(persist_dir)
    vs.delete_collection()
    vs = get_vectorstore(persist_dir)

    vs.add_documents(chunks)
    # Chroma 0.4+ persists automatically once persist_directory is set —
    # no explicit vs.persist() call needed (it was removed from the API).


def retrieve(vs: Chroma, question: str, k: int = 4):
    return vs.similarity_search(question, k=k)


# -------- LangGraph nodes --------

def node_retrieve(state: RAGState) -> RAGState:
    vs = get_vectorstore(os.getenv("CHROMA_DIR", "chroma_db"))
    top_k = int(os.getenv("TOP_K", "4"))
    docs = retrieve(vs, state["question"], k=top_k)
    state["retrieved"] = docs

    if state.get("show_sources"):
        print("\n--- RETRIEVED SOURCES ---\n")
        for i, d in enumerate(docs, start=1):
            src = d.metadata.get("source", "unknown")
            snippet = d.page_content.strip().splitlines()[0][:140]
            print(f"[{i}] {src}")
            print(f"    {snippet}...\n")
        print("-------------------------\n")

    return state


def format_context(docs: List[Document]) -> str:
    lines = []
    for i, d in enumerate(docs, start=1):
        src = d.metadata.get("source", "unknown")
        lines.append(f"[{i}] SOURCE: {src}\n{d.page_content.strip()}\n")
    return "\n".join(lines)


def node_answer(state: RAGState) -> RAGState:
    context = format_context(state["retrieved"])

    llm = ChatOllama(
        model=os.getenv("OLLAMA_CHAT_MODEL", "qwen2.5:7b"),
        temperature=0.2,
    )

    system = SystemMessage(content=(
        "Answer using ONLY the provided context. "
        "If the answer is not in the context, say you don't know. "
        "Cite snippets using [1], [2], etc."
    ))

    user = HumanMessage(content=(
        f"QUESTION:\n{state['question']}\n\n"
        f"CONTEXT:\n{context}"
    ))

    resp = llm.invoke([system, user])
    state["answer"] = resp.content.strip()
    return state


def build_graph():
    g = StateGraph(RAGState)
    g.add_node("retrieve", node_retrieve)
    g.add_node("answer", node_answer)
    g.set_entry_point("retrieve")
    g.add_edge("retrieve", "answer")
    g.add_edge("answer", END)
    return g.compile()


# -------- CLI --------

def main():
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument("--index", action="store_true")
    parser.add_argument("--files", default="files")
    parser.add_argument("--question", default="")
    parser.add_argument("--show-sources", action="store_true")
    args = parser.parse_args()

    persist_dir = os.getenv("CHROMA_DIR", "chroma_db")

    if args.index:
        print("Building index...")
        build_or_update_index(args.files, persist_dir)
        print("Index complete.")
        return

    if not args.question:
        raise SystemExit("Provide --question or run with --index first.")

    graph = build_graph()
    result = graph.invoke({
        "question": args.question,
        "show_sources": args.show_sources
    })

    print("\n--- ANSWER ---\n")
    print(result["answer"])
    print("\n--------------\n")


if __name__ == "__main__":
    main()

Run It

Build index:

python rag_app.py --index --files files

Ask a question:

python rag_app.py --question "What topics are covered?" --show-sources

You will see:

  1. Retrieved snippets
  2. Then the grounded answer

That’s RAG in action.


Troubleshooting

❗ “No .md files found”

Make sure your files/ folder contains Markdown files.


❗ Ollama model not found

Run:

ollama pull nomic-embed-text
ollama pull qwen2.5:7b

❗ Slow answers

Use a smaller chat model and reduce retrieval size:

export OLLAMA_CHAT_MODEL="qwen2.5:7b"
export TOP_K=3

Windows:

$env:OLLAMA_CHAT_MODEL="qwen2.5:7b"
$env:TOP_K="3"

What You Built

You now have:

  • A local RAG system
  • A persistent vector index
  • A LangGraph workflow
  • Source visibility
  • Cross-platform compatibility

And it fits on one screen of code.

That’s the kind of minimal system I like.

RAG is not about Markdown. It’s about loaders.

One small exercise before you go, because it proves the point.


Little Exercise: Add .txt or .pdf Support

Right now, our system only loads:

for path in base.rglob("*.md"):

That’s intentional — we kept it minimal.

But RAG works with any text-based content.

Let’s extend it.


Supporting .txt Files (Very Easy)

Change the loader section to:

for path in base.rglob("*"):
    if path.suffix.lower() in [".md", ".txt"]:
        loader = TextLoader(str(path), encoding="utf-8")
        loaded = loader.load()
        for d in loaded:
            d.metadata["source"] = str(path)
        docs.extend(loaded)

That’s it.

Now your RAG system supports:

  • Markdown
  • Plain text notes
  • Logs
  • Exported documentation

No other change required.


Supporting .pdf Files (Slightly More Interesting)

First install a PDF loader:

pip install pypdf

Then update the imports:

from langchain_community.document_loaders import TextLoader, PyPDFLoader

Modify the file-loading loop:

for path in base.rglob("*"):
    if path.suffix.lower() == ".md" or path.suffix.lower() == ".txt":
        loader = TextLoader(str(path), encoding="utf-8")

    elif path.suffix.lower() == ".pdf":
        loader = PyPDFLoader(str(path))

    else:
        continue

    loaded = loader.load()
    for d in loaded:
        d.metadata["source"] = str(path)
    docs.extend(loaded)

Rebuild your index:

python rag_app.py --index

Now your RAG can search PDFs too.


What Changed?

Only the loader.

Not:

  • The vector store
  • The embeddings
  • The retrieval
  • The graph
  • The answering logic

That’s the beauty of this architecture.


Why This Is a Good Exercise

It teaches three key lessons:

  1. RAG is modular.
  2. Loaders define what you can search.
  3. The orchestration layer doesn’t care what format your documents are.

Once you understand that, you can index:

  • Your Obsidian vault
  • Research papers
  • Code repositories
  • Legal documents
  • Meeting transcripts

Same structure. Different loader.


Retrieval Quality Controls

  1. Tune chunk size and overlap per document type.
  2. Store metadata (source, section, timestamp) with each chunk.
  3. Return top-k citations alongside the answer.
  4. Reject answers when retrieval confidence is low.
  5. Re-index incrementally when source files change.
desktop bg dark

About Elena

Elena, a PhD in Computer Science, simplifies AI concepts and helps you use machine learning.

Citation
Elena Daehnhardt. (2026) 'Tiny Local RAG: LangChain + LangGraph + Ollama (Markdown Files)', daehnhardt.com, 22 September 2026. Available at: https://daehnhardt.com/blog/2026/09/22/tiny-local-rag/
All Posts