Il motore di retrieval di Camilla, costruito da zero: 101.000 query in produzione nei primi 6 mesi di rollout, latenza media end-to-end di 2,3 secondi, un tasso di difetti nell’ordine di una segnalazione da cliente ogni diverse settimane di traffico reale.
Il motore di ricerca è un tool a disposizione del chatbot/agente Camilla, ed elabora query reali di utenti reali, traducendo le domande degli utenti che cercano bandi sulla base delle più svariate caratteristiche in risposte verificabili e riferite ai bandi che corrispondono alle richieste degli utenti. L’obiettivo non era dare ‘suggerimenti’, ma fornire risposte il più possibile esatte.
Quando il sistema era ancora in fase embrionale, mi sono fermato a riflettere sul significato e sull’aspettativa che gli utenti avrebbero assegnato al chatbot, in particolar modo quando chiedevano “quali sono i bandi per …” e indicavano delle proprie preferenze. L’idea di usare dense retrieval, ovvero confronto puramente semantico tra testo dei bandi e richiesta dell’utente non mi convinceva. L’utente ci stava comunicando delle informazioni ben più ricche di quanto può essere rappresentato in un embedding. Se diceva “Ho una laurea magistrale in ingegneria aerospaziale”, stava esprimendo un requisito formale, idem se indicava la città in cui era disposto a lavorare, e così via.
Questo primo aspetto mi ha fatto tornare in mente un articolo di Wen et al. del 2017 che presentava quelli che all’epoca erano chiamati Task-oriented Dialogue Systems ovvero assistenti come Siri, Cortana, Alexa e similari. I modelli di linguaggio erano seq2seq e non GPT come nel nostro caso, ma un parallelo era chiaro: separare la parte di retrieval di informazioni strutturate (nel caso dell’articolo poteva essere trovare ristoranti, o prenotare voli), da quella di encoding/decoding del linguaggio naturale.
Questo paradigma sembra essersi evoluto trovando la sua naturale prosecuzione nell’architettura ad agenti, con la separazione tra orchestratore e tool. In un certo senso, questo motore di ricerca è un tool dell’agente, o meglio un sottoagente, perché riceve una query utente grezza e si occupa di scorporarla (entity extraction), risolvere ciascuna entità rispetto ad un dizionario (fonti ISTAT per le aree geografiche, MIUR per le lauree), produrre un set di bandi candidati applicando tali filtri, e infine un reranking/filtro ulteriore con FTS su Postgres.
Inoltre, questo sistema doveva reggere una scala di dati che sarebbe cresciuta di giorno in giorno, fino a superare decine di migliaia di bandi, ciascuno costituito da decine di pagine con tabelle, liste, riferimenti a leggi e regole. Puntare sul retrieval denso, o anche ibrido, per questo task ci avrebbe costretto ad affidare la scalabilità a indici in spazi vettoriali (esempio HNSW) su una mole di dati rumorosa ed enorme. Inoltre, avrebbe stressato molto di più i sistemi al momento dell’inferenza/query, perché un dato meno strutturato è per definizione più difficile da organizzare e cercare velocemente.
Al contrario ci siamo affidati ai dati strutturati per i metadati dei bandi, ovvero usando un DB relazionale (Postgres) per elencare i bandi, le lauree che esprimevano nei requisiti, le sedi di lavoro, i profili lavorativi, e normalizzando tutte queste entità rispetto a dizionari canonici. In questo modo quelle che erano centinaia di migliaia di pagine di testo sono state ridotte a poche decine di migliaia di dati strutturati navigabili in millisecondi tramite i classici indici B-Tree.
Infine, la struttura relazionale era davvero evidente guardando le entità con cui stavamo lavorando. Stati -> Regioni -> Province -> Comuni, lauree che potevano essere espresse genericamente (es. ’laurea triennale’) o specifiche (es. ‘LM-18’ o ‘Laurea Magistrale in Informatica’). Le variazioni lessicali sembravano infinite, ma la struttura sottostante era chiara e per catturarla serviva seguirne la filosofia. In nessun altro modo avremmo potuto espandere ‘Laurea triennale in Matematica’ nel possesso dei requisiti ‘L-35’, ‘Laurea triennale (generico)’, e tutte le lauree equipollenti, secondo il catalogo MIUR.
Gestire la complessità amministrativa tramite la riconduzione di entrambi i lati (bando, query utente) ci ha permesso di rendere governabile in modo chiaro e spiegabile questo dominio. Infatti, ad ogni query utente seguono l’elenco dei bandi trovati, e l’esatto set di filtri che sono stati applicati. Questa era la visione che mi ha spinto fin da subito con decisione verso questa soluzione ibrida, più ancora delle questioni sulla latenza, che seppur importanti avremmo potuto risolvere aumentando le risorse.
In produzione, però, ogni anello di questa catena prima o poi si inceppa: l’endpoint LLM che va in errore sotto un picco di richieste o per un content filter che scatta a vuoto, un ente o titoli di studio scritti in una forma che non esiste in nessun dizionario, una combinazione di filtri perfettamente legittima che però azzera i risultati. La regola di progetto è che nessun fallimento interrompe la ricerca: tutto retrocede a testo libero nella stringa FTS. Se l’LLM non risponde (dopo i retry sugli errori recuperabili), l’estrazione passa a semplici regex; un’entità che non si risolve viene retrocessa a testo libero; se i filtri esatti azzerano il set, si sciolgono tutti nella stringa FTS con un LIMIT 3 a garantire comunque una risposta. Così il recall non va mai a zero per colpa della nostra stessa pipeline, e la FTS su Postgres (word_similarity su una superficie testuale precalcolata e indicizzata GIN) fa da filtro testuale e reranker nel caso normale, e da rete di sicurezza in tutti gli altri.
query utente
│
▼
estrazione entità (LLM) ── LLM ko ──► regex ──┐
│ │
▼ ▼
risoluzione sui dizionari stringa FTS
│ (ISTAT, MIUR) ── entità ignota ──────►│
▼ │
filtri esatti Postgres │
│ (+ espansioni) ── 0 bandi ───────────►│
▼ │
FTS: filtro x keyword e rerank ◄──────────────┘
│ (word_similarity, indice GIN)
▼
agente
A monte di tutto c’è anche una scorciatoia: se la query ricalca quasi alla lettera il titolo di un bando (similarità FTS > 0,95), il motore risponde direttamente, saltando l’intera pipeline.
Tutto questo però risolve solo metà del problema: capire di quale bando sta parlando l’utente. Una volta fissato il bando, la domanda cambia natura (“quante prove prevede il concorso?”, “posso portare la calcolatrice?”) e lì il confronto semantico torna ad avere senso. Il secondo stadio esegue ricerca ibrida su Weaviate, BM25 più embedding, ristretta ai contenuti di quel solo bando: poche decine di pagine invece di centinaia di migliaia, dove il recall semantico serve davvero e il rumore è sotto controllo.
La lezione di fondo è stata di semplificare il più possibile la superficie di ricerca (i bandi disponibili) spostando tutto il carico possibile (ovvero la creazione di indici e strutture) nel momento in cui i bandi venivano acquisiti nel sistema (di notte, per non creare disservizio), e contemporaneamente di trattenere più informazioni possibili dalla query utente, sfruttando la ricchezza e tassonomia del dominio in cui eravamo collocati, ovvero bandi pubblici.
Sul versante unit testing e valutazione, abbiamo preparato una suite di regressione interna di diverse centinaia di query annotate, che oggi passa al 97%: ogni modifica al motore viene ritestata su quelle query prima di arrivare in produzione.
Architettura e scelte di governance sono raccontate nell’articolo su Agenda Digitale.