RAG nad sopstvenom dokumentacijom

„Hoću da model zna našu dokumentaciju" je najčešći zahtev koji ćeš dobiti. Odgovor gotovo nikad nije fine-tuning — nego RAG, postupak koji relevantne delove tvojih fajlova pronađe i ubaci u prompt pre nego što model odgovori.

Open WebUI to već ume, kako je pokazano u tekstu o njemu. Ovaj tekst objašnjava šta se ispod dešava i kako se gradi sopstveno rešenje, jer ćeš ga pre ili kasnije trebati.

Postupak

Priprema (jednom):
  dokumenti → sečenje na delove → embeddings → vektorska baza

Pri svakom pitanju:
  pitanje → embedding → pretraga po sličnosti → n najbližih delova
          → prompt: "Odgovori na osnovu ovog konteksta: ..." → model

Model se ne menja. Težine ostaju iste; menja se samo ono što mu stiže u promptu. To je razlog zašto RAG radi na svakom modelu i zašto se dokument dodat jutros koristi već u podne.

Embedding

Numerički zapis značenja teksta. Dva teksta sličnog značenja imaju bliske zapise, i to bez ijedne zajedničke reči:

"restart nginx servisa"      → [0.021, -0.134, 0.087, ...]
"kako ponovo pokrenuti veb server" → [0.019, -0.129, 0.091, ...]

Zato RAG nalazi i ono što grep promaši. Traži se poseban model — onaj za razgovor ovo ne radi:

$ ollama pull nomic-embed-text
$ curl -s http://localhost:11434/v1/embeddings \
  -H "Content-Type: application/json" \
  -d '{"model": "nomic-embed-text", "input": "restart nginx servisa"}' \
  | jq '.data[0].embedding | length'
768

Sečenje na delove

Ovde se odlučuje kvalitet celog sistema, više nego kod izbora modela.

Predugačak deo razblažuje značenje — jedan embedding za pet strana teksta ne pokazuje ni na šta određeno. Prekratak gubi kontekst: rečenica sa komandom bez pasusa oko sebe ne znači ništa.

Vrsta sadržaja Veličina dela
Tehnička dokumentacija 400 – 800 znakova
Procedure i uputstva Po koraku ili po odeljku
Duži tekst 800 – 1500 znakova
Kod i konfiguracije Po funkciji ili bloku

Preklapanje od desetak procenata sprečava da rečenica presečena na granici propadne. Najbolje rezultate daje sečenje po strukturi — kod Markdown fajlova po naslovima, jer naslov nosi kontekst za sve ispod njega.

Vektorska baza

Do nekoliko hiljada delova dovoljan je i običan spisak u memoriji. Preko toga treba baza koja ume brzo da pretraži po sličnosti.

$ docker run -d --name qdrant \
    -p 127.0.0.1:6333:6333 \
    -v /data/qdrant:/qdrant/storage \
    --restart unless-stopped \
    qdrant/qdrant
$ curl -s http://localhost:6333/collections | jq

Qdrant je ovde izbor jer je jedan kontejner bez zavisnosti i ima pristojan REST API. Alternative su Chroma za probanje i pgvector ako već držiš PostgreSQL — načelo je isto.

Vezivanje za 127.0.0.1 je i ovde obavezno, iz istog razloga kao kod Ollame.

Priprema podataka

#!/usr/bin/env python3
"""Učitavanje dokumenata u vektorsku bazu."""

import os
import sys
import hashlib
from pathlib import Path

import requests
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

AI_URL = os.environ.get("AI_BASE_URL", "http://localhost:11434/v1")
EMB_MODEL = "nomic-embed-text"
ZBIRKA = "dokumentacija"
DIMENZIJA = 768

baza = QdrantClient(url="http://localhost:6333")


def embedding(tekst):
    o = requests.post(
        f"{AI_URL}/embeddings",
        json={"model": EMB_MODEL, "input": tekst},
        timeout=120,
    )
    o.raise_for_status()
    return o.json()["data"][0]["embedding"]


def iseci(tekst, velicina=700, preklop=100):
    """Sečenje po pasusima, sa spajanjem do zadate veličine."""
    pasusi = [p.strip() for p in tekst.split("\n\n") if p.strip()]
    delovi, tekuci = [], ""

    for pasus in pasusi:
        if len(tekuci) + len(pasus) < velicina:
            tekuci += pasus + "\n\n"
        else:
            if tekuci:
                delovi.append(tekuci.strip())
            tekuci = tekuci[-preklop:] + pasus + "\n\n" if preklop else pasus + "\n\n"

    if tekuci.strip():
        delovi.append(tekuci.strip())
    return delovi


def ucitaj(putanja):
    if not baza.collection_exists(ZBIRKA):
        baza.create_collection(
            collection_name=ZBIRKA,
            vectors_config=VectorParams(size=DIMENZIJA, distance=Distance.COSINE),
        )

    tacke, ukupno = [], 0

    for fajl in Path(putanja).rglob("*"):
        if fajl.suffix not in (".md", ".txt", ".conf"):
            continue

        try:
            sadrzaj = fajl.read_text(encoding="utf-8")
        except (UnicodeDecodeError, PermissionError) as g:
            print(f"preskačem {fajl}: {g}", file=sys.stderr)
            continue

        for i, deo in enumerate(iseci(sadrzaj)):
            oznaka = hashlib.md5(f"{fajl}:{i}".encode()).hexdigest()
            tacke.append(PointStruct(
                id=int(oznaka[:15], 16),
                vector=embedding(deo),
                payload={"tekst": deo, "izvor": str(fajl), "deo": i},
            ))
            ukupno += 1

            if len(tacke) >= 100:
                baza.upsert(collection_name=ZBIRKA, points=tacke)
                tacke = []
                print(f"  {ukupno} delova...", file=sys.stderr)

    if tacke:
        baza.upsert(collection_name=ZBIRKA, points=tacke)

    print(f"Učitano {ukupno} delova.")


if __name__ == "__main__":
    ucitaj(sys.argv[1] if len(sys.argv) > 1 else "./dokumenti")
$ pip install qdrant-client requests
$ python3 ucitaj.py /srv/dokumentacija
  100 delova...
  200 delova...
Učitano 247 delova.

Oznaka se izvodi iz putanje i rednog broja, pa ponovno pokretanje osvežava postojeće zapise umesto da pravi duplikate. Slanje u paketima od sto je znatno brže od pojedinačnog upisa.

Pretraga i odgovor

#!/usr/bin/env python3
"""Pitanje nad učitanom dokumentacijom."""

import os
import sys
import requests
from qdrant_client import QdrantClient

AI_URL = os.environ.get("AI_BASE_URL", "http://localhost:11434/v1")
MODEL = os.environ.get("AI_MODEL", "qwen3:8b")
EMB_MODEL = "nomic-embed-text"
ZBIRKA = "dokumentacija"
PRAG = 0.5

baza = QdrantClient(url="http://localhost:6333")


def embedding(tekst):
    o = requests.post(f"{AI_URL}/embeddings",
                      json={"model": EMB_MODEL, "input": tekst}, timeout=120)
    o.raise_for_status()
    return o.json()["data"][0]["embedding"]


def pronadji(pitanje, koliko=5):
    rezultati = baza.query_points(
        collection_name=ZBIRKA,
        query=embedding(pitanje),
        limit=koliko,
        score_threshold=PRAG,
    ).points
    return [(r.payload["tekst"], r.payload["izvor"], r.score) for r in rezultati]


SISTEMSKI = """Odgovaraš isključivo na osnovu priloženog konteksta.
Ako odgovor nije u kontekstu, reci: "To ne piše u dokumentaciji."
Ne dopunjuj iz opšteg znanja. Navedi iz kog izvora je odgovor.
Odgovaraj na srpskom."""


def pitaj(pitanje):
    delovi = pronadji(pitanje)

    if not delovi:
        return "Ništa relevantno nije pronađeno u dokumentaciji."

    kontekst = "\n\n---\n\n".join(
        f"[izvor: {izvor}]\n{tekst}" for tekst, izvor, _ in delovi
    )

    o = requests.post(
        f"{AI_URL}/chat/completions",
        json={
            "model": MODEL,
            "messages": [
                {"role": "system", "content": SISTEMSKI},
                {"role": "user", "content": f"KONTEKST:\n{kontekst}\n\nPITANJE: {pitanje}"},
            ],
            "temperature": 0,
        },
        timeout=300,
    )
    o.raise_for_status()
    return o.json()["choices"][0]["message"]["content"]


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit("Upotreba: pitaj-doks.py \"pitanje\"")

    upit = " ".join(sys.argv[1:])
    print(pitaj(upit))

    if os.environ.get("RAG_DEBUG"):
        print("\n--- pronađeni delovi ---", file=sys.stderr)
        for _, izvor, ocena in pronadji(upit):
            print(f"  {ocena:.3f}  {izvor}", file=sys.stderr)
$ python3 pitaj-doks.py "kako se restartuje naš aplikacioni server"
Prema dokumentaciji (/srv/dokumentacija/procedure/restart.md), server se
restartuje komandom `systemctl restart app-backend`, uz prethodnu proveru
da nema aktivnih transakcija...

Tri stvari koje čine razliku

score_threshold odbacuje delove koji nisu dovoljno bliski. Bez praga uvek dobijaš pet najboljih — čak i kad ni jedan nema veze sa pitanjem, pa model odgovara na osnovu smeća.

Uputstvo da ne dopunjuje iz opšteg znanja je razlika između sistema kome se veruje i onog kome se ne veruje. Model inače rado spoji ono što je našao sa onim što misli da zna, a ti ne vidiš gde je granica.

Navođenje izvora omogućava proveru. Odgovor bez izvora u tehničkoj dokumentaciji vredi upola manje.

Promenljiva RAG_DEBUG ispisuje šta je pronađeno i sa kojom ocenom — prvo mesto gde gledaš kad odgovori nisu dobri.

Kada RAG ne radi dobro

Vredi znati unapred, da ne obećaš previše.

  • Pitanja koja traže pregled celine. „Koliko servera imamo" zahteva sve dokumente, a pretraga vraća pet delova.
  • Poređenja kroz više dokumenata. Ako odgovor treba spojiti iz sedam mesta, pet najboljih nije dovoljno.
  • Loše strukturirani izvori. Skenirani PDF bez tekstualnog sloja ne daje ništa:
$ pdftotext dokument.pdf - | head
  • Zastareli podaci. Baza pamti ono što si učitao. Bez osvežavanja odgovara po dokumentaciji od pre šest meseci.
  • Podaci u tabelama. Struktura se gubi pri sečenju; za brojeve iz tabela bolja je obična baza i upit nad njom.

Osvežavanje

$ sudo crontab -e
0 3 * * * /opt/rag/venv/bin/python /opt/rag/ucitaj.py /srv/dokumentacija >> /var/log/rag.log 2>&1

Za veće zbirke učitavaj samo izmenjeno:

$ find /srv/dokumentacija -name '*.md' -newermt '1 day ago'

Obrisani fajlovi ostaju u bazi dok ih ne skloniš izričito — što je čest uzrok odgovora koji se pozivaju na dokument koji više ne postoji. Zakazivanje pokriva tekst o cronu.

Prava pristupa

Vektorska baza ne zna ko pita. Sve što je u nju učitano dostupno je svakome ko ima pristup servisu — uključujući delove dokumenata do kojih taj čovek inače ne bi došao.

Ako dokumentacija sadrži nešto osetljivo, ili odvoji zbirke po nivou pristupa, ili filtriraj pri pretrazi:

from qdrant_client.models import Filter, FieldCondition, MatchValue

rezultati = baza.query_points(
    collection_name=ZBIRKA,
    query=embedding(pitanje),
    query_filter=Filter(must=[
        FieldCondition(key="nivo", match=MatchValue(value="javno"))
    ]),
    limit=5,
).points

Polje nivo postaviš pri učitavanju, na osnovu putanje ili sadržaja. Šire o rizicima govori tekst o bezbednosti — vredi imati u vidu da dokument koji neko drugi može da menja postaje ulaz u tvoj prompt.

Praktični scenariji

Scenario 1: odgovori nemaju veze sa pitanjem

Uključi RAG_DEBUG i pogledaj ocene. Ako su sve ispod 0.4, ili u dokumentaciji nema odgovora, ili je sečenje loše.

Scenario 2: model dopunjuje iz opšteg znanja

Pooštri sistemski prompt i spusti temperaturu na nulu. Neki modeli ovo poštuju bolje od drugih.

Scenario 3: učitavanje traje satima

Embedding model radi na procesoru. Proveri kroz ollama ps, pa podigni veličinu paketa pri upisu.

Scenario 4: isti delovi se pojavljuju dvaput

Oznake se ne poklapaju između pokretanja. Izvedi ih iz putanje i rednog broja, ne iz sadržaja.

Scenario 5: greška o dužini konteksta

Pet delova po hiljadu znakova plus pitanje premašuje mali prozor. Smanji broj delova ili podigni kontekst, uz proveru iz teksta o VRAM-u.

Scenario 6: dokument je izmenjen, odgovor je star

Baza nije osvežena. Zakaži učitavanje i proveri log poslednjeg izvršavanja.

Kratka referenca

  • ollama pull nomic-embed-text — poseban model za embeddinge
  • /v1/embeddings — put za numerički zapis teksta
  • Sečenje — 400 do 800 znakova za tehnički tekst, uz preklop
  • Sečenje po strukturi daje bolje rezultate od sečenja po dužini
  • qdrant/qdrant — vektorska baza u jednom kontejneru
  • Distance.COSINE — uobičajena mera sličnosti
  • score_thresholdbez praga dobijaš smeće
  • Sistemski prompt mora zabraniti dopunjavanje iz opšteg znanja
  • Navođenje izvora omogućava proveru odgovora
  • upsert u paketima od sto — višestruko brže
  • query_filter — jedini način da se ograniči pristup
  • Baza ne zna ko pita — sve učitano dostupno je svima
  • Obrisani fajlovi ostaju dok ih ne skloniš

Vežba

  1. Pokreni Qdrant i potvrdi da odgovara na /collections.
  2. Učitaj desetak sopstvenih Markdown fajlova i prebroj koliko je delova nastalo.
  3. Postavi pet pitanja i uz RAG_DEBUG pogledaj ocene pronađenih delova.
  4. Postavi pitanje čiji odgovor sigurno nije u dokumentaciji i proveri da li model to prizna.
  5. Promeni veličinu dela sa 700 na 300 pa na 1500 i uporedi kvalitet odgovora.
  6. Dodaj polje nivo pri učitavanju i filtriraj pretragu po njemu.

Sledeći tekst u serijalu: Benchmark lokalnog modela: koliko tokena u sekundi zaista dobijaš

Povezano:

Comments

Popular posts from this blog

Početak u Linuxu — šta je i zašto se uči

Konverzija tipova podataka u Pythonu

groupadd