Startseite / Artikel

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.

Verlag: REACTAPP.TOP10 Min. Lesezeit2293 Wörter

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.

  1. Naive Textextraktion mit Werkzeugen wie pypdf oder pdfminer ist schnell und verbraucht wenig Speicher, doch sie vereinfacht mehrspaltigen Text, zerstört die Tabellengestruktur und ignoriert Diagramme völlig.
  2. 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.
  3. 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.
  • Schutzmechanismen gegen Halluzinationen sorgen dafür, dass die Ausgabe zuverlässig ist. Das Modell muss in strukturiertem JSON antworten, und die von ihm gemeldeten Zahlen werden mit dem rohen OCR-Text aus dem Bildrahmen abgeglichen, sodass die Angaben bereits vor dem Eintreffen in die Vektordatenbank auf dem tatsächlichen Bild beruhen.
  • Durch die Kontextfusion werden die einzelnen Bestandteile wieder zusammengefügt. Visuelle Zusammenfassungen werden neben Metadaten wie Seitenzahlen und Bildreferenzen in den Markdown-Stream eingefügt, und erst danach erfolgt die Indizierung.
  • 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 mit der sqlite-vec-Erweiterung fügt eine Vektorsuche direkt zu SQLite hinzu – ideal für alle, die eine Abhängigkeit weniger haben möchten.
  • Pinecone und Weaviate sind cloud-basierte Optionen für Projekte, die über die Kapazitäten eines Laptops hinauswachsen.
  • 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