Галоўная / Артыкулы

Прыватны мультымадальны RAG на Mac з M1: выбарачнае чытанне графікаў з Ollama

Прыватны мультымадальны RAG на Mac з M1 і Ollama: PyMuPDF для тэксту і табліц, маленькая vision-мадэль 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 спрычыняе выкарыстоўванне RAM, займае хвіліны на адны дакумент і прыносіць памылкі 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 ГБ RAM вальнымі для Ollama. Аднак такія прыемнікі не ўскладненыя ў сваім дызайне. Ёх замяну на іншы хранільнік патрабуе толькі незначных змян у процэсе запоўнення дадзеннямі:

    • LanceDB адказвае за дужа шырокі пошук на дыску з апрыксімацыямі за дапамою натыўнага інтэрфейса на Python.
    • Qdrant — гэта база дадзеных высокай працэздатнасі, якая добра працуе локальна і паўніста падтрымлеў фільтрацыю.
    • FAISS, створаныя Meta, — гэта чыстая бібліятэка, якая працуе толькі ў памяці і ўзнімае вельмі вялікія швыдкасці, калі ёсць достатак RAM.
  • 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 пасля вычысцэння старых дадзэнняў, трэба дадаць флаг 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