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
- Wyodrębnisz tekst każdej strony za pomocą
pypdf. - Podzielisz każdą stronę na zachodzące na siebie fragmenty.
- Zamienisz każdy fragment na embedding modelem embeddingowym w Ollamie (
embeddinggemma). - Zapiszesz fragmenty, embeddingi oraz nazwę pliku i numer strony w Chroma — bazie wektorowej, która mieszka w folderze na Twoim dysku.
- 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.
pip install ollama chromadb pypdf
ollama pull embeddinggemmaNastę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:
"""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ściCHUNK_CHARSznaków, które dzieląOVERLAP_CHARSznakó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żywaupsert, więc ponowne uruchomienie na tym samym folderze aktualizuje indeks zamiast go dublować.ask()pobieraknajbliż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:
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.
pypdfnie 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_CHARSiOVERLAP_CHARS, usuń folderrag-dbi 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-dbi uruchomindexjeszcze raz. - Inne języki.
embeddinggemmabył 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
- Chat with your PDFs privately: przepis bez kodu (po angielsku).
- Wprowadzenie do RAG: czym jest RAG, prostymi słowami.
- Niezawodny JSON z lokalnych LLM-ów: gdy odpowiedź ma trafić do innego programu.
- Open WebUI + Ollama: interfejs w przeglądarce z wbudowanym wyszukiwaniem w dokumentach.
Sources, checked 2026-10-15: Ollama download · Ollama embeddings: ollama.embed, the embeddings field · embeddinggemma, multilingual embedding model · Ollama Python library: pip install ollama, chat · Chroma: own embeddings alongside documents · Chroma: query_embeddings, results grouped per query · Chroma: PersistentClient · pypdf: extract_text, no text from images.