Najlepšie sa učím vtedy, keď mám pred sebou reálny problém. Dokumentácia a návody sú užitočné, ale technológii začnem naozaj rozumieť až vo chvíli, keď sa musím rozhodovať, opravovať slabé výsledky a udržať celý proces funkčný.

Aj preto som začal robiť Lexio.

Lexio je môj vlastný produkt a zároveň priestor, v ktorom sa učím, ako dostať AI do reálnej aplikácie. Odpovedá na otázky pomocou kontextu získaného z vybraných slovenských právnych dokumentov. Aktuálna skúšobná verzia má zámerne obmedzený rozsah. Odpovede majú informačný charakter a treba ich overiť v oficiálnom znení alebo u odborne spôsobilej osoby.

Nechcel som vytvoriť iba ďalšie AI demo

Mojím cieľom nebolo obaliť API jazykového modelu chatovacím oknom. Chcel som pochopiť, ako vyzerá RAG — retrieval-augmented generation — keď sa z ukážky stane súčasť produktu.

Na prvý pohľad je postup jednoduchý: nájsť relevantný text, poslať ho modelu ako kontext a zobraziť odpoveď. V reálnej aplikácii sa však za touto vetou skrýva indexovanie dokumentov, práca s bežným jazykom, viac spôsobov vyhľadávania, hodnotenie výsledkov, fallbacky, streaming a observability.

Práve tieto vrstvy ma na Lexiu bavia najviac.

Čo sa deje ešte pred prvou otázkou

Vstupom môžu byť PDF aj HTML verzie právnych dokumentov. Nestačí ich rozdeliť každých niekoľko tisíc znakov. Takýto rez by mohol oddeliť názov paragrafu od jeho obsahu alebo rozdeliť jeden odsek na dve nesúvisiace časti.

Preto má Lexio vlastný LegalTextChunker. Najprv odstráni hlavičky strán a zalomené slová, potom rozpozná paragrafy a prílohy. Dlhý paragraf delí až následne, ideálne na hranici odseku alebo vety.

01 / IndexovanieAko sa zákon zmení na vyhľadateľný kontext
§ ostáva spolu s textomslang + skratky256 rozmerov

hranice podľa §, nie náhodný rez textu

Jadro chunkera je malé, ale pre výsledok veľmi dôležité:

private static final Pattern PARAGRAPH_HEADING =
        Pattern.compile("^§\\s*(\\d+[a-zA-Z]*)$");

List<Section> sections = parseSections(cleanLines(rawText));
for (Section section : sections) {
    chunks.addAll(chunks(section, Math.max(1000, maxCharacters)));
}

Každá časť si nesie dokument, označenie paragrafu, nadpis, text a stabilný chunkKey. Pred vytvorením embeddingu ju môže ďalší krok obohatiť o právne pojmy, skratky, synonymá, slang a typické používateľské otázky. Model pritom dostáva strict JSON schému a pri chybe sa systém bezpečne vráti k pôvodnému textu.

Embeddingy majú v aktuálnej verzii 256 rozmerov. Ukladajú sa do PostgreSQL cez pgvector a HNSW index. Obsah aj jednotlivé chunky majú hash, takže viem dohľadať, s akou verziou dokumentu pracujem.

Jedna otázka spustí viac než jeden search

Používateľ nepíše ako právnik. Napíše „zbroják“, „ZP“ alebo celú situáciu bežnou vetou. Priamy embedding takejto otázky niekedy nájde tematicky podobný text, ale minie presné ustanovenie.

Query planning preto vytvorí viac pohľadov na rovnakú otázku. Doplní známe skratky a doménové pojmy, pri situačných otázkach môže použiť AI query rewrite a najlepšie predchádzajúce plány vie nájsť cez malú vektorovú pamäť. Výsledkom nie je odpoveď, ale sada krátkych vyhľadávacích dotazov.

02 / Jedna otázkaCesta od bežnej vety k odpovedi so zdrojom
„zbroják“ → právny pojem40 kandidátovnízke skóre? hľadaj znova

↖ retrieval trace · eval · kontrola v admine

Samotné vyhľadávanie je hybridné. Pre každý dotaz sa vykoná vektorový search a popri ňom aj klasické lexikálne hľadanie. Kandidáti sa spoja, odstránia sa duplicity a znovu zoradia:

for (float[] embedding : embeddings) {
    repository.search(embedding, CANDIDATE_LIMIT)
            .forEach(chunk -> candidates.add(new RetrievalCandidate(chunk, "vector")));
}

repository.searchText(searchTerms(plan), CANDIDATE_LIMIT)
        .forEach(chunk -> candidates.add(new RetrievalCandidate(chunk, "lexical")));

List<RetrievalCandidate> ranked = rerank(candidates, plan);

Pri rerankingu zohľadňujem podobnosť aj doménové signály. Z vybraných častí sa vypočíta confidence skóre kombinujúce silu najlepšieho výsledku, pokrytie rozpoznaných pojmov, rôznorodosť zdrojov a počet kandidátov. Nízke skóre môže odporučiť druhé vyhľadávanie namiesto toho, aby systém predstieral istotu.

Každý search zároveň vytvorí retrieval trace: pôvodnú otázku, upravené dotazy, rozpoznané pojmy, poradie chunkov, ich skóre, vzdialenosť a informáciu, či prišli z vektorového alebo textového vyhľadávania. Keď je odpoveď slabá, viem sa pozrieť o krok späť a zistiť, či zlyhal model alebo už samotný retrieval.

Spring AI bol začiatok, nie konečný tvar

Keď som s projektom začínal, chcel som sa naučiť pracovať so Spring AI a pochopiť, čo mi framework pri AI aplikácii dokáže zjednodušiť. Počas vývoja som sa však rozhodol ísť v niektorých miestach o úroveň nižšie.

Aktuálna implementácia stojí na Jave 17 a Spring Boote. OpenAI Responses API a embeddings volám cez vlastnú tenkú provider vrstvu postavenú na RestClient a HttpClient. Vďaka rozhraniu AiProvider nie je zvyšok aplikácie pevne zviazaný s jedným modelom alebo providerom.

Tento prístup mi ukázal aj detaily, ktoré wrapper ľahko schová: SSE udalosti pri streamingu, usage tokeny, prompt cache key, timeouty, chybové odpovede a odhad ceny každého typu volania. Streamovaná odpoveď ide do browsera ako NDJSON a používateľ vidí skutočné tokeny hneď, ako prichádzajú — nie iba animáciu hotového textu.

Admin rozhranie uzatvára celý cyklus

Admin nie je iba tabuľka otázok. Pri každej odpovedi vidím použitý model, zdroj odpovede, stav, počet tokenov, odhad ceny a celkovú latenciu. Samostatne sa ukladajú aj časy krokov ako história konverzácie, query planning, retrieval, model a persistencia.

Záznamy otázok a vygenerovaných odpovedí nie sú verejné. Používam ich na hľadanie opakujúcich sa problémov:

  • retrieval našiel tematicky podobný, ale nesprávny paragraf,
  • odpoveď vynechala dôležité obmedzenie,
  • jazyk bol príliš technický,
  • alebo model dostal nejasnú inštrukciu.

Prompt potom neupravujem naslepo. Viem nájsť konkrétny trace, zopakovať otázku a porovnať výsledok. Aplikácia podporuje aj paralelné porovnanie pôvodného „celý dokument v kontexte“ prístupu s novým RAG výsledkom bez toho, aby experiment videl používateľ.

Evaly namiesto pocitu

V repozitári mám malý dataset otázok s očakávanými zdrojmi. Eval runner vykoná retrieval pre každú otázku a skončí chybou, ak medzi výsledkami nenájde očakávaný paragraf alebo dokument.

{
  "id": "weapon-license-abbreviation",
  "question": "Co znamena ZP?",
  "expectedSources": ["zbrojny preukaz"]
}

Nie je to kompletné hodnotenie kvality odpovede. Je to však jednoduchá poistka proti tomu, aby úprava chunkingu, query rewrite alebo rerankingu potichu pokazila prípady, ktoré už fungovali. K tomu pribúdajú jednotkové testy pre hranice paragrafov, dlhé ustanovenia, právne skratky, out-of-scope otázky aj nezobrazovanie zdrojov pri odpovedi „nenašlo sa“.

Čo ma Lexio zatiaľ naučilo

Najväčšie zistenie je, že RAG nie je jedna feature a už vôbec nie mágia. Kvalitu odpovede určuje celý reťazec: zdrojový dokument, chunking, obohatenie, query plán, retrieval, prompt, model aj spôsob vyhodnocovania.

Zároveň sa učím pristupovať k promptom podobne ako ku kódu. Potrebujú jasný účel, pozorovateľné správanie, reálne testovacie prípady a malé zmeny, ktorých výsledok viem porovnať.

Lexio je stále malý skúšobný produkt a je to zámer. Obmedzený rozsah mi umožňuje lepšie vidieť, čo funguje a čo zlyháva. Najbližším cieľom nie je, aby chat pôsobil múdrejšie. Chcem hlavne rozšíriť eval dataset, zlepšiť prácu s confidence skóre a spraviť overovanie zdrojov ešte jednoduchšie.

Aktuálnu verziu si môžete vyskúšať na lexio.donit.sk/lexio.

Článok vychádza z môjho reálneho kódu a skúseností s vývojom Lexia. Pri jazykovej úprave a preklade som použil AI.