Головна / Статті

Приватний багатомодальний RAG на Mac з процесором M1: вибіркове читання графіків за допомогою Ollama

Приватний багатомодальний RAG на Mac з процесором M1 та Ollama: PyMuPDF для тексту та таблиць, невелика візуальна модель Qwen лише для діаграм, перевірки чисел та пошуку в ChromaDB.

Видавець: REACTAPP.TOP10 хв читання2293 слів

Джерела: Zero-Cost Local Multimodal RAG: Selective Vision Processing with ChromaDB

Інженери, фінансові аналітики та дослідники регулярно працюють із конфіденційними PDF-файлами на власних ноутбуках: внутрішня технічна документація, квартальні звіти про прибутки, матеріали клінічних випробувань. Вони потребують приватного, безкоштовного асистента для пошуку, який би працював виключно на MacBook із чіпом M1 та все одно точно відповідав на запитання, навіть у складних форматах сторінок, фінансових таблицях та графіках.

Існує важлива обмеження, на яку потрібно звернути увагу. Обробка кожної сторінки 100-сторінкового PDF як зображення на чіпі M1 призводить до дуже довгого часу очікування, інтенсивного використання пам’яті та активної роботи вентилятора. Вибірковий гібридний підхід дозволяє уникнути цього, і жоден байт даних не потребує виходу за межі пристрою.

Три проблеми, які потрібно вирішити одночасно

Обробка складних PDF-файлів на простішому обладнанні, такому як M1, створює кілька окремих труднощів — деякі з них пов’язані з самим обладнанням, а інші — з моделями.

  1. Наївне вилучення тексту за допомогою інструментів на кшталт pypdf чи pdfminer є швидким та економним з точки зору використання пам’яті, проте воно спрощує багатоколонковий текст, руйнує структуру таблиць та повністю ігнорує діаграми.
  2. Обробка зображень усієї сторінки зберігає візуальний контекст, проте це сильно навантажує процесор. Обробка кожної сторінки за допомогою VLM споживає оперативну пам’ять, займає хвилини на документ та призводить до помилок OCR у коротких фрагментах тексту.
  3. Галюцинації є поширеним явищем у всіх моделях візуального розуміння мови, незалежно від того, чи вони працюють на M1, A100 чи CPU. Читання дрібних позначок вісок, дрібного шрифту та щільних таблиць у форматі електронних таблиць вони виконують ненадійно, часто вигадуючи числа чи неправильно описуючи тенденції у узагальненнях. Для фінансових чи клінічних матеріалів, де точність є обов’язковою, це серйозний недолік будь-якої багатомодальної системи RAG.

Ефективний дизайн має враховувати обмеження пам’яті, втрату структури тексту та звичку одночасного вигадування чисел.

Селективний гібридний дизайн

Єдина архітектура пам’яті M1 дозволяє CPU та GPU користуватися одним швидким резервуаром, що робить його дивовижно придатним для локальних AI-моделей, за умови розумного використання цього резервуара. Канал обробки розділяє роботу на чотири частини.

  • Швидкий шлях обробляє текст та таблиці. Легкий парсер, який враховує структуру, PyMuPDF, читає оригінальний текст, зберігає ієрархію заголовків у вигляді рівнів Markdown та перетворює таблиці на чистий формат Markdown, усе це за кілька мілісекунд.
  • Селективний шлях обробляє візуальний контент. Діаграми, графіки та блок-схеми обрізаються та зберігаються як окремі файли зображень. Лише ці окремі зображення надходять до квантованої, невеликої локальної VLM-моделі qwen2.5-vl:3b, яка працює через Ollama, що значно зменшує використання пам’яті та час обробки.
  • Механізми захисту від галюцинацій роблять результат надійним. Модель повинна відповідати у структурованому форматі JSON, а наведені нею числа перевіряються порівняно з необробленим текстом, отриманим за допомогою OCR з діапазону координат зображення, тож твердження ґрунтуються на фактичній картинці ще до того, як потрапляють у векторну базу даних.
  • Злиття контексту допомагає об’єднати всі елементи. Візуальні узагальнення вставляються у потік Markdown поруч із метаданими, такими як номери сторінок та посилання на зображення, а індексування відбувається після цього.
  • Архітектура

    Потік даних від локального сховища документів через парсинг, обрізку, узагальнення та індексування виглядає так:

                           ┌───────────────────────────────────────┐
                           │   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 з розширенням sqlite-vec додає функцію векторного пошуку безпосередньо в SQLite, що ідеально підходить для тих, хто хоче мати на одну залежність менше.
  • Pinecone та Weaviate — це хмарні рішення для проектів, які виросли за межі ноутбука.
  • Процеси чанкінгу, створення ембеддингів та пошуку працюють однаково, незалежно від обраного інструменту.

    Пошук батьківських та дочірніх елементів

    Резюме діаграм не повинні існувати окремо від тексту навколо них. Індекс зберігає невеликі дочірні чанки об’ємом близько 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