Inicio / Artículos

RAG multimodal privado en una Mac M1: lectura selectiva de gráficos con Ollama

RAG multimodal privado en una Mac M1 con Ollama: PyMuPDF para texto y tablas, un pequeño modelo de visión Qwen solo para gráficos, verificación de números y recuperación desde ChromaDB.

Editor: REACTAPP.TOP10 min de lectura2293 palabras

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

Los ingenieros, analistas financieros e investigadores manejan regularmente documentos PDF confidenciales en sus propios portátiles: documentación técnica interna, informes de resultados trimestrales, artículos de ensayos clínicos. Lo que desean es un asistente de búsqueda privado y gratuito que funcione íntegramente en una MacBook con chip M1 y que, aun así, responda con precisión a partir de diseños de página complejos, tablas y gráficos financieros.

Existe un límite importante a tener en cuenta. Procesar cada página de un PDF de 100 páginas como imagen en un chip M1 mediante un modelo de visión y lenguaje implica esperas muy largas, un intenso intercambio de memoria y un ventilador que suena como si estuviera a punto de arrancar. Un sistema híbrido selectivo evita esto, y ningún byte de los datos necesita salir de la máquina.

Tres problemas que resolver al mismo tiempo

Procesar PDFs complejos en hardware modesto como el M1 plantea varias dificultades distintas, algunas relacionadas con el hardware y otras con los propios modelos.

  1. La extracción de texto ingenua con herramientas como pypdf o pdfminer es rápida y consume poca memoria, pero aplana el texto multicolónico, destruye la estructura de las tablas e ignora por completo los gráficos.
  2. El procesamiento de la visión de página completa mantiene el contexto visual, pero sobrecarga el chip. Procesar cada página con un VLM consume RAM, tarda minutos por documento y genera errores de OCR en el texto breve.
  3. Las alucinaciones son comunes en todos los modelos de visión-lenguaje, ya sea que funcionen en un M1, un A100 o una CPU. Leer etiquetas de ejes detalladas, texto pequeño y tablas densas al estilo de hojas de cálculo lo hacen de manera poco fiable, e inventan frecuentemente números o describen erróneamente las tendencias en resúmenes libres. En materiales financieros o clínicos, donde la precisión es obligatoria, eso constituye un defecto grave en cualquier sistema multimodal RAG.

Un diseño funcional debe hacer frente al límite de memoria, a la estructura de texto perdida y a la costumbre de inventar números al mismo tiempo.

El diseño híbrido selectivo

La arquitectura de memoria unificada del M1 permite que la CPU y la GPU compartan un conjunto de memoria rápido, lo que lo hace sorprendentemente capaz para la IA local siempre y cuando dicho conjunto se utilice de manera inteligente. La tubería de procesamiento divide el trabajo en cuatro partes.

  • Una ruta rápida se encarga del texto y las tablas. Un analizador ligero sensible al diseño, PyMuPDF, lee el texto nativo, mantiene la jerarquía de encabezados como niveles de Markdown y convierte las tablas en formato Markdown limpio, todo en cuestión de milisegundos.
  • Una ruta selectiva se ocupa de los elementos visuales. Los gráficos, diagramas y diagramas de flujo se recortan y se guardan como archivos de imagen separados. Solo esas imágenes independientes se envían a un VLM local cuantizado y de tamaño reducido, qwen2.5-vl:3b proporcionado a través de Ollama, lo que reduce drásticamente el uso de memoria y el tiempo de inferencia.
  • Las medidas de protección contra las alucinaciones hacen que los resultados sean fiables. El modelo debe responder en JSON estructurado, y las cifras que indica se verifican comparándolas con el texto OCR original extraído del cuadro delimitador de la imagen, de modo que las afirmaciones se basan en la imagen real antes de llegar a la base de datos vectorial.
  • La fusión de contexto reúne todas las partes. Los resúmenes visuales se insertan en el flujo de Markdown junto a metadatos como los números de página y las referencias de imagen, y la indexación se realiza posteriormente.

Arquitectura

El flujo desde el almacén local de documentos, pasando por el análisis, el recorte, el resumen y la indexación, es el siguiente:

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

Reforzando la seguridad

Un script prototípico difiere de una aplicación local fiable, y algunas decisiones relacionadas con la infraestructura marcan la diferencia.

Elegir el almacén de vectores

ChromaDB es adecuado para esta configuración porque funciona dentro del proceso, no requiere infraestructura y almacena los datos en el disco mediante una base de datos SQLite integrada, dejando 8 o 16 GB de RAM disponibles para Ollama. Sin embargo, el diseño no está vinculado a él. Sustituirlo por otro almacén solo requiere pequeños cambios en el proceso de ingestión:

  • LanceDB ofrece búsquedas aproximadas muy rápidas basadas en disco con una interfaz nativa en Python.
  • Qdrant es una base de datos de alto rendimiento que funciona bien localmente y cuenta con funciones avanzadas de filtrado.
  • FAISS, de Meta, es una biblioteca puramente en memoria que es extremadamente rápida cuando hay suficiente RAM.
  • SQLite con la extensión sqlite-vec agrega búsquedas vectoriales directamente a SQLite, lo cual es ideal para quienes desean tener una dependencia menos.
  • Pinecone y Weaviate son opciones alojadas en la nube para proyectos que ya no caben en una laptop.

El procesamiento en bloques, la creación de embeddings y la recuperación funcionan de la misma manera independientemente de la opción elegida.

Recuperación de elementos padre-hijo

Los resúmenes de gráficos no deberían quedar separados del texto que los rodea. El índice almacena pequeños bloques hijos de unos 200 tokens para una coincidencia precisa, pero devuelve al modelo LLM la sección padre más grande, de aproximadamente 800 tokens, de modo que el modelo lea párrafos completos al responder.

Mantener la precisión en las cifras

Existen dos medidas de seguridad que ayudan a evitar las alucinaciones, independientemente del hardware utilizado. Primero, se indica a Ollama que responda con un esquema JSON, por ejemplo, un tipo de gráfico junto con listas de valores de ejes y puntos de datos, en lugar de texto libre, lo cual es más sencillo de analizar y validar. Segundo, PyMuPDF extrae el texto en bruto del área exacta delimitada por la imagen recortada, y cada número generado por el VLM debe aparecer en esa cadena antes de que se escriba cualquier cosa en la base de datos. Los resúmenes que no pasan esta verificación se marcan para revisión manual o se descartan.

Configuración del proyecto y código

Estructura de carpetas

Cree el directorio de trabajo en una terminal:

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

Dependencias

Enumere los paquetes en requirements.txt:

pymupdf
chromadb
ollama
pillow

El único script

Toda la lógica se encuentra en main.py: el análisis del PDF, la codificación de las imágenes en formato Base64 para Ollama, la escritura de los fragmentos padre e hijo en Chroma, y la exposición de una pequeña interfaz de línea de comandos. La opción --clear reinicia la base de datos, de modo que se puede procesar un nuevo PDF sin mezclar datos.

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

Ejecutarlo

Paso 1: Instalar Ollama y descargar los modelos

Ollama se instala a través de Homebrew, y los modelos qwen2.5vl requieren la versión 0.7.0 o superior:

# 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

Paso 2: Crear el entorno de 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

Paso 3: Instalar las dependencias

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

Paso 4: Obtener una muestra de PDF

Un informe financiero IFRS ilustrativo de EY sirve como documento de prueba conveniente:

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

Paso 5: Ingestión

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

Espera una barra de progreso para cada página, seguida de un mensaje indicando que la ingestión se ha completado. Para cargar un PDF diferente después de borrar los datos antiguos, agrega la bandera de eliminación:

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

Paso 6: Hacer una pregunta

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

Resumen del enfoque

Es posible crear un sistema RAG multimodal local en una M1. PyMuPDF se encarga de la extracción rápida de texto y tablas, Ollama con qwen2.5vl:3b interpreta únicamente las imágenes que lo requieren, ChromaDB u otro almacenamiento ligero mantiene un índice persistente de tipo padre-hijo, y un script de línea de comandos lo integra todo. El resultado no tiene costo alguno, no envía datos a ningún lugar y responde a partir de documentos confidenciales que se encuentran en la misma máquina.

También se puede clonar el proyecto final desde su repositorio:

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

Incluye una herramienta setup.sh que crea el archivo .gitignore, el README y la licencia, además de inicializar Git, lo cual es útil al hacer forks o personalizar el proyecto. Después de clonar, sigue los pasos manuales anteriores o ejecuta la herramienta:

./setup.sh

Leer más