Manapság a RAG olykor rossz hírnévnek örvend: egyesek szerint már teljesen triviális (elkezdeni valóban az, skálázni kevésbé), mások pedig úgy gondolják, hogy az „ágensalapú rendszerek” meghaladták (amelyek a felszín alá nézve sok esetben nagyon gyorsan a RAG-hoz kezdenek hasonlítani…).
Ebben a blogbejegyzésben néhány részletes példán keresztül mutatjuk be, hogyan kezeljük a gyakori kihívásokat, nevezetesen:
Vegyes szöveges és numerikus adatok kezelése, és hogy ezek miért teszik működésképtelenné a naiv RAG-ot: a kulcsszavak ütköznek, a számoknak pedig nincs szemantikai jelentésük.
Miért segít, ha az embeddingeket az összefoglalásokra építjük: minden darabhoz rövid, leíró összefoglalót készítünk, majd ezt ágyazzuk be és kérdezzük le.
Hogyan készítsünk kontextuális összefoglalókat: adjuk meg a szülődokumentum kontextusát, hogy a hasonló felépítésű statisztikák egyértelműen elkülönüljenek.
Mikor támaszkodjunk kódra és Pydantic-modellekre: ahol fontos a szó szerinti tartalom, a megbízhatóság érdekében kombináljuk az egyedi kódot és/vagy Pydantic-modellt LLM-hívásokkal.
Az alapok
A RAG-rendszerek az ügyféltámogató botoktól a belső tudásasszisztensekig számos megoldást működtetnek.
A háttérben jellemzően a következő történik:
Darabokra bontjuk a forrásdokumentumokat
Beágyazzuk az egyes darabokat egy vektortérbe
Lekérjük a K legjobb darabot a lekérdezéskor
Létrehozunk egy választ az adott darabok alapján
A népszerű eszközkészletek – a LangChain, a LlamaIndex és az OpenAI Filestore – szinte triviálissá teszik ezeket a lépéseket. A valós adatfolyamokban azonban nem csupán tömény szöveggel találkozunk, és az alapvető RAG ezzel nehezen boldogulhat. A következő részekben konkrét adatkezelési kihívásokat mutatunk be, majd a növekvő összetettséggel párhuzamosan, fokozatosan építjük fel a megoldást.
Amikor az adatok nem csupán szövegből állnak (ami valójában gyakori)
Vegyük a következő, játékokhoz kapcsolódó adatrészletet:
JSON
A beágyazások a szavak szemantikai jelentéséből és a nyelvtanból megtanult kapcsolatoknak köszönhetően működnek. A fenti adatok szöveget és számokat egyaránt tartalmaznak, ám ebből a konkrét kontextusból kiragadva a számok nem kapcsolódnak a szavakhoz. Mondhatjuk tehát, hogy ez az adatrészlet lényegében néhány többé-kevésbé leíró szóból és az utánuk következő véletlenszerű számokból áll.
Ez önmagában nem jelentene problémát, ha csak ilyen adataink lennének, hiszen a néhány rendelkezésre álló leíró szó beágyazása alapján továbbra is végezhetnénk lekérést (vagy egyszerűen használhatnánk text-to-SQL megoldást). De mi történik akkor, ha ez a darab sok tömény szöveges részlet között rejtőzik, amelyekben ugyanezek a szavak is szerepelnek? Például:
JSON
Most képzeljük el, hogy ezt szeretnénk lekérdezni: “What is the attack range with Draconic Ascension?” Nagy valószínűséggel nem tudjuk lekérni a kívánt releváns darabot, mert elveszik az azonos kulcsszavakat tartalmazó többi darab zajában.
A probléma gyökere, hogy nem tudjuk jól megkülönböztetni ezeket az adatrészleteket, noha ugyanarról a témáról eltérő típusú információkat tartalmaznak. Vajon gazdagíthatnánk vagy javíthatnánk valahogyan ezeket? Természetesen igen:smile:
Gazdagítsuk az adatokat összefoglalással – igen, jól olvasta
Ahelyett, hogy közvetlenül magát a darabot ágyaznánk be, először készíthetünk egy összefoglalót arról, miről szólnak az adatok, majd ezt ágyazhatjuk be és kérdezhetjük le. A generálás során továbbra is az összefoglaláshoz kapcsolt eredeti adatokat használnánk.
A fenti két példához tehát a következőkhöz hasonló összefoglalásokat készítenénk:
A támadás hatótávolságának, sebességének és sebzésének statisztikái (alapértelmezés szerint és Draconic Ascension használatával).
A Draconic Ascension leírása és részletei, beleértve az aktiválási feltételeket, a vizuális hatásokat és a háttértörténetet.
Ezután a lekérdezést is kibővítjük, hogy „igazodjon” az összefoglaláshoz. Például a “What is the attack range with Draconic Ascension?” kérdésből ezt készítenénk: “What is the statistics of attack range with Draconic Ascension?” Ez különösen fontos, amikor a lekérdezést nem műszaki területen dolgozó felhasználók ~~„szabad stílusban”~~ hétköznapi nyelven fogalmazzák meg, hiszen nem feladatuk tudni, hogyan működik a RAG, és miként maximalizálható a pontosság és a találati arány.


Ne ragadjuk ki a dolgokat a kontextusukból (ez általában az életben is hasznos)
A következő helyzetben rengeteg, az alábbiakhoz hasonló, szinte egyforma adatrészletet kell kezelnünk:
Plain Text
Ha az eddigi megközelítésnél maradunk, képzeljük el a következő kérdést: “what is character X’s attack range?” Az imént létrehozott összefoglalásokkal szerencsejátékká válna a találgatás, mert azok is nagyon hasonlóan néznének ki. Hogyan különböztethetnénk meg őket?
A válasz egyszerű: adjunk kontextust. Egyszerűen elhelyezhetnénk az adatrészletben egy hivatkozást annak szülődokumentumára, ebben az esetben például ezt: {”Karakter”: “X”}. Így pontosan lekérhetnénk az X Karakterhez tartozó megfelelő adatokat akkor is, ha az Y és Z Karakterről ugyanilyen adataink vannak.
Jobb és általánosabban alkalmazható megoldás azonban, ha kontextuális összefoglalót készítünk a darabról. Vagyis ahelyett, hogy csak magáról az adatrészletről készítenénk összefoglalót, a szülődokumentumot és a darabot egyaránt átadhatnánk egy általános kontextuális összefoglaló létrehozásához. Ebben azt is leírnánk, hogyan illeszkedik a darab a szülődokumentumba, például:
Ez a darab részletes statisztikákat közöl az X Karakter … értékeiről. A darab azzal illeszkedik a teljes dokumentumba, hogy bemutatja X erősségét a támadási sebesség terén…
Ez a darab részletes statisztikákat közöl az Y Karakter … értékeiről. A darab azzal illeszkedik a teljes dokumentumba, hogy bemutatja Y különleges képességével megnövelt értékeit…
Ez a darab részletes statisztikákat közöl a Z Karakter … értékeiről. A darab azzal illeszkedik a teljes dokumentumba, hogy bemutatja Z értékeit, amelyek miatt kiváló tankként használható csapatmérkőzéseken…
Ez a módszer (amelyet részben az Anthropic ihletett) túlzásnak tűnhet a fenti példánál, ám nagyon hatékony az olyan darabok esetében, amelyek „kontextusukból kiragadva” félreérthetők. Emellett minden darabnál használható egységes megközelítést kínál, így a mérnöki adatfolyam is rendezett marad.


Amikor ~~mániákusan irányítani akarunk~~ szigorúnak kell lennünk
Az adatokat általában teljes egészként kapjuk meg, majd darabokra bontjuk őket egy RAG-rendszer számára. Ebben a példában kissé más a helyzet: az adatokat már darabokra bontották, de rosszul. Egy logikai egység véletlenszerű részeiről van szó, amelyeket újra össze kell fűzni. A logikai egység olyan tartalomrész, amely természetes módon összetartozik, például egy dokumentum alfejezete vagy egy koherens bekezdés.


Első próbálkozásként minden adatot átadunk egy LLM-hívásnak, megkérjük, hogy saját belátása szerint csoportosítsa őket, majd adja vissza a csoportosított tartalmat. Az LLM bizonyára remekül megoldja, igaz? Nos, igen is, meg nem is.
Több más alkalommal is azt tapasztaltuk, hogy az LLM-ek hajlamosak felületesen dolgozni, és megbízhatatlanok, ha a teljes, pontos tartalomra van szükség, különösen hosszú kontextus esetén. Ez teljesen érthető. Ebben a konkrét felhasználási esetben azonban ez kizáró ok volt, mert a pontos, szóról szóra egyező tartalomra van szükségünk: sem összefoglalás, sem az eredeti tartalom bármely részének kihagyása nem elfogadható. Egyetlen részlet sem veszhet el.
Az „igen” része természetesen az volt, hogy kiválóan megértette a töredezett darabok szemantikáját és szerkezetét. Már amikor nem tagadta meg a pontos tartalom visszaidézését. A fenébe:/
Hogyan használhatnánk ki azt, amiben az LLM jó, miközben elkerüljük azt, amiben megbízhatatlan? Régi jó barátunkhoz, a kódhoz fordultunk (értsd: egyedi Python-függvényhez). Valamint egy „ennél egyszerűbb már nem is lehetne” Pydantic-modellhez. Íme a megoldás:
Végiglépkedünk a szakaszokon, miközben nyilvántartjuk az aktuális logikai egységet
Minden szakasznál megkérdezzük az LLM-et: ez a szakasz az aktuális logikai egységhez tartozik? A válasz igen vagy nem legyen (a Pydantic-modell szerint).
Ha igen, hozzáfűzzük a szakaszt az egységhez. Ha nem, kiírjuk a befejezett aktuális logikai egységet, majd a szakasszal új egységet kezdünk.


Természetesen így valamivel több tokent használunk, mint a teljes tartalom egyszeri feldolgozásával, de ebben a felhasználási esetben, ahol a pontos tartalom megőrzése a legfontosabb, bőven megérte a csekély többletköltséget.
Ez nagyon egyszerű megoldás, de egy fontos alapelvet követ: ha szigorúságra van szükség, nem érdemes kizárólag az LLM-ekre hagyatkozni, hiszen valószínűségi alapon működnek.
Egyedi kóddal, függvényekkel és Pydantic-modellekkel kiszámítható, megbízható eredményt érhetünk el, miközben az LLM-ek képességeit is kiaknázzuk.
Egy generatív MI-megoldás létrehozása éppannyira mérnöki, mint MI-szakmai kihívás. Reméljük, ezek a példák ihletet adtak ahhoz, hogy megbirkózzon saját, egyedi kihívásaival. Ha többet szeretne megtudni a mérnöki szemléletű generatív MI-megoldásokról, olvassa el blogbejegyzésünket az útválasztó-alapú ágensrendszerek tervezéséről.