RAG u produkciji: greške koje vide samo oni koji su ga gradili

Prvi RAG sistem koji sam pustio u pravi saobraćaj imao je savršen demo i užasnu prvu nedelju. Pitanja koja su korisnici postavljali nisu ličila ni na jedno pitanje iz mog test seta, a odgovori su bili uverljivi i pogrešni. Tutorijal ti kaže "chunk, embed, retrieve, generate". Produkcija ti pokaže da je svaka od te četiri reči mesto gde stvari tiho pucaju.
Ovo su greške na koje sam naletao u sistemima koje sam gradio (uključujući lokalne LLM RAG pipeline-ove sa pgvector-om), i popravke koje su mi se isplatile.
Retrieval se najčešće kvari tamo gde ga ne meriš
Najčešći uzrok lošeg RAG odgovora nije model, nego to što pravi chunk nikad nije stigao u prompt. Zato prvo merim retrieval odvojeno od generisanja: da li se tačan chunk nalazi u top-k, a tek onda da li je model dobro odgovorio.
Konkretno, napraviš set od nekoliko desetina realnih pitanja sa poznatim izvornim chunkom i meriš recall@k. Bez toga svaka promena (novi embedding model, drugačiji chunking, reranker) je vera, a ne inženjering. Kad sam prvi put to uradio, otkrio sam da je kod mene "model halucinira" u većini slučajeva zapravo bilo "retrieval nije našao dokument".
Dva pravila koja držim:
- Pitanja za evaluaciju uzimam iz pravih logova, ne iz glave. Ljudi pitaju skraćeno, sa greškama, mešaju srpski i engleski, pominju interne nazive. Pitanja koja sam sam smislio bila su preuredna.
- Ne gledam samo prosek. Gledam koja pitanja padaju. Obično su to isti obrasci: šifre proizvoda, skraćenice, tačni nazivi funkcija.
Taj poslednji obrazac me vodi do sledeće greške.
Čist vektorski search ne ume da traži tačne reči
Embeddings su odlični za značenje, a loši za tačne stringove. Upit "greška ERR_4012" ili naziv konkretnog API endpoint-a vektorska pretraga često promaši, jer se "slično po značenju" ne poklapa sa "isti niz karaktera". Rešenje je hibridna pretraga: BM25 (leksička) plus vektori, spojeni preko Reciprocal Rank Fusion.
RRF je lep baš zato što ne mora da poredi neporedive skorove. Svaki retriever daje svoju rang-listu, a dokument dobija skor 1 / (rank_constant + rank) sabran preko listi. U Elasticovoj dokumentaciji rank_constant ima podrazumevanu vrednost 60. Ne tvrdim da je 60 optimalno za svaki korpus (izvori ga opisuju kao podrazumevanu, empirijski odabranu vrednost), pa ga tretiram kao parametar koji se podešava na tvom evaluacionom setu, ne kao zakon.
Minimalna implementacija na Postgresu izgleda ovako:
WITH vec AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS r
FROM chunks ORDER BY embedding <=> $1 LIMIT 50
),
lex AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS r
FROM chunks, plainto_tsquery('simple', $2) q
WHERE tsv @@ q LIMIT 50
)
SELECT id,
COALESCE(1.0/(60+vec.r),0) + COALESCE(1.0/(60+lex.r),0) AS score
FROM vec FULL OUTER JOIN lex USING (id)
ORDER BY score DESC LIMIT 20;
Napomena za naš jezik: za srpski nema dobrog out-of-the-box stemmer-a u Postgresu, pa često završim sa simple konfiguracijom. Latinica i ćirilica u istom korpusu su posebna priča, i bez normalizacije (transliteracija na jedno pismo pre indeksiranja) leksički deo tiho gubi pola pogodaka. To je greška koju ne vidiš dok ne pogledaš koji upiti padaju.
Chunk bez konteksta je chunk koji se ne može naći
Treća klasična greška: seckaš dokument na komade i svaki komad izgubi ono što ga je činilo razumljivim. Chunk koji kaže "Rok je produžen za 30 dana" ne sadrži ni o kom proizvodu, ni o kom ugovoru, ni o kom periodu govori. Embedding takvog komada je u suštini šum.
Anthropic je ovo adresirao pristupom koji zovu Contextual Retrieval: pre embedovanja svakom chunku dodaš kratak kontekst koji ga smešta u dokument, i taj tekst koristiš i za embeddings i za BM25. Prema njihovom merenju, contextual embeddings smanjuju broj neuspelih pretraga za 35% (sa 5,7% na 3,7%), uz contextual BM25 za 49%, a uz dodatni reranking za 67% (stopa neuspeha u top-20 pada sa 5,7% na 1,9%). Izvor: Anthropic, Contextual retrieval.
Važna rezerva, koju bih naglasio svakom klijentu: to su brojevi iz njihovih internih evaluacija na određenim korpusima. Ne obećavaju isti dobitak na tvojim podacima. Ja ih čitam kao "ovaj pravac vredi probati", a ne kao očekivani rezultat. Na tvom korpusu merenje radiš ti, na svom evaluacionom setu iz prve sekcije.
Trade-off je jasan: plaćaš jedan LLM poziv po chunku u vreme indeksiranja, jednokratno (ili pri svakoj izmeni dokumenta). Prompt caching tu pomaže, jer je ceo dokument isti za sve njegove chunkove. Prema Anthropic cenovniku, čitanje iz keša košta 0,1x osnovne cene ulaza, a upis 1,25x za 5-minutni TTL. Za jednokratni batch indeksiranja to je skoro uvek isplativo, ali proveri aktuelne cene na Anthropic stranici sa cenama pre nego što računaš budžet.
Više konteksta nije uvek bolje, ali ni manje nije
Zamka na suprotnoj strani: ubaciš top-3 chunka da bude jeftino i brzo, pa pravi odgovor ostane na mestu 4. Anthropic u zaključcima navodi da je prosleđivanje top-20 chunkova modelu efikasnije nego samo top-10 ili top-5. Zvuči kontraintuitivno, ali smisao je: šire mrežu u retrievalu, a onda reranker i/ili model biraju.
Drugi deo priče je gde u promptu stavljaš te chunkove. Rad "Lost in the Middle" (Liu et al.) pokazuje da performanse modela opadaju kad je relevantna informacija u sredini dugog konteksta, a najbolje su kad je na početku ili na kraju. Pažnja: to je merenje na modelima iz 2023 (MPT-30B-Instruct, LongChat-13B, GPT-3.5-Turbo, Claude-1.3), pa ne tvrdim da isti U-oblik važi za današnje modele. Ali kao praktična higijena dovoljno je jeftino da je primenim bez dokaza: najrelevantnije chunkove stavljam na početak i kraj, ne zakopane u sredinu, a svoje pretpostavke proveravam na sopstvenom setu. Rad je na arXiv-u.
Praktičan obrazac koji koristim:
| Korak | Šta radim | Zašto |
|---|---|---|
| Retrieval | hibrid (vektor + BM25), široko, npr. top-50 | recall je prioritet |
| Rerank | cross-encoder ili LLM reranker na tih 50 | preciznost na vrhu |
| Prompt | top ~20 u promptu, najjači na početku i kraju | pokriće vs. šum |
| Generisanje | model citira ID chunka | proverljivost |
Tačne brojeve (koliko kandidata, koliko u promptu) ne nosim iz tuđih članaka. Zavise od dužine chunkova, modela i tipa pitanja, i određujem ih na evaluacionom setu.
Cena i latencija: tamo gde sistem stvarno kvari budžet
U demou niko ne gleda račun. U produkciji, trošak se krije na dva mesta koja ljudi zanemare.
Embedding je jeftin, ponovno embedovanje nije. Prema trećestranom pregledu cena (zvaničnu OpenAI stranicu nisam uspeo da potvrdim), OpenAI embeddings koštaju $0,02 po milion tokena za text-embedding-3-small i $0,13 za text-embedding-3-large, što je 6,5x razlike. Na milion tokena to su sitni iznosi, ali kad promeniš chunking strategiju i moraš da prepraviš ceo korpus, ili kad menjaš embedding model, to je prepravka celog indeksa. Zato verzionišem indeks (model + parametri chunkinga u metapodacima) i nikad ne menjam model "u hodu" nad istim indeksom. Mešanje vektora iz dva modela u istoj tabeli je tihi ubica kvaliteta.
Izbor embedding modela ne prepuštam tabeli rangiranja. MTEB benchmark pokriva 8 tipova zadataka i 112 jezika, a autori sami izveštavaju da nijedna metoda ne dominira na svim zadacima. Za retrieval se gleda nDCG@10 (izvor: MTEB). Za srpski ili miks srpskog i engleskog, javni lider nije garancija ništa, i zato opet moj mali set pitanja odlučuje. U Anthropic testu su Voyage i Gemini embeddings dali najbolje rezultate, ali to je njihov korpus, ne tvoj.
Laka klasifikacija i rutiranje ne treba da idu na najjači model. Za predobradu (da li je pitanje uopšte iz domena, kom indeksu ga slati, prepis upita) koristim najmanji i najjeftiniji model. Prema Anthropic dokumentaciji, Haiku klasa je upravo za to namenjena (visok obim, mala latencija: klasifikacija, ekstrakcija, rutiranje), a cene su na zvaničnoj stranici o prompt cachingu.
Nemam pouzdane javne brojeve za latenciju rerankera i vektorskih baza koje bih ovde izneo kao činjenice, pa ih ne navodim. Merim na sopstvenom hardveru i sopstvenom saobraćaju, p95 a ne prosek, jer korisnik pamti najsporiji odgovor.
Sistem mora da zna da kaže "ne znam"
Poslednja i najopasnija greška nije tehnička. RAG koji uvek odgovara je RAG koji će te jednom osramotiti. Kad retrieval vrati slabe pogotke, model će ipak složiti uverljiv odgovor od onoga što ima.
Šta radim:
- Prag na skoru retrievala. Ako najbolji pogodak nije dovoljno dobar, ne zovem generisanje nego vraćam "nemam taj podatak, evo kome da se obratite".
- Obavezni citati. Svaka tvrdnja u odgovoru nosi ID izvornog chunka. Ako ga model ne može navesti, tvrdnju ne prikazujem.
- Logovanje svega. Upit, vraćeni chunkovi, skorovi, odgovor, povratna informacija korisnika. Bez toga ne možeš da popravljaš ono što se zaista dešava.
Prag se ne izvodi iz teorije. Izvlačim distribuciju skorova za pitanja na koja znam da odgovor postoji i za ona na koja ne postoji, i tu povlačim liniju. Proces je dosadan, ali mi je donosio više poverenja korisnika nego bilo koja promena modela.
Šta bih ja uradio
Da danas krećem od nule, redosled bi bio ovakav, i ne bih ga menjao:
- Evaluacioni set pre svega. Pedesetak realnih pitanja sa poznatim izvornim chunkom, recall@k kao prva metrika. Sve ostalo je nagađanje bez ovoga.
- Hibridna pretraga od prvog dana, BM25 plus vektori preko RRF-a. Čist vektorski search koristim samo ako evaluacija pokaže da leksika ne treba.
- Normalizacija teksta (pismo, dijakritici, skraćenice) pre indeksiranja, pogotovo za srpski.
- Contextual chunking kao eksperiment, ne kao dogma: probam, merim na svom setu, zadržavam samo ako pomaže.
- Široko dohvatanje, reranker, pa uži prompt, sa najjačim chunkovima na početku i kraju.
- Prag i citati da sistem zna da kaže "ne znam".
- Verzionisan indeks i pun logging, da svaka promena modela ili chunkinga bude kontrolisana, ne slepa.
Stav koji iz ovoga iznosim: u RAG-u se kvalitet ne dobija boljim modelom nego disciplinom merenja retrievala. Većina "AI problema" koje sam dijagnostikovao bila je problem pretrage.
Ako gradiš ili popravljaš RAG sistem i želiš drugi par očiju na arhitekturi, evaluaciji ili troškovima, javi se na lazar-milicevic.com/#contact. Rado ću pogledati šta tačno puca kod vas.
Često postavljana pitanja
Zašto RAG sistem u produkciji daje pogrešne odgovore iako demo radi savršeno?
Najčešći uzrok nije sam model, nego to što pravi chunk nikad nije stigao u prompt, pa model odgovara uverljivo ali pogrešno. Test pitanja koja sam sam smislio bila su preuredna, dok pravi korisnici pitaju skraćeno, sa greškama i mešaju srpski i engleski. Zato retrieval merim odvojeno od generisanja, preko recall@k na pitanjima uzetim iz pravih logova. U mom slučaju je većina "halucinacija" zapravo bila propust retrievala.
Kako da izmerim kvalitet retrievala u RAG sistemu?
Napravim set od nekoliko desetina realnih pitanja sa poznatim izvornim chunkom i merim recall@k, odnosno da li se tačan chunk nalazi u top-k rezultatima. Pitanja uzimam iz pravih logova, ne iz glave. Ne gledam samo prosek, nego koja pitanja padaju, jer su to obično isti obrasci: šifre proizvoda, skraćenice i tačni nazivi funkcija. Bez ovog seta svaka promena embedding modela, chunkinga ili rerankera je vera, a ne inženjering.
Zašto čista vektorska pretraga ne radi dobro za tačne nazive i šifre grešaka?
Embeddings hvataju značenje, ali loše pogađaju tačne stringove, pa upit poput "ERR_4012" ili naziva konkretnog endpoint-a često promaši. Rešenje je hibridna pretraga: BM25 (leksička) plus vektori, spojeni preko Reciprocal Rank Fusion, gde dokument dobija skor 1 / (rank_constant + rank) sabran preko rang-lista. Elasticova dokumentacija navodi rank_constant 60 kao podrazumevanu vrednost, ali je tretiram kao parametar koji se podešava na sopstvenom evaluacionom setu. Za srpski u Postgresu često završim sa simple konfiguracijom, uz transliteraciju na jedno pismo pre indeksiranja.
Šta je Contextual Retrieval i koliko poboljšava RAG?
To je Anthropicov pristup u kom se svakom chunku pre embedovanja doda kratak kontekst koji ga smešta u dokument, a taj tekst se koristi i za embeddings i za BM25. Anthropic navodi da contextual embeddings smanjuju broj neuspelih pretraga za 35%, uz contextual BM25 za 49%, a uz reranking za 67%. To su brojevi iz njihovih internih evaluacija na određenim korpusima, pa ne garantuju isti dobitak na drugim podacima. Cena je jedan LLM poziv po chunku pri indeksiranju, što prompt caching znatno olakšava.
Koliko chunkova treba proslediti modelu i gde ih u promptu staviti?
Anthropic u zaključcima navodi da je prosleđivanje top-20 chunkova efikasnije nego samo top-10 ili top-5, pa retrieval treba da širi mrežu, a reranker ili model da biraju. Rad "Lost in the Middle" pokazuje da performanse padaju kad je relevantna informacija u sredini dugog konteksta, ali na modelima iz 2023, pa ne tvrdim da isto važi za današnje. Kao jeftinu higijenu najrelevantnije chunkove stavljam na početak i kraj prompta. Pretpostavke uvek proveravam na sopstvenom evaluacionom setu.
Gradiš nešto teško sa AI-jem ili automatizacijom? Otvoren sam za razgovor.
Javi se