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.
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.
- L’extraction naïve de texte à l’aide d’outils tels que
pypdfoupdfminerest 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. - 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.
- 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:3bfourni 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-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.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