Esempi pratici di soluzioni RAG personalizzate

Esempi reali mostrano come sistemi RAG su misura risolvano complessi problemi di conoscenza aziendale.

Oggi il RAG gode talvolta di cattiva fama: c’è chi lo considera ormai del tutto banale (lo è all’inizio, meno quando occorre scalarlo) e chi pensa sia stato superato dai «sistemi agentici» (che, in molti casi, a un esame più attento iniziano presto ad assomigliare molto al RAG…).

In questo articolo presentiamo un paio di esempi pratici per mostrare come affrontiamo alcune sfide comuni, tra cui:

  • Gestire dati misti, testuali e numerici, e capire perché mettono in crisi i sistemi RAG più semplici: le parole chiave si sovrappongono e i numeri non hanno significato semantico.

  • Perché è utile progettare embedding basati prima di tutto sui riepiloghi: si genera un breve riepilogo descrittivo per ogni blocco, quindi lo si usa per l’embedding e le query.

  • Come generare riepiloghi contestuali: si include il contesto del documento principale per distinguere statistiche dalla struttura simile.

  • Quando affidarsi al codice e ai modelli Pydantic: se il contenuto letterale è importante, per una maggiore affidabilità si combinano codice personalizzato e/o modelli Pydantic con chiamate a LLM.

Creare soluzioni RAG personalizzate

Le basi

I sistemi RAG alimentano soluzioni che spaziano dai bot di assistenza agli assistenti per la conoscenza interna.

In genere, dietro le quinte si procede così:

  1. Suddividere in blocchi i documenti di origine

  2. Incorporare ogni blocco in uno spazio vettoriale

  3. Recuperare i K blocchi più rilevanti al momento della query

  4. Generare una risposta basata su quei blocchi

Toolkit diffusi come LangChain, LlamaIndex e Filestore di OpenAI rendono questi passaggi quasi banali. Nelle pipeline reali, però, ci si imbatte in dati che non sono solo testo denso, e i sistemi RAG di base possono avere difficoltà. Nelle sezioni seguenti mostreremo esempi concreti di problemi legati ai dati e costruiremo gradualmente la soluzione, introducendo livelli di complessità sempre maggiori.

Quando le cose si complicano

  1. Quando i dati non sono solo testo (caso tutt’altro che raro)

Consideriamo il seguente blocco di dati relativo a un videogioco:

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, } }} 

Gli embedding funzionano grazie alle relazioni apprese tra le parole attraverso il significato semantico e la grammatica. Nei dati precedenti troviamo una combinazione di testo e numeri; al di fuori di questo specifico contesto, i numeri non hanno alcuna relazione con le parole. Potremmo quindi dire che questo blocco di dati è sostanzialmente una combinazione di parole abbastanza descrittive seguite da numeri casuali.

Non sarebbe un problema se disponessimo solo di questo tipo di dati, perché potremmo comunque effettuare il recupero tramite gli embedding delle poche parole descrittive disponibili (oppure usare semplicemente text-to-SQL). Ma cosa succede se questo blocco è sommerso da molti blocchi di testo denso in cui compaiono le stesse parole? Per esempio:

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." } }}

Ora immaginiamo di voler recuperare «What is the attack range with Draconic Ascension?»» Molto probabilmente non riusciremo a recuperare il blocco rilevante, perché è sommerso dal rumore di altri blocchi contenenti le stesse parole chiave.

Il problema di fondo è che non riusciamo a distinguere bene questi blocchi di dati, sebbene contengano tipi di informazioni diversi sullo stesso argomento. Potremmo arricchirli o migliorarli in qualche modo? Certo che sì:smile:

  1. Arricchire i dati riassumendoli: sì, avete letto bene

Anziché incorporare direttamente il blocco, possiamo prima generare un riepilogo che descriva i dati e poi usare quel riepilogo per l’embedding e il recupero. In fase di generazione continueremo a usare i dati originali collegati al riepilogo.

Per i due blocchi di esempio precedenti genereremmo quindi riepiloghi come:

  1. Attack statistics of range, speed, and damage (default and with Draconic Ascension).

  2. Description and details of the Draconic Ascension, including activation conditions, visual effects, and lore.

Successivamente arricchiamo anche la query per «allinearla» al riepilogo. Per esempio, trasformeremmo «What is the attack range with Draconic Ascension?»» in «What is the statistics of attack range with Draconic Ascension?»» Questo è particolarmente importante quando le query di recupero provengono da utenti non tecnici, che si esprimono in un linguaggio ~~«libero»~~ umano e normale: dopotutto, non spetta a loro sapere come funziona un sistema RAG per massimizzare precision e recall.

Diagramma che illustra quando le cose si complicano.

  1. Non estrapolare le cose dal contesto (regola valida anche nella vita)

Un altro scenario consiste nel gestire moltissimi blocchi di dati che sembrano uguali, come quelli riportati di seguito:

Plain Text

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

Mantenendo lo stesso approccio, immaginiamo di chiedere: «what is character X’s attack range?»» Con i riepiloghi appena generati sarebbe un gioco d’azzardo, perché anch’essi apparirebbero molto simili. Come potremmo quindi distinguerli?

La risposta è semplice: fornendo il contesto. Potremmo semplicemente includere nel blocco di dati un riferimento al documento principale, per esempio {”character”: “X”} in questo caso. In questo modo potremmo recuperare con precisione i dati corretti per il personaggio X, anche disponendo degli stessi dati per i personaggi Y e Z.

Un approccio migliore e più generalizzabile, tuttavia, consiste nel generare un riepilogo contestuale del blocco. In altre parole, anziché generare un riepilogo del solo blocco di dati, potremmo fornire sia il documento principale sia il blocco per generare un riepilogo contestuale generale, spiegando al suo interno come il blocco si inserisce nel documento principale. Per esempio:

  1. This chunk provides detailed statistics of … for character X. The chunk fits into the full document by showing X’s strength in attack speed…

  2. This chunk provides detailed statistics of … for character Y. The chunk fits into the full document by showing Y’s boosted stats with her special ability…

  3. This chunk provides detailed statistics of … for character Z. The chunk fits into the full document by showing Z’s stats that’s well suited as a tank in team matches…

Questo metodo (in parte ispirato ad Anthropic) può sembrare eccessivo per l’esempio precedente, ma è molto efficace per i blocchi che potrebbero essere interpretati erroneamente «fuori contesto». Offre inoltre un approccio unificato, applicabile a tutti i blocchi, che mantiene ordinata la pipeline tecnica.

Diagramma che illustra quando le cose si complicano.

  1. Quando occorre essere ~~maniaci del controllo~~ rigorosi

Di solito riceviamo dati completi e li suddividiamo in blocchi per un sistema RAG. In questo esempio mostriamo qualcosa di leggermente diverso: dati già suddivisi, ma in blocchi inadeguati. Sono sezioni casuali di un blocco logico che devono essere nuovamente raggruppate. Un blocco logico è un insieme di contenuti che per sua natura dovrebbe rimanere unito, come una sottosezione di un documento o un paragrafo coerente.

Diagramma che illustra quando le cose si complicano.

Nel primo tentativo forniamo tutti questi dati a un LLM, chiedendogli di raggrupparli come ritiene opportuno e restituire il contenuto raggruppato. Un LLM dovrebbe essere piuttosto bravo, giusto? Sì e no.

Abbiamo riscontrato, anche in diverse altre occasioni, che gli LLM tendono a essere sbrigativi e inaffidabili quando si richiede il contenuto completo ed esatto, soprattutto con un contesto lungo. Ed è perfettamente comprensibile. Per questo specifico caso d’uso, però, era un problema decisivo: ci serviva il contenuto esatto, parola per parola, senza riepiloghi né omissioni di alcuna parte dell’originale. Non possiamo tralasciare alcun dettaglio.

Il lato positivo, naturalmente, era l’ottima capacità di comprendere la semantica e la struttura dei blocchi frammentati. Sempre che non si rifiuti di riportare il contenuto esatto. Accidenti:/

Come potevamo sfruttare i punti di forza di un LLM evitando gli aspetti in cui è inaffidabile? Ci siamo rivolti al nostro buon vecchio amico: il codice (ovvero una funzione Python personalizzata). E a un modello Pydantic «più semplice di così non si può». Ecco la soluzione:

  • Scorrere le sezioni mantenendo un blocco logico corrente

  • Per ogni sezione, chiedere all’LLM: «does this section belong to the current logical chunk, answer yes or no (following the pydantic model)?»

  • Se la risposta è sì, aggiungere la sezione al blocco; se è no, restituire il blocco logico corrente, ormai completo, quindi crearne uno nuovo con la sezione.

Diagramma che illustra quando le cose si complicano.

Naturalmente usiamo un po’ più di token rispetto a una singola elaborazione dell’intero contenuto, ma per questo caso d’uso, in cui mantenere il contenuto esatto è la priorità assoluta, il (modesto) costo aggiuntivo ne è valso la pena.

È una soluzione molto semplice, ma segue un principio importante: quando serve rigore, non vogliamo affidarci esclusivamente agli LLM, che restano probabilistici.

Codice e funzioni personalizzati, insieme ai modelli Pydantic, permettono di ottenere risultati prevedibili e affidabili, sfruttando al contempo le capacità degli LLM.

Conclusioni

Creare una soluzione di IA generativa è una sfida tanto ingegneristica quanto legata all’IA. Ci auguriamo che questi esempi vi abbiano ispirato ad affrontare le vostre sfide specifiche. Per saperne di più sulle soluzioni di IA generativa progettate con un approccio ingegneristico, leggete il nostro articolo sulla progettazione di sistemi agentici basati su router.

Autore

Cynthia Yu