Strona główna / Artykuły

Prywatny multimodalny RAG na M1 Mac: selektywne czytanie wykresów z Ollamą

Prywatny multimodalny RAG na Macu z procesorem M1 z Ollamą: PyMuPDF do obsługi tekstu i tabel, mały model wizyjny Qwen wykorzystywany wyłącznie do wykresów, weryfikacji numerów oraz wyszukiwania w ChromaDB.

Wydawca: REACTAPP.TOP10 min czytania2293 słów

Źródła: Zero-Cost Local Multimodal RAG: Selective Vision Processing with ChromaDB

Inżynierowie, analitycy finansowi i badacze regularnie pracują z poufnymi plikami PDF na swoich laptopach: wewnętrzną dokumentacją techniczną, kwartalnymi raportami finansowymi oraz materiałami z badań klinicznych. Chcą mieć prywatnego, darmowego asystenta do wyszukiwania, który działa wyłącznie na MacBooku z chipem M1 i nadal precyzyjnie odpowiada na pytania mimo skomplikowanych układów stron, tabel finansowych i wykresów.

Należy uważać na jeden istotny ograniczenie. Przetwarzanie każdej strony 100-stronicowego pliku PDF jako obrazu w modelu rozumiejącym język obrazowy na chipie M1 powoduje bardzo długie czasy oczekiwania, intensywne wymiany pamięci oraz hałas wentylatora przypominający dźwięk przygotowującego się do startu. Wybiórczy, hybrydowy proces unika tego problemu, dzięki czemu żaden bajt danych nie musi opuszczać urządzenia.

Trzy problemy do rozwiązania jednocześnie

Przetwarzanie złożonych plików PDF na skromnym sprzęcie takim jak M1 stwarza kilka odrębnych trudności, z których niektóre wynikają z samego sprzętu, a inne z modeli.

  1. Ekstrakcja tekstu w prosty sposób za pomocą narzędzi takich jak pypdf lub pdfminer jest szybka i nie wymaga dużej ilości pamięci, ale spłaszcza tekst wielokolumnowy, niszczy strukturę tabel i całkowicie ignoruje wykresy.
  2. Przetwarzanie obrazu całej strony zachowuje kontekst wizualny, jednak obciąża to procesor. Przetwarzanie każdej strony za pomocą VLM zużywa pamięć RAM, trwa minuty na dokument i powoduje błędy OCR w krótkich fragmentach tekstu.
  3. Halucynacje są charakterystyczne dla wszystkich modeli wizji-języka, niezależnie od tego, czy działają na procesorze M1, A100, czy CPU. Odczytywanie drobnych etykiet osi, małego druku oraz gęstych tabel w stylu arkuszy kalkulacyjnych jest dla nich niepewne – często wymyślają liczby lub błędnie opisują trendy w swobodnych streszczeniach. W przypadku materiałów finansowych lub klinicznych, gdzie precyzja jest obowiązkowa, stanowi to poważną wadę każdego multimodalnego systemu RAG.

Skuteczny projekt musi radzić sobie z ograniczeniami pamięci, utraconą strukturą tekstu oraz nawykiem wymyślania numerów jednocześnie.

Selektywny projekt hybrydowy

Ujednolicona architektura pamięci M1 umożliwia CPU i GPU współdzielenie jednego szybkiego zasobu pamięci, co sprawia, że urządzenie jest zaskakująco przydatne do zastosowań lokalnej sztucznej inteligencji, o ile zasób ten jest mądrze wykorzystywany. Proces jest dzielony na cztery etapy.

  • Szybka ścieżka zajmuje się tekstem i tabelami. Lekki parser uwzględniający układ, PyMuPDF, odczytuje tekst w formacie oryginalnym, zachowuje hierarchię nagłówków jako poziomy Markdown i przekształca tabeli w czysty format Markdown, wszystko to w ciągu zaledwie kilku milisekund.
  • Selektywna ścieżka zajmuje się elementami wizualnymi. Wykresy, diagramy i schematy są przycinane i zapisywane jako osobne pliki obrazowe. Tylko te samodzielne obrazy trafiają do skwantyzowanego, małego lokalnego VLM, qwen2.5-vl:3b, dostępnego przez Ollama, co znacznie zmniejsza zużycie pamięci i czas obliczeń.
  • Mechanizmy chroniące przed halucynacjami sprawiają, że wyniki są wiarygodne. Model musi odpowiadać w formacie strukturyzowanego JSON, a podawane przez niego liczby są weryfikowane w porównaniu z surowym tekstem OCR pobranym z obszaru obrazu, dzięki czemu informacje są uzasadnione rzeczywistym obrazem przed trafięciem do bazy danych wektorowej.
  • Fuzja kontekstu łączy wszystkie elementy. Podsumowania wizualne są wstawiane do strumienia Markdown obok metadanych, takich jak numery stron i odniesienia do obrazów, a indeksowanie następuje później.
  • Architektura

    Przepływ danych od lokalnego magazynu dokumentów, przez analizę, obcinanie, tworzenie podsumowań i indeksowanie, wygląda następująco:

                           ┌───────────────────────────────────────┐
                           │   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)  │
                             └───────────────────────────────────┘
    

    Wzmacnianie bezpieczeństwa

    Prototyp skryptu różni się od niezawodnej aplikacji lokalnej, a kilka decyzji dotyczących infrastruktury ma na to wpływ.

    Wybór magazynu wektorów

    ChromaDB nadaje się do tego rozwiązania, ponieważ działa wewnątrz procesu, nie wymaga infrastruktury i przechowuje dane na dysku za pomocą wbudowanej bazy danych SQLite, co pozostawia 8 lub 16 GB pamięci RAM dostępnych dla Ollamy. Projekt nie jest jednak od niego uzależniony. Zastąpienie go innym magazynem wymaga jedynie niewielkich zmian w procesie pobierania danych:

    • LanceDB oferuje bardzo szybkie wyszukiwanie przybliżone na dysku z użyciem natywnego interfejsu w Pythonie.
    • Qdrant to baza danych o wysokiej wydajności, która dobrze funkcjonuje lokalnie i umożliwia zaawansowane filtrowanie.
    • FAISS od Meta to czysta biblioteka pamięciowa, która jest niezwykle szybka, gdy jest dostępna duża ilość pamięci RAM.
    • SQLite z rozszerzeniem sqlite-vec dodaje funkcję wyszukiwania wektorowego bezpośrednio do SQLite, co jest doskonałym rozwiązaniem dla tych, którzy chcą mieć o jedną zależność mniej.
    • Pinecone i Weaviate to opcje hostowane w chmurze dla projektów, które przekraczają możliwości laptopa.

    Niezależnie od wybranej opcji, proces dzielenia na fragmenty, tworzenia embeddingów oraz wyszukiwania funkcjonuje w ten sam sposób.

    Wyszukiwanie elementów rodzicielskich i potomnych

    Podsumowania wykresów nie powinny znajdować się oddzielnie od otaczającego je tekstu. Indeks przechowuje małe fragmenty potomne o długości około 200 tokenów w celu dokładnego dopasowania, ale zwraca do modelu LLM większą sekcję rodzicielską liczącą około 800 tokenów, dzięki czemu model może czytać pełne akapity podczas udzielania odpowiedzi.

    Dbanie o dokładność danych

    Dwa proste zabezpieczenia chronią przed halucynacjami niezależnie od używanego sprzętu. Po pierwsze, Ollama jest instruowany do odpowiadania w formacie JSON schema – na przykład z typem wykresu oraz listami wartości osi i punktów danych – zamiast tekstu prostego, który jest łatwiejszy do parsowania i walidacji. Po drugie, PyMuPDF wyodrębnia surowy tekst z dokładnych granic obrazu obciętego, a każda liczba wygenerowana przez VLM musi pojawić się w tym ciągu znaków, zanim cokolwiek zostanie zapisane do bazy danych. Streszczenia, które nie przechodzą tej weryfikacji, są oznaczane do ręcznej oceny lub odrzucane.

    Ustawienie projektu i kod

    Struktura folderów

    Stwórz katalog roboczy w terminalu:

    mkdir ~/m1_multimodal_rag && cd ~/m1_multimodal_rag
    mkdir data chroma_db images_cache
    touch main.py requirements.txt
    

    Zależności

    Wymień paczki z pliku requirements.txt:

    pymupdf
    chromadb
    ollama
    pillow
    

    Jedyny skrypt

    Cała logika znajduje się w main.py: parsowanie PDF, kodowanie obrazów w formacie Base64 dla Ollamy, zapisywanie fragmentów rodzicielskich i potomnych do Chromy oraz dostarczanie prostego interfejsu wiersza poleceń. Flaga --clear służy do resetowania bazy danych, dzięki czemu nowy PDF może zostać przetworzony bez mieszania się danych.

    #!/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()
    

    Uruchamianie

    Krok 1: Zainstaluj Ollamę i pobierz modele

    Ollamę instaluje się za pomocą Homebrew, a modele qwen2.5vl wymagają wersji 0.7.0 lub nowszej:

    # 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
    

    Krok 2: Stworzenie środowiska Pythona

    # 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
    

    Krok 3: Zainstaluj zależności

    # 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!')"
    

    Krok 4: Pobranie przykładowego pliku PDF

    Ilustracyjny raport finansowy zgodny z standardem IFRS od EY stanowi wygodny dokument do testowania:

    # 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_"
    

    Krok 5: Przyjmowanie

    python main.py ingest --pdf data/sample_financials.pdf
    

    Oczekuj pola postępu dla każdej strony, a następnie komunikatu potwierdzającego zakończenie procesu pobierania. Aby załadować inny plik PDF po usunięciu starych danych, dodaj flagę clear:

    # Ingest a new PDF and clear the database first
    python main.py ingest --pdf data/<your financial data file>.pdf --clear
    

    Krok 6: Zadaj pytanie

    python main.py query --question "What was the total revenue shown in the financial statements?"
    

    Podsumowanie podejścia

    Możliwe jest stworzenie lokalnego, multimodalnego systemu RAG na procesorze M1. PyMuPDF umożliwia szybką ekstrakcję tekstu i tabel, Ollama z modeliem qwen2.5vl:3b interpretuje tylko te elementy wizualne, które tego wymagają, ChromaDB lub inny lekki system przechowywania utrzymuje trwały indeks rodzic-dziecko, a jeden skrypt w linii poleceń łączy wszystko razem. Taki rozwiązanie nie generuje żadnych kosztów, nie wysyła żadnych danych i odpowiada na pytania na podstawie poufnych dokumentów znajdujących się bezpośrednio na maszynie.

    Gotowy projekt można również sklonować z jego repozytorium:

    git clone https://github.com/froilan-sia/m1_multimodal_rag.git
    cd m1_multimodal_rag
    

    Zawiera pomocnik setup.sh, który tworzy pliki .gitignore, README oraz licencję oraz inicjuje Git, co przydaje się podczas tworzenia odgałęzień lub dostosowywania. Po sklonowaniu można albo postępować zgodnie z powyższymi instrukcjami ręcznymi, albo uruchomić ten pomocnik:

    ./setup.sh