#engineering

Oltre il chatbot: un assistente RAG multi-tenant in cui le garanzie stanno nell’architettura

Per un’azienda di servizi tecnologici abbiamo progettato una piattaforma di assistenti basati sui documenti, offerta a molti clienti: l’isolamento dei dati è una proprietà di ogni query, il lavoro costoso dei modelli non si paga due volte e un rilascio non interrompe un’indicizzazione in corso.

Architettura software · AWS · Django · FastAPI · LangGraph · Celery · pgvector

Costruire un chatbot che risponde a partire da qualche PDF richiede un pomeriggio. Costruire una piattaforma su cui molte organizzazioni caricano grandi quantità di documenti e li interrogano dalla web app, da un widget sul proprio sito, da un’app di messaggistica e da un’API è un altro mestiere. Lì le domande difficili non riguardano il modello linguistico: che cosa impedisce ai documenti di un cliente di finire nella risposta data a un altro, che cosa impedisce di pagare due volte la stessa chiamata a un modello, che cosa succede a un’indicizzazione lunga quando si rilascia una nuova versione.

Per un’azienda di servizi tecnologici abbiamo progettato e costruito una piattaforma di questo tipo, offerta ai suoi clienti come servizio. Ogni organizzazione lavora in uno o più spazi di conoscenza, ciascuno con i propri documenti, permessi e configurazione di modelli e retrieval. Il principio che abbiamo seguito è uno: in un prodotto AI multi-tenant, isolamento, costi e continuità del servizio devono essere proprietà dell’architettura, non attenzioni affidate a ogni singola funzionalità. Una garanzia che dipende dal ricordarsi un filtro, prima o poi, viene meno.

Due servizi, ma un solo proprietario per identità e schema

La piattaforma ha due metà con esigenze opposte. Il governo della piattaforma è fatto di richieste brevi e transazionali su un dominio ricco di modelli e permessi. La parte AI tiene aperte le richieste per tutto lo streaming, passa il tempo in chiamate verso i fornitori dei modelli e cambia dipendenze a un ritmo molto più rapido. Per questo sono due servizi distinti.

Servizio Responsabilità Stack
Control Plane identità, organizzazioni, team, permessi, spazi, documenti, chiavi API, canali, quote Django, Gunicorn
Runtime AI conversazione in streaming, agenti, retrieval, ingestion dei documenti, valutazioni FastAPI asincrono, LangChain, LangGraph, Celery

Avere due servizi è la parte facile. Il rischio vero è dividere anche l’autorità: due servizi che decidono ciascuno chi è l’utente, o che modificano ciascuno lo schema, prima o poi divergono, e la divergenza si scopre in produzione. Per questo i due servizi condividono lo stesso PostgreSQL, ma l’autorità resta al Control Plane.

Lo schema appartiene alle migrazioni Django, e il Runtime vi accede con modelli SQLAlchemy rigenerati dallo schema, non scritti a mano. Anche l’identità ha un solo proprietario: qualunque canale usi l’utente, il Runtime chiede al Control Plane di risolvere la sua credenziale in utente, spazio e permessi effettivi, con un token di servizio firmato e a vita brevissima. La pipeline rende queste regole verificabili: i test del Runtime girano sullo schema prodotto dalle migrazioni del Control Plane, e una migrazione già integrata non si può modificare. Un disallineamento emerge sulla pull request, non in produzione, e un nuovo canale è un tipo di richiesta da risolvere, non una copia delle regole di accesso.

L’isolamento fra clienti è una proprietà di ogni query

In un sistema RAG i dati di un cliente stanno anche nei frammenti dei documenti e nei loro vettori. La soluzione più diffusa è affiancare al database applicativo un database vettoriale dedicato, con un filtro per cliente su ogni ricerca. Funziona, ma sposta l’isolamento in un secondo sistema, con transazioni e garanzie diverse da quelle del resto dei dati, e fa di ogni cancellazione un’operazione da coordinare fra due archivi.

Abbiamo scelto di tenere vettori, frammenti e dati applicativi nello stesso Aurora PostgreSQL, con pgvector. L’isolamento si esprime con un join sulla tabella dei documenti, nella stessa transazione e con le stesse garanzie del resto dei dati. Cancellare un documento è un’operazione su un solo database, e la ricerca full-text, che affianca quella semantica, usa lo stesso motore. Ogni frammento registra anche il modello di embedding che ne ha prodotto il vettore: vettori di modelli diversi convivono nella stessa tabella, la ricerca confronta solo vettori omogenei, e uno spazio può passare a un modello migliore senza migrare gli altri.

In pratica, un nuovo cliente è un’organizzazione e qualche spazio, non un nuovo deployment, e PostgreSQL resta l’unica fonte di verità: anche il grafo della conoscenza, come vedremo, si ricostruisce dai dati che vi sono salvati.

La qualità di una risposta si decide in due momenti

Un sistema RAG risponde bene solo se trova i passaggi giusti, e i passaggi giusti si preparano quando il documento entra, non solo quando arriva la domanda.

All’ingresso si decidono anzitutto i costi: solo i PDF con immagini rilevanti, come tabelle, schemi o scansioni, passano da un modello visivo, tutti gli altri vengono estratti come testo, e il modello visivo si paga solo dove serve. Il problema meno visibile è che un frammento estratto da un documento lungo spesso non basta a se stesso. “Il termine è di trenta giorni” non serve a nulla se non si sa di quale contratto e di quale clausola si parla. Prima di calcolare i vettori, ogni frammento viene arricchito con le informazioni che lo collocano nel documento, secondo la tecnica del contextual retrieval, con parametri configurabili per spazio: un archivio di contratti e una raccolta di schede prodotto non si indicizzano allo stesso modo.

Alla domanda, la ricerca semantica da sola ha un punto debole noto: trova i passaggi che dicono la stessa cosa con altre parole, ma tende a diluire codici, sigle e identificativi. Per questo la conversazione viene riformulata due volte, una frase per la ricerca sui vettori e poche parole chiave per la ricerca full-text, e le due ricerche partono in parallelo. La scelta più importante è dove ordinare: ordinare ogni ricerca per conto suo produce due classifiche che nessuno confronta. Noi uniamo i candidati e li facciamo ordinare una sola volta da un modello di reranking: è lì che si decide quali passaggi arrivano davvero al modello.

Ogni fase viene registrata, dalle interrogazioni generate ai punteggi finali. Quando una risposta non convince, si ricostruisce esattamente che cosa il modello ha visto e perché.

Le dipendenze esterne vivono dietro un contratto nostro

Il cuore dell’indicizzazione semantica si basa su una libreria RAG open source con algoritmi di retrieval di valore, ma pensata per un contesto diverso: gestiva da sé la propria persistenza, parlava con i modelli attraverso un’interfaccia diversa da quella dei fornitori scelti e non era pensata per molti clienti su un unico database. Davanti a una dipendenza così le strade ovvie sono due, entrambe costose: riscriverla, o continuare ad adattarsi a lei.

Abbiamo scelto una terza strada dopo averla scomposta modulo per modulo: tenere gli algoritmi di retrieval, la parte di maggior valore, e sostituire tutto ciò che li collega al mondo, cioè archivio e indice vettoriale reimplementati sulle tabelle della piattaforma, con l’isolamento per spazio in ogni query, e adattatori verso i modelli di tutti i fornitori.

Gli agenti non vedono la libreria: vedono un retriever LangChain come tutti gli altri, e la libreria si può sostituire senza toccarli. Ogni agente è un grafo LangGraph con nodi che si tracciano e si testano uno per uno; prompt e modelli sono configurazione per spazio, così un nuovo modello di un fornitore già integrato è un record, non un rilascio. Il consumo di token è registrato per passo, modello e spazio: quanto costa un cliente si legge nei dati, non si stima.

Il grafo della conoscenza: la parte costosa si paga una volta sola

La ricerca per similarità risponde bene a domande locali, ma fatica quando la risposta richiede di collegare entità sparse in documenti diversi: quali componenti compaiono in più progetti, quali procedure ricorrono. Per queste domande la piattaforma affianca alla ricerca un grafo della conoscenza, costruito facendo analizzare ogni frammento a un modello linguistico.

È il punto in cui i costi possono sfuggire. Lasciare che la libreria del grafo faccia tutto è la strada più rapida per sperimentare, ma così calcola da sé gli embedding e genera risposte che l’applicazione deve poi rigenerare, con chiamate a pagamento duplicate. Per questo la libreria costruisce e attraversa il grafo, ma persistenza, chiamate ai modelli e controlli di accesso restano alla piattaforma.

L’ingestion è divisa in due fasi con costi molto diversi. Nell’estrazione il modello analizza ogni frammento, e il risultato viene salvato in PostgreSQL con la sua provenienza: impronta del prompt, modello, versioni del software. Un frammento già estratto con la stessa provenienza non viene estratto di nuovo. Nella costruzione il grafo viene ricostruito dai dati salvati, senza chiamate ai modelli e con identificativi stabili. Il grafo si può quindi ricostruire in qualunque momento senza ripagare l’estrazione.

Anche qui l’isolamento non si affida a un solo controllo. L’identità sul grafo è ricavata sempre dallo spazio già autorizzato, mai dalla richiesta; ogni risultato dell’attraversamento deve appartenere a quello spazio, altrimenti la richiesta fallisce; e le evidenze superstiti vengono riautorizzate in SQL con le stesse regole della ricerca classica.

Worker: ogni ritmo di lavoro ha la sua corsia

I lavori in background non sono tutti uguali. Un messaggio da un canale conversazionale deve ricevere risposta in pochi secondi; l’indicizzazione di un documento o l’estrazione del grafo possono durare minuti. In un’unica coda, un caricamento massivo ritarderebbe la risposta a un utente in attesa. Per questo i task Celery sono divisi in corsie, ciascuna con la propria coda SQS e il proprio servizio ECS: una interattiva per i messaggi e lo smistamento dei documenti, una di elaborazione per indicizzazione, grafo e valutazioni.

In entrambe ogni worker prende un solo lavoro alla volta, invece dei quattro che Celery prenota di default: nessun messaggio resta in attesa dietro un’indicizzazione mentre un altro worker è libero. Rinunciamo a un po’ di densità in cambio di un comportamento prevedibile, e per questo l’autoscaling segue la coda, non la CPU: la metrica è il numero di messaggi in attesa per worker attivo. Un picco di caricamenti diventa più worker, e nei periodi di inattività la corsia di elaborazione si spegne del tutto.

Nessun lavoro interrotto, nemmeno durante un rilascio

Autoscaling e rilasci hanno un punto cieco in comune: per ridurre la capacità o sostituire una versione, ECS manda SIGTERM ai container e, scaduto lo stopTimeout, li termina con SIGKILL. Non sa se un worker è a metà di un’indicizzazione, e il warm shutdown di Celery aspetta la fine del task solo finché ECS glielo concede.

La soluzione più diffusa è alzare lo stopTimeout di tutti i container fino a coprire il task più lungo. Su Fargate però il limite è di due minuti, mentre un’indicizzazione o un’estrazione del grafo possono superarli. E anche dove basterebbe, un tempo di arresto tarato sul caso peggiore rallenta ogni rilascio. L’alternativa, accettare l’interruzione e riaccodare, significa rifare un lavoro che include chiamate ai modelli già pagate.

Abbiamo scelto di invertire la responsabilità: è il worker a dire a ECS quando non può essere fermato. Un supervisore avvolge il processo Celery e riceve, attraverso un socket Unix locale, i segnali di ciclo di vita di ogni task. Tiene il registro dei task attivi con la loro scadenza massima e mantiene attiva la task scale-in protection di ECS fino a quella scadenza. Finché il worker lavora, l’autoscaler non può sceglierlo; appena torna libero, la protezione cade.

Task avviato, con limite di tempo Protezione attiva fino alla scadenza Riduzione della capacità respinta Task concluso Protezione rimossa Il task può essere fermato Processo Celery Supervisore Agente ECS Autoscaling e rilasci

Lo stesso supervisore gestisce i rilasci: quando scopre che il deployment primario del servizio non usa più la sua versione, chiede a Celery un warm shutdown, cioè finire il task in corso, non prenderne altri e chiudersi. Il supervisore aggiorna la protezione solo quando la scadenza cambia davvero, e la lascia cadere da solo se un evento si perde: un worker non resta mai protetto per sempre.

La protezione funziona tanto meglio quanto più i task sono brevi, e il progetto ne ha fatto una regola: un task che tiene occupato un worker troppo a lungo è un difetto di progettazione, non un problema di configurazione. Le nuove elaborazioni a più passi nascono scomposte in passi brevi e idempotenti che salvano i propri progressi e accodano il passo successivo, così un riavvio costa un passo, non l’intero lavoro. Per i lavori che non si possono spezzare è predisposta una corsia a parte: EventBridge Pipes avvia un task ECS dedicato per ogni messaggio, che vive esattamente quanto il lavoro.

Il risultato è che un rilascio o una riduzione di capacità aspettano che l’indicizzazione in corso finisca, invece di interromperla, con tempi di arresto normali per tutti gli altri container.

Che cosa ottiene il prodotto

Il cliente offre la piattaforma come servizio, e farla crescere non richiede di ripensarla. Un nuovo cliente è un’organizzazione e qualche spazio; un nuovo canale è un tipo di richiesta da risolvere nel Control Plane; un nuovo modello è configurazione; un nuovo agente è un grafo che eredita le capacità esistenti, e una nuova tecnica di retrieval, come il grafo della conoscenza, si attiva spazio per spazio.

È il modo in cui affrontiamo i prodotti AI: il modello è il componente che cambia più spesso, e proprio per questo non può essere il posto in cui vivono le garanzie. Le garanzie stanno nell’architettura che gli sta intorno: un solo proprietario per identità e schema, l’isolamento dentro ogni query, i lavori costosi pagati una volta, worker che sanno quando non possono essere fermati. Ognuna di queste scelte costa di più all’inizio di una scorciatoia; insieme permettono al prodotto di crescere per clienti, documenti e funzionalità senza rimettere mano alle fondamenta.