Privater Multimodal-RAG auf einem M1-Mac: Selektives Lesen von Charts mit Ollama
Privater multimodaler RAG auf einem M1 Mac mit Ollama: PyMuPDF für Texte und Tabellen, ein kleines Qwen-Vision-Modell ausschließlich für Diagramme, Zahlenerfassung sowie Abrufe aus ChromaDB.
Quellen: Zero-Cost Local Multimodal RAG: Selective Vision Processing with ChromaDB
Ingenieure, Finanzanalysten und Forscher arbeiten regelmäßig mit vertraulichen PDF-Dateien auf ihren eigenen Laptops: interne technische Dokumentationen, Quartalsberichte über die Erträge sowie wissenschaftliche Arbeiten zu klinischen Studien. Was sie benötigen, ist ein privater, kostenloser Suchassistent, der vollständig auf einem MacBook mit M1-Chip läuft und dennoch auch aus komplexen Seitenlayouts, Finanztabellen und -grafiken präzise Antworten liefert.
Es gibt jedoch eine wichtige Grenze zu beachten. Wenn jede Seite einer 100-seitigen PDF-Datei als Bild an ein Vision-Language-Modell auf einem M1-Chip übergeben wird, führt das zu sehr langen Wartezeiten, intensivem Speicheraustausch sowie einem Lüfter, der so laut ist, als würde er jeden Moment starten. Ein selektiver hybrider Ansatz vermeidet das, und kein einziger Byte der Daten muss das Gerät verlassen.
Drei Probleme, die gleichzeitig gelöst werden müssen
Die Verarbeitung komplexer PDF-Dateien auf bescheidenen Hardware-Systemen wie dem M1-Chip stellt mehrere spezifische Schwierigkeiten dar – einige davon hängen mit der Hardware zusammen, andere mit den Modellen selbst.
- Naive Textextraktion mit Werkzeugen wie
pypdfoderpdfminerist schnell und verbraucht wenig Speicher, doch sie vereinfacht mehrspaltigen Text, zerstört die Tabellengestruktur und ignoriert Diagramme völlig. - Die Verarbeitung des gesamten Seitenbildes bewahrt den visuellen Kontext, überlastet jedoch den Prozessor. Die Durchführung jeder Seite mit einem VLM verbraucht RAM, dauert pro Dokument mehrere Minuten und führt bei kurzen Textabschnitten zu OCR-Fehlern.
- Halluzinationen sind bei allen Vision-Language-Modellen üblich, egal ob sie auf einem M1, A100 oder einer CPU laufen. Das Lesen feiner Achsenbeschriftungen, kleiner Schriftarten sowie dichter Tabellen im Tabellenkalkulationsstil erledigen sie unzuverlässig; häufig erfinden sie Zahlen oder beschreiben Trends in freien Zusammenfassungen falsch. Für finanzielle oder klinische Materialien, bei denen Präzision unerlässlich ist, stellt das ein ernstes Manko in jedem multimodalen RAG-System dar.
Ein praktikabler Entwurf muss mit der Speichergrenze, der verlorenen Textstruktur sowie der Gewohnheit, Zahlen gemeinsam zu erfinden, umgehen können.
Das selektive hybride Design
Die vereinigte Speicherarchitektur des M1 ermöglicht es der CPU und der GPU, einen schnellen Speicherpool zu teilen, was ihn für lokale KI überraschend leistungsfähig macht, sofern der Pool klug genutzt wird. Der Pipeline teilt die Arbeit in vier Teile auf.
- Ein schneller Weg kümmert sich um Texte und Tabellen. Ein leichtgewichtiger, layoutbewusster Parser namens PyMuPDF liest den nativen Text, behält die Überschriftenhierarchie als Markdown-Ebenen bei und wandelt Tabellen in sauberes Markdown um – alles innerhalb von Millisekunden.
- Ein selektiver Weg kümmert sich um visuelle Elemente. Diagramme, Grafiken und Flussdiagramme werden zugeschnitten und als separate Bilddateien gespeichert. Nur diese eigenständigen Bilder gelangen in einen quantisierten, kleinen lokalen VLM,
qwen2.5-vl:3b, der über Ollama bereitgestellt wird und so den Speicherverbrauch sowie die Inferenzzeit erheblich reduziert.
Architektur
Der Ablauf vom lokalen Dokumentenspeicher über die Analyse, das Ausschneiden, die Zusammenfassung und die Indizierung sieht wie folgt aus:
┌───────────────────────────────────────┐
│ Local PDF Document Store (M1 Mac) │
└───────────────────┬───────────────────┘
│
┌───────────────┴───────────────┐
│ Fast Layout-Aware Parser │
└───────┬───────────────┬───────┘
│ │
[Text & Tables] │ │ [Embedded Images]
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Markdown Stream │ │ Cropped Images │
└─────────┬─────────┘ └─────────┬─────────┘
│ │
│ ▼
│ ┌───────────────────┐
│ │ Base64 Scaling & │
│ │ Native BBox OCR │
│ └─────────┬─────────┘
│ │
│ ▼
│ ┌───────────────────┐
│ │ Local Metal VLM │
│ │ (Ollama via UMA) │
│ └─────────┬─────────┘
│ │
│ [Text Summaries]
│ │
▼ ▼
┌───────────────────────────────────┐
│ Unified Chunking & Context Engine │
└─────────────────┬─────────────────┘
│
▼
┌───────────────────────────────────┐
│ Disk-Persisted Vector DB & Parent │
│ Context Stores (ChromaDB SQLite) │
└───────────────────────────────────┘
Verstärkung der Sicherheit
Ein Prototypenskript unterscheidet sich von einer zuverlässigen lokalen Anwendung, und einige Infrastrukturentscheidungen machen den Unterschied aus.
Wahl des Vektorlagers
ChromaDB eignet sich für diese Konfiguration, da es im Prozess läuft, keine Infrastruktur benötigt und die Daten über eine eingebettete SQLite-Datenbank auf der Festplatte speichert, wodurch 8 oder 16 GB RAM für Ollama frei bleiben. Das Design ist jedoch nicht darauf festgelegt. Der Wechsel auf einen anderen Lager erfordert nur geringe Anpassungen bei der Dateneingabe:
- LanceDB bietet eine sehr schnelle, auf der Festplatte basierende annähernde Suche mit einer nativen Python-Schnittstelle.
- Qdrant ist eine leistungsstarke Datenbank, die lokal gut funktioniert und über umfangreiche Filterfunktionen verfügt.
- FAISS von Meta ist eine reine In-Memory-Bibliothek, die bei ausreichendem RAM äußerst schnell ist.
sqlite-vec-Erweiterung fügt eine Vektorsuche direkt zu SQLite hinzu – ideal für alle, die eine Abhängigkeit weniger haben möchten.Egal, welche Methode Sie wählen, das Aufteilen in Blöcke, das Erstellen von Embeddings und die Abfrage funktionieren auf dieselbe Weise.
Eltern-Kind-Abfrage
Diagrammzusammenfassungen sollten nicht losgelöst vom umgebenden Text erscheinen. Der Index speichert kleine „Kind-Blöcke“ mit etwa 200 Tokenn für präzise Abgleiche, gibt dem LLM jedoch den größeren „Eltern-Teil“ mit etwa 800 Tokenn zurück, sodass das Modell bei der Antwort vollständige Absätze liest.
Akkurate Angaben
Zwei einfache Schutzmaßnahmen begegnen Halluzinationen unabhängig von der verwendeten Hardware. Erstens wird Ollama angewiesen, anstelle von freiem Text ein JSON-Schema zurückzugeben – beispielsweise einen Diagrammtyp zusammen mit Listen der Achsenwerte und Datenspunkte – da dieses einfacher zu parsen und zu validieren ist. Zweitens extrahiert PyMuPDF den Rohtext aus dem genauen Begrenzungsbereich des zugeschnittenen Bildes, und jede Zahl, die vom VLM erzeugt wird, muss in diesem String enthalten sein, bevor etwas in die Datenbank geschrieben wird. Zusammenfassungen, die die Überprüfung nicht bestehen, werden für eine manuelle Überprüfung markiert oder verworfen.
Projekteinrichtung und Code
Ordnerrahmenstruktur
Erstellen Sie den Arbeitsordner in einer Terminal:
mkdir ~/m1_multimodal_rag && cd ~/m1_multimodal_rag
mkdir data chroma_db images_cache
touch main.py requirements.txt
Abhängigkeiten
Listen Sie die Pakete in requirements.txt auf:
pymupdf
chromadb
ollama
pillow
Die einzige Skriptdatei
Sämtliche Logik befindet sich in main.py: Das PDF wird analysiert, Bilder als Base64 für Ollama kodiert, Eltern- und Kind-Chunks in Chroma geschrieben, und es wird eine einfache CLI bereitgestellt. Die Flagge --clear löscht die Datenbank, sodass ein neues PDF ohne Vermischung von Daten aufgenommen werden kann.
#!/usr/bin/env python3
"""
Multimodal RAG for Apple Silicon (M1/M2/M3)
Usage:
python main.py ingest --pdf data/report.pdf
python main.py ingest --pdf data/new_report.pdf --clear
python main.py query --question "What was the Q3 revenue?"
"""
import argparse
import base64
import os
import sys
import uuid
from io import BytesIO
from pathlib import Path
import chromadb
import pymupdf as fitz
import ollama
from PIL import Image
# ---------- CONFIG ----------
TEXT_MODEL = "llama3.2:3b" # For final RAG answers (8GB friendly)
VISION_MODEL = "qwen2.5vl:3b" # For charts (8GB friendly)
CHROMA_PATH = "./chroma_db"
IMAGE_CACHE = "./images_cache"
# Initialize persistent Chroma client (SQLite, not RAM)
chroma_client = chromadb.PersistentClient(path=CHROMA_PATH)
child_collection = chroma_client.get_or_create_collection(name="child_chunks")
parent_collection = chroma_client.get_or_create_collection(name="parent_chunks")
Path(IMAGE_CACHE).mkdir(exist_ok=True)
# ---------- HELPER: Encode Image for Ollama ----------
def encode_image_for_ollama(image_bytes: bytes, max_size=800) -> str:
"""Convert PDF image bytes to Base64 data URI with size limiting."""
img = Image.open(BytesIO(image_bytes))
# Convert RGBA/P to RGB to avoid JPEG alpha errors
if img.mode in ('RGBA', 'LA', 'P'):
img = img.convert('RGB')
# Downscale massive images to save VRAM on M1
img.thumbnail((max_size, max_size))
buffered = BytesIO()
img.save(buffered, format="JPEG", quality=85)
img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
return img_base64
# ---------- PHASE 1: INGESTION ----------
def ingest_pdf(pdf_path: str):
"""Parse PDF, extract text, crop images, run VLM, and store in Chroma."""
print(f" Processing: {pdf_path}")
doc = fitz.open(pdf_path)
for page_num in range(len(doc)):
page = doc[page_num]
print(f" Page {page_num + 1}/{len(doc)}")
# 1. Extract main text
page_text = page.get_text("text").strip()
if not page_text:
page_text = "[No extractable text on this page]"
# 2. Find and process images
image_list = page.get_images(full=True)
visual_summaries = []
for img_idx, img in enumerate(image_list):
xref = img[0]
try:
base_image = doc.extract_image(xref)
image_bytes = base_image["image"]
# Encode for Ollama
encoded_img = encode_image_for_ollama(image_bytes)
# Prompt designed for financial charts with structured output
prompt = """
Extract key insights from this chart and return valid JSON.
Use this schema: {"chart_type": "", "x_axis": [], "y_axis": [], "key_trend": "", "data_points": []}
If it's not a chart, describe it briefly in text.
"""
response = ollama.chat(
model=VISION_MODEL,
messages=[{
"role": "user",
"content": prompt,
"images": [encoded_img]
}]
)
summary = response["message"]["content"]
visual_summaries.append(f"[Chart on page {page_num+1}]: {summary}")
except Exception as e:
print(f" Skipped image {img_idx} (Error: {e})")
continue
# 3. Merge text and summaries
full_page_content = page_text + "\n" + "\n".join(visual_summaries)
if not full_page_content.strip():
continue # Skip completely empty pages
# 4. Split into Parent (big) and Child (small) for retrieval
parent_text = full_page_content # Full page is the "Parent"
# Split into ~200 token chunks for children (roughly 800 chars)
child_chunks = []
chunk_size = 800
for i in range(0, len(parent_text), chunk_size):
child_chunks.append(parent_text[i:i+chunk_size])
if not child_chunks:
child_chunks = [parent_text] # Fallback
# 5. Store in Chroma (Parent-Child)
parent_id = str(uuid.uuid4())
metadata = {
"source": os.path.basename(pdf_path),
"page": page_num + 1,
"type": "hybrid"
}
# Store Parent (full context) - Persisted to disk, not RAM
parent_collection.add(
ids=[parent_id],
documents=[parent_text],
metadatas=[metadata]
)
# Store Children (granular search)
child_ids = []
child_metadatas = []
for idx, chunk in enumerate(child_chunks):
child_id = f"{parent_id}_child_{idx}"
child_ids.append(child_id)
child_metadatas.append({
**metadata,
"parent_ref": parent_id
})
child_collection.add(
ids=child_ids,
documents=child_chunks,
metadatas=child_metadatas
)
doc.close()
print(" Ingestion complete!")
# ---------- PHASE 2: QUERY ----------
def query_rag(question: str):
"""Retrieve relevant context using Child chunks, fetch Parent, ask LLM."""
print(f"❓ Query: {question}")
# 1. Retrieve top matching child chunks
results = child_collection.query(
query_texts=[question],
n_results=3
)
if not results["ids"] or not results["ids"][0]:
print(" No relevant documents found in the database.")
return
# 2. Fetch the full Parent contexts
parent_ids = list(set([m["parent_ref"] for m in results["metadatas"][0]]))
parent_results = parent_collection.get(ids=parent_ids)
full_context = "\n\n---\n\n".join(parent_results["documents"])
# 3. Build prompt for the text-only LLM
prompt = f"""
You are a financial research assistant. Answer the question based strictly on the context below.
If the context contains chart summaries or tables, use those numbers specifically.
If you cannot answer from the context, say "I don't have that information."
Context:
{full_context}
Question: {question}
Answer:
"""
# 4. Generate answer locally
response = ollama.chat(
model=TEXT_MODEL,
messages=[{"role": "user", "content": prompt}]
)
print("\n Answer:")
print(response["message"]["content"])
print("\n Sources:", ", ".join(parent_ids))
# ---------- DATABASE CLEAR FUNCTION ----------
def clear_database():
"""Delete all collections to reset the database."""
try:
chroma_client.delete_collection("child_chunks")
chroma_client.delete_collection("parent_chunks")
print(" Database cleared successfully!")
except ValueError:
print(" Database was already empty. Nothing to clear.")
except Exception as e:
print(f" Could not clear database: {e}")
# ---------- CLI ENTRY POINT ----------
def main():
parser = argparse.ArgumentParser(description="M1 Multimodal RAG Pipeline")
subparsers = parser.add_subparsers(dest="command", required=True)
# Ingest command with --clear flag
ingest_parser = subparsers.add_parser("ingest", help="Ingest a PDF")
ingest_parser.add_argument("--pdf", required=True, help="Path to PDF file")
ingest_parser.add_argument("--clear", action="store_true", help="Clear the database before ingesting")
# Query command
query_parser = subparsers.add_parser("query", help="Ask a question")
query_parser.add_argument("--question", required=True, help="Your question")
args = parser.parse_args()
if args.command == "ingest":
if not os.path.exists(args.pdf):
print(f" File not found: {args.pdf}")
sys.exit(1)
# Clear the database if the flag is set
if args.clear:
clear_database()
# Re-initialize collections after clearing
global child_collection, parent_collection
child_collection = chroma_client.get_or_create_collection(name="child_chunks")
parent_collection = chroma_client.get_or_create_collection(name="parent_chunks")
ingest_pdf(args.pdf)
elif args.command == "query":
query_rag(args.question)
if __name__ == "__main__":
main()
Es ausführen
Schritt 1: Ollama installieren und die Modelle herunterladen
Ollama wird über Homebrew installiert, und die qwen2.5vl-Modelle erfordern Version 0.7.0 oder neuer:
# Install Ollama via Homebrew
brew install ollama
# Verify version (requires >= 0.7.0 for qwen2.5vl models)
ollama --version
# Start the Ollama service (keep this running in a separate terminal tab)
ollama serve
# Pull the recommended models (For 8GB M1 Mac)
ollama pull qwen2.5vl:3b
ollama pull llama3.2:3b
# (For 16GB+ M1/M2/M3, optionally pull larger models)
# ollama pull llama3.2-vision:11b
# ollama pull qwen2.5:7b
# displays a list of all AI models stored locally on your machine
ollama list
Schritt 2: Erstellen Sie die Python-Umgebung
# Ensure you are in the project root
cd ~/m1_multimodal_rag
# Create a virtual environment
python3 -m venv venv
# Activate the environment
source venv/bin/activate
Schritt 3: Installieren Sie die Abhängigkeiten
# Upgrade pip
pip install --upgrade pip
# Install requirements
pip install -r requirements.txt
# Verify installation
python -c "import chromadb, fitz, ollama, PIL; print(' All dependencies ready!')"
Schritt 4: Ein PDF-Beispiel herunterladen
Ein veranschaulichendes IFRS-Finanzbericht von EY eignet sich hervorragend als Testdokument:
# Download a sample financial report (EY IFRS Illustrative)
curl -L -o data/sample_financials.pdf \
"https://drive.google.com/uc?export=download&id=1OOE1vPBwPP31cB0_MNgooTrhw6KpY6n_"
Schritt 5: Eingabe
python main.py ingest --pdf data/sample_financials.pdf
Erwarten Sie einen Fortschrittsbalken für jede Seite, gefolgt von einer Meldung, dass die Einpflegung abgeschlossen ist. Um nach dem Löschen der alten Daten ein anderes PDF zu laden, fügen Sie das Löschflagge hinzu:
# Ingest a new PDF and clear the database first
python main.py ingest --pdf data/<your financial data file>.pdf --clear
Schritt 6: Stellen Sie eine Frage
python main.py query --question "What was the total revenue shown in the financial statements?"
Zusammenfassung des Ansatzes
Ein lokales multimodales RAG-System auf einem M1 ist realisierbar. PyMuPDF kümmert sich um die schnelle Extraktion von Texten und Tabellen, Ollama mit qwen2.5vl:3b interpretiert nur die benötigten Visualisierungen, ChromaDB oder ein anderes leichtgewichtiges Speichersystem führt einen persistenten Eltern-Kind-Index, und ein Skript in der Kommandozeile verbindet alles miteinander. Das Ergebnis kostet nichts, sendet keine Daten an externe Orte und bezieht seine Antworten aus vertraulichen Dokumenten auf demselben Gerät.
Das fertige Projekt kann auch aus seinem Repository geklont werden:
git clone https://github.com/froilan-sia/m1_multimodal_rag.git
cd m1_multimodal_rag
Es enthält einen setup.sh-Hilfsprogramm, das das .gitignore, die README-Datei sowie die Lizenz erstellt und Git initialisiert, was bei Forking oder Anpassungen hilft. Nach dem Klonen können Sie entweder die oben genannten manuellen Schritte befolgen oder das Hilfsprogramm ausführen:
./setup.sh