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 kontejneruDistance.COSINE— uobičajena mera sličnostiscore_threshold— bez praga dobijaš smeće- Sistemski prompt mora zabraniti dopunjavanje iz opšteg znanja
- Navođenje izvora omogućava proveru odgovora
upsertu paketima od sto — višestruko bržequery_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
- Pokreni Qdrant i potvrdi da odgovara na
/collections. - Učitaj desetak sopstvenih Markdown fajlova i prebroj koliko je delova nastalo.
- Postavi pet pitanja i uz
RAG_DEBUGpogledaj ocene pronađenih delova. - Postavi pitanje čiji odgovor sigurno nije u dokumentaciji i proveri da li model to prizna.
- Promeni veličinu dela sa 700 na 300 pa na 1500 i uporedi kvalitet odgovora.
- Dodaj polje
nivopri učitavanju i filtriraj pretragu po njemu.
Sledeći tekst u serijalu: Benchmark lokalnog modela: koliko tokena u sekundi zaista dobijaš
Povezano:
- AI na Linux serveru — pregled celog serijala
- journalctl i LLM — prethodni tekst
- Open WebUI — gotov RAG bez pisanja koda
- Rečnik AI pojmova — embedding, vektorska baza, RAG
- Python klijent za lokalni model — osnova ovih skripti
Comments
Post a Comment