Главная / Статьи

Приватный мультимодальный 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 делить один быстрый пул памяти, что делает его удивительно подходящим для локальных ИИ-моделей при разумном использовании этого пула. Пайплайн разделяет работу на четыре этапа.

  • Быстрый путь обрабатывает текст и таблицы. Легкий парсер, учитывающий структуру документа, 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 токенов для точного совпадения, но возвращает 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