// AI ·
Una chat LLM con RAG dentro WordPress, in una notte di vibe (senza plugin, senza servizi esterni, senza piangere, gratis insomma)
Sul mio sito c’era una chat finta. Bella da vedere, una bolla in fondo alla home con tre quick-reply («chi sei?», «cosa fai?», «contatti»), che attivavano risposte scritte a mano da me. Funzionava benissimo, nel senso in cui funziona benissimo un manichino: sta lì, sorride, e non dice niente che non gli abbia già messo in bocca io.
Questo è il racconto di come quella chat è diventata un assistente conversazionale vero, agganciato ai contenuti del sito, tutto dentro un tema WordPress e senza tirare su un solo servizio esterno, a parte le API gratuite di Mistral. Tre sessioni in un weekend, costo runtime: zero. C’è anche un bug abbastanza umiliante in mezzo, ma ci arriviamo.
TL;DR
- Front-end di chat già esistente (bubble + quick-reply) ? l’ho cablato a un endpoint reale.
- Modello: Mistral AI, free tier “Experiment” (server in UE, niente carta di credito). Modello chiacchierino consigliato:
open-mistral-nemo. - Tutto dentro un tema WordPress:
functions.php+site.js+site.css. Zero plugin, zero microservizi. - Aggiunto un tab “LLM” nel configuratore del tema: API key mascherata, modello, system prompt, temperature, rate limit, bottone test.
- Quando il modello ha iniziato a sostenere che il mio libro parlava di AI, ho aggiunto un RAG locale: tabella custom in MySQL, embedding via
mistral-embed, cosine similarity in PHP, top-K iniettati nel system prompt. - Reindex automatico a ogni
save_post. 32 fonti, 170 chunk, ~700 KB di indice. Overhead di retrieval: ~50 ms, cioè invisibile.
Il punto di partenza
L’obiettivo era semplice: collegare quella bolla a un LLM vero, tenendo le quick-reply locali per chi ha fretta e non vuole aspettare la rete, senza far esplodere i costi e senza esporre una chiave API in giro.
Mi servivano quattro cose dal modello: italiano decente, un tier gratuito senza carta di credito, server in UE (lavoro in Microsoft, il GDPR non è un dettaglio folkloristico) e un’API OpenAI-compatibile, così da non riscrivere niente se un giorno cambio fornitore.
La risposta è stata Mistral AI, tier “Experiment”: circa un miliardo di token al mese gratis, endpoint https://api.mistral.ai/v1/chat/completions, schema identico a OpenAI. Ho iniziato con mistral-small-latest, poi sono passato a open-mistral-nemo quando il primo ha cominciato a restituirmi 502 — Service tier capacity exceeded con una certa insistenza. Tradotto: il free tier va a singhiozzo nelle ore di punta europee. Niente di drammatico, basta saperlo e scegliere il modello giusto come default.
Architettura – versione 1: solo chat
sequenceDiagram
autonumber
participant U as Browser
participant WP as WordPress REST<br/>/wp-json/ppv4/v1/chat
participant RL as Rate limiter<br/>(IP transient + day option)
participant M as Mistral API<br/>/v1/chat/completions
U->>WP: POST { message, history[] }
WP->>RL: check IP & global quota
alt limite raggiunto
RL-->>U: 429 (minute | day)
else ok
WP->>WP: build messages[]<br/>system + history + user
WP->>M: Bearer key + payload
M-->>WP: choices[0].message.content
WP-->>U: { reply }
U->>U: render bubble
end
Il pattern è il classico proxy: il browser non parla mai direttamente con Mistral, parla con un endpoint REST di WordPress che fa da intermediario. Così la chiave API non esce mai dal server.
A proposito di chiave: segue una priorità a tre livelli, costante in wp-config.php, poi option dal configuratore, poi option legacy. In questo modo non finisce né nel repo né, in chiaro, nel form admin.
function ppv4_llm_key() {
if ( defined( 'PPV4_MISTRAL_KEY' ) && PPV4_MISTRAL_KEY ) return PPV4_MISTRAL_KEY;
$s = ppv4_llm_settings();
if ( ! empty( $s['api_key'] ) ) return $s['api_key'];
return (string) get_option( 'ppv4_mistral_key', '' );
}
In admin la chiave viene mostrata mascherata (••••••••vtsj) e il sanitizer salta l’update se il valore contiene il carattere •. Traduzione per umani: se salvi il form senza ritoccare la chiave, non te la cancello. Sembra una banalità, ma è esattamente il tipo di banalità che ti rovina un pomeriggio quando manca.
Poi il rate limit, a due livelli: un transient per-IP che blocca lo script-kiddie di turno, e un counter giornaliero globale su una option che protegge la quota Mistral da me stesso, cioè da un bug del frontend che spara richieste in loop.
function ppv4_llm_rate_limited( $ip ) {
// per-IP: transient 1 minuto
$ip_key = 'ppv4_llm_ip_' . md5( $ip );
if ( (int) get_transient( $ip_key ) >= $rpm ) return 'minute';
set_transient( $ip_key, $count + 1, MINUTE_IN_SECONDS );
// globale: counter giornaliero su option
$day_key = 'ppv4_llm_day_' . gmdate( 'Ymd' );
if ( (int) get_option( $day_key, 0 ) >= $rpd ) return 'day';
update_option( $day_key, $day + 1, false );
return false;
}
Lato browser, l’esperienza è quella a cui ti ha abituato qualunque chat: la bolla utente parte subito, compaiono i tre puntini animati di “typing”, e quando arriva la risposta sostituisco la bolla typing col testo vero. Le quick-reply continuano a rispondere in locale, latency zero. E mando in history gli ultimi 6 messaggi, così il bot ha un minimo di memoria multi-turn e non si comporta come uno con dieci secondi di lucidità.
Funzionava. Ho fatto il test. E qui inizia la parte umiliante.
Quando il bot ha riscritto la mia biografia
Test da mobile, domanda innocente:
In 2 frasi, di cosa parla il libro “Vivere senza slot”?
Risposta del bot, con tono sicurissimo:
Il libro “Vivere senza slot” di Pietro Pace esplora come l’intelligenza artificiale sta cambiando il nostro modo di vivere e lavorare. L’autore offre una prospettiva umana e ironica sull’argomento.
Tutto sbagliato. Tutto. Il libro è del 2013, è un’opera collettiva (Collettivo Senza Slot), edito da nuovadimensione, e parla di gioco d’azzardo. L’intelligenza artificiale non c’entra assolutamente niente.
Cosa è successo: il modello non sapeva nulla del libro, quindi ha fatto l’unica cosa che un LLM sa fare quando non sa: ha completato in base agli indizi che aveva a disposizione, molto male. Vede “Pietro Pace”, vede un sito che parla di AI architecture, e ne deduce con allegra disinvoltura che anche il libro parli di AI. Mi ha cancellato anni di lavoro su un tema serio per sostituirlo con qualcosa che suonava almeno “plausibile”. Le allucinazioni sono fatte così: non sono errori rumorosi, sono bugie ben vestite, sono indiscrezioni (cit.).
Primo fix (v062). Ho iniettato nel system prompt le alcune FAQ che avevo scritto e scritte direttamente nel tema, marcandole quindi come “fatti verificati”, più qualche regola esplicita stile “non inventare, se non sai una cosa dillo”. Funziona. Ma è una toppa che non scala: il blog ha decine di post su argomenti abbastanza vari, non posso ficcarli tutti dentro un prompt e sperare bene.
Secondo fix (v063). Ho pensato che l’unica soluzione fosse un RAG vero.
La prima cosa che ho pensato è stata: come lo faccio spendendo il meno possibile e sfruttando al massimo quello che ho già?
E qui le cose si fanno interessanti.
Architettura – versione 2: con RAG
L’idea del RAG (Retrieval-Augmented Generation, sì, un altro acronimo) è meno esotica di quanto il nome lasci intendere: prima di rispondere, vai a pescare dai contenuti reali del sito i pezzi più rilevanti per la domanda, e li passi al modello come contesto. Il modello non deve più ricordare cosa c’è nel mio sito: gli arriva già pronto nel piatto.
Si divide in due fasi.
Fase 1 – indicizzazione (offline, quando salvo qualcosa)
flowchart LR
A[save_post hook] --> X{{ppv4_rag_index_source}}
B[update_option<br/>ppv4_home_settings] --> X
C[Admin button<br/>Ricostruisci indice ora] --> X
X --> D[Chunk text<br/>~700 char + 100 overlap]
D --> E[POST mistral-embed<br/>/v1/embeddings]
E --> F[Normalize L2<br/>pack 'f*']
F --> G[(wp_ppv4_rag<br/>chunk + embedding BLOB<br/>+ url + title)]
Ogni post viene spezzato in chunk, ogni chunk viene trasformato in un vettore di 1024 numeri (l’embedding) dal modello mistral-embed, e il tutto finisce in una tabella MySQL.
Niente di magico: è una libreria, e l’embedding è solo la sua collocazione sullo scaffale.
Fase 2 – query (a ogni messaggio in chat)
flowchart TB
Q[Messaggio utente] --> E1[Embed query<br/>Mistral /v1/embeddings]
E1 --> S[(SELECT * FROM wp_ppv4_rag<br/>~170 righe)]
S --> COS[Cosine similarity<br/>in PHP loop]
COS --> K[Top-K = 4<br/>score >= 0.45]
K --> CTX[Inject nel system prompt:<br/>## CONTESTO DAL SITO]
CTX --> CHAT[Mistral<br/>/v1/chat/completions]
CHAT --> R[Reply al browser]
Quando arrivi con una domanda, la trasformo anch’essa in embedding, la confronto con tutti i chunk in archivio, prendo i 4 più simili e li infilo nel system prompt sotto un’intestazione ## CONTESTO DAL SITO. Il modello risponde con quei pezzi davanti agli occhi. Se chiedi del libro Vivere senza slot, ora si ritrova nel contesto il testo vero della pagina del sito dedicata proprio a quel libro e smette di fare lo sceneggiatore da quattrosoldi (F4 cit.).
Le scelte di design (e perché non ho usato un vector DB)
Qui la tentazione era una sola: tirare su Pinecone o Qdrant, mettere “vector database” nel post e sentirmi un ingegnere serio. Non l’ho fatto, e per buoni motivi.
| Scelta | Perché |
|---|---|
| Tabella MySQL custom, non un vector DB | Il sito ha ~30 fonti e ~170 chunk. La cosine in PHP su array da 1024 float gira in ~5 ms. Pinecone & co. qui sono overkill: aggiungono un single point of failure, costano, e risolvono un problema di scala che semplicemente non ho. |
Embedding salvato come BLOB (pack('f*', …)) | 1024 float × 4 byte = 4 KB a chunk. Niente JSON da serializzare e parsare: l’unpack è una singola operazione. |
| Normalizzazione L2 all’inserimento | Se i vettori sono già normalizzati, la cosine similarity diventa un banale prodotto scalare: 1024 moltiplicazioni in un loop. Veloce e stupido, esattamente come l’esterno destro che piace a me. |
| Chunk ~700 char, overlap 100 | Sweet spot tra precisione e numero di chiamate. Taglio su \n\n o fine frase, per non spezzare le parole a metà. |
| Prefisso col titolo prima dell’embedding | "Titolo del post\n\nchunk..." ? la similarity matcha meglio quando la domanda cita il titolo. |
Reindex automatico su save_post | Costa una chiamata embed per post (in media ?10 chunk). La quota free regge senza fiatare. |
| min_score 0.45 / top-K 4 | Sotto 0.45 il chunk è probabilmente fuori tema e iniettarlo confonde il modello. 4 è il compromesso tra contesto ricco e budget di token del prompt. |
Lo schema della tabella, per i curiosi:
CREATE TABLE wp_ppv4_rag (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
source_type VARCHAR(20) NOT NULL, -- 'post' | 'page' | 'home'
source_id VARCHAR(64) NOT NULL,
chunk_idx INT NOT NULL DEFAULT 0,
url VARCHAR(255) NOT NULL DEFAULT '',
title VARCHAR(255) NOT NULL DEFAULT '',
chunk MEDIUMTEXT NOT NULL,
embedding LONGBLOB NOT NULL,
updated_at DATETIME NOT NULL,
KEY source (source_type, source_id)
);
Migrazione gestita con dbDelta() su hook init, idempotente grazie a una option di versione. WordPress questa roba la digerisce da almeno vent’anni (MALOX).
Il retrieval vero e proprio è un loop onesto: niente indici esotici, solo un SELECT * e una moltiplicazione.
function ppv4_rag_retrieve_context( $query ) {
if ( empty( ppv4_llm_settings()['rag_enabled'] ) ) return '';
$q = ppv4_rag_embed( array( $query ) )[0] ?? null;
if ( ! $q ) return '';
$rows = $wpdb->get_results( "SELECT id, url, title, chunk, embedding FROM {$table}", ARRAY_A );
$scored = array();
foreach ( $rows as $r ) {
$emb = ppv4_rag_unpack( $r['embedding'] );
$score = 0.0;
for ( $i = 0; $i < 1024; $i++ ) $score += $emb[$i] * $q[$i];
$scored[] = array( 'score' => $score, 'row' => $r );
}
usort( $scored, fn( $a, $b ) => $b['score'] <=> $a['score'] );
$top = array_filter(
array_slice( $scored, 0, 4 ),
fn( $x ) => $x['score'] >= 0.45
);
// Formatta in "## CONTESTO DAL SITO\n[1] Titolo — URL\nchunk...\n..."
}
E nel chat handler? Una riga in più. Una.
$system_prompt = ppv4_llm_system_prompt();
$ctx = ppv4_rag_retrieve_context( $message );
if ( '' !== $ctx ) $system_prompt .= "\n\n" . $ctx;
Tutta l’architettura di sopra, i diagrammi, le tabelle BLOB ecc.. alla fine si appoggiano a una concatenazione di stringhe. A volte l’ingegneria è più banale e semplice di come la disegniamo.
L’esperienza admin
Niente di tutto questo è utile se per cambiare la temperature devo aprire functions.php. Quindi ho aggiunto un tab “LLM” dentro il configuratore del tema, tutto in una schermata:
- API key mascherata, sostituita in silenzio solo se la cambi davvero
- Modello con datalist di suggerimenti
- System prompt override (vuoto = usa il default, mostrato come placeholder)
- Temperature, max tokens, history size
- Rate limit per-IP e globale
- Counter “Uso oggi”
- Toggle RAG + statistiche (chunk / fonti / data ultimo indice) + bottone “Ricostruisci indice ora”
- Un campo di test inline: scrivi, premi, parte un
fetchautenticato all’endpoint REST e vedi la risposta: comodissimo per validare la chiave senza nemmeno aprire la chat.
Il rebuild totale ha un permission_callback admin-only: fa N chiamate embed, e non voglio che il primo passante possa innescarmi un costo per sport.
Cosa ho imparato (la parte onesta)
- Il free tier di Mistral funziona per un sito personale. Tre giorni di uso, zero euro.
mistral-small-latestperò va in capacity exceeded più spesso: usaopen-mistral-nemocome default e dormi sereno. - Il system prompt FAQ-grounded è già un mini-RAG statico. Se hai un sito piccolo e una lista chiusa di domande, parti da lì prima di tirare su un indice vero. Non tutto deve essere montato su un’architettura complessa.
- Il RAG riduce le allucinazioni, non le elimina. Con
temperaturealta il modello continua a “interpretare” il contesto a modo suo. Per task informativi scendi a 0.2–0.3 e lascia perdere la creatività. - L’overhead di latenza del retrieval è invisibile. Una chiamata embed (~50 ms) + cosine in PHP (~5 ms) + una
SELECT(~1 ms). Sotto la soglia di percezione umana. Nessuno se ne accorge. - WordPress regge benissimo. REST API, tabella custom,
dbDelta, hooks: pane quotidiano. Niente Node, niente FastAPI, niente microservizi da babysittare. - Mascherare la chiave è doveroso ma fragile. Il trucco del “se contiene
•non aggiornare” è il minimo sindacale che funziona. La soluzione pulita resta la costante inwp-config.php: priorità più alta, mai esposta nel form. - Il rate limit a due livelli ti salva. Per-IP contro gli estranei, globale-giornaliero contro te stesso. La seconda categoria, statisticamente, fa più danni.
Cosa vorrei farei: next steps (cosa onnipresente nei miei ppt!)
- Chunking semantico vero, basato sugli heading H2/H3, invece della finestra fissa.
- Re-ranking: un secondo giro in cui chiedo al modello quali chunk usare davvero.
- Citazioni cliccabili: parsare i
[1]nelle risposte e linkarli al post di origine (adesso l’esperienza utente è molto mhe). - Cache degli embedding di query, per non rifare la stessa chiamata se mi fai due domande simili nella stessa sessione.
- Dump statico dell’indice, per una modalità “offline preview” del sito.
- Provare con modelli diversi, un domani dovessi diventare famoso… vorrei un pannello fatto meglio dove scegliere il modello giusto!
Stack finale
| Layer | Cosa | File / endpoint |
|---|---|---|
| Frontend chat | UI con typing dots, history client-side | site.js, site.css |
| Proxy LLM | Endpoint REST, rate limit, prompt builder | functions.php ? /wp-json/ppv4/v1/chat |
| Storage RAG | Tabella custom MySQL | wp_ppv4_rag |
| Indicizzazione | Chunker + embed + insert | hooks save_post, configuratore, admin button |
| Retrieval | Cosine in PHP, top-K injection | ppv4_rag_retrieve_context() |
| LLM provider | Mistral AI (chat + embed) | api.mistral.ai/v1/{chat/completions, embeddings} |
| Config | Tab “LLM” nel configuratore tema | ppv4_render_llm_settings_panel() |
Tempo totale: 3 sessioni circa. Codice nuovo: ~600 righe in functions.php, ~80 in site.js, ~20 in site.css. Praticamente tutto Vibe con Github Copilot (multi modello). Costo runtime: zero.
La chat in fondo alla home adesso è quella vera. Le fai una domanda sul libro e ti risponde che parla di gioco d’azzardo il che, lo so, è proprio solo il minimo. Ma fino a un qualche giorn fa mi dava del collega che scrive di AI, quindi io lo considero progresso. Meglio del ppt in pratica.