0%
SustainSys AI Academy · Student Guide

Building a Local RAG Pipeline
From Scratch

A complete, hands-on guide to building a privacy-first Retrieval-Augmented Generation system that runs entirely on your own machine — querying PDFs, Excel files, images, and emails with zero data sent to the cloud.

Python 3.10+ Ollama · llama3.1:8b nomic-embed-text Qdrant (local) SQLite Flask LangChain Splitters PyMuPDF · Tesseract 7 Build Stages

What is RAG?

Retrieval-Augmented Generation (RAG) is an AI architecture pattern that gives a large language model (LLM) access to a private knowledge base at query time — without retraining or fine-tuning the model itself.

Instead of relying solely on knowledge baked into the model during training, RAG retrieves relevant text chunks from your own documents and injects them directly into the LLM's prompt as context. The model then synthesises an answer grounded in your data.

The Core Idea

Think of RAG like an open-book exam. The LLM is the student — smart, articulate, capable of reasoning — but it is allowed to look at your documents before answering. Without RAG, the student relies on memory alone (training data). With RAG, the student looks up the relevant pages first, then writes the answer.

  User Question
       │
       ▼
  ┌──────────────┐     embed      ┌─────────────────┐
  │  Your Query  │ ────────────► │  Vector Store   │
  └──────────────┘               │  (Qdrant)       │
                                  └────────┬────────┘
                                           │ top-k similar chunks
                                           ▼
  ┌──────────────────────────────────────────────────┐
  │  PROMPT =  System instruction                    │
  │         +  Retrieved chunks  (context)           │
  │         +  User question                         │
  └──────────────────────┬───────────────────────────┘
                         │
                         ▼
                  ┌─────────────┐
                  │  LLM        │  (llama3.1:8b via Ollama)
                  │  Answer     │
                  └─────────────┘

RAG vs Fine-Tuning vs Base LLM

Base LLM (no RAG)
  • Only knows training data (cut-off date)
  • Cannot access private documents
  • Hallucinations with no grounding
  • Expensive to update knowledge
  • No citation of sources
RAG Pipeline
  • Answers grounded in your documents
  • Works with PDFs, Excel, images, email
  • Cites exact source + page number
  • Knowledge updated by adding files
  • 100% private — no cloud required
💡 Key Insight

RAG is the right choice when you have a stable, private document corpus you want to query — not when you need the model to learn new reasoning patterns (that requires fine-tuning).

Why Run Locally?

Most RAG tutorials use the OpenAI API — fast to prototype, but every document chunk gets sent to a remote server. For accountants, law firms, medical practices, and any organisation handling sensitive data, that is not acceptable.

Our implementation runs entirely on a Mac mini M4 using Apple Metal GPU acceleration. Nothing leaves the machine.

Cloud RAG (OpenAI / Pinecone)
  • Document text sent to third-party servers
  • Embedding API costs money per token
  • LLM inference requires internet
  • GDPR / data residency concerns
  • Vendor lock-in
  • Outages affect your system
Local RAG (this implementation)
  • Zero data leaves the machine
  • Embeddings are free (nomic-embed-text)
  • LLM runs offline via Ollama
  • Full GDPR compliance by design
  • Open-source stack, no lock-in
  • Works without internet
⚠ Trade-Off

Local models (llama3.1:8b) are less capable than GPT-4 on complex reasoning. For private document Q&A on well-structured content — invoices, bank statements, reports — they perform excellently. For creative or highly complex tasks, a cloud model may be preferable.

System Architecture

The system has two distinct pipelines: an ingestion pipeline that runs once per document, and a query pipeline that runs on every user question.

Ingestion Pipeline

01
File Detection

The ingest script detects file type (PDF, XLSX, image) and routes to the correct extractor. A SHA-256 hash prevents duplicate ingestion.

02
Text Extraction

PyMuPDF extracts text from PDFs page-by-page. Scanned pages fall back to Tesseract OCR. openpyxl reads Excel sheets row-by-row. pytesseract handles standalone images.

03
Chunking

LangChain's RecursiveCharacterTextSplitter breaks text into 512-character chunks with 64-character overlap. Each chunk preserves its source filename and page number as metadata.

04
Embedding

Each chunk is sent to nomic-embed-text (running locally via Ollama) which returns a 768-dimensional float vector representing the semantic meaning of the text.

05
Storage

Vectors are stored in Qdrant (local file mode). Document and chunk metadata is stored in SQLite. Every operation is audit-logged.

Query Pipeline

Q1
Question Embedding

The user's question is embedded using the same nomic-embed-text model, producing a 768-dim vector representing the question's meaning.

Q2
Vector Search

Qdrant performs cosine similarity search against all stored chunk vectors, returning the top-k most semantically similar chunks (default k=6).

Q3
Prompt Assembly

Retrieved chunks are formatted with their source citations and assembled into a structured prompt with a system instruction for the LLM.

Q4
LLM Generation

llama3.1:8b (running via Ollama) receives the prompt and generates a grounded answer. Temperature is set to 0.1 for factual, consistent responses.

Q5
Response & Citation

The answer is returned to the user with clickable source chips showing filename, page number, and cosine similarity score for every chunk used.

Tech Stack

Every component in this stack is open-source and runs locally. There is no dependency on any paid API or cloud service.

ComponentToolPurpose
LLM RuntimeOllamaServes llama3.1:8b locally using Apple Metal GPU acceleration on Mac M-series chips
Language Modelllama3.1:8b4.9GB quantised model — capable of instruction-following and factual Q&A
Embedding Modelnomic-embed-textProduces 768-dimensional semantic vectors; 274MB; also served via Ollama
Vector StoreQdrant (local)High-performance cosine similarity search; runs entirely in a local folder
Metadata StoreSQLiteStores document metadata, chunk records, and full audit log
PDF ExtractionPyMuPDF (fitz)Fast, accurate text extraction from digital PDFs page-by-page
OCR FallbackTesseract + pytesseractOptical character recognition for scanned/image PDFs
Excel ExtractionopenpyxlSheet-aware extraction from .xlsx files with smart header detection
Image ExtractionPIL + pytesseractPreprocessing (greyscale, threshold) then OCR for image files
ChunkingLangChain Text SplittersRecursiveCharacterTextSplitter with 512-char chunks, 64-char overlap
Web UIFlaskLightweight Python web server; single-file app serving the AccountIQ interface
ConfigPyYAMLAll paths, model names, and parameters in a single config.yaml
ℹ Why Qdrant instead of ChromaDB?

ChromaDB relies on Pydantic V1 internals which are incompatible with Python 3.14. Qdrant uses a clean REST/gRPC API and works perfectly with any Python version. It also supports filtering by metadata, which lets us implement doc-type filtering in the UI.

Project Structure

The project is organised as a clean Python package. Each concern (ingestion, indexing, retrieval, LLM, web) lives in its own module.

rag_project/ ├── app.py ← Flask web UI (AccountIQ) ├── ingest_file.py ← Single file ingestion CLI ├── ingest_batch.py ← Batch folder ingestion ├── index_all.py ← Embed all chunks into Qdrant ├── query.py ← CLI query tool (typer) ├── config.yaml ← All config (models, paths, chunking) ├── .gitignore ← Excludes data_raw/, qdrant_db/, sqlite/ ├── src/ │ ├── ingest/ │ │ ├── base.py ← ExtractedPage, ExtractedDocument dataclasses │ │ ├── pdf_extractor.py ← PyMuPDF + Tesseract OCR fallback │ │ ├── xlsx_extractor.py← openpyxl smart header detection │ │ └── image_extractor.py← PIL + pytesseract preprocessing │ ├── index/ │ │ ├── chunker.py ← LangChain RecursiveCharacterTextSplitter │ │ └── vector_store.py ← Qdrant client (query_points API) │ ├── db/ │ │ ├── schema.py ← SQLite init + CREATE TABLE statements │ │ └── document_store.py← save_document, save_chunks, log_event │ ├── llm/ │ │ └── rag_chain.py ← build_prompt, ask, format_answer │ └── utils/ │ ├── config.py ← load_config, ensure_dirs │ └── hashing.py ← sha256_file (deduplication) ├── data_raw/ ← gitignored — your private documents ├── sqlite/ │ └── metadata.db ← SQLite database └── qdrant_db/ ← gitignored — vector store files

config.yaml

All tunable parameters live in a single config file. This is the first place to look when adjusting model behaviour or file paths.

config.yaml
ollama:
  base_url: "http://localhost:11434"
  llm_model: "llama3.1:8b"
  embed_model: "nomic-embed-text"

paths:
  data_raw: "./data_raw"
  chroma_db: "./qdrant_db"       # key kept for compatibility
  sqlite_db: "./sqlite/metadata.db"

chunking:
  chunk_size: 512
  chunk_overlap: 64

retrieval:
  top_k: 6
  score_threshold: 0.3

Document Ingestion

The ingestion layer is the most complex part of the pipeline. It handles four fundamentally different file formats, each requiring a different extraction strategy.

PDF Extraction

PDFs come in two flavours: digital (text is directly encoded) and scanned (text exists only as an image). Our extractor handles both:

src/ingest/pdf_extractor.py — core logic
import fitz  # PyMuPDF
import pytesseract
from PIL import Image

def extract_pdf(filepath):
    doc = fitz.open(filepath)
    pages = []
    for page_num, page in enumerate(doc):
        text = page.get_text("text").strip()
        if not text:
            # Scanned page — render and OCR
            pix = page.get_pixmap(dpi=200)
            img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
            text = pytesseract.image_to_string(img)
        pages.append(ExtractedPage(
            page_number=page_num + 1,
            text=text,
            extraction_method="pymupdf" if text else "ocr"
        ))
    return ExtractedDocument(filename=filepath.name, pages=pages)

Excel Extraction

Excel files require special handling because data is structured in rows and columns, not prose paragraphs. Our extractor finds the real header row (skipping merged cells and branding rows at the top) and converts each sheet to readable text.

💡 Why Not Just Read All Cells?

Many Excel files have company logos, merged header rows, or empty rows before the actual data begins. Naively reading all cells produces garbage like col_1 col_2 col_3 as headers. The smart header detection scans each row until it finds the first row where most cells are populated strings.

Image Extraction

For standalone image files (receipts photographed on a phone, scanned documents), the image extractor applies preprocessing before OCR: converting to greyscale and applying a binary threshold significantly improves Tesseract accuracy on real-world photos.

Deduplication

Before ingesting any file, we compute its SHA-256 hash. If that hash already exists in the SQLite documents table, the file is skipped. This means you can safely re-run the ingestion script on a folder — it will only process new files.

src/utils/hashing.py
import hashlib

def sha256_file(filepath) -> str:
    h = hashlib.sha256()
    with open(filepath, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            h.update(chunk)
    return h.hexdigest()

Chunking Strategy

LLMs have a finite context window. You cannot feed an entire 50-page PDF into a prompt. Chunking is the process of splitting extracted text into smaller pieces that fit within the model's context — while preserving enough surrounding text that each chunk is self-contained and meaningful.

Chunk Size & Overlap

We use RecursiveCharacterTextSplitter from LangChain with these parameters:

  • chunk_size = 512 characters — each chunk is roughly one paragraph
  • chunk_overlap = 64 characters — consecutive chunks share 64 characters to prevent a sentence from being cut at a chunk boundary and losing context
  • Separators — the splitter tries to split on \n\n first, then \n, then spaces, then characters — always preferring natural breaks
  Document text (2000 chars)
  │
  ├── Chunk 1: chars   0 – 511   (512 chars)
  ├── Chunk 2: chars 447 – 959   (512 chars, 64 overlap with chunk 1)
  ├── Chunk 3: chars 895 – 1407  (512 chars, 64 overlap with chunk 2)
  └── Chunk 4: chars 1343 – 1855 (512 chars, 64 overlap with chunk 3)

Chunk Metadata

Every chunk is stored with metadata that travels with it into Qdrant:

Metadata attached to each chunk
{
  "chunk_id":   "abc123-0",         # unique ID
  "doc_id":    "uuid-of-parent-doc",
  "filename":  "Invoice_April.pdf",
  "page_number": 3,
  "chunk_index": 0,
  "doc_type":  "pdf",
  "word_count": 84
}
⚠ Chunk Size Trade-offs

Smaller chunks (256 chars) give more precise retrieval but lose sentence context. Larger chunks (1024+ chars) preserve context but reduce retrieval precision and use more of the LLM's context window. 512 is a good general-purpose default for document Q&A.

Embeddings & Vector Storage

What is an Embedding?

An embedding is a list of numbers (a vector) that encodes the semantic meaning of a piece of text. Two pieces of text with similar meaning will have vectors that point in similar directions — their cosine similarity will be close to 1.0.

  "What is the BT bill?"         → [0.12, -0.34, 0.87, ... ]  (768 numbers)
  "BT invoice April £41.17"      → [0.11, -0.31, 0.84, ... ]  ← very similar!
  "The cat sat on the mat"       → [-0.45, 0.67, -0.12, ...]  ← very different

nomic-embed-text

We use nomic-embed-text served by Ollama. It produces 768-dimensional vectors and runs entirely on-device using Metal GPU acceleration. At only 274MB, it is fast enough to embed hundreds of chunks in seconds on a Mac mini M4.

src/index/vector_store.py — embedding call
import requests

def embed_text(text: str) -> list[float]:
    resp = requests.post(
        "http://localhost:11434/api/embeddings",
        json={"model": "nomic-embed-text", "prompt": text}
    )
    return resp.json()["embedding"]   # list of 768 floats

Qdrant Vector Store

Vectors are stored in Qdrant running in local file mode — no server process required, just a folder on disk (./qdrant_db/). The collection uses cosine similarity distance.

💡 Important: query_points vs search()

Qdrant renamed its primary search method in v1.7+. Use client.query_points() — the older client.search() method is deprecated. This was one of the bugs discovered during our build and it is a common source of errors in tutorials written before 2024.

src/index/vector_store.py — querying
def query_collection(client, question, n_results=6, filter_metadata=None):
    query_vector = embed_text(question)

    results = client.query_points(
        collection_name="rag_chunks",
        query=query_vector,
        limit=n_results,
        with_payload=True
    )

    hits = []
    for point in results.points:
        hits.append({
            "chunk_id": point.payload["chunk_id"],
            "text":     point.payload["text"],
            "metadata": point.payload,
            "score":    round(point.score, 3)
        })
    return hits

Retrieval

Retrieval is the bridge between the user's question and the LLM's answer. The quality of retrieval directly determines the quality of the final response.

Cosine Similarity

Qdrant compares the query vector against all stored chunk vectors using cosine similarity — a measure of the angle between two vectors in high-dimensional space. A score of 1.0 means identical direction (identical meaning); 0.0 means unrelated.

  Cosine Similarity Score Guide:
  ───────────────────────────────────────────────
  0.85 – 1.00   Very high relevance  ████████████
  0.70 – 0.84   Good relevance       ████████░░░░
  0.50 – 0.69   Moderate             ████░░░░░░░░
  0.30 – 0.49   Weak (often noise)   ██░░░░░░░░░░
  below  0.30   Filter out           ░░░░░░░░░░░░

top_k Parameter

The top_k parameter controls how many chunks are retrieved. Our default is 6. Users can adjust this via a slider in the web UI (range: 3–12). More chunks = more context for the LLM, but also more tokens consumed and potentially more noise.

Metadata Filtering

The UI provides source-type filter pills (All / PDF / Excel / Image). When a filter is active, Qdrant applies a metadata filter so that only chunks from documents of that type are considered in the search.

✓ Validated Query Results

During testing, our pipeline correctly answered: "What was the BT bill in April 2025?" → £41.17, "What are the total immigration visa fees?" → £9,048.57, and "Which bank transactions were over £500?" — all with correct source citations.

LLM Chain & Prompting

The LLM chain is where retrieved chunks are assembled into a prompt and sent to llama3.1:8b via Ollama's chat API.

Prompt Structure

The prompt has three parts: a system instruction that defines the LLM's role and output format, the retrieved context chunks with their source labels, and the user's question.

src/llm/rag_chain.py — prompt assembly
def build_prompt(question: str, hits: list) -> list[dict]:
    context_parts = []
    for i, hit in enumerate(hits, 1):
        fn  = hit["metadata"].get("filename", "?")
        pg  = hit["metadata"].get("page_number", "?")
        txt = hit["text"]
        context_parts.append(f"[Source {i}: {fn}, page {pg}]\n{txt}")

    context = "\n\n".join(context_parts)

    return [
        {"role": "system", "content": (
            "You are a private AI accounting assistant. "
            "Answer ONLY using the provided context. "
            "Always cite your sources as [Source: filename, page X]."
        )},
        {"role": "user", "content":
            f"Context:\n{context}\n\nQuestion: {question}"
        }
    ]

Ollama Chat API Call

src/llm/rag_chain.py — ask()
def ask(question: str, hits: list) -> str:
    messages = build_prompt(question, hits)

    resp = requests.post(
        "http://localhost:11434/api/chat",
        json={
            "model":   "llama3.1:8b",
            "messages": messages,
            "stream":  False,
            "options": {"temperature": 0.1, "num_ctx": 8192}
        },
        timeout=120
    )
    return resp.json()["message"]["content"]
💡 temperature: 0.1

For factual document Q&A, set temperature close to 0. Higher temperature introduces creativity — useful for creative writing, harmful for accounting questions where you need the exact number from the invoice.

Web UI (Flask)

The web interface is a single-file Flask application (app.py) that serves the AccountIQ dashboard and exposes two API endpoints.

API Endpoints

EndpointMethodPurpose
GET /GETServes the full AccountIQ HTML dashboard (rendered with render_template_string)
/api/statsGETReturns live document and chunk counts from SQLite for the dashboard KPI tiles
/api/queryPOSTAccepts {question, top_k, doc_type}, runs the full RAG pipeline, returns {answer, hits}

UI Features

  • Workflow sidebar — 6 accounting workflow modes (Ask Documents, VAT Filing, Month-End Recon, etc.)
  • Source filter pills — filter retrieval by doc type (PDF / Excel / Image)
  • Context chunks slider — adjust top_k from 3 to 12 in real time
  • Source chips — each answer shows clickable chips with filename, page, and similarity score
  • Chunk modal — click any source chip to see the exact text that grounded the answer
  • Query history — sidebar remembers recent queries for quick re-run
  • Live KPI counters — document count and vector count polled from /api/stats on load
⚠ Port Note

macOS Monterey+ reserves port 5000 for AirPlay Receiver. Run the app on port 5001: app.run(host="0.0.0.0", port=5001). Access at http://127.0.0.1:5001.

The 7 Build Stages

The project was built in seven sequential stages. Each stage builds on the previous and can be tested independently.

A

Environment Setup

Install Ollama, pull llama3.1:8b and nomic-embed-text, create Python virtual environment, install dependencies, write config.yaml, verify Ollama connectivity with test_ollama.py.

B

Document Ingestion Pipeline

Build extractors for PDF (PyMuPDF + OCR fallback), Excel (openpyxl), and images (PIL + pytesseract). Define ExtractedPage and ExtractedDocument dataclasses. Implement SHA-256 deduplication.

C

SQLite Metadata Store

Design schema with three tables: documents, chunks, audit_log. Implement save_document(), save_chunks(), and log_event() functions. Run ALTER TABLE to add extraction_method column discovered missing during testing.

D

Chunking + Qdrant Indexing

Implement RecursiveCharacterTextSplitter chunking. Set up Qdrant client in local file mode. Fix ChromaDB Python 3.14 incompatibility by switching to Qdrant. Update to query_points() API.

E

Retrieval + LLM Chain

Build embed_text() and query_collection() functions. Build build_prompt(), ask(), and format_answer() in rag_chain.py. Test with CLI query tool using typer. Validate against real documents.

F

Flask Web UI

Build the AccountIQ dashboard in app.py with hero banner, stat strip, workflow cards, director checklist, and chat interface. Wire up /api/query and /api/stats. Embed SustainSys logo as base64.

G

Static Site + GitHub Deploy

Export AccountIQ as a standalone static HTML page for the SustainSys website. Push the full rag_project to a private GitHub repository (Sustainsys2025/AccountingIQ).

Key Bugs Fixed Along the Way

Real-world builds always hit unexpected problems. Here are the significant issues resolved during this build — each one is a learning opportunity:

BugCauseFix
ChromaDB import errorChromaDB uses Pydantic V1 internals; broken on Python 3.14Switched to Qdrant local mode
client.search() AttributeErrorQdrant v1.7+ renamed methodChanged to client.query_points()
save_chunks() missingFunction not imported in document_store.pyAdded save_chunks() implementation
extraction_method column missingSchema written before column was addedALTER TABLE chunks ADD COLUMN extraction_method TEXT
Port 5000 refusedmacOS AirPlay Receiver holds port 5000Changed to port 5001
Logo injection failed with sedBase64 string contains special charactersUsed Python file write instead of sed

Key Terms Glossary

RAG

Retrieval-Augmented Generation. An architecture that retrieves relevant document chunks and injects them as context into an LLM prompt before generation.

Embedding

A numerical vector (list of floats) that encodes the semantic meaning of a piece of text. Similar texts produce similar vectors.

Vector Store

A database optimised for storing and searching vectors by similarity. We use Qdrant in local file mode.

Cosine Similarity

A measure of the angle between two vectors. Score of 1.0 = identical meaning; 0.0 = completely unrelated.

Chunking

Splitting long documents into smaller text pieces (chunks) that fit within the LLM's context window and can be indexed individually.

top_k

The number of most-similar chunks returned by the vector search. Higher k = more context, more tokens, potentially more noise.

Ollama

A tool for running LLMs locally. Manages model downloads, serves a REST API, and handles Metal GPU acceleration on Apple Silicon.

LLM

Large Language Model. A neural network trained on text that can generate, summarise, translate, and reason. Here: llama3.1:8b.

Temperature

A parameter controlling LLM output randomness. 0.0 = deterministic/factual; 1.0+ = creative/varied. We use 0.1 for document Q&A.

OCR

Optical Character Recognition. Converts images of text into machine-readable text. Used for scanned PDFs and photograph receipts.

Context Window

The maximum number of tokens an LLM can process in one prompt+response. llama3.1:8b supports 8,192 tokens (num_ctx: 8192).

Ingestion Pipeline

The one-time process of extracting text from documents, chunking it, embedding each chunk, and storing vectors in Qdrant.

Where to Go Next

You have built a working local RAG pipeline from scratch. Here are the logical next steps to extend it:

H

Evaluation Framework

Build a test set of question-answer pairs from your documents. Score retrieval precision (did the right chunks come back?) and answer accuracy. Use RAGAS or a hand-crafted eval script.

I

Hybrid Retrieval

Combine dense vector search (semantic) with BM25 keyword search (lexical). Hybrid retrieval outperforms either approach alone, especially for precise terms like invoice numbers and amounts.

J

Re-ranking

After retrieving top-k chunks, apply a cross-encoder re-ranker (e.g. ms-marco-MiniLM) to reorder them by relevance. Often dramatically improves answer quality with minimal latency cost.

K

Streaming Responses

Set stream: true in the Ollama API call and pipe tokens to the browser via Server-Sent Events. Makes the UI feel responsive rather than waiting 10–15 seconds for the full response.

L

PII Redaction

Use spaCy or a regex-based PII detector to automatically redact names, NI numbers, and sort codes before storing chunks. Essential for any production accounting or legal system.

M

Email Ingestion

Add an email extractor using the mailparser or email standard library. Parse .eml files, extract text and attachments, and feed them through the existing ingestion pipeline.

📚 Further Reading

Explore the Qdrant documentation at qdrant.tech, the Ollama model library at ollama.com/library, and the original RAG paper: Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (Lewis et al., 2020).