/Intelligenza artificiale/Go/Architetture software/di Gianluca Epifani
Un coach AI per una piattaforma di contenuti – I parte – Un RAG scritto a mano in Go
Premessa
Nel 2024 abbiamo deciso di creare un “coach” personale per una piattaforma che eroga contenuti: una chat a cui chiedere “cosa mi consigli per imparare a delegare?” oppure “nel capitolo che ho appena letto cosa intende l’autore con deep work?” e ricevere una risposta costruita sui contenuti del catalogo, non su quello che il modello ha letto in giro per Internet.
Il requisito sembra banale e invece contiene tutte le insidie del mestiere:
- il modello deve rispondere solo con ciò che c’è nel catalogo (e ammettere quando non c’è);
- deve farlo nella lingua dell’utente, anche quando l’utente cambia lingua a metà conversazione;
- deve ricordare il filo della chat senza che il costo di ogni messaggio cresca a dismisura;
- e, cosa che nessuno mette nelle specifiche, quando qualcosa va storto dobbiamo poter capire perché quella risposta era sbagliata.
Questa è la prima parte di due. Qui racconto come è nato il servizio, perché l’ho riscritto in Go e come è fatta la pipeline di retrieval. Nella seconda parte vedremo come lo stesso servizio è diventato un agente con tool use via MCP.
RAG in due righe (per chi arriva da altri lidi)
RAG sta per Retrieval-Augmented Generation. In pratica, invece di sperare che il modello “sappia” le cose, prima cerchiamo nei nostri contenuti i pezzi pertinenti alla domanda, poi li mettiamo nel prompt insieme alla domanda e chiediamo al modello di rispondere usando solo quelli. La ricerca non è testuale: ogni pezzo di contenuto viene trasformato in un vettore di numeri (un embedding) e la domanda pure; i pezzi “vicini” alla domanda nello spazio dei vettori sono quelli che parlano della stessa cosa.
Tutto il resto, chunking, riscrittura della domanda, prompt, osservabilità, è dove si decide se il coach funziona o se fa la figura del pappagallo.
Il prototipo: Python, LangChain e una settimana di entusiasmo
La prima versione l’abbiamo fatta come tutti: Python, Flask, Celery per i lavori lunghi, LangChain per incollare i pezzi, un vector database gestito, streaming della risposta in SSE e Langfuse per le tracce. Ha funzionato, e ci ha detto una cosa importante: il prodotto aveva senso, gli utenti lo usavano.
Ha anche detto altre cose, meno gradevoli:
- Il nostro backend è in Go. Avere un secondo linguaggio in produzione per un solo servizio significa pipeline, immagini, monitoraggio e competenze duplicate.
- LangChain cambiava sotto i piedi. Ogni aggiornamento rompeva un’astrazione; e le astrazioni erano molte più di quelle che ci servivano.
- Quando la risposta era sbagliata non sapevamo dove guardare. La “catena” era una scatola nera dentro un’altra scatola nera.
Così ho preso la decisione che un architetto dovrebbe prendere più spesso: non ho cercato il framework Go equivalente. Ho elencato cosa usavamo davvero di LangChain e l’ho portato nel repo, pezzo per pezzo.
Cosa ho portato in Go (e cosa ho lasciato fuori)
Di tutto LangChain, il servizio usava quattro cose:
- i text splitter (a token, ricorsivo per caratteri, per intestazioni Markdown): sono poche centinaia di righe ciascuno;
- un encoder tiktoken, per contare i token esattamente come li conta il modello; senza di questo il chunking “a 1000 token” è un chunking “a circa 1000 token”, e il circa si paga in contesto tagliato;
- gli schemi:
Document,ChatMessage, le interfacceVectorStoreeLLM; - il tracer verso Langfuse.
Niente chain, niente agent, niente “memory” generiche: la memoria della conversazione è una tabella Postgres, e la gestisco io.
Il risultato è un package langchain/ interno di una ventina di file, dietro a interfacce mie. Il resto del servizio è un normale microservizio go-kit come tutti gli altri della piattaforma: service, repository, endpoint e la solita catena di middleware (cache, metriche, reporting degli errori). Il coach non è un oggetto strano con un ciclo di vita a parte: si deploya, si monitora e si scala come tutto il resto.
// Le sole astrazioni che servono davvero.
type VectorStore interface {
AddDocuments(ctx context.Context, docs []*Document, p *RequestParams) error
DeleteDocuments(ctx context.Context, ids []string, p *RequestParams) error
GetSimilarDocuments(ctx context.Context, f *VectorStoreDocumentFilter, p *RequestParams) ([]*Document, error)
}
type LLM interface {
GetEmbeddings(ctx context.Context, docs []*Document) []error
MakeChatHumanQuestion(ctx context.Context, q, system string, p *ChatParams) (*ChatResponse, error)
// ... streaming
}
Cambiare vector database o fornitore del modello vuol dire scrivere un adapter, non riscrivere il servizio. Lo dico con cognizione di causa, perché nella seconda parte lo abbiamo fatto.
L’ingestione: una sorgente, tanti vettori
Il contenuto entra nel servizio come sorgente: un testo, un identificativo esterno (per i capitoli, qualcosa come bid-<libro>-ch-<capitolo>) e dei metadati (lingua, titolo, autore…). Il servizio:
- spezza il testo con il token splitter, con dimensione del chunk e sovrapposizione prese dalla configurazione;
- calcola gli embedding di ogni chunk;
- scrive i vettori nel vector database con i metadati, lingua compresa;
- salva nel database relazionale l’elenco degli id dei vettori insieme alla sorgente.
Il punto 4 è quello che si tende a dimenticare e che si paga dopo. Quando un capitolo viene corretto, il CMS rimanda la stessa sorgente: il servizio cancella i vecchi vettori usando gli id che aveva salvato e inserisce i nuovi. Senza quella lista, l’aggiornamento di un capitolo lascia in giro chunk fantasma della versione precedente, che prima o poi finiscono in un prompt.
C’è anche una modalità CleanText: si salvano solo vettori e metadati, senza il testo. Serve per l’indice per contenuto che vedremo fra poco, dove il testo non serve a nessuno.
L’ingestione non la lancia nessuno a mano. Il backend principale ascolta gli eventi di salvataggio del CMS: ogni volta che la redazione pubblica o corregge una traduzione, le sorgenti partono verso il coach in parallelo (una goroutine ciascuna) e un contenuto che torna “non pubblicato” viene tolto dall’indice. L’indice è sempre allineato al catalogo, senza cron notturni.
Dall’indice per capitolo all’indice per contenuto
La prima versione dell’indice l’abbiamo fatta come dice il manuale: capitolo per capitolo. Ecco perché il capitolo compare nella chiave della sorgente. Ogni contenuto del catalogo produceva quindi molti vettori, anche più di uno per capitolo, visto che i capitoli lunghi venivano spezzati in chunk.
Funzionava, ma guardando le risposte (e le tracce, ci arrivo fra poco) abbiamo notato che il coach era spesso parziale: rispondeva bene, ma come se nel catalogo esistesse un solo contenuto sull’argomento. Il motivo è aritmetico. Il numero di vettori simili che si estraggono per ogni domanda è limitato: se su una domanda prendiamo i k vettori più vicini, può capitare che sette o otto appartengano allo stesso contenuto, perché quel contenuto ha dieci capitoli che parlano tutti dell’argomento. Gli altri contenuti, anche se pertinenti, restavano fuori dai k posti disponibili o non superavano la soglia di pertinenza. Il coach non vedeva il catalogo, vedeva un libro.
Abbiamo allora fatto un test con un’indicizzazione diversa. Invece di indicizzare i capitoli, abbiamo chiesto a un LLM di scrivere un riassunto del contenuto con i suoi argomenti salienti (qualche centinaio di parole), lo abbiamo arricchito con titolo, autori, categorie e competenze associate, e abbiamo creato un indice in cui ogni vettore rappresenta un contenuto del catalogo, uno per lingua.
Book Title: ...
Authors: ...
Categories: ...
Skills: ...
<riassunto con gli argomenti salienti>
Al servizio lo mandiamo con una dimensione del chunk enorme (“non spezzare”) e in modalità CleanText, cioè senza salvare il testo. E qui c’è l’osservazione che cambia il modo di vedere il RAG su un catalogo di contenuti pubblicati: non occorre dare al modello il capitolo intero, perché un LLM conosce già quel contenuto nella sua interezza, dalla sua knowledge, spesso meglio di come glielo potremmo fornire noi in tre chunk. Ciò che il retrieval deve dirgli è quali contenuti del catalogo usare per rispondere, non cosa dicono. L’indice smette di essere una copia del catalogo e diventa una mappa.
Con un vettore per contenuto, k vettori estratti sono k contenuti diversi: le risposte hanno smesso di essere parziali e il coach ha cominciato a mettere in relazione più fonti, che è esattamente il lavoro che ci aspettiamo da un coach. Anche questo indice si aggiorna sugli eventi del CMS, e il riassunto viene rigenerato quando la redazione corregge il contenuto.
L’indice per capitolo non è stato cancellato: nessuno di noi era sicuro, il primo giorno, che il nuovo sistema fosse davvero migliore, così il vecchio è rimasto come riserva, ancora aggiornato dagli stessi eventi. Oggi nessun chatbot in produzione lo usa. Tenere la strada del ritorno aperta finché i numeri non parlano è una buona abitudine, soprattutto quando il “numero” è la soddisfazione di un utente che chatta.
La domanda: prima si riscrive, poi si cerca
Qui c’è il cuore del servizio, la funzione che prepara la conversazione prima di chiamare il modello. In ordine:
1. Si recupera la storia della chat, tagliata agli ultimi N messaggi (configurabile; otto è un buon compromesso fra contesto e costo).
2. Si riscrive la domanda in forma autonoma. Se l’utente ha scritto “e il secondo punto?” dopo tre scambi, cercare “e il secondo punto?” nel vector database non trova niente. Quindi una chiamata separata, a un modello più economico di quello della chat e con la “creatività” ridotta al minimo (nel 2024 si faceva con la temperatura a zero; i modelli di ragionamento di oggi quel parametro non lo accettano più e il controllo si fa tutto nell’istruzione), riceve la storia e la domanda e restituisce una domanda che sta in piedi da sola: “quale è il secondo principio della delega secondo l’autore X?”. È il singolo passo che più ha migliorato la qualità delle risposte.
3. Si calcola l’embedding della domanda riscritta (1536 dimensioni, lo stesso modello usato in ingestione: ovvio, ma è un errore che ho visto fare).
4. Si interrogano i vettori con un filtro sulla lingua richiesta, un top-k piccolo e una soglia di similarità sotto la quale il chunk viene scartato. Senza la soglia, a una domanda fuori tema il vector database risponde comunque con i tre chunk “meno lontani”, e il modello li usa.
5. Si ricostruiscono i chunk dai metadati e si iniettano nel prompt di sistema.
6. Il prompt viene ritoccato per il formato di uscita (Markdown sì o no) e per la lingua di risposta, che non è “quella del prompt” ma quella chiesta dal client.
7. Si salva il messaggio dell’utente, e qui un dettaglio: si salva la domanda originale, non quella riscritta. La riscrittura è un artificio del retrieval e non deve inquinare la storia che l’utente rilegge e che verrà riscritta al giro dopo.
Una scelta che ha retto bene nel tempo: i prompt vivono nel database, non nel codice. Ogni conversazione è agganciata a un prompt tramite uno slug; la redazione può cambiare il tono del coach senza un deploy, e una conversazione iniziata con un prompt resta coerente anche se nel frattempo ne è stato attivato un altro.
E un limite che questa pipeline porta con sé per costruzione: il retrieval si fa sempre, a ogni domanda. Se l’utente, dopo aver chiesto consigli su un contenuto, sposta la conversazione su come applicare quei consigli a una vicenda personale, ogni sua domanda passa comunque per una ricerca nel catalogo, inutile, che va a riempire il contesto di chunk che non servono (e che il modello, diligente, cerca di usare). In una pipeline fissa non c’è nessuno che possa decidere “questa volta non serve cercare”. Il flag “senza retrieval” sui prompt, che permette di saltare del tutto la costruzione del contesto, è nato proprio per risolvere questo, ma ha senso solo quando qualcun altro può chiedere i contenuti all’occorrenza: è la storia della seconda parte.
Osservabilità: Langfuse, o di come smettere di indovinare
Se c’è una cosa che il prototipo ci aveva insegnato è che un sistema RAG senza strumenti di osservazione non si può migliorare, si può solo ritoccare a caso.
Ogni richiesta al coach produce una trace su Langfuse, e dentro la trace gli span seguono i passi della pipeline qui sopra: riscrittura della domanda, ricerca dei contenuti simili, generazione. Per ogni passo si vedono tempi, modello usato, token consumati e, per la ricerca, quanti risultati sono stati trovati e con quali punteggi. Il tracer viaggia nel context di Go e ogni passo apre e chiude il suo span: il codice resta leggibile e niente va perso se un passo fallisce.
Il pezzo che chiude il cerchio è il feedback: il pollice su o giù che l’utente può dare a una risposta diventa uno score sulla trace corrispondente. Così, invece di “il coach ogni tanto risponde male”, abbiamo una domanda precisa: su quali passi della pipeline si concentrano le risposte con il pollice giù? Lo si guarda in aggregato, per lingua, per prompt, per periodo, ed è lì che si decide dove intervenire.
Tre cose che abbiamo capito così, e che non avremmo capito a tavolino:
- Quando una risposta era debole, il problema stava quasi sempre nel retrieval, non nella generazione. Pochi contenuti diversi fra i risultati, oppure risultati con punteggi bassi. È da qui che è nato il passaggio all’indice per contenuto.
- La riscrittura della domanda andava tenuta a freno: un’istruzione stretta (riformula, non rispondere, non aggiungere nulla) ha fatto sparire una classe intera di risposte fuori tema.
- La soglia di similarità ha un valore giusto diverso per lingua: tarata sui numeri, non a occhio.
Sono onesto su un punto: non abbiamo ancora un set di valutazione offline, con domande e risposte attese da far girare a ogni modifica. Oggi i segnali di qualità sono il feedback degli utenti e le metriche delle tracce. È la prossima cosa da fare.
Lo streaming
La risposta arriva al client in streaming, token per token, via Server-Sent Events. Sembra la parte facile ed è quella che mi ha regalato il bug più divertente di tutto il progetto: una race fra la goroutine che produce i token e il client che se ne va, capace di far cadere tutte le connessioni del processo. Merita un articolo a parte, corto e con il test: La race sul canale SSE che può buttare giù un pod Go.
Cosa rifarei uguale e cosa no
Uguale:
- riscrivere la domanda prima di cercare, con un modello economico e un’istruzione stretta;
- un vettore per contenuto, costruito da un riassunto, al posto dei capitoli a chunk;
- gli id dei vettori salvati con la sorgente;
- i prompt in database;
- le tracce dal primo giorno, con il feedback degli utenti agganciato alle tracce.
Diverso:
- il set di valutazione offline l’avrei messo in piedi subito, con venti domande, prima ancora di andare in produzione;
- avrei guardato prima da quanti contenuti diversi arrivavano i vettori estratti per ogni domanda: il problema delle risposte parziali era lì, nelle tracce, fin dal primo giorno;
- avrei separato prima la configurazione “per bot” da quella “per servizio”: oggi lo stesso binario serve più assistenti e alcune impostazioni nate globali sono diventate per-prompt strada facendo.
Nella seconda parte il coach smette di fare retrieval da solo: diventa un agente che usa dei tool esposti via MCP per leggere i dati dell’utente (cosa ha letto, cosa gli è piaciuto) e cercare nel catalogo, mantenendo lo stesso contratto HTTP verso le app. E vedremo cosa è cambiato nelle tracce di Langfuse passando da una pipeline fissa a un loop agentico.