KODEKS je pravni asistent koji gradim za policijske službenike u Bosni i Hercegovini. Odgovara na pitanja na osnovu zakona i propisa, i isključivo na osnovu njih. Pravni tekstovi su podijeljeni na odlomke (chunkove), pretvoreni u embeddinge OpenAI-jevim modelom text-embedding-3-small i pohranjeni u Supabase uz pgvector. Kada neko postavi pitanje, pokrećem pretragu po sličnosti, relevantne odlomke šaljem Claudeu, a Claude odgovara uz navođenje izvora.
Arhitektura je opisana u studiji slučaja KODEKS, a ovakve sisteme gradim u okviru usluge AI integracija.
Tokom testiranja pipeline je za većinu pitanja radio dobro. Za neka, međutim, nije vraćao ništa korisno. Bez greške, bez pada sistema, samo prazan ili izbjegavajući odgovor. Ovaj tekst govori o tome zašto se to dešavalo i šta sam promijenio.
Simptom
Obrazac nije bio slučajan. Pitanja koja su koristila istu formulaciju kao zakonski tekst radila su bez problema. Pitanja postavljena onako kako bi ih čovjek zaista postavio, svakodnevnim jezikom, često su se vraćala prazna.
Za ovakav alat to je ozbiljan problem. Policijski službenici ne postavljaju pitanja jezikom zakona. Oni opisuju situaciju. Ako asistent radi samo kada već znate tačnu pravnu formulaciju, onda to i nije neki asistent.
Uzrok 1: prag sličnosti je odbacivao sve
Korak pretrage (retrieval) radio je dvije stvari: rangirao je odlomke po kosinusnoj sličnosti s pitanjem i odbacivao svaki odlomak ispod fiksnog praga sličnosti. Prag je trebao spriječiti da irelevantni odlomci uđu u prompt.
Kod pitanja formulisanih blizu zakonskog teksta, relevantni odlomci su imali rezultat znatno iznad praga. Kod drugačije formulisanih pitanja, relevantni odlomci su i dalje bili na vrhu rangiranja, ali su im rezultati bili niži i padali su tik ispod granice. Filter ih je sve uklonio, a model nije dobio nikakav kontekst.
Pojednostavljena verzija funkcije za pretragu izgledala je ovako:
-- Pojednostavljena skica, nije produkcijska funkcija
create or replace function match_chunks(
query_embedding vector(1536),
match_threshold float,
match_count int
)
returns table (id bigint, content text, source text, similarity float)
language sql stable
as $$
select id, content, source, 1 - (embedding <=> query_embedding) as similarity
from legal_chunks
where 1 - (embedding <=> query_embedding) > match_threshold
order by embedding <=> query_embedding
limit match_count;
$$;
U where klauzuli je cijela priča. Prag je jedan globalni broj, a rezultati sličnosti snažno zavise od toga kako je pitanje formulisano. Jedan broj ne može biti ispravan za svako pitanje.
Uzrok 2: prompt nije imao plan za "nema relevantnog konteksta"
Drugi problem je pogoršavao prvi. System prompt je govorio Claudeu da odgovara na osnovu dostavljenih odlomaka i da ih citira. Nije govorio ništa o tome šta da radi kada odlomaka nema, ili kada odlomci zapravo ne odgovaraju na pitanje.
Kada bi pretraga vratila prazan rezultat, model se nalazio u nedefinisanoj situaciji. Nekad bi odgovorio neodređeno. Nekad bi vratio nešto što je bilo gotovo ništa. U pravnom kontekstu neodređen odgovor je opasniji od ta dva, jer može zvučati sigurno, a da se ne oslanja na tekst.
Rješenje
Dva uzroka sam rješavao odvojeno.
1. Podešavanje praga na stvarnim pitanjima
Umjesto da prag biram napamet, napravio sam mali testni skup stvarnih pitanja, napisanih onako kako ih ljudi zaista postavljaju, i svakom pridružio odlomke koji bi na njega trebali odgovoriti. Zatim sam zasebno mjerio samu pretragu: da li su se za svako pitanje tačni odlomci pojavili u rezultatima i pri kojem pragu su nestajali?
Tako je kompromis postao vidljiv. Strog prag drži kontekst čistim, ali gubi svakodnevne formulacije. Labav prag hvata više pravih odlomaka, ali propušta šum. Uz testni skup mogao sam vrijednost izabrati na osnovu dokaza, a ne nagađanja.
2. Dodavanje top-k fallbacka
Čak i dobro podešen prag ponekad će odbaciti sve. Zato se pretraga sada, kada ništa ne prođe prag, vraća na nekoliko najsličnijih rezultata, bez obzira na njihov rezultat. Model zatim procjenjuje da li su ti odlomci zaista relevantni, a to je procjena u kojoj je mnogo bolji od jednog fiksnog broja.
// Pojednostavljena skica koraka pretrage
const { data: matches } = await supabase.rpc("match_chunks", {
query_embedding: embedding,
match_threshold: THRESHOLD,
match_count: MAX_CHUNKS,
});
let context = matches ?? [];
let usedFallback = false;
if (context.length === 0) {
// Ništa nije prošlo prag: svejedno uzmi najbliže odlomke
// i pusti model da procijeni da li odgovaraju na pitanje.
const { data: nearest } = await supabase.rpc("match_chunks", {
query_embedding: embedding,
match_threshold: -1,
match_count: FALLBACK_CHUNKS,
});
context = nearest ?? [];
usedFallback = true;
}
3. Jasna uputa modelu šta da radi kada kontekst nedostaje
System prompt sada ima izričito definisano ponašanje za slučaj kada odlomci ne odgovaraju na pitanje: reći šta nedostaje i ne nagađati. U praksi to znači da asistent službeniku kaže da dostavljeni pravni tekstovi ne pokrivaju pitanje, ili koji dio pitanja nije pokriven, umjesto da prazninu popuni općim znanjem.
Odgovaraj isključivo na osnovu dostavljenih odlomaka i za svaku tvrdnju navedi izvor. Ako odlomci ne sadrže odgovor, jasno reci koja informacija nedostaje. Ne nagađaj i ne koristi znanje izvan odlomaka.
Ova uputa djeluje u oba smjera. Rješava prazne odgovore, a uklanja i rizik da model izmišlja zakon kada je pretraga slaba.
Pouka: pretragu evaluirati odvojeno od generisanja
Najkorisnija stvar koju sam iz ovoga izvukao je način debugovanja, a ne neka konkretna postavka.
Kada RAG sistem da loš odgovor, primamljivo je pogledati konačni odgovor i početi mijenjati prompt. Ali RAG pipeline ima dva nezavisna dijela i oni zakazuju na različite načine:
- Pretraga (retrieval) može ne pronaći prave odlomke, zbog pragova, načina dijeljenja teksta ili embedding modela.
- Generisanje može loše iskoristiti dobre odlomke, ili se loše ponašati kada odlomci nedostaju.
Ako evaluirate samo konačni odgovor, ne možete znati koji je dio zakazao. U mom slučaju prompt uopće nije bio osnovni uzrok praznih odgovora, nego pretraga. Ali prompt je ipak trebalo popraviti, jer nije imao definisano ponašanje za situaciju koju će pretraga uvijek s vremena na vrijeme proizvesti.
Zato ih sada evaluiram odvojeno. Za pretragu: da li se za skup stvarnih pitanja vraćaju pravi odlomci? Za generisanje: kada dobije prave odlomke, ili namjerno nikakve, da li model odgovara tačno, navodi izvore i priznaje kada nešto nedostaje?
Ta podjela pretvara "asistent je ponekad loš" u konkretne probleme koji se mogu riješiti. To je i metoda koju želim koristiti prije nego što KODEKS proširim: prvo izmjeriti kvalitet odgovora na stvarnim pitanjima, pa tek onda širiti.
