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.
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
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
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
The ingest script detects file type (PDF, XLSX, image) and routes to the correct extractor. A SHA-256 hash prevents duplicate ingestion.
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.
LangChain's RecursiveCharacterTextSplitter breaks text into 512-character chunks with 64-character overlap. Each chunk preserves its source filename and page number as metadata.
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.
Vectors are stored in Qdrant (local file mode). Document and chunk metadata is stored in SQLite. Every operation is audit-logged.
Query Pipeline
The user's question is embedded using the same nomic-embed-text model, producing a 768-dim vector representing the question's meaning.
Qdrant performs cosine similarity search against all stored chunk vectors, returning the top-k most semantically similar chunks (default k=6).
Retrieved chunks are formatted with their source citations and assembled into a structured prompt with a system instruction for the LLM.
llama3.1:8b (running via Ollama) receives the prompt and generates a grounded answer. Temperature is set to 0.1 for factual, consistent responses.
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.
| Component | Tool | Purpose |
|---|---|---|
| LLM Runtime | Ollama | Serves llama3.1:8b locally using Apple Metal GPU acceleration on Mac M-series chips |
| Language Model | llama3.1:8b | 4.9GB quantised model — capable of instruction-following and factual Q&A |
| Embedding Model | nomic-embed-text | Produces 768-dimensional semantic vectors; 274MB; also served via Ollama |
| Vector Store | Qdrant (local) | High-performance cosine similarity search; runs entirely in a local folder |
| Metadata Store | SQLite | Stores document metadata, chunk records, and full audit log |
| PDF Extraction | PyMuPDF (fitz) | Fast, accurate text extraction from digital PDFs page-by-page |
| OCR Fallback | Tesseract + pytesseract | Optical character recognition for scanned/image PDFs |
| Excel Extraction | openpyxl | Sheet-aware extraction from .xlsx files with smart header detection |
| Image Extraction | PIL + pytesseract | Preprocessing (greyscale, threshold) then OCR for image files |
| Chunking | LangChain Text Splitters | RecursiveCharacterTextSplitter with 512-char chunks, 64-char overlap |
| Web UI | Flask | Lightweight Python web server; single-file app serving the AccountIQ interface |
| Config | PyYAML | All paths, model names, and parameters in a single config.yaml |
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.
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.yamlollama:
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 logicimport 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.
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.
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\nfirst, 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
}
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.
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.
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.
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.
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 assemblydef 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"]
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
| Endpoint | Method | Purpose |
|---|---|---|
| GET / | GET | Serves the full AccountIQ HTML dashboard (rendered with render_template_string) |
| /api/stats | GET | Returns live document and chunk counts from SQLite for the dashboard KPI tiles |
| /api/query | POST | Accepts {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
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.
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.
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.
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.
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.
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.
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.
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:
| Bug | Cause | Fix |
|---|---|---|
| ChromaDB import error | ChromaDB uses Pydantic V1 internals; broken on Python 3.14 | Switched to Qdrant local mode |
| client.search() AttributeError | Qdrant v1.7+ renamed method | Changed to client.query_points() |
| save_chunks() missing | Function not imported in document_store.py | Added save_chunks() implementation |
| extraction_method column missing | Schema written before column was added | ALTER TABLE chunks ADD COLUMN extraction_method TEXT |
| Port 5000 refused | macOS AirPlay Receiver holds port 5000 | Changed to port 5001 |
| Logo injection failed with sed | Base64 string contains special characters | Used Python file write instead of sed |
Key Terms Glossary
Retrieval-Augmented Generation. An architecture that retrieves relevant document chunks and injects them as context into an LLM prompt before generation.
A numerical vector (list of floats) that encodes the semantic meaning of a piece of text. Similar texts produce similar vectors.
A database optimised for storing and searching vectors by similarity. We use Qdrant in local file mode.
A measure of the angle between two vectors. Score of 1.0 = identical meaning; 0.0 = completely unrelated.
Splitting long documents into smaller text pieces (chunks) that fit within the LLM's context window and can be indexed individually.
The number of most-similar chunks returned by the vector search. Higher k = more context, more tokens, potentially more noise.
A tool for running LLMs locally. Manages model downloads, serves a REST API, and handles Metal GPU acceleration on Apple Silicon.
Large Language Model. A neural network trained on text that can generate, summarise, translate, and reason. Here: llama3.1:8b.
A parameter controlling LLM output randomness. 0.0 = deterministic/factual; 1.0+ = creative/varied. We use 0.1 for document Q&A.
Optical Character Recognition. Converts images of text into machine-readable text. Used for scanned PDFs and photograph receipts.
The maximum number of tokens an LLM can process in one prompt+response. llama3.1:8b supports 8,192 tokens (num_ctx: 8192).
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:
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.
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.
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.
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.
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.
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.
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).