Accueil / Articles

RAG multimodal privé sur un Mac M1 : lecture sélective de graphiques avec Ollama

RAG multimodal privé sur un Mac M1 avec Ollama : PyMuPDF pour le texte et les tableaux, un petit modèle visuel Qwen uniquement pour les graphiques, les vérifications de nombres et la recherche dans ChromaDB.

Éditeur : REACTAPP.TOP10 min de lecture2293 mots

Sources: Zero-Cost Local Multimodal RAG: Selective Vision Processing with ChromaDB

Les ingénieurs, les analystes financiers et les chercheurs traitent régulièrement des PDF confidentiels sur leurs ordinateurs portables : documentation technique interne, rapports de résultats trimestriels, documents relatifs à des essais cliniques. Ce qu’ils souhaitent, c’est un assistant de recherche privé et gratuit qui fonctionne entièrement sur un MacBook équipé d’un chip M1 et qui parvienne néanmoins à fournir des réponses précises malgré des mises en page complexes, des tableaux financiers et des graphiques.

Il existe une limite importante à prendre en compte. Traiter chaque page d’un PDF de 100 pages en tant qu’image via un modèle de vision-langage sur un M1 entraîne des temps d’attente très longs, un échange massif de mémoire et un ventilateur qui semble prêt à décoller. Une approche hybride sélective permet d’éviter cela, sans que aucun octet de données ne doive quitter la machine.

Trois problèmes à résoudre en même temps

Le traitement de PDF complexes sur du matériel modeste comme l’M1 soulève plusieurs difficultés distinctes, certaines liées au matériel et d’autres aux modèles eux-mêmes.

  1. L’extraction naïve de texte à l’aide d’outils tels que pypdf ou pdfminer est rapide et consomme peu de mémoire, mais elle aplatisse les textes à plusieurs colonnes, détruit la structure des tableaux et ignore complètement les graphiques.
  2. Le traitement visuel de toute la page permet de conserver le contexte visuel, mais il surcharge le processeur. Traiter chaque page avec un VLM consomme de la RAM, prend plusieurs minutes par document et entraîne des erreurs d’OCR sur les petits textes.
  3. Les hallucinations sont courantes chez tous les modèles visuel-langage, qu’ils fonctionnent sur un M1, un A100 ou un CPU. La lecture des étiquettes d’axe fines, des textes petits et des tableaux denses de type feuille de calcul est une tâche qu’ils accomplissent de manière peu fiable, et ils inventent fréquemment des chiffres ou décrivent incorrectement les tendances dans leurs résumés libres. Pour des documents financiers ou cliniques où la précision est indispensable, c’est un défaut grave dans tout système multimodal RAG.

Un design fonctionnel doit faire face au plafond de mémoire, à la perte de la structure du texte et à l’habitude d’inventer des numéros en même temps.

Le design hybride sélectif

L’architecture mémoire unifiée du M1 permet au CPU et à la GPU de partager un pool rapide, ce qui le rend étonnamment apte à l’IA locale tant que ce pool est utilisé judicieusement. Le pipeline divise le travail en quatre parties.

  • Un chemin rapide gère le texte et les tableaux. Un analyseur léger sensible au format, PyMuPDF, lit le texte natif, conserve la hiérarchie des titres sous forme de niveaux Markdown et transforme les tableaux en Markdown propre, tout cela en quelques millisecondes.
  • Un chemin sélectif s’occupe des éléments visuels. Les graphiques, diagrammes et organigrammes sont recadrés et enregistrés en fichiers d’image séparés. Seuls ces fichiers indépendants sont envoyés à un VLM local quantifié et de petite taille, qwen2.5-vl:3b fourni via Ollama, ce qui réduit fortement l’utilisation de la mémoire et le temps d’inférence.
  • Les mécanismes de protection contre les hallucinations assurent la fiabilité des résultats. Le modèle doit répondre sous forme de JSON structuré, et les chiffres qu’il indique sont vérifiés par rapport au texte OCR brut extrait de la boîte de délimitation de l’image, de sorte que les affirmations s’appuient sur l’image réelle avant d’être stockées dans la base de données vectorielle.
  • La fusion du contexte permet de reconstituer l’ensemble des informations. Des résumés visuels sont insérés dans le flux Markdown à côté de métadonnées telles que les numéros de page et les références d’image, puis l’indexation a lieu par la suite.

Architecture

Le flux allant du stockage local des documents, en passant par le parsing, le recadrage, le résumé et l’indexation, se présente comme suit :

                       ┌───────────────────────────────────────┐
                       │   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)  │
                         └───────────────────────────────────┘

Renforcer la sécurité

Un script prototype diffère d’une application locale fiable, et quelques décisions relatives à l’infrastructure font la différence.

Choix du stockage vectoriel

ChromaDB convient à cette configuration car il s’exécute en interne, ne nécessite aucune infrastructure et stocke les données sur disque via une base de données SQLite intégrée, laissant ainsi 8 ou 16 GB de RAM disponibles pour Ollama. Cependant, la conception n’est pas liée à lui. Remplacer ce stockage par un autre ne nécessite que de petites modifications dans le processus d’ingestion :

  • LanceDB offre une recherche approximative très rapide basée sur le disque, avec une interface Python native.
  • Qdrant est une base de données à haute performance qui fonctionne bien en local et dispose de fonctionnalités de filtrage avancées.
  • FAISS, développé par Meta, est une bibliothèque purement en mémoire qui est extrêmement rapide lorsque la RAM est abondante.
  • SQLite avec l’extension sqlite-vec intègre directement la recherche vectorielle dans SQLite, ce qui est idéal pour ceux qui souhaitent réduire le nombre de dépendances.
  • Pinecone et Weaviate sont des solutions hébergées en cloud adaptées aux projets qui dépassent les capacités d’un ordinateur portable.
  • Que vous choisissiez l’une ou l’autre méthode, le découpage en blocs, la génération d’embeddings et la récupération fonctionnent de la même manière.

    Récupération parent-enfant

    Les résumés de graphiques ne devraient pas être isolés du texte qui les entoure. L’index stocke de petits blocs enfants d’environ 200 tokens pour une correspondance précise, mais renvoie au modèle de langage le plus grand bloc parent d’environ 800 tokens, permettant ainsi au modèle de lire des paragraphes complets lorsqu’il répond.

    Restaurer l’exactitude des chiffres

    Deux mécanismes de protection simples permettent de lutter contre les hallucinations, quel que soit le matériel utilisé. Premièrement, Ollama est instruit de répondre avec un schéma JSON, par exemple un type de graphique ainsi que des listes des valeurs des axes et des points de données, au lieu de texte libre, ce qui est plus simple à parser et à valider. Deuxièmement, PyMuPDF extrait le texte brut de la boîte de délimitation exacte de l’image recoupée, et chaque nombre généré par le VLM doit apparaître dans cette chaîne avant que quoi que ce soit ne soit enregistré dans la base de données. Les résumés qui ne passent pas cette vérification sont marqués pour une révision manuelle ou supprimés.

    Mise en place du projet et code

    Structure du dossier

    Créez le répertoire de travail dans un terminal :

    mkdir ~/m1_multimodal_rag && cd ~/m1_multimodal_rag
    mkdir data chroma_db images_cache
    touch main.py requirements.txt
    

    Dépendances

    Énumérez les paquets dans requirements.txt :

    pymupdf
    chromadb
    ollama
    pillow
    

    Le seul script

    Toute la logique se trouve dans main.py : le parsing du PDF, l’encodage des images en Base64 pour Ollama, l’écriture des blocs parents et enfants dans Chroma, ainsi que la mise à disposition d’une petite interface en ligne de commande. Le flag --clear permet de réinitialiser la base de données, afin que de nouveaux PDF puissent être traités sans mélanger les données.

    #!/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()
    

    Le lancement

    Étape 1 : Installer Ollama et télécharger les modèles

    Ollama s’installe via Homebrew, et les modèles qwen2.5vl nécessitent la version 0.7.0 ou ultérieure :

    # 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
    

    Étape 2 : Créer l’environnement Python

    # 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
    

    Étape 3 : Installer les dépendances

    # 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!')"
    

    Étape 4 : Récupérer un échantillon PDF

    Un rapport financier IFRS illustratif d’EY constitue un document de test pratique :

    # 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_"
    

    Étape 5 : Ingestion

    python main.py ingest --pdf data/sample_financials.pdf
    

    Vous verrez une barre de progression pour chaque page, suivie d’un message indiquant que l’ingestion est terminée. Pour charger un PDF différent après avoir effacé les anciennes données, ajoutez le flag de suppression :

    # Ingest a new PDF and clear the database first
    python main.py ingest --pdf data/<your financial data file>.pdf --clear
    

    Étape 6 : Poser une question

    python main.py query --question "What was the total revenue shown in the financial statements?"
    

    Résumé de l’approche

    Un système RAG multimodal local fonctionnant sur une M1 est réalisable. PyMuPDF permet une extraction rapide de texte et de tableaux, Ollama avec qwen2.5vl:3b ne traite que les éléments visuels nécessaires, ChromaDB ou un autre stockage léger conserve un index parent-enfant persistant, et un script en ligne de commande relie tout cela. Ce système ne coûte rien, n’envoie aucune donnée et fournit des réponses à partir de documents confidentiels présents sur la machine elle-même.

    Le projet final peut également être cloné depuis son répertoire de dépôt :

    git clone https://github.com/froilan-sia/m1_multimodal_rag.git
    cd m1_multimodal_rag
    

    Celui-ci comprend un outil setup.sh qui crée le fichier .gitignore, le README ainsi que la licence, et initialise Git, ce qui est utile lors de la création de branches ou de personnalisations. Après le clonage, suivez soit les étapes manuelles indiquées ci-dessus, soit exécutez cet outil :

    ./setup.sh