Nell’articolo sulla storia dei chatbot avevo promesso di raccontare come costruisco un chatbot RAG, cioè un chatbot che risponde usando i tuoi documenti invece di quello che il modello si ricorda. Eccolo. E invece di spiegarlo in astratto, uso quello che hai sotto gli occhi: il pulsante “Chiedi al blog” in basso a destra, che risponde alle domande pescando dagli articoli di questo sito.
La particolarità è che non usa nessun database vettoriale. Niente Pinecone, niente Qdrant, niente pgvector: solo PHP, il MariaDB che WordPress aveva già e qualche chiamata API. In questo articolo ti mostro come funziona pezzo per pezzo, con il codice vero, e soprattutto fino a che punto questo approccio regge.
Cos’è un chatbot RAG, in un minuto
RAG sta per Retrieval-Augmented Generation. L’idea è semplice: un modello linguistico non conosce i tuoi articoli, le tue procedure interne o il tuo listino, e se glielo chiedi rischia di inventare. Allora, a ogni domanda, prima cerchi i pezzi di documento pertinenti, poi li passi al modello insieme alla domanda e gli dici di rispondere solo con quelli.
Il lavoro difficile non è la risposta, che la scrive il modello. È la ricerca: se i pezzi che passi sono quelli sbagliati, anche il modello migliore del mondo risponde male. Per questo quasi tutto questo articolo parla di come trovare i pezzi giusti.
Perché niente database vettoriale
I tutorial sui chatbot RAG partono quasi tutti da un database vettoriale, un sistema specializzato nel trovare velocemente i vettori più simili tra milioni. Ottimo strumento, ma va installato, pagato o mantenuto, e il blog gira su un hosting condiviso dove non posso aggiungere servizi.
Poi ho fatto due conti. Oggi il chatbot conosce 125 pezzi: gli articoli del blog, le schede del mio profilo professionale e i modelli del tracker dei modelli IA. Tutti i vettori insieme pesano 250 KB. Confrontarli uno per uno in PHP, senza nessun indice, richiede circa 3 millisecondi, misurati sul server del blog. La chiamata al modello che scrive la risposta ne richiede circa 2.000. Ottimizzare quei 3 millisecondi con un’infrastruttura in più non ha senso.
Come funziona il chatbot RAG, passo per passo
Il flusso ha due metà: una che prepara l’archivio, una volta sola e poi a ogni aggiornamento, e una che gira a ogni domanda.
- Indicizzazione: divido i contenuti in pezzi, calcolo per ognuno un embedding (un vettore di numeri che rappresenta il significato del testo) e salvo tutto in una tabella di MariaDB.
- Domanda: calcolo l’embedding della domanda e cerco i pezzi più vicini, sia per significato sia per parole.
- Risposta: passo al modello i 6 pezzi migliori con la domanda, e lui risponde citando le fonti con dei numeri tra parentesi quadre.
Dividere i contenuti in pezzi
Un articolo intero è troppo grande e parla di troppe cose per essere un buon risultato di ricerca. Lo divido quindi seguendo la struttura che ha già: l’introduzione è un pezzo e ogni sezione H2 è un altro pezzo. Se una sezione è molto lunga, la spezzo sui paragrafi in blocchi da circa 1.200 caratteri, senza mai tagliare un paragrafo a metà.
Il dettaglio che fa la differenza: il testo che trasformo in embedding non è solo il paragrafo, ma comincia con il titolo dell’articolo e della sezione. Un pezzo che dice “il file va messo in questa cartella” da solo non significa niente; con davanti “PHP-FPM su Plesk” diventa trovabile.
Gli embedding, a 512 dimensioni
Per gli embedding uso text-embedding-3-small di OpenAI, che costa 2 centesimi di dollaro per milione di token: indicizzare tutto il blog è costato meno di un decimo di centesimo. Di base restituisce vettori da 1.536 numeri, ma i modelli di questa famiglia permettono di chiederne meno con il parametro dimensions, perdendo pochissimo in qualità. Io ne chiedo 512: un terzo dello spazio e un terzo dei calcoli.
Per salvarli non serve niente di speciale. Ogni vettore diventa una stringa binaria di numeri float a 32 bit, 2 KB a pezzo, in una normale colonna MEDIUMBLOB:
// Salvataggio: 512 float32 = 2.048 byte
$blob = pack( 'g*', ...$vettore );
// Lettura
$vettore = unpack( 'g*', $blob );
Un’accortezza: normalizzo ogni vettore in modo che abbia lunghezza 1. Così la similarità del coseno, la misura classica per dire quanto due testi si somigliano, diventa un semplice prodotto scalare: moltiplichi i numeri a coppie e li sommi.
Non ripagare quello che non è cambiato
Ogni pezzo ha un hash del suo testo. Quando un articolo viene modificato, lo rispezzo, confronto gli hash e ricalcolo l’embedding solo per i pezzi cambiati. Se correggo un refuso in una sezione, pago una sezione, non tutto l’articolo. Per questo reindicizzare costa praticamente zero e posso farlo tutte le volte che voglio.
La ricerca ibrida: significato più parole
Questa è la parte più importante. Gli embedding sono bravissimi a capire il senso: “quanto costa usare Claude via API” trova la sezione sui prezzi anche se le parole sono diverse. Però sono deboli sui nomi esatti: sigle di modelli come “GPT-6 Sol”, comandi come php_settings -u, codici di errore. Lì una banale ricerca per parole vince.
Quindi faccio tutte e due le ricerche. La prima confronta l’embedding della domanda con tutti i pezzi, la seconda usa l’indice FULLTEXT che MariaDB ha di serie. Ognuna restituisce i suoi 20 migliori, e le due classifiche vengono fuse con la Reciprocal Rank Fusion: ogni pezzo prende un punteggio che dipende solo dalla sua posizione in ciascuna lista, 1 / (60 + posizione). Chi compare in alto in entrambe vince. Il bello è che non devi confrontare punteggi di natura diversa (una similarità da 0 a 1 contro un punteggio testuale), conta solo l’ordine.
Ecco il cuore della ricerca, leggermente semplificato:
// 1. Significato: prodotto scalare tra vettori normalizzati
$sim = array();
foreach ( $wpdb->get_results( "SELECT id, emb FROM $t", ARRAY_A ) as $r ) {
$v = unpack( 'g*', $r['emb'] );
$s = 0.0;
$i = 0;
foreach ( $v as $x ) {
$s += $x * $vq[ $i++ ];
}
$sim[ (int) $r['id'] ] = $s;
}
arsort( $sim );
$rank_v = array_slice( array_keys( $sim ), 0, 20 );
// 2. Parole: indice FULLTEXT di MariaDB
$rank_t = $wpdb->get_col( $wpdb->prepare(
"SELECT id FROM $t WHERE MATCH(titolo, sezione, testo) AGAINST (%s)
ORDER BY MATCH(titolo, sezione, testo) AGAINST (%s) DESC LIMIT 20",
$query, $query
) );
// 3. Reciprocal Rank Fusion: conta la posizione, non il punteggio
$score = array();
foreach ( array( $rank_v, array_map( 'intval', $rank_t ) ) as $lista ) {
foreach ( $lista as $pos => $id ) {
$score[ $id ] = ( $score[ $id ] ?? 0 ) + 1 / ( 60 + $pos );
}
}
arsort( $score );
Tutto qui. Trenta righe di PHP al posto di un servizio esterno.
Scegliere i pezzi da passare al modello
Dalla classifica finale prendo i primi 6 pezzi, con una regola in più: al massimo 2 dalla stessa fonte. Senza questo limite, a una domanda generica un articolo lungo potrebbe prendersi tutti e sei i posti e la risposta si appiattirebbe su quello, anche quando un altro articolo dice qualcosa di utile. A questi aggiungo sempre la scheda con i miei contatti, così se qualcuno chiede come scrivermi o prenotare una chiamata il modello ha il dato giusto sotto mano e non se lo inventa.
Per le domande di seguito, tipo “e quanto costa?”, la ricerca usa anche la domanda precedente: da sola quella frase non si capisce di cosa parla.
Risposte con le fonti, e niente link inventati
I pezzi arrivano al modello numerati, e le istruzioni gli chiedono di mettere il numero della fonte dopo ogni informazione, tipo [2], e di dire chiaramente quando le fonti non contengono la risposta invece di completare con quello che sa. Sotto la risposta il widget mostra i link agli articoli citati.
Le istruzioni però non bastano, quindi c’è anche un controllo lato server: ogni URL nella risposta viene confrontato con quelli presenti nelle fonti, e se non c’è viene tolto. Un modello può sempre sbagliare, ma così non può mandare nessuno su un link inventato.
Tenere l’indice aggiornato
Un chatbot RAG che non conosce l’ultimo articolo è un chatbot che mente per omissione. Qui l’aggiornamento è automatico:
- quando pubblico, modifico o ritiro un articolo, WordPress programma la reindicizzazione di quell’articolo 30 secondi dopo, fuori dalla richiesta dell’editor, così il salvataggio non rallenta;
- quando cambia un dato nel tracker dei modelli, il tracker avvisa il chatbot con un hook di WordPress e i modelli vengono reindicizzati un minuto dopo;
- ogni notte un cron ricontrolla il tracker e cancella il log delle domande più vecchio di 180 giorni.
Sicurezza e limiti
Un chatbot pubblico è un endpoint che chiunque può chiamare, e ogni chiamata costa. Queste sono le protezioni che ho messo:
- Limiti per visitatore: 8 domande ogni 10 minuti e 40 al giorno. Per contarle non salvo l’indirizzo IP, ma un suo hash con una chiave segreta, che dura al massimo un giorno.
- Limiti globali: 200 domande al giorno per tutto il sito e un budget mensile calcolato sommando il costo di ogni risposta registrata. Superato uno dei due, il bot risponde gentilmente di riprovare domani.
- Controllo dell’origine: le richieste che arrivano da un altro sito vengono rifiutate, e le domande oltre i 500 caratteri pure.
- Prompt injection: le istruzioni dicono al modello che il testo delle fonti e delle domande è materiale da consultare, non ordini da eseguire. L’ho messo alla prova con il classico “ignora le istruzioni precedenti”, e com’è andata lo racconto nell’articolo su quanto costa davvero un chatbot AI.
- Chiavi API sul server: il widget parla solo con un endpoint del blog, le chiavi stanno in
wp-config.phpe il browser non le vede mai.
La stessa idea in azienda
Lo stesso principio l’ho usato anche fuori dal blog, in un chatbot aziendale che risponde alle domande dei colleghi sulla documentazione interna. Lì è un’applicazione Laravel, con gli embedding di OpenAI e Claude Haiku per le risposte, e i vettori non stanno nemmeno in un database: stanno in un archivio JSON. Per il volume di documenti di un’azienda di quelle dimensioni basta e avanza.
Cambiano le cose intorno, non il cuore. Con documenti interni devi pensare a chi può vedere cosa, a dove finiscono i dati che mandi alle API e a come tenere allineato l’indice quando i documenti vivono in un Drive condiviso invece che in un CMS. Ma pezzi, embedding, ricerca e risposta con le fonti sono identici.
Quando serve davvero un database vettoriale
Il confronto uno per uno cresce in modo lineare: il doppio dei pezzi, il doppio del tempo. Facendo le proporzioni dai 3 millisecondi di oggi, con 10.000 pezzi si arriverebbe intorno al quarto di secondo per ricerca, con 20 MB di vettori da leggere ogni volta. È una stima, ma dà l’ordine di grandezza.
Quindi, a spanne, passerei a un database vettoriale vero (pgvector se hai già PostgreSQL, Qdrant o simili se parti da zero) quando:
- i pezzi diventano decine di migliaia, per esempio con una knowledge base aziendale grande o un sito con migliaia di pagine;
- servono filtri complessi insieme alla ricerca, tipo “solo i documenti di questo reparto, dopo questa data, visibili a questo utente”;
- arrivano tante domande in contemporanea e anche un quarto di secondo di calcolo per ciascuna comincia a pesare sul server.
Fino a lì, la soluzione più semplice è anche quella giusta: meno pezzi da mantenere, niente costi fissi, e tutto il tempo risparmiato lo puoi spendere dove conta davvero, cioè sulla qualità della ricerca.
Provalo
Il modo migliore per capire come funziona è fargli qualche domanda: apri Chiedi al blog e prova a chiedergli di un articolo, dei prezzi di un modello o di qualcosa di cui non ho mai scritto, per vedere come se la cava quando la risposta non c’è.
Se ti interessano i costi e il confronto tra modelli, trovi i numeri nell’articolo su quanto costa davvero un chatbot AI. Se invece parti da zero con le API, comincia dalla guida alle API di intelligenza artificiale. E se stai pensando a un chatbot come questo per la tua azienda, qui trovi come lavoro.


Lascia un commento