Приватний багатомодальний RAG на Mac з процесором M1: вибіркове читання графіків за допомогою Ollama
Приватний багатомодальний RAG на Mac з процесором M1 та Ollama: PyMuPDF для тексту та таблиць, невелика візуальна модель Qwen лише для діаграм, перевірки чисел та пошуку в ChromaDB.
Джерела: Zero-Cost Local Multimodal RAG: Selective Vision Processing with ChromaDB
Інженери, фінансові аналітики та дослідники регулярно працюють із конфіденційними PDF-файлами на власних ноутбуках: внутрішня технічна документація, квартальні звіти про прибутки, матеріали клінічних випробувань. Вони потребують приватного, безкоштовного асистента для пошуку, який би працював виключно на MacBook із чіпом M1 та все одно точно відповідав на запитання, навіть у складних форматах сторінок, фінансових таблицях та графіках.
Існує важлива обмеження, на яку потрібно звернути увагу. Обробка кожної сторінки 100-сторінкового PDF як зображення на чіпі M1 призводить до дуже довгого часу очікування, інтенсивного використання пам’яті та активної роботи вентилятора. Вибірковий гібридний підхід дозволяє уникнути цього, і жоден байт даних не потребує виходу за межі пристрою.
Три проблеми, які потрібно вирішити одночасно
Обробка складних PDF-файлів на простішому обладнанні, такому як M1, створює кілька окремих труднощів — деякі з них пов’язані з самим обладнанням, а інші — з моделями.
- Наївне вилучення тексту за допомогою інструментів на кшталт
pypdfчиpdfminerє швидким та економним з точки зору використання пам’яті, проте воно спрощує багатоколонковий текст, руйнує структуру таблиць та повністю ігнорує діаграми. - Обробка зображень усієї сторінки зберігає візуальний контекст, проте це сильно навантажує процесор. Обробка кожної сторінки за допомогою VLM споживає оперативну пам’ять, займає хвилини на документ та призводить до помилок OCR у коротких фрагментах тексту.
- Галюцинації є поширеним явищем у всіх моделях візуального розуміння мови, незалежно від того, чи вони працюють на M1, A100 чи CPU. Читання дрібних позначок вісок, дрібного шрифту та щільних таблиць у форматі електронних таблиць вони виконують ненадійно, часто вигадуючи числа чи неправильно описуючи тенденції у узагальненнях. Для фінансових чи клінічних матеріалів, де точність є обов’язковою, це серйозний недолік будь-якої багатомодальної системи RAG.
Ефективний дизайн має враховувати обмеження пам’яті, втрату структури тексту та звичку одночасного вигадування чисел.
Селективний гібридний дизайн
Єдина архітектура пам’яті M1 дозволяє CPU та GPU користуватися одним швидким резервуаром, що робить його дивовижно придатним для локальних AI-моделей, за умови розумного використання цього резервуара. Канал обробки розділяє роботу на чотири частини.
- Швидкий шлях обробляє текст та таблиці. Легкий парсер, який враховує структуру, PyMuPDF, читає оригінальний текст, зберігає ієрархію заголовків у вигляді рівнів Markdown та перетворює таблиці на чистий формат Markdown, усе це за кілька мілісекунд.
- Селективний шлях обробляє візуальний контент. Діаграми, графіки та блок-схеми обрізаються та зберігаються як окремі файли зображень. Лише ці окремі зображення надходять до квантованої, невеликої локальної VLM-моделі
qwen2.5-vl:3b, яка працює через Ollama, що значно зменшує використання пам’яті та час обробки.
Архітектура
Потік даних від локального сховища документів через парсинг, обрізку, узагальнення та індексування виглядає так:
┌───────────────────────────────────────┐
│ 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) │
└───────────────────────────────────┘
Зміцнення безпеки
Прототипскрипт відрізняється від надійного локального додатку, і кілька рішень щодо інфраструктури впливають на цю різницю.
Вибір сховища даних
ChromaDB підходить для цієї конфігурації, оскільки він працює безпосередньо в процесі, не потребує інфраструктури та зберігає дані на диску за допомогою вбудованої бази даних SQLite, залишаючи 8 або 16 ГБ оперативної пам’яті вільними для Ollama. Однак ця архітектура не є обов’язковою; для заміни на інше сховище потрібні лише незначні зміни у процесі завантаження даних:
- LanceDB пропонує дуже швидкий приблизний пошук на основі диска з вбудованим інтерфейсом Python.
- Qdrant — це база даних високої продуктивності, яка добре працює локально та має розширені можливості фільтрації.
- FAISS від Meta — це чиста бібліотека, яка працює в оперативній пам’яті та є надзвичайно швидкою, коли її достатньо.
sqlite-vec додає функцію векторного пошуку безпосередньо в SQLite, що ідеально підходить для тих, хто хоче мати на одну залежність менше.Процеси чанкінгу, створення ембеддингів та пошуку працюють однаково, незалежно від обраного інструменту.
Пошук батьківських та дочірніх елементів
Резюме діаграм не повинні існувати окремо від тексту навколо них. Індекс зберігає невеликі дочірні чанки об’ємом близько 200 токенів для точного зіставлення, але повертає більшу батьківську частину об’ємом близько 800 токенів до LLM, щоб модель могла читати повні абзаци під час відповідей.
Збереження точності даних
Два механізми захисту допомагають боротися з галюцинаціями незалежно від використовуваного обладнання. По-перше, Ollama проситься відповідати у форматі JSON-схеми — наприклад, з описом типу діаграми та списками значень вісей та точок даних — замість вільного тексту, що полегшує його обробку та перевірку. По-друге, PyMuPDF витягує необроблений текст безпосередньо з обмеженої області обрізаної зображення, і кожне число, створене VLM, має бути присутнім у цьому рядку перед тим, як щось буде записано до бази даних. Резюме, які не пройшли перевірку, позначаються для ручної перевірки або видаляються.
Налаштування проекту та код
Структура папок
Створіть робочу директорію в терміналі:
mkdir ~/m1_multimodal_rag && cd ~/m1_multimodal_rag
mkdir data chroma_db images_cache
touch main.py requirements.txt
Залежності
Перелічте пакети у файлі requirements.txt:
pymupdf
chromadb
ollama
pillow
Єдиний скрипт
Уся логіка знаходиться у main.py: обробка PDF, кодування зображень у форматі Base64 для Ollama, зберігання батьківських та дочірніх частин у Chroma, а також створення простого інтерфейсу командного рядка. Прапорець --clear скидає базу даних, щоб можна було обробити новий PDF без змішування даних.
#!/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()
Запуск
Крок 1: Встановіть Ollama та завантажте моделі
Ollama встановлюється через Homebrew, а моделі qwen2.5vl вимагають версії 0.7.0 або новішої:
# 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
Крок 2: Створення середовища 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
Крок 3: Встановіть залежності
# 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!')"
Крок 4: Отримати зразок PDF
Ілюстративний фінансовий звіт за стандартами IFRS від EY є зручним документом для тестування:
# 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_"
Крок 5: Прийом даних
python main.py ingest --pdf data/sample_financials.pdf
Очікуйте панель прогресу для кожної сторінки, а потім повідомлення про завершення обробки. Щоб завантажити інший PDF після видалення старих даних, додайте флаг очищення:
# Ingest a new PDF and clear the database first
python main.py ingest --pdf data/<your financial data file>.pdf --clear
Крок 6: Задайте запитання
python main.py query --question "What was the total revenue shown in the financial statements?"
Підсумок підходу
Місцева багатомодальна система RAG на процесорі M1 є досяжною. PyMuPDF забезпечує швидке вилучення тексту та таблиць, Ollama з моделлю qwen2.5vl:3b обробляє лише ті візуальні елементи, які це необхідно, ChromaDB або інший легкий сховище підтримує постійний індекс батьківсько-дочірніх структур, а скрипт у командному рядку об’єднує все це. Цей підхід не коштує нічого, не надсилає жодних даних та надає відповіді з конфіденційних документів безпосередньо на самому пристрої.
Готовий проект також можна клонувати з його репозиторію:
git clone https://github.com/froilan-sia/m1_multimodal_rag.git
cd m1_multimodal_rag
Він містить допоміжний скрипт setup.sh, який створює файли .gitignore, README та ліцензію, а також ініціалізує Git, що допомагає під час клонування чи налаштування. Після клонування можна або виконати вищезазначені ручні кроки, або запустити цей допоміжний скрипт:
./setup.sh