Prywatny multimodalny RAG na M1 Mac: selektywne czytanie wykresów z Ollamą
Prywatny multimodalny RAG na Macu z procesorem M1 z Ollamą: PyMuPDF do obsługi tekstu i tabel, mały model wizyjny Qwen wykorzystywany wyłącznie do wykresów, weryfikacji numerów oraz wyszukiwania w ChromaDB.
Źródła: Zero-Cost Local Multimodal RAG: Selective Vision Processing with ChromaDB
Inżynierowie, analitycy finansowi i badacze regularnie pracują z poufnymi plikami PDF na swoich laptopach: wewnętrzną dokumentacją techniczną, kwartalnymi raportami finansowymi oraz materiałami z badań klinicznych. Chcą mieć prywatnego, darmowego asystenta do wyszukiwania, który działa wyłącznie na MacBooku z chipem M1 i nadal precyzyjnie odpowiada na pytania mimo skomplikowanych układów stron, tabel finansowych i wykresów.
Należy uważać na jeden istotny ograniczenie. Przetwarzanie każdej strony 100-stronicowego pliku PDF jako obrazu w modelu rozumiejącym język obrazowy na chipie M1 powoduje bardzo długie czasy oczekiwania, intensywne wymiany pamięci oraz hałas wentylatora przypominający dźwięk przygotowującego się do startu. Wybiórczy, hybrydowy proces unika tego problemu, dzięki czemu żaden bajt danych nie musi opuszczać urządzenia.
Trzy problemy do rozwiązania jednocześnie
Przetwarzanie złożonych plików PDF na skromnym sprzęcie takim jak M1 stwarza kilka odrębnych trudności, z których niektóre wynikają z samego sprzętu, a inne z modeli.
- Ekstrakcja tekstu w prosty sposób za pomocą narzędzi takich jak
pypdflubpdfminerjest szybka i nie wymaga dużej ilości pamięci, ale spłaszcza tekst wielokolumnowy, niszczy strukturę tabel i całkowicie ignoruje wykresy. - Przetwarzanie obrazu całej strony zachowuje kontekst wizualny, jednak obciąża to procesor. Przetwarzanie każdej strony za pomocą VLM zużywa pamięć RAM, trwa minuty na dokument i powoduje błędy OCR w krótkich fragmentach tekstu.
- Halucynacje są charakterystyczne dla wszystkich modeli wizji-języka, niezależnie od tego, czy działają na procesorze M1, A100, czy CPU. Odczytywanie drobnych etykiet osi, małego druku oraz gęstych tabel w stylu arkuszy kalkulacyjnych jest dla nich niepewne – często wymyślają liczby lub błędnie opisują trendy w swobodnych streszczeniach. W przypadku materiałów finansowych lub klinicznych, gdzie precyzja jest obowiązkowa, stanowi to poważną wadę każdego multimodalnego systemu RAG.
Skuteczny projekt musi radzić sobie z ograniczeniami pamięci, utraconą strukturą tekstu oraz nawykiem wymyślania numerów jednocześnie.
Selektywny projekt hybrydowy
Ujednolicona architektura pamięci M1 umożliwia CPU i GPU współdzielenie jednego szybkiego zasobu pamięci, co sprawia, że urządzenie jest zaskakująco przydatne do zastosowań lokalnej sztucznej inteligencji, o ile zasób ten jest mądrze wykorzystywany. Proces jest dzielony na cztery etapy.
- Szybka ścieżka zajmuje się tekstem i tabelami. Lekki parser uwzględniający układ, PyMuPDF, odczytuje tekst w formacie oryginalnym, zachowuje hierarchię nagłówków jako poziomy Markdown i przekształca tabeli w czysty format Markdown, wszystko to w ciągu zaledwie kilku milisekund.
- Selektywna ścieżka zajmuje się elementami wizualnymi. Wykresy, diagramy i schematy są przycinane i zapisywane jako osobne pliki obrazowe. Tylko te samodzielne obrazy trafiają do skwantyzowanego, małego lokalnego VLM,
qwen2.5-vl:3b, dostępnego przez Ollama, co znacznie zmniejsza zużycie pamięci i czas obliczeń.
Architektura
Przepływ danych od lokalnego magazynu dokumentów, przez analizę, obcinanie, tworzenie podsumowań i indeksowanie, wygląda następująco:
┌───────────────────────────────────────┐
│ 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) │
└───────────────────────────────────┘
Wzmacnianie bezpieczeństwa
Prototyp skryptu różni się od niezawodnej aplikacji lokalnej, a kilka decyzji dotyczących infrastruktury ma na to wpływ.
Wybór magazynu wektorów
ChromaDB nadaje się do tego rozwiązania, ponieważ działa wewnątrz procesu, nie wymaga infrastruktury i przechowuje dane na dysku za pomocą wbudowanej bazy danych SQLite, co pozostawia 8 lub 16 GB pamięci RAM dostępnych dla Ollamy. Projekt nie jest jednak od niego uzależniony. Zastąpienie go innym magazynem wymaga jedynie niewielkich zmian w procesie pobierania danych:
- LanceDB oferuje bardzo szybkie wyszukiwanie przybliżone na dysku z użyciem natywnego interfejsu w Pythonie.
- Qdrant to baza danych o wysokiej wydajności, która dobrze funkcjonuje lokalnie i umożliwia zaawansowane filtrowanie.
- FAISS od Meta to czysta biblioteka pamięciowa, która jest niezwykle szybka, gdy jest dostępna duża ilość pamięci RAM.
- SQLite z rozszerzeniem
sqlite-vecdodaje funkcję wyszukiwania wektorowego bezpośrednio do SQLite, co jest doskonałym rozwiązaniem dla tych, którzy chcą mieć o jedną zależność mniej. - Pinecone i Weaviate to opcje hostowane w chmurze dla projektów, które przekraczają możliwości laptopa.
Niezależnie od wybranej opcji, proces dzielenia na fragmenty, tworzenia embeddingów oraz wyszukiwania funkcjonuje w ten sam sposób.
Wyszukiwanie elementów rodzicielskich i potomnych
Podsumowania wykresów nie powinny znajdować się oddzielnie od otaczającego je tekstu. Indeks przechowuje małe fragmenty potomne o długości około 200 tokenów w celu dokładnego dopasowania, ale zwraca do modelu LLM większą sekcję rodzicielską liczącą około 800 tokenów, dzięki czemu model może czytać pełne akapity podczas udzielania odpowiedzi.
Dbanie o dokładność danych
Dwa proste zabezpieczenia chronią przed halucynacjami niezależnie od używanego sprzętu. Po pierwsze, Ollama jest instruowany do odpowiadania w formacie JSON schema – na przykład z typem wykresu oraz listami wartości osi i punktów danych – zamiast tekstu prostego, który jest łatwiejszy do parsowania i walidacji. Po drugie, PyMuPDF wyodrębnia surowy tekst z dokładnych granic obrazu obciętego, a każda liczba wygenerowana przez VLM musi pojawić się w tym ciągu znaków, zanim cokolwiek zostanie zapisane do bazy danych. Streszczenia, które nie przechodzą tej weryfikacji, są oznaczane do ręcznej oceny lub odrzucane.
Ustawienie projektu i kod
Struktura folderów
Stwórz katalog roboczy w terminalu:
mkdir ~/m1_multimodal_rag && cd ~/m1_multimodal_rag
mkdir data chroma_db images_cache
touch main.py requirements.txt
Zależności
Wymień paczki z pliku requirements.txt:
pymupdf
chromadb
ollama
pillow
Jedyny skrypt
Cała logika znajduje się w main.py: parsowanie PDF, kodowanie obrazów w formacie Base64 dla Ollamy, zapisywanie fragmentów rodzicielskich i potomnych do Chromy oraz dostarczanie prostego interfejsu wiersza poleceń. Flaga --clear służy do resetowania bazy danych, dzięki czemu nowy PDF może zostać przetworzony bez mieszania się danych.
#!/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()
Uruchamianie
Krok 1: Zainstaluj Ollamę i pobierz modele
Ollamę instaluje się za pomocą Homebrew, a modele qwen2.5vl wymagają wersji 0.7.0 lub nowszej:
# 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
Krok 2: Stworzenie środowiska Pythona
# 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
Krok 3: Zainstaluj zależności
# 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!')"
Krok 4: Pobranie przykładowego pliku PDF
Ilustracyjny raport finansowy zgodny z standardem IFRS od EY stanowi wygodny dokument do testowania:
# 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_"
Krok 5: Przyjmowanie
python main.py ingest --pdf data/sample_financials.pdf
Oczekuj pola postępu dla każdej strony, a następnie komunikatu potwierdzającego zakończenie procesu pobierania. Aby załadować inny plik PDF po usunięciu starych danych, dodaj flagę clear:
# Ingest a new PDF and clear the database first
python main.py ingest --pdf data/<your financial data file>.pdf --clear
Krok 6: Zadaj pytanie
python main.py query --question "What was the total revenue shown in the financial statements?"
Podsumowanie podejścia
Możliwe jest stworzenie lokalnego, multimodalnego systemu RAG na procesorze M1. PyMuPDF umożliwia szybką ekstrakcję tekstu i tabel, Ollama z modeliem qwen2.5vl:3b interpretuje tylko te elementy wizualne, które tego wymagają, ChromaDB lub inny lekki system przechowywania utrzymuje trwały indeks rodzic-dziecko, a jeden skrypt w linii poleceń łączy wszystko razem. Taki rozwiązanie nie generuje żadnych kosztów, nie wysyła żadnych danych i odpowiada na pytania na podstawie poufnych dokumentów znajdujących się bezpośrednio na maszynie.
Gotowy projekt można również sklonować z jego repozytorium:
git clone https://github.com/froilan-sia/m1_multimodal_rag.git
cd m1_multimodal_rag
Zawiera pomocnik setup.sh, który tworzy pliki .gitignore, README oraz licencję oraz inicjuje Git, co przydaje się podczas tworzenia odgałęzień lub dostosowywania. Po sklonowaniu można albo postępować zgodnie z powyższymi instrukcjami ręcznymi, albo uruchomić ten pomocnik:
./setup.sh