RAG u produkciji: greške koje vide samo graditelji

Prvi RAG koji sam pustio u produkciju radio je savršeno na demo pitanjima. Onda je stigao pravi saobraćaj: korisnici postavljaju pitanja na tri jezika, pola njih ima tipfelere, dokumenta se menjaju svaki dan, a neko je ubacio PDF od 400 strana skeniran krivo. Tačnost je pala sa 92% na 61% za nedelju dana. Ovaj tekst je o tome šta sam tada naučio, i šta i dalje viđam kod skoro svakog RAG sistema koji nasledim.
Chunking nije "podeli na 500 tokena"
Najčešća greška koju vidim: neko uzme RecursiveCharacterTextSplitter sa default vrednostima, prepusti se sudbini i čudi se zašto retrieval vraća pola rečenice bez konteksta. Chunking je proizvod razumevanja dokumenta, ne mehanička operacija.
Tri stvari koje uvek prilagodim po tipu sadržaja:
- Granica preseka. Tehnička dokumentacija se seče po H2/H3, ne po broju karaktera. Ako presečem
## Konfiguracijana pola, retrieval vraća besmislene fragmente. Za markdown i HTML, prvo parsiram strukturu, pa onda chunk-ujem unutar sekcija. - Overlap. 10-15% overlap je dovoljno za tekuću prozu. Za pravne ugovore i tabelarne podatke, idem do 25% jer referenca ("kao što je navedeno u članu 4") često pada preko granice.
- Veličina po domenu. Za FAQ i kratke pasuse 200-400 tokena. Za narativne dokumente 600-900. Za kod i log fajlove sasvim drugi pristup, najčešće po funkciji ili po stack trace bloku.
Konkretan primer: na jednom sistemu sa internim wiki sadržajem, prelazak sa fiksnog 512-token chunking-a na strukturni markdown chunking podigao mi je recall@5 sa 0.71 na 0.86. Ništa drugo nisam menjao.
# umesto ovoga
splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50)
# radim ovo za markdown
headers = [("##", "h2"), ("###", "h3")]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)
sections = md_splitter.split_text(doc)
# pa onda po sekciji, samo ako je predugačka
for s in sections:
if len(s.page_content) > 1200:
sub = RecursiveCharacterTextSplitter(
chunk_size=800, chunk_overlap=120
).split_documents([s])
Zvuči trivijalno. Ali devet od deset RAG sistema koje sam video u produkciji ima default chunking i nikad ga nije ozbiljno meren.
Vektorska sličnost nije relevantnost
Embedding sličnost meri semantičku blizinu, što nije isto što i "ovaj chunk odgovara na pitanje korisnika". Kada korisnik pita "Koliko traje otkazni rok?", semantički najbliži chunk može biti drugi pasus o otkaznom roku iz neke druge politike, dok pravi odgovor leži u tabeli koja kaže "30 dana" i nema gotovo nijednu zajedničku reč sa pitanjem.
Tri popravke koje uvek primenim:
- Hibridni retrieval (BM25 + vektor). BM25 hvata egzaktne termine, brojeve, šifre proizvoda, imena. Vektor hvata semantiku. Spajam ih kroz Reciprocal Rank Fusion. Razlika u tačnosti za upite koji sadrže šifre ili imena je dramatična, kod jednog sistema sa katalogom proizvoda recall je skočio sa 0.58 na 0.89.
- Reranker preko top-50. Vraćam 50 kandidata iz hibridnog retrieval-a, pa ih reranker (cross-encoder) presortira i uzimam top 5-8. Latencija raste 150-300ms, ali tačnost odgovora raste znatno više nego što bih dobio menjajući LLM.
- Metadata filteri pre semantike. Ako korisnik pita o pravilima za 2026, prvo filtriram po
year=2026pa onda radim semantičko pretraživanje. Vektorska sličnost ne razume vremenske kontekste.
# RRF u 8 linija
from collections import defaultdict
def rrf(rankings, k=60):
scores = defaultdict(float)
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] += 1 / (k + rank)
return sorted(scores.items(), key=lambda x: -x[1])
final = rrf([bm25_results, vector_results])
Eval set odlučuje da li sistem zaista radi
Ako nemaš eval set sa 100-300 realnih pitanja i očekivanih odgovora, nemaš RAG sistem, imaš demo. Ovo je jedna od najbolnijih lekcija. Bez eval-a, svaka izmena (drugi embedding model, drugi chunking, drugi prompt) je nagađanje.
Kako pravim eval set u praksi:
- Prvih 50 pitanja izvlačim iz pravih logova, ne izmišljam ih. Ako sistem još nije u produkciji, pitam stakeholder-e da napišu po 10 pitanja koja zaista očekuju.
- Za svako pitanje označim koji chunk(ovi) bi morali biti u retrieval rezultatu (gold passages) i kakav odgovor je tačan.
- Merim odvojeno retrieval kvalitet (recall@k, MRR) i generation kvalitet (faithfulness, answer correctness). Ako padne odgovor, prvo gledam da li je retrieval doneo prave chunkove. U 70% slučajeva problem je u retrieval-u, ne u LLM-u.
Mala tabela kako stvarno izgleda iteracija:
| Verzija | Retrieval recall@5 | Faithfulness | Latencija p95 |
|---|---|---|---|
| v1 baseline (vektor only, 512) | 0.71 | 0.78 | 1.2s |
| v2 hibridni RRF | 0.83 | 0.81 | 1.4s |
| v3 + strukturni chunking | 0.86 | 0.84 | 1.4s |
| v4 + reranker top-50 | 0.91 | 0.89 | 1.7s |
| v5 + metadata filteri | 0.93 | 0.91 | 1.5s |
Bez ove tabele, nemaš pojma da li sledeća promena pomaže ili odmaže. Sa njom, razgovor sa stakeholder-ima prestaje da bude "deluje bolje" i postaje "evo brojeva".
Stale data je tihi ubica tačnosti
RAG sistemi koje vidim posle 6 meseci u produkciji skoro uvek imaju isti problem: indeks je zastareo. Neko je promenio politiku, ažurirao cenovnik, dodao novi proizvod, a embedding indeks i dalje servira staru verziju. Korisnik dobije siguran, dobro formulisan odgovor koji je netačan. Ovo je gore od "ne znam".
Šta radim:
- Incremental indexing sa content hash-om. Svakom dokumentu računam SHA-256 sadržaja. Ako se hash promenio, re-embed; ako nije, preskačem. Štedi vreme i novac, ali ključno: ne dozvoljava da nešto bude "zaboravljeno" pri update-u.
- TTL na chunk-ove. Za sadržaj koji ima rok važenja (cenovnici, promocije, pravila za određenu godinu), upisujem
valid_untilu metapodatke i filtriram pri retrieval-u. - Delete je važniji od insert-a. Ako je dokument obrisan iz izvora, mora biti obrisan iz indeksa. Vidim sisteme gde se samo dodaje, nikad ne briše, i posle godinu dana imaju paralelne "verzije istine" u istom indeksu.
Konkretno za pgvector korisnike, ovo radim sa jednostavnom tabelom:
CREATE TABLE chunks (
id uuid PRIMARY KEY,
doc_id text NOT NULL,
content_hash text NOT NULL,
content text,
embedding vector(1536),
valid_until timestamptz,
source_updated_at timestamptz,
metadata jsonb
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON chunks (doc_id);
CREATE INDEX ON chunks (content_hash);
Sync job poredi content_hash po doc_id, briše chunkove kojih više nema u izvoru, dodaje nove, ažurira promenjene. Banalno, ali fundamentalno.
Prompt i context window: manje je više
Klasična greška: "imamo 200k context, ubacimo svih top 20 chunkova, LLM će se snaći". Neće. Lost in the middle je realan fenomen, model obraća najviše pažnje na početak i kraj konteksta, a srednji deo često ignoriše. Pored toga, plaćaš tokene i čekaš sporiji odgovor.
Praktična pravila koja koristim:
- Top 5-8 chunkova, ne 20. Ako reranker ne može da nađe odgovor u top 8, problem je u retrieval-u, ne u količini.
- Najrelevantnije na kraj. Reranker mi vrati sortirane chunkove, a ja u prompt-u stavim najrelevantniji na kraj (neposredno pre pitanja). Model ga tako bolje koristi.
- Eksplicitno "ne znam". U system prompt-u jasno kažem: ako kontekst ne sadrži odgovor, reci da ne znaš i predloži ko može pomoći. Bez ovoga, model halucinira da bi bio "uslužan".
- Citiranje izvora. Tražim od modela da vrati
[source_id]markere u odgovoru, pa u UI-u prikazujem klikabilan link na originalni chunk. Ovo radi dve stvari: drži model čvršće za kontekst i daje korisniku verifikaciju.
Mali deo prompt-a koji koristim kao šablon:
Odgovori SAMO na osnovu sadržaja u <context>.
Ako kontekst ne sadrži odgovor, reci: "Nemam tu informaciju u dostupnoj dokumentaciji."
Posle svake tvrdnje stavi [source_id] u zagradi.
<context>
{najmanje_relevantan_chunk}
...
{najrelevantniji_chunk}
</context>
Pitanje: {question}
Posle uvođenja eksplicitnog "ne znam" pravila na jednom sistemu, broj netačnih odgovora pao je za 40%, a broj "ne znam" odgovora porastao za 12%. To je dobra trampa, jer "ne znam" korisnik ume da obradi, dok netačan odgovor potkopava poverenje u ceo sistem.
Observability: bez logova, slep si
RAG sistem u produkciji bez observability-ja je crna kutija. Ne znaš zašto je odgovor bio loš, ne možeš da ga reprodukuješ, ne možeš da ga popraviš. Minimum koji uvek logujem po zahtevu:
- Pun query korisnika (sa session ID-jem)
- Retrieval kandidati i njihovi scores (BM25, vektor, RRF, reranker)
- Tačan kontekst koji je otišao u LLM (chunk ID-jevi i sadržaj)
- Pun prompt i pun odgovor modela
- Latencija po fazi (retrieval, rerank, LLM)
- Feedback signal (thumbs up/down, ili implicitno: da li je korisnik postavio follow-up koji liči na "ne, nisi me razumeo")
Sa ovim, kada neko prijavi loš odgovor, mogu da pogledam tačno šta se desilo i da iz toga izvedem novi eval primer. Bez ovoga, popravljaš sistem koji ne razumeš.
Šta bih ja uradio kad bih sutra krenuo iz nule
Da krenem ponovo na novom RAG projektu, evo redosleda po kome bih radio, naučenog iz nekoliko sistema koje sam ranije pogrešno postavio:
- Prvo eval, pa kod. 50 realnih pitanja sa očekivanim odgovorima, pre nego što napišem ijednu liniju retrieval logike.
- Najjednostavniji baseline. BM25 + jedan embedding model + naivan chunking + GPT/Claude. Izmerim. Ovo je realna polazna tačka, ne nešto čega se stidim.
- Iterativno popravljanje. Hibridni retrieval, pa strukturni chunking, pa reranker, pa metadata filteri. Svaka iteracija mora pomeriti eval metriku, inače je vraćam.
- Observability od prvog dana. Pun log retrieval-a i prompt-a. Neuporedivo je lakše dodati ovo na početku nego retrofit-ovati posle.
- Sync pipeline pre lansiranja. Kako se sadržaj ažurira, kako se briše, kako se verifikuje. Ako ovo nije rešeno, sistem će biti tačan prve nedelje i sve gori svake naredne.
- Tek tada UI i integracija. Lepa chat traka ne pomaže ako retrieval ne radi.
I jedna stvar koju ne bih radio: ne bih krenuo od fine-tuning-a embedding modela. To je optimizacija koja ima smisla tek kada si iscrpeo sve gore navedeno, što kod 95% projekata nikad ne stigneš.
Zatvaranje
RAG u tutorijalu i RAG u produkciji su dva različita posla. Tutorijal te nauči API pozive; produkcija te nauči chunking po domenu, hibridni retrieval, eval discipline, sync pipeline i observability. Sve ostalo je detalj.
Ako gradite RAG sistem i osećate da "deluje, ali ne znate zašto ponekad ne deluje", to je tačno ono mesto na kome najviše pomaže neko ko je već prošao kroz ove greške. Slobodno se javite preko lazar-milicevic.com/#contact, volim ovakve razgovore i kada ne završe saradnjom.
Često postavljana pitanja
Kako pravilno raditi chunking dokumenata za RAG sistem?
Chunking nije mehaničko sečenje na 500 tokena, već proizvod razumevanja strukture dokumenta. Za markdown i HTML prvo parsiram strukturu po H2/H3 zaglavljima pa onda chunk-ujem unutar sekcija, a veličinu prilagođavam domenu: 200-400 tokena za FAQ, 600-900 za narativne tekstove, dok kod i logove sečem po funkcijama ili stack trace blokovima. Overlap držim na 10-15% za tekuću prozu i do 25% za pravne ugovore i tabele gde reference često padaju preko granice. Na jednom internom wiki sistemu prelazak sa fiksnog 512-token chunking-a na strukturni markdown chunking podigao mi je recall@5 sa 0.71 na 0.86 bez ijedne druge izmene.
Zašto vektorska sličnost nije dovoljna za relevantnost u RAG sistemima?
Embedding sličnost meri semantičku blizinu, ali to nije isto što i odgovor na korisnikovo pitanje, jer pravi odgovor često leži u tabeli ili kratkom pasusu koji nema gotovo nijednu zajedničku reč sa upitom. Zato uvek koristim hibridni retrieval koji kombinuje BM25 (za egzaktne termine, brojeve i šifre) i vektorsku pretragu kroz Reciprocal Rank Fusion. Dodajem i cross-encoder reranker preko top-50 kandidata da presortira rezultate, kao i metadata filtere (npr. po godini) pre semantičke pretrage. U jednom katalogu proizvoda hibridni pristup je podigao recall sa 0.58 na 0.89.
Kako napraviti eval set za RAG sistem?
Bez eval seta od 100-300 realnih pitanja sa očekivanim odgovorima nemaš RAG sistem, već demo, jer je svaka izmena bez merenja čisto nagađanje. Prvih 50 pitanja izvlačim iz pravih produkcijskih logova, a ako sistem još nije pušten, tražim od stakeholder-a da napišu po 10 pitanja koja zaista očekuju. Za svako pitanje označavam koji chunk-ovi moraju biti u retrieval rezultatu (gold passages) i kakav odgovor je tačan. Merim odvojeno retrieval kvalitet (recall@k, MRR) i generation kvalitet (faithfulness, answer correctness), jer je u oko 70% slučajeva kada padne odgovor problem zapravo u retrieval-u, a ne u LLM-u.
Šta je Reciprocal Rank Fusion (RRF) i kako se koristi u RAG-u?
Reciprocal Rank Fusion je tehnika za spajanje rezultata iz više retrieval sistema (npr. BM25 i vektorske pretrage) u jednu rang listu, gde svaki dokument dobija skor 1/(k+rank) iz svake liste, a default k je 60. Prednost je što ne zahteva normalizaciju skorova između različitih sistema, što ga čini idealnim za hibridni retrieval. Implementacija staje u 8 linija Python koda korišćenjem defaultdict-a za akumulaciju skorova. Koristim ga kao standardni način spajanja BM25 i vektorskih rezultata pre nego što ih prosledim reranker-u.
Kako sprečiti da RAG sistem servira zastarele podatke u produkciji?
Stale data je tihi ubica tačnosti jer korisnik dobije siguran i dobro formulisan odgovor koji je netačan, što je gore od poruke „ne znam". Koristim incremental indexing sa SHA-256 content hash-om, pa re-embed-ujem samo dokumente kojima se sadržaj promenio, čime štedim vreme i sprečavam da nešto bude zaboravljeno pri update-u. Za sadržaj sa rokom važenja (cenovnici, promocije, pravila za određenu godinu) upisujem `valid_until` u metapodatke i filtriram pri retrieval-u. Posebnu pažnju posvećujem brisanju: ako je dokument obrisan iz izvora, mora biti uklonjen i iz indeksa, jer su delete operacije važnije od insert-a.
Gradiš nešto teško sa AI-jem ili automatizacijom? Otvoren sam za razgovor.
Javi se