Tutti i progetti scolastici

RAG vettoriale contro GraphRAG

Un archivio di ticket di assistenza interrogabile in linguaggio naturale, costruito in due modi diversi e misurato con lo stesso metro: da una parte il knowledge graph del prototipo aziendale, dall'altra la baseline vettoriale che ho costruito io.

Contesto
Stage in APRA S.p.A.
Stack
Python · Qdrant · LangGraph · Docker
Dati
1359 schede di ticket reali
Stato
Consegnato
Interfaccia della chat in modalità confronto: due colonne affiancate, a sinistra il RAG vettoriale con 1133 ticket indicizzati, a destra il GraphRAG con 1000 ticket, 2889 entità e 1674 community.
La modalità Confronto: la stessa domanda va alle due pipeline e le risposte si leggono affiancate
Gli stessi 1024 ticket in due spazi vettoriali diversi, da girare con il mouse: i gruppi sono calcolati una volta sola a sinistra e riusati a destra, quindi un gruppo che resta compatto anche a destra è un gruppo che il secondo modello riconosce davvero. Aprilo a tutto schermo

Descrizione del progetto

Il supporto clienti di un gestionale riceve richieste via email. Ogni richiesta è un ticket: un thread che parte da un problema e finisce, quasi sempre, con una soluzione. Un modello linguistico ha distillato ogni thread in una scheda (problema, causa, soluzione), e le schede sono 1359.

Il prototipo aziendale trasforma quelle schede in un knowledge graph temporale: entità collegate da relazioni, dove ogni fatto ha una finestra di validità. Costruirlo costa, perché un modello deve leggere tutto l'archivio ed estrarne le entità. La domanda a cui serviva rispondere è se quel costo si ripaga: un RAG vettoriale fatto bene, che non costa niente da costruire, quanto ci va vicino?

Il mio lavoro è stato costruire quel termine di paragone e misurarlo. Una regola sopra tutte: il confronto deve isolare il retrieval, quindi stessi dati, stesso modello di generazione, stesso prompt, stesso benchmark e stessa forma di pipeline. L'unica cosa che cambia è il modo di trovare i documenti.

La stessa web app, con dentro tutte e due

Il frontend era quello del ragazzo che ha implementato il GraphRAG. Sopra quella web app ho implementato anche il RAG vettoriale, su richiesta del tutor della formazione scuola-lavoro, in modo da avere un confronto diretto dalla stessa interfaccia.

Giravano più container Docker, ognuno con il suo volume Docker: uno per Phoenix, uno per il RAG vettoriale e uno per il RAG a grafo. Il tutto era servito su una macchina di Hetzner, raggiungibile in HTTP su IP pubblico.

I due pannelli Come è stata costruita la risposta affiancati: a sinistra il vettoriale con 10 ticket recuperati, strategia ibrida con fusione RRF e MMR, 6,7 secondi; a destra il grafo con 4 fatti, strategia ibrida con cross-encoder e community, 109,9 secondi.
Lo stesso quesito, due pipeline: il pannello di dettaglio rende leggibile anche la differenza di tempo

Tech stack

Tre vincoli hanno deciso quasi ogni scelta: i dati non escono dal computer, il confronto deve avvenire a parità di condizioni, e i testi sono in italiano e pieni di sigle di dominio. Il terzo è il più sottile: un modello semantico capisce che due frasi diverse dicono la stessa cosa, ma su un codice come R007 non ha niente da capire, o lo trova scritto uguale o non lo trova. Per questo il retrieval è ibrido e non solo vettoriale.

LinguaggioPython 3.13
Database vettorialeQdrant 1.19, denso e sparso nella stessa collection
Embeddingharrier-oss-v1-0.6b, 1024 dimensioni, finestra da 32.768 token, in locale
Ramo lessicaleBM25 italiano di FastEmbed, stemmer Snowball e stopword
FusioneReciprocal Rank Fusion lato server, più reranker cross-encoder e MMR
PipelineLangGraph: riscrittura della domanda, ricerca, giudizio di rilevanza, risposta
OsservabilitàPhoenix per tracce, costi e valutazioni
InterfacciaAngular, già esistente, servita da nginx dentro il suo container
InfrastrutturaDocker Compose: Qdrant, backend vettoriale, backend a grafo, frontend

La finestra da 32.768 token del modello di embedding non è un dettaglio di listino: le schede distillate ci stanno dentro intere, quindi non serve spezzarle in pezzi più piccoli. Una scheda è un documento, che è la baseline più pulita da mettere accanto al grafo.

I due ranking, quello semantico e quello lessicale, si uniscono guardando la posizione in classifica e non il punteggio: sulla stessa scheda il coseno vale 0,74 e BM25 vale 10,91, due numeri che non si possono sommare. Unire due classifiche invece si può, ed è quello che fa la fusione RRF, dentro il database e non nel nostro codice: meno codice nostro significa meno differenze fra le due pipeline che non siano il retrieval, che è proprio la cosa che l'esperimento vuole misurare.

Ogni passaggio della pipeline finisce dentro Phoenix: la traccia della singola domanda con i tempi di ciascun nodo, i token spesi, il costo in dollari e i giudizi del benchmark. Senza quel registro le misure della sezione qui sotto sarebbero racconti, e la latenza di un pezzo si potrebbe solo indovinare.

Pannello dei progetti di Phoenix: il progetto chatbot-web con 801 tracce, 150 sessioni e latenza mediana di 1,9 secondi.
Phoenix: tracce, sessioni e latenza mediana della pipeline, raccolte mentre gira

Problema risolto

Il problema a cui serve tutto questo è che la conoscenza dell'assistenza sta dentro migliaia di email, e chi ha davanti un caso nuovo non ha modo di sapere se qualcuno l'ha già risolto tre anni fa. Un archivio interrogabile a parole risponde a questo, e le risposte citano le schede da cui vengono.

Il problema che ho risolto io è un altro, ed è di metodo: sapere quanto valga davvero il grafo. Un'affermazione del tipo "il grafo funziona bene" non si può verificare senza un termine di paragone costruito con la stessa cura, sugli stessi dati e con lo stesso giudice. La baseline serve a quello, e il risultato è un numero per ogni domanda invece di un'impressione.

Un solo intoppo vero, ed è l'hardware: il modello di embedding gira in locale, quindi la macchina si sente, in indicizzazione e in risposta. Non è una scelta rinunciabile. Le schede contengono nomi, indirizzi e ragioni sociali di clienti reali, e mandarle a un servizio di embedding online significherebbe farle uscire dall'azienda. Girare in locale è la condizione che rende il progetto utilizzabile davvero, e il costo in prestazioni è il prezzo di quella condizione.

È stata la mia seconda esperienza in un ambito professionale all'interno di un'azienda, e ho imparato molto di come funziona un progetto reale, dove l'organizzazione è fondamentale: ho dovuto gestire varie cose contemporaneamente, e perdermi tra queste sarebbe stato un disastro e non mi avrebbe permesso di portare a termine in maniera efficace il progetto, soprattutto entro le poche settimane a disposizione.

I numeri

Stesso benchmark, stesse 32 domande, stesso modello di risposta, stesso giudice. Cambia solo il modo di trovare i documenti.

Retrieval, almeno un documento giusto0,96 vettoriale · 0,37 grafo
Retrieval, quanti dei documenti giusti0,93 vettoriale · 0,31 grafo
Fedeltà della risposta al contesto0,97 vettoriale · 0,69 grafo
Correttezza della risposta0,75 vettoriale · 0,69 grafo
Robustezza temporalesolo il grafo, 1,00
Tempo di una risposta6,7 secondi contro 109,9
  • Il retrieval non è vicino, è un'altra scala — su 32 domande la baseline recupera almeno una scheda giusta in 31 casi, il grafo in 12. La differenza non è di rifinitura.
  • La fedeltà segue il retrieval — 0,97 contro 0,69, con lo stesso modello e lo stesso prompt: quando nel contesto c'è davvero la scheda giusta, il modello non ha vuoti da riempire e smette di inventare.
  • La correttezza si muove molto meno — 0,75 contro 0,69, cioè due domande su 32. Un guadagno enorme di retrieval si traduce in un guadagno modesto di risposta finale, ed è il risultato più interessante dei tre.
  • Il grafo tiene una cosa che il vettoriale non può avere — la validità nel tempo. Segnalare che una soluzione è stata superata da una successiva è fuori dalla portata di un indice di vettori, e su un archivio che invecchia non è un dettaglio.

I due spazi vettoriali della galleria qui sopra raccontano un'altra parte della stessa storia: sono gli stessi 1024 ticket visti da due modelli di embedding diversi, con i gruppi calcolati una volta sola sul primo e riusati sul secondo. Dei dieci vicini più simili, i due modelli ne condividono circa tre, il che vuol dire che la scelta del modello di embedding sposta davvero quali schede finiscono in contesto.

Due nuvole di punti affiancate: a sinistra lo spazio del modello harrier a 1024 dimensioni, con gruppi di colore compatti, a destra lo stesso archivio visto da multilingual-e5-base a 768 dimensioni, con gruppi più sparsi.
A sinistra harrier, a destra e5-base: gli stessi ticket, gli stessi colori, gruppi che si sfaldano

Nozioni apprese

  • Un confronto vale quanto il suo controllo — se cambiano due cose alla volta, il risultato non dice niente. Tenere fermi dati, modello, prompt e giudice è stata la parte più importante del lavoro, molto prima del codice.
  • Il retrieval è il collo di bottiglia, ma non spiega tutto — recuperare quasi sempre la scheda giusta migliora enormemente la fedeltà e pochissimo la correttezza. La risposta finale dipende anche da come il modello legge quello che ha davanti.
  • Semantica e parole esatte servono insieme — sulle sigle di dominio il ramo lessicale trova quello che il ramo semantico si perde, e due schede quasi identiche possono differire per un solo verbo.
  • In locale si paga in hardware e si guadagna in dati che restano dentro — con dati aziendali reali non è un compromesso, è il requisito.
  • Containerizzare separa le responsabilità — aggiungere una pipeline senza toccare quella di un altro è stato possibile perché ognuna vive nella sua immagine, con il frontend che smista le chiamate.
  • Misurare costa poco e cambia le conclusioni — ripetere lo stesso benchmark due volte mostra quanto si muovono i numeri da soli, e senza quella ripetizione una differenza piccola sembrerebbe un risultato.