Quasi isolato è una violazione rimandata
Prima che qualcuno dicesse «RAG», avevo già costruito la versione noiosa dello stesso problema.
Per una startup legal-tech dovevo garantire una cosa semplice da enunciare e facile da sbagliare: ogni documento appartiene a un tenant, e solo i membri che quel tenant ha autorizzato possono toccarlo, secondo un modello di accesso concordato col business, non inventato da me. Documenti dentro, autorizzazione imposta, nessuno vede ciò che non è suo.
Il problema è tutto qui. Il retrieval-augmented generation non lo cambia. Aggiunge solo un layer che rende sbagliarlo molto più facile da fare, e molto più difficile da notare.
Perché la ricerca semantica rende pericoloso un confine soft
Il controllo d'accesso classico ha una proprietà misericordiosa: è binario e visibile. Una query ritorna le tue righe oppure ritorna niente. Il confine è una clausola WHERE, e quando manca, il risultato di solito sembra palesemente sbagliato.
Il retrieval semantico toglie quella misericordia. Il retriever ordina i documenti per similarità alla domanda, e alla similarità non importa di chi siano i dati. Metti i documenti di due tenant in un solo index e, appena il contratto di un altro tenant è un match migliore della domanda rispetto al tuo, sale in classifica. Viene recuperato. Viene passato al modello come contesto. E il modello risponde, fluente, sicuro, con tanto di citazione, usando un testo che l'utente non avrebbe mai potuto vedere.
Nessun errore. Nessun 403. Nessuno stack trace. Solo una risposta dall'aria utile costruita sui dati di qualcun altro: un leak travestito da buona UX.
L'isolamento è un pre-filter, non un segnale di ranking
L'istinto è risolverla con un ranking migliore: promuovi i documenti dell'utente, penalizza quelli degli altri. Non farlo. Un confine imposto dal punteggio è un confine che stai solo sperando che tenga, e la speranza non è un controllo di sicurezza. Sbaglia i pesi, inserisci un tenant con dati insolitamente simili a quelli di un vicino, cambia il modello di embedding tra sei mesi, e il leak torna in silenzio.
L'isolamento non è una proprietà di come i risultati vengono ordinati. È una proprietà di cosa viene cercato.
Imponilo prima del retrieval, al livello dell'index. In concreto è una di tre cose: un index separato per tenant, un namespace per tenant dentro un index condiviso (la primitiva di multi-tenancy in store come Pinecone), o un filtro sui metadati (tenant_id == acme) applicato prima che parta la ricerca dei vicini più prossimi, pre-filtrando l'insieme dei candidati invece di ordinare i risultati dopo. Il retriever non deve nemmeno posare gli occhi sui vettori di un altro tenant. Poi la similarità è libera di fare il suo lavoro, perché tutto ciò che può vedere è già tuo. Non puoi arrivare all'isolamento tramite il ranking. Puoi solo fare in modo che la ricerca non veda mai ciò che non è tuo.
Il filtro è il tuo modello di autorizzazione, non solo un tenant ID
Un index per tenant è la metà facile. La metà che morde è dentro il tenant.
In quell'azienda legal-tech, la regola non era mai solo «lo studio A non può vedere lo studio B». Era «solo i membri che questo studio ha autorizzato possono toccare questa pratica»: un partner vede l'intero caso, un legale esterno vede una sola cartella, un praticante vede ciò che gli è stato assegnato. Quel modello di autorizzazione era il confine; il tenant ne era solo il livello più grossolano.
Il RAG appiattisce tutto questo per default. Spezzetta i documenti di un tenant, li trasforma in embedding, e il retriever tirerà fuori volentieri un chunk di una pratica a cui l'utente che interroga non ha mai avuto accesso: stesso tenant, permesso sbagliato. Un index per tenant qui non serve a nulla: il leak è dentro l'index.
Quindi il pre-filter non può essere solo tenant_id == acme. Dev'essere l'intero predicato di autorizzazione dell'utente, risolto dall'identità autenticata al momento della query e applicato prima della ricerca: l'insieme dei documenti che questa persona specifica ha il diritto di vedere, adesso. L'isolamento non è un confine che imposti una volta in fase di ingestion. È il tuo modello di controllo d'accesso, valutato a ogni retrieval, perché i permessi cambiano, e l'index no.
E deve fallire in sicurezza
Dove vive il confine ha una domanda gemella: cosa succede quando il codice se lo dimentica.
Quel filtro sui metadati è quello pericoloso. Se l'isolamento dipende dal fatto che l'applicazione si ricordi di passare tenant_id == acme a ogni singola query, allora la separazione dei tuoi dati sopravvive solo finché nessuno dimentica quel parametro, e quella dimenticanza fallisce nella direzione peggiore possibile. Una ricerca senza filtro non ritorna un errore. Ritorna i documenti di tutti. Silenziosa, e massimamente dannosa: le due proprietà che meno vuoi in una modalità di fallimento.
Metti il confine dove un errore fallisce in sicurezza. Un index o namespace per tenant restringe la ricerca ai vettori di un solo tenant, così uno scope dimenticato ritorna niente dagli altri invece di tutto. Meglio ancora, spingilo sotto la query completamente: tieni gli embedding nel tuo database come righe pgvector sotto la row-level security di Postgres, e il confine è imposto a ogni lettura a prescindere da cosa la query si è ricordata di chiedere, e la policy può portare il predicato di autorizzazione, non solo il tenant. Dimentica la WHERE e la policy regge lo stesso; dimentica di impostare l'identità e non matcha nulla, così ottieni una risposta vuota e palesemente rotta, di quelle che qualcuno segnala in cinque minuti, invece dell'intera base clienti servita a un solo utente.
Returns nothing è un bug recuperabile. Returns everyone è una violazione. Progetta perché i tuoi errori cadano sul primo.
Un test che prova ad infrangerlo
Imporre il confine una volta non è la stessa cosa che sapere che terrà ancora dopo il prossimo refactor, il prossimo cambio di modello di embedding, il prossimo sviluppatore che «semplifica» la query. Quindi testa la violazione direttamente: semina in un tenant un documento sentinella, fai passare l'identità di un altro tenant per l'intero percorso di retrieval con una query scritta apposta per matchare quella sentinella, e verifica che non torni alcunché. Collega tutto questo alla CI, e il giorno in cui qualcuno toglie il filtro o si fida del default sbagliato, la build diventa rossa invece di un cliente che diventa silenzioso.
È la stessa mossa di qualsiasi fitness function: non testare che l'happy path funzioni, testa che la cosa di cui hai paura non possa accadere.
Il test è la tua prova a build-time. La sua controparte a runtime è un audit trail di quali documenti sono stati recuperati per quale identità, perché un fallimento così silenzioso non lascia altra prova di essere avvenuto. In un dominio regolato, quel log non è osservabilità. È il registro.
La stessa lezione, un livello più sotto
Se ti suona familiare, è giusto così. Un confine di tenant che imponi nel codice applicativo è un confine che stai affidando a ogni query futura perché se lo ricordi: ogni query che uno sviluppatore stanco scrive alle 18 di venerdì, e ogni query che un assistente AI genera senza aver mai sentito parlare dei tuoi tenant. Imponilo nell'index, o nelle regole d'accesso del database stesso, e non resta nulla da ricordare. Il confine smette di essere un'istruzione e diventa un muro.
È lo stesso principio del far rispettare una regola architetturale nella build invece che in una pagina wiki: un invariante che protegge denaro, fiducia o compliance non appartiene a una convenzione che qualcuno, o qualcosa, deve scegliere di onorare.
La regola che ti lascio
In un sistema che risponde a domande sui dati di qualcuno, «quasi isolato» non è isolato: è una violazione rimandata. La ricerca semantica prima o poi tirerà fuori il record del vicino; l'unica domanda aperta è se la tua architettura l'ha reso possibile.
Metti il confine di tenant dove la ricerca non può girare senza, e dove dimenticarlo ritorna niente invece di tutti. Tutto ciò che sta sopra il layer di retrieval, il modello, il prompt, il reranker, è un comfort, non un controllo.
Il percorso di retrieval è il leak su cui mi sono concentrato qui; cache, log e assemblaggio del prompt meritano la stessa disciplina, ma è un altro pezzo.
Dove, nel tuo percorso di retrieval, l'isolamento è un filtro che applichi, invece di un muro che l'index semplicemente è?