在M1 Mac上运行的私有多模态RAG:借助Ollama实现选择性图表阅读
在配备 Ollama 的 M1 Mac 上运行的私有多模态 RAG 系统:使用 PyMuPDF 处理文本和表格,搭配小型 Qwen 视觉模型仅用于处理图表与数字校验,同时通过 ChromaDB 进行信息检索。
来源: Zero-Cost Local Multimodal RAG: Selective Vision Processing with ChromaDB
工程师、金融分析师和研究人员经常在自己的笔记本电脑上处理机密PDF文件:内部技术文档、季度财报、临床试验报告等。他们需要的是一款可在配备M1芯片的MacBook上运行的私人免费搜索助手,即便面对复杂的页面布局、财务表格和图表也能准确回答问题。
这里有一个必须注意的硬性限制。如果将100页PDF的每一页都以图像形式输入到M1芯片的视觉语言模型中,将会导致极长的等待时间、大量的内存交换,还会让风扇发出仿佛即将起飞般的响声。而采用选择性混合处理流程则可以避免这些问题,且数据无需离开机器。
需同时解决的三个问题
在像M1这样的普通硬件上处理复杂的PDF文件会带来若干不同的难题,有些与硬件有关,有些则与模型本身相关。
- 使用
pypdf或pdfminer等工具进行简单的文本提取虽然速度快且内存占用低,但会将多列文本展平,破坏表格结构,还会完全忽略图表内容。 - 全页视觉处理能够保留视觉上下文,但却会给芯片带来巨大负担。对每一页都使用视觉语言模型处理会消耗大量内存,每处理一份文档需要数分钟时间,还会在较小的正文内容上出现OCR识别错误。
- 无论是在M1、A100还是CPU上运行的视觉语言模型,都容易出现幻觉现象。它们在读取精细的轴标签、小字以及密集的电子表格式表格时表现不可靠,还经常在自由文本总结中编造数字或错误描述趋势。对于精度至关重要的金融或医疗领域资料而言,这是任何多模态RAG系统都存在的严重缺陷。
一个可行的设计需要解决内存限制、文本结构丢失以及随意编造数字的问题。
选择性混合设计
M1的统一内存架构使得CPU和GPU能够共享同一个高速内存池,只要合理使用这个内存池,它就能在本地AI处理方面展现出惊人的性能。处理流程被分为四个部分。
- 快速路径负责处理文本和表格。轻量级的布局感知解析器PyMuPDF可在毫秒级内读取原始文本,保持Markdown级别的标题层级,并将表格转换为格式规范的Markdown格式。
- 选择性路径负责处理视觉元素。图表、曲线图和流程图会被裁剪并保存为独立的图像文件。只有这些独立的图像才会被送入通过Ollama提供的量化版小型本地VLM
qwen2.5-vl:3b中,从而大幅降低内存占用和推理时间。
架构
从本地文档存储开始,经过解析、裁剪、总结到索引的整个流程如下:
┌───────────────────────────────────────┐
│ 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数据库将数据存储在磁盘上,从而为Ollama留出8或16GB的RAM空间。不过该设计并非固定绑定于ChromaDB,更换其他存储库只需对数据导入部分进行少量修改:
- LanceDB提供基于磁盘的快速近似搜索功能,并拥有原生Python接口。
- Qdrant是一种高性能数据库,在本地运行表现优异,且具备丰富的过滤功能。
- Meta开发的FAISS是一个纯内存库,在RAM充足时速度极快。
sqlite-vec扩展的SQLite可直接实现向量搜索,非常适合那些希望减少依赖项数量的用户。无论选择哪种方式,分块、嵌入和检索的流程都是相同的。
父子级检索
图表摘要不应与周围的文本分离。索引会存储约200个标记的较小子块以实现精确匹配,但会向大语言模型返回约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
安永提供的一份示例性国际财务报告可作为便捷的测试文档:
# 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?"
方法总结
在 M1 上构建本地多模态 RAG 系统是完全可行的。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