Praktijkvoorbeelden van RAG-oplossingen op maat

Praktijkvoorbeelden laten zien hoe RAG-systemen op maat complexe kennisproblemen binnen bedrijven oplossen.

Tegenwoordig heeft RAG soms een slechte naam: mensen vinden het inmiddels óf doodsimpel (dat geldt voor het opzetten, maar minder voor het opschalen), óf denken dat het is ingehaald door "agentische systemen" (die bij nadere beschouwing vaak al snel sterk op RAG lijken...)

In deze blog werken we een paar voorbeelden uit om te laten zien hoe we omgaan met enkele veelvoorkomende uitdagingen:

  • Omgaan met een combinatie van tekst en numerieke gegevens en waarom naïeve RAG daar niet mee overweg kan: trefwoorden overlappen en getallen hebben geen semantische betekenis.

  • Waarom embeddings op basis van samenvattingen helpen: genereer per chunk een korte beschrijvende samenvatting en gebruik die vervolgens voor de embedding en zoekopdracht.

  • Contextuele samenvattingen genereren: neem context uit het bovenliggende document op, zodat vergelijkbare statistieken van elkaar te onderscheiden blijven.

  • Wanneer je code en Pydantic-modellen moet gebruiken: als de inhoud letterlijk moet worden overgenomen, combineer je voor een betrouwbaar resultaat aangepaste code en/of een Pydantic-model met LLM-aanroepen.

RAG-oplossingen op maat bouwen

De basis

RAG-systemen vormen de basis voor uiteenlopende toepassingen, van supportbots tot interne kennisassistenten.

Onder de motorkap doe je doorgaans het volgende:

  1. Je brondocumenten in chunks opdelen

  2. Elke chunk als vector in een vectorruimte inbedden

  3. Bij een zoekopdracht de beste K chunks ophalen

  4. Op basis van die chunks een antwoord genereren

Populaire toolkits zoals LangChain, LlamaIndex en OpenAI Filestore maken die stappen bijna triviaal. In echte pipelines kom je echter ook gegevens tegen die niet alleen uit compacte tekst bestaan, en daar kan eenvoudige RAG moeite mee hebben. In de volgende paragrafen tonen we concrete voorbeelden van problemen met gegevens en bouwen we de oplossing stapsgewijs uit naarmate de complexiteit toeneemt.

Wanneer het ingewikkelder wordt

  1. Wanneer je gegevens niet alleen uit tekst bestaan (wat best vaak voorkomt)

Neem de volgende gegevenschunk uit een gamecontext:

JSON

{ "Attack": { "Range": { "default": 5, "with_Draconic_Ascension": 5.5, }, "Speed": { "default": "2 seconds", "with_Draconic_Ascension": "2.2 seconds", }, "Damage": { "default": 10, "with_Draconic_Ascension": 12, } }} 

Embeddings werken dankzij de aangeleerde relaties tussen woorden op basis van semantische betekenis en grammatica. In de bovenstaande gegevens staan tekst en getallen door elkaar. Buiten deze specifieke context hebben die getallen geen verband met de woorden. Je kunt deze gegevenschunk dus eigenlijk zien als een combinatie van enigszins beschrijvende woorden, gevolgd door een paar willekeurige getallen.

Dit zou geen probleem zijn als dit het enige type gegevens was, want dan konden we nog steeds zoeken via de embeddings van de paar beschikbare beschrijvende woorden (of gewoon tekst-naar-SQL gebruiken). Maar wat als deze chunk verscholen zit tussen talloze tekstrijke chunks waarin dezelfde woorden voorkomen? Bijvoorbeeld:

JSON

{"Draconic_Ascension": { "description": "Transform into dragon form. Attack range and speed are increased by 10%, damage is boosted by 20%.", "details": { "activation_conditions": "Can only be activated when HP is below 50%or Fury meter is full.", "visual_effects": "Wings unfurl, scales shimmer with embers, voice lines change to echoing growls.", "lore": "An ancient bloodline awakens. The bearer of the mark channels the soul of the last Flamewing Wyrm, becoming a living storm of fire and fury." } }}

Stel dat we nu het volgende willen ophalen: "What is the attack range with Draconic Ascension?" Waarschijnlijk halen we niet de gewenste relevante chunk op, omdat die verloren gaat in de ruis van andere chunks met dezelfde trefwoorden.

Het kernprobleem is dat we deze gegevenschunks niet goed van elkaar kunnen onderscheiden, hoewel ze verschillende soorten informatie over hetzelfde onderwerp bevatten. Kunnen we die gegevens op de een of andere manier verrijken of verbeteren? Natuurlijk kan dat:smile:

  1. Verrijk je gegevens door ze samen te vatten. Ja, dat lees je goed

In plaats van de chunk zelf rechtstreeks in te bedden, kunnen we eerst een samenvatting genereren die de gegevens beschrijft. Vervolgens gebruiken we die samenvatting voor de embedding en het ophalen. Bij het genereren gebruiken we nog steeds de oorspronkelijke gegevens die aan de samenvatting zijn gekoppeld.

Voor de twee bovenstaande voorbeeldchunks zouden we dus ongeveer de volgende samenvattingen genereren:

  1. Aanvalsstatistieken voor bereik, snelheid en schade (standaard en met Draconic Ascension).

  2. Beschrijving en details van Draconic Ascension, waaronder activeringsvoorwaarden, visuele effecten en achtergrondverhaal.

Vervolgens breiden we ook de zoekvraag uit om die op de samenvatting "af te stemmen". We zouden bijvoorbeeld "What is the attack range with Draconic Ascension?" veranderen in "What is the statistics of attack range with Draconic Ascension?" Dit is vooral belangrijk wanneer de zoekvraag afkomstig is van gebruikers zonder technische achtergrond, die vragen stellen in ~~"freestyle"~~ gewone mensentaal. Zij weten immers niet hoe RAG werkt en hoeven zich ook niet bezig te houden met het maximaliseren van precision en recall.

Diagram dat laat zien wanneer het ingewikkelder wordt.

  1. Haal dingen niet uit hun context (een goede algemene levensles)

Een volgend scenario is dat we te maken hebben met enorme aantallen gegevenschunks die er hetzelfde uitzien, zoals hieronder:

Plain Text

# Chunk one{ "Attack": { "Range": { "default": 9, }, ... }}# Chunk two{ "Attack": { "Range": { "default": 6, }, ... }}# Chunk three{ "Attack": { "Range": { "default": 7, }, ... }}

Stel dat we dezelfde aanpak blijven volgen en vragen: "what is character X’s attack range?" Met de zojuist gegenereerde samenvattingen zou dat neerkomen op een gokspel, want ook die zouden sterk op elkaar lijken. Hoe kunnen we ze dan van elkaar onderscheiden?

Het eenvoudige antwoord: voeg context toe. We kunnen simpelweg een verwijzing naar het bovenliggende document van de chunk in de gegevenschunk opnemen, in dit geval bijvoorbeeld {”character”: “X”}. Zo kunnen we nauwkeurig de juiste gegevens voor personage X ophalen, zelfs als we dezelfde gegevens voor personages Y en Z hebben.

Een betere en algemener toepasbare aanpak is echter om een contextuele samenvatting van de chunk te genereren. In plaats van alleen een samenvatting van de gegevenschunk zelf te genereren, kunnen we zowel het bovenliggende document als de chunk doorgeven om een algemene contextuele samenvatting te genereren. Daarin beschrijven we hoe deze chunk in het bovenliggende document past, bijvoorbeeld:

  1. Deze chunk bevat gedetailleerde statistieken over ... voor personage X. De chunk past in het volledige document doordat deze de kracht van X op het gebied van aanvalssnelheid laat zien...

  2. Deze chunk bevat gedetailleerde statistieken over ... voor personage Y. De chunk past in het volledige document doordat deze de verbeterde statistieken van Y bij gebruik van haar speciale vaardigheid laat zien...

  3. Deze chunk bevat gedetailleerde statistieken over ... voor personage Z. De chunk past in het volledige document doordat deze laat zien dat de statistieken van Z hem zeer geschikt maken als tank in teamwedstrijden...

Deze methode (die deels is geïnspireerd door Anthropic) lijkt misschien overdreven voor het bovenstaande voorbeeld. Ze is echter bijzonder effectief voor chunks die "buiten hun context" verkeerd kunnen worden geïnterpreteerd. Bovendien biedt ze één uniforme aanpak voor alle chunks, waardoor de technische pipeline overzichtelijk blijft.

Diagram dat laat zien wanneer het ingewikkelder wordt.

  1. Wanneer je ~~een controlefreak~~ nauwgezet moet zijn

Gewoonlijk ontvangen we gegevens als complete gehelen en delen we die voor een RAG-systeem op in chunks. In dit voorbeeld tonen we iets anders: gegevens die al in chunks zijn opgedeeld, maar dan in slechte chunks. Het zijn willekeurige delen van een logische chunk die eigenlijk weer moeten worden samengevoegd. Een logische chunk is een stuk inhoud dat van nature bij elkaar hoort, zoals een subparagraaf van een document of een samenhangende alinea.

Diagram dat laat zien wanneer het ingewikkelder wordt.

Bij onze eerste poging voeren we alle gegevens in één LLM-aanroep in, vragen we de LLM om ze naar eigen inzicht te groeperen en vervolgens de gegroepeerde inhoud terug te geven. Een LLM zou daar behoorlijk goed in moeten zijn, toch? Nou, ja en nee.

We hebben, ook bij verschillende andere gelegenheden, gemerkt dat LLM's de neiging hebben lui te werk te gaan en onbetrouwbaar zijn wanneer je de volledige, exacte inhoud nodig hebt, vooral bij een lange context. Dat is ook volkomen logisch. Maar voor deze specifieke toepassing was dat onaanvaardbaar, omdat we de exacte inhoud woord voor woord nodig hebben: geen samenvattingen en geen enkel stukje van de oorspronkelijke inhoud overslaan. We mogen geen enkel detail missen.

Het positieve was natuurlijk dat de LLM de semantiek en structuur van de kapotte chunks uitstekend begreep. Tenminste, zolang de LLM niet weigert de exacte inhoud te citeren. Verdorie:/

Hoe kunnen we dan benutten waar een LLM goed in is en tegelijkertijd vermijden waar die onbetrouwbaar in is? We klopten aan bij onze goede oude vriend: code (lees: een Python-functie op maat). En een Pydantic-model dat niet eenvoudiger kan. Dit is de oplossing:

  • Loop door de secties en houd daarbij een actuele logische chunk bij

  • Vraag bij elke sectie aan de LLM: hoort deze sectie bij de actuele logische chunk? Antwoord met ja of nee (volgens het Pydantic-model).

  • Zo ja, voeg de sectie aan de chunk toe. Zo nee, voer de actuele logische chunk uit, want die is compleet, en begin met deze sectie een nieuwe chunk.

Diagram dat laat zien wanneer het ingewikkelder wordt.

Natuurlijk gebruiken we hiermee iets meer tokens dan wanneer we de volledige inhoud in één keer verwerken. Maar voor deze specifieke toepassing, waarbij het behoud van de exacte inhoud de hoogste prioriteit heeft, waren de geringe extra kosten het meer dan waard.

Dit is een heel eenvoudige oplossing, maar ze volgt een belangrijk principe: wanneer nauwkeurigheid vereist is, willen we niet uitsluitend op LLM's vertrouwen, omdat die nu eenmaal probabilistisch zijn.

Met aangepaste code en functies en Pydantic-modellen kunnen we een voorspelbaar en betrouwbaar resultaat behalen, terwijl we toch de mogelijkheden van LLM's volledig benutten.

Tot slot

Het bouwen van een generatieve-AI-oplossing is zowel een technische als een AI-uitdaging. We hopen dat deze voorbeelden je inspireren om je eigen unieke uitdagingen aan te pakken. Wil je meer lezen over generatieve-AI-oplossingen waarbij engineering vooropstaat? Bekijk dan onze blogpost over het ontwerp van agentische systemen op basis van routers.

Auteur

Cynthia Yu