Build a private chatbot that answers questions from your own Markdown files — completely offline.

Most AI chat tools send your data to the cloud. This one keeps everything on your machine: your notes never leave your computer, and you control the model.

What you’ll build

  • Load a folder of Markdown notes
  • Create embeddings and store them locally
  • Ask natural-language questions
  • Get answers grounded in your own documents
  • Run everything with Docker Compose (optional but recommended)

How RAG works (quick version)

  1. Retrieve – Find the most relevant chunks from your notes
  2. Augment – Put those chunks into the prompt
  3. Generate – Let the LLM answer using only that context

This is why the answers stay accurate and don’t hallucinate as much as pure chat.

Prerequisites

  • Python 3.11+
  • Ollama installed
  • A folder of Markdown notes (your personal knowledge base)

Pull a good local model and an embedding model:

ollama pull llama3.2          # or mistral, phi3, etc.
ollama pull nomic-embed-text  # excellent open embedding model

Project structure

local-rag/
├── notes/                  # put your .md files here
├── app/
│   ├── main.py
│   ├── ingest.py
│   └── chat.py
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── .env.example

Step 1 – Install dependencies

requirements.txt:

llama-index==0.12.5
llama-index-llms-ollama
llama-index-embeddings-ollama
llama-index-readers-file
python-dotenv
fastapi
uvicorn
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt

Step 2 – Ingest your notes

Create app/ingest.py:

from pathlib import Path
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
from llama_index.embeddings.ollama import OllamaEmbedding
from llama_index.llms.ollama import Ollama
from llama_index.core.node_parser import SentenceSplitter

# Configure models
Settings.embed_model = OllamaEmbedding(model_name="nomic-embed-text")
Settings.llm = Ollama(model="llama3.2", request_timeout=120.0)

def ingest(notes_dir: str = "./notes", persist_dir: str = "./storage"):
    notes_path = Path(notes_dir)
    if not notes_path.exists():
        raise FileNotFoundError(f"Notes folder not found: {notes_dir}")

    print(f"Loading Markdown files from {notes_dir}...")
    documents = SimpleDirectoryReader(
        input_dir=notes_dir,
        required_exts=[".md"],
        recursive=True
    ).load_data()

    print(f"Found {len(documents)} documents. Chunking and embedding...")

    # Split into reasonable chunks
    parser = SentenceSplitter(chunk_size=512, chunk_overlap=50)
    nodes = parser.get_nodes_from_documents(documents)

    index = VectorStoreIndex(nodes)
    index.storage_context.persist(persist_dir=persist_dir)

    print(f"Index saved to {persist_dir}")
    return index

if __name__ == "__main__":
    ingest()

Run it once:

python app/ingest.py

This creates a ./storage folder with your vector index.

Step 3 – Chat interface

Create app/chat.py:

from llama_index.core import StorageContext, load_index_from_storage, Settings
from llama_index.embeddings.ollama import OllamaEmbedding
from llama_index.llms.ollama import Ollama

Settings.embed_model = OllamaEmbedding(model_name="nomic-embed-text")
Settings.llm = Ollama(model="llama3.2", request_timeout=120.0)

def get_query_engine(persist_dir: str = "./storage"):
    storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
    index = load_index_from_storage(storage_context)

    return index.as_query_engine(
        similarity_top_k=4,          # how many chunks to retrieve
        response_mode="compact"      # good balance of quality/speed
    )

def chat_loop():
    engine = get_query_engine()
    print("Local RAG Chat ready. Type 'exit' to quit.\n")

    while True:
        question = input("You: ").strip()
        if question.lower() in {"exit", "quit"}:
            break
        if not question:
            continue

        response = engine.query(question)
        print(f"\nAssistant: {response}\n")

if __name__ == "__main__":
    chat_loop()

Start chatting:

python app/chat.py

Example:

You: What did I write about Docker networking?
Assistant: According to your notes...

Step 4 – Optional FastAPI web interface

Create app/main.py if you want a simple web UI later:

from fastapi import FastAPI
from pydantic import BaseModel
from chat import get_query_engine

app = FastAPI(title="Local RAG Chat")
engine = None

class Question(BaseModel):
    question: str

@app.on_event("startup")
def startup():
    global engine
    engine = get_query_engine()

@app.post("/ask")
def ask(q: Question):
    response = engine.query(q.question)
    return {"answer": str(response)}

@app.get("/health")
def health():
    return {"status": "ok"}

Run with:

uvicorn app.main:app --reload --port 8000

Dockerfile:

FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app ./app
COPY notes ./notes

# The storage folder will be mounted as a volume
CMD ["python", "app/chat.py"]

docker-compose.yml:

services:
  rag:
    build: .
    volumes:
      - ./notes:/app/notes
      - ./storage:/app/storage
    environment:
      - OLLAMA_HOST=http://host.docker.internal:11434
    extra_hosts:
      - "host.docker.internal:host-gateway"
    stdin_open: true
    tty: true

Note: Ollama still runs on the host. The container talks to it via host.docker.internal.

Tips for better results

TipWhy it matters
Keep notes reasonably structuredClear headings improve retrieval
Use chunk_size=512 as starting pointToo big = noisy context, too small = lost meaning
Re-run ingest.py after adding notesThe index is not updated automatically
Try different modelsllama3.2, mistral, phi3, gemma2 all work well
Increase similarity_top_kMore context when answers feel incomplete

Common issues

“No relevant information found”
→ Your question doesn’t match the notes well. Try rephrasing or check that the Markdown files were actually loaded.

Slow responses
→ Normal for local models. Use a smaller model (phi3, tinyllama) for speed, or a quantized version.

Ollama connection errors inside Docker
→ Make sure Ollama is running on the host and the extra_hosts line is present.

What you learned

  • The full RAG loop: load → chunk → embed → retrieve → generate
  • How to keep everything local and private
  • How to persist a vector index so you don’t re-embed every time
  • A clean foundation you can later extend (web UI, multiple collections, hybrid search, etc.)

This is one of the highest-leverage AI projects you can build: once it works with your own notes, you suddenly have a private research assistant that actually knows your material.