Прыватны мультымадальны RAG на Mac з M1: выбарачнае чытанне графікаў з Ollama
Прыватны мультымадальны RAG на Mac з M1 і Ollama: PyMuPDF для тэксту і табліц, маленькая vision-мадэль Qwen толькі для графікаў, праверка лічбаў і пошук праз ChromaDB.
Крыніцы: Zero-Cost Local Multimodal RAG: Selective Vision Processing with ChromaDB
Інжынеры, фінансавыя аналітыкі і дзеярнікі рэгулярна працуюць з конфідэнцыйнымі PDF-файламі на сваіх ноутбуках: внутрашняя тэхнічная дакументацыя, квартальныя звясткі пра дохады, матэрыялы клінічных апробацый. Ім патрэбны прыватны, бесплатны асістэнт для пошуку, які мог бы працаваць выключна на MacBook з чыпам M1 і ўсё ж точна адпавядаць на запыткі, нават у складных макетах сторанак, фінансовых табліцах і графіках.
Існуе жорсткія абмежэнні, якія трэба врачыць. Перадача кожнай сторанкі 100-сторанковага PDF-файлу ў модэль, якая розумее звуку і зразкі візуальнага матэрыялу, у вастоце зображэння на прыстрое з чыпам M1, ведае за сабой дужа дугі час чакання, інтэнсіўную замену памяці і актывную працу вентыляцыі. Выборчы гібрыдны падход дапамагае ухіліцца ад гэтага, і жаданы байт дадзэнняў не патрабуецца выходзіць за межы прыстрою.
Тры проблемы, якія трэба рашыць адразу
Апрацоўка складных PDF-файлов на простым апаратным забезпечэнні, такім як чып M1, стварае некалькі адзінаковых труднасцей — часткі з яых стосуюцца самага апаратнага забезпечэння, а часткі — самых модэляў.
- Простая выкалываннець тэксту з адпамяненням такіх інструментоў, як
pypdfчыстаpdfminer, ёсць шырока і не выкарыстоўвае многа памяці, але яна спрычыняе сплюшчэнне тэксту з калькамі, разрушае структуру табліц і зусім ігнаруе дыяграмы. - Апрацоўка візуальнага кантэксту цэлага староні зберагае візуальную сутнасць, але гэта сільна навантажвае процэсар. Апрацоўка кожнай староні за дапамою VLM спрычыняе выкарыстоўванне RAM, займае хвіліны на адны дакумент і прыносіць памылкі 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 ГБ RAM вальнымі для Ollama. Аднак такія прыемнікі не ўскладненыя ў сваім дызайне. Ёх замяну на іншы хранільнік патрабуе толькі незначных змян у процэсе запоўнення дадзеннямі:
- LanceDB адказвае за дужа шырокі пошук на дыску з апрыксімацыямі за дапамою натыўнага інтэрфейса на Python.
- Qdrant — гэта база дадзеных высокай працэздатнасі, якая добра працуе локальна і паўніста падтрымлеў фільтрацыю.
- FAISS, створаныя Meta, — гэта чыстая бібліятэка, якая працуе толькі ў памяці і ўзнімае вельмі вялікія швыдкасці, калі ёсць достатак RAM.
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 пасля вычысцэння старых дадзэнняў, трэба дадаць флаг clear:
# 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