Poradnik 12: Kompletny lokalny pipeline RAG

Autor: Jakub RusinowskiOstatnia aktualizacja 12 min czytaniaZaawansowany

Zbuduj „rozmowę z własnymi dokumentami” samodzielnie, w jednym krótkim skrypcie w Pythonie: odczytaj pliki PDF, podziel je na fragmenty, zamień fragmenty na embeddingi lokalnym modelem w Ollamie, zapisz je w lokalnej bazie wektorowej i odpowiadaj na pytania z cytatami. Nic nie opuszcza Twojego komputera.

Która strona do czego. Chcesz, żeby działało bez pisania kodu? Skorzystaj z przepisu Chat with your PDFs privately (AnythingLLM, Open WebUI lub LM Studio; strona po angielsku). Chcesz najpierw zrozumieć, czym jest RAG? Przeczytaj Wprowadzenie do RAG. Ta strona to wersja „zrób to sam” — dla tych, którzy chcą widzieć i kontrolować każdy krok.

Co zbudujesz

  1. Wyodrębnisz tekst każdej strony za pomocą pypdf.
  2. Podzielisz każdą stronę na zachodzące na siebie fragmenty.
  3. Zamienisz każdy fragment na embedding modelem embeddingowym w Ollamie (embeddinggemma).
  4. Zapiszesz fragmenty, embeddingi oraz nazwę pliku i numer strony w Chroma — bazie wektorowej, która mieszka w folderze na Twoim dysku.
  5. Wyszukasz fragmenty najbliższe pytaniu i odpowiesz lokalnym modelem czatu, który musi wskazać stronę źródłową.

1. Instalacja

Potrzebujesz aktualnego Pythona 3 i Ollamy.

bash
pip install ollama chromadb pypdf
ollama pull embeddinggemma

Następnie pobierz model czatu do odpowiedzi. Wyszukiwarka modeli dobierze taki, który zmieści się na Twoim komputerze; zadziała też każdy model czatu, który masz już w Ollamie.

2. Skrypt

Zapisz to jako rag.py:

python
"""Local RAG: index a folder of PDFs with Ollama embeddings into Chroma, then ask questions."""
import argparse
import pathlib

import chromadb
import ollama
from pypdf import PdfReader

EMBED_MODEL = "embeddinggemma"   # an Ollama embedding model; change it and you must re-index
DB_PATH = "rag-db"               # where Chroma keeps the index on disk
CHUNK_CHARS = 1200               # passage length, in characters
OVERLAP_CHARS = 200              # how much consecutive passages share


def chunks(text: str):
    step = CHUNK_CHARS - OVERLAP_CHARS
    for start in range(0, len(text), step):
        yield text[start:start + CHUNK_CHARS]


def embed(texts: list[str]) -> list[list[float]]:
    return ollama.embed(model=EMBED_MODEL, input=texts)["embeddings"]


def collection():
    return chromadb.PersistentClient(path=DB_PATH).get_or_create_collection("docs")


def index(folder: str) -> None:
    col = collection()
    for pdf in sorted(pathlib.Path(folder).glob("*.pdf")):
        for page_no, page in enumerate(PdfReader(pdf).pages, start=1):
            text = (page.extract_text() or "").strip()
            if not text:
                print(f"  skipped {pdf.name} page {page_no}: no text layer (a scan needs OCR first)")
                continue
            parts = list(chunks(text))
            col.upsert(
                ids=[f"{pdf.name}:{page_no}:{i}" for i in range(len(parts))],
                documents=parts,
                embeddings=embed(parts),
                metadatas=[{"file": pdf.name, "page": page_no}] * len(parts),
            )
        print(f"indexed {pdf.name}")


def ask(question: str, model: str, k: int) -> None:
    hits = collection().query(query_embeddings=embed([question]), n_results=k)
    context = "\n\n".join(
        f"[{meta['file']} p.{meta['page']}]\n{doc}"
        for doc, meta in zip(hits["documents"][0], hits["metadatas"][0])
    )
    reply = ollama.chat(model=model, messages=[
        {"role": "system", "content": "Answer only from the context. After each claim, cite the [file p.N] tag it came from. "
                                      "If the context does not contain the answer, say you cannot find it."},
        {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"},
    ])
    print(reply["message"]["content"])


if __name__ == "__main__":
    p = argparse.ArgumentParser()
    sub = p.add_subparsers(dest="cmd", required=True)
    sub.add_parser("index").add_argument("folder")
    a = sub.add_parser("ask")
    a.add_argument("question")
    a.add_argument("--model", required=True, help="a chat model you have pulled in Ollama")
    a.add_argument("-k", type=int, default=4, help="how many passages to retrieve")
    args = p.parse_args()
    if args.cmd == "index":
        index(args.folder)
    else:
        ask(args.question, args.model, args.k)

Co robi każda część:

  • chunks() tnie stronę na fragmenty o długości CHUNK_CHARS znaków, które dzielą OVERLAP_CHARS znaków z następnym fragmentem, więc zdanie przecięte na granicy nadal w całości trafia do jednego fragmentu.
  • embed() wywołuje API embeddingów Ollamy. Ten sam model musi przetwarzać i dokumenty, i pytania.
  • index() zapisuje każdy fragment z nazwą pliku i numerem strony i używa upsert, więc ponowne uruchomienie na tym samym folderze aktualizuje indeks zamiast go dublować.
  • ask() pobiera k najbliższych fragmentów i każe modelowi czatu odpowiadać wyłącznie na ich podstawie oraz cytować [plik p.N].

3. Uruchomienie

Umieść pliki PDF w folderze, a następnie:

bash
python rag.py index ./my-pdfs
python rag.py ask "What is the notice period for ending the lease?" --model <your-chat-model>

Dobra odpowiedź przytacza właściwy fragment i kończy się znacznikiem w rodzaju [lease.pdf p.1]. Otwórz tę stronę i sprawdź. Pytania możesz oczywiście zadawać po polsku.

4. Jak to ulepszyć

  • Zeskanowane PDF-y nie mają warstwy tekstowej. pypdf nie odczyta tekstu z obrazów; skrypt pomija takie strony i o tym informuje. Najpierw przepuść je przez OCR (na przykład Tesseract), a potem zaindeksuj wynik.
  • Długość fragmentu. Krótkie fragmenty lepiej trafiają w konkretne fakty, dłuższe utrzymują więcej kontekstu razem. Zmień CHUNK_CHARS i OVERLAP_CHARS, usuń folder rag-db i zaindeksuj ponownie.
  • Liczba fragmentów. Zwiększ -k, gdy odpowiedziom umykają informacje rozrzucone po wielu stronach. Więcej fragmentów wymaga dłuższego okna kontekstu modelu czatu.
  • Zmiana modelu embeddingowego oznacza ponowne indeksowanie. Embeddingi z różnych modeli do siebie nie pasują: usuń rag-db i uruchom index jeszcze raz.
  • Inne języki. embeddinggemma był trenowany na tekstach w wielu językach, więc dokumenty po polsku działają bez zmian.
  • Streszczenie to nie wyszukiwanie. „Streść cały dokument” wymaga całego dokumentu, a nie czterech fragmentów. Streszczaj stronę po stronie.

Ocena, czy pobrany fragment jest naprawdę istotny, to pytanie typu tak/nie, a model decyzyjny zwraca prawdopodobieństwo, któremu możesz ustawić próg; jak dobrać ten próg opisuje osobny poradnik.

Dalej