Приватный мультимодальный 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 делить один быстрый пул памяти, что делает его удивительно подходящим для локальных ИИ-моделей при разумном использовании этого пула. Пайплайн разделяет работу на четыре этапа.
- Быстрый путь обрабатывает текст и таблицы. Легкий парсер, учитывающий структуру документа, 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 токенов для точного совпадения, но возвращает LLM более крупный родительский фрагмент объемом около 800 токенов, чтобы модель могла читать полные абзацы при ответе.
Честность цифр
Два механизма защиты помогают против галлюцинаций независимо от используемого оборудования. Во-первых, 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