TL;DR: la ricerca interna di WordPress trova le parole, non i concetti. Da MariaDB 11.8 hai una colonna VECTOR e un indice nativi nello stesso database del sito, quindi la ricerca semantica non richiede più un servizio esterno. Costa pochi centesimi di embedding per un blog intero — gemini-embedding-001 sta a 0,15 $ per milione di token, 0,075 $ in batch — e tre trappole che ti fanno perdere il pomeriggio: l’ottimizzatore MariaDB usa l’indice vettoriale solo con una forma di query molto precisa, i vettori troncati di gemini-embedding-001 vanno normalizzati a mano e con il default euclidean di MariaDB non farlo restituisce i vicini sbagliati, e il giorno che cambi modello di embedding devi rigenerare tutto. Questa è la guida operativa, con lo schema, le query e il conto.
Il problema vero: chi cerca sul tuo sito non usa le tue parole
Prendi il caso tipico di un sito di consulenza o di uno studio professionale: quattrocento articoli, una pagina servizi, qualche caso studio. Il visitatore digita «il sito non carica su cellulare». Nel tuo blog c’è un pezzo che spiega esattamente quel problema, ma dentro si parla di Core Web Vitals, LCP e immagini non ottimizzate. La ricerca di WordPress cerca quelle parole nel titolo e nel contenuto, non trova «cellulare» accanto a «carica», e restituisce una pagina vuota o tre risultati fuori tema.
Quella pagina vuota è la fine di una visita che avevi già pagato in annunci, SEO o newsletter: il contenuto giusto esiste, ma il motore non sa che «lento su cellulare» e «LCP alto su mobile» sono la stessa domanda.
La ricerca semantica risolve esattamente questo e nient’altro: confronta il significato della query con quello dei documenti. Non è un chatbot, non genera risposte, non allucina — trasforma testi in vettori e ordina i contenuti per distanza da quello della domanda. Fino a poco tempo fa era anche l’intervento AI con l’infrastruttura più sproporzionata: un database vettoriale dedicato, da pagare e mantenere, per quattrocento articoli.
Cosa è cambiato: il vettore sta nel database del sito
MariaDB ha introdotto il tipo di dato VECTOR e l’indice vettoriale nella Community Server 11.7, una rolling release, e li ha portati in disponibilità generale con la 11.8, la prima release long-term support che li include. Su MariaDB Enterprise Server la ricerca vettoriale è stata portata indietro nella serie 11.4, dalla 11.4.5-3.
Il punto operativo non è la novità tecnica, è dove finisce il dato: nello stesso database che ospita wp_posts. Nessun servizio esterno, nessuna chiave in più, nessun secondo sistema da sincronizzare, e i vettori entrano nel backup insieme ai contenuti. È lì che le architetture «con un vector DB accanto» divergono: ripristini il backup di tre giorni fa e l’indice esterno continua a rispondere sui contenuti di oggi, senza che nessun errore te lo segnali.
Prima di scrivere una riga, apri phpMyAdmin o Adminer e lancia SELECT VERSION();. Dieci secondi che evitano di progettare qualcosa che il tuo server non può fare, e il margine è ampio: WordPress raccomanda PHP 8.3 e MariaDB 10.11 o MySQL 8.0, e continua a girare su PHP 7.4 e MySQL 5.5.5 — versioni fuori supporto, ma vive. Un hosting perfettamente in linea con le raccomandazioni può quindi essere lontanissimo dalla 11.8, e uno «che funziona» può essere di due generazioni indietro. Se usi MySQL, verifica la documentazione della tua versione specifica prima di dare per scontata una colonna vettoriale.
Il conto, prima del codice
Il costo di una ricerca semantica è quasi tutto lavoro tuo, non fattura del fornitore. Faccio il conto con numeri espliciti, così puoi rifarlo con i tuoi.
Ipotesi: 400 contenuti fra articoli e pagine, lunghezza media 1.500 parole, quindi circa 600.000 parole. In italiano un tokenizer moderno consuma indicativamente 1,5 token per parola — è una stima, non un dato di targa, e va verificata sul tuo corpus contando i token restituiti dall’API. Con quel rapporto sei intorno a 900.000 token per indicizzare tutto l’archivio.
La pagina prezzi dell’API Gemini mette gemini-embedding-001 a 0,15 $ per milione di token in input sul tier a pagamento, e a 0,075 $ con la Batch API. Il modello multimodale gemini-embedding-2 costa 0,20 $ per milione sul testo, 0,10 $ in batch. Fai la moltiplicazione: l’indicizzazione iniziale del tuo archivio costa circa 13 centesimi, meno di sette in batch. Sul tier gratuito è zero, con i limiti di frequenza del tier gratuito.
Le query costano ancora meno: una ricerca sono cinque o dieci parole. Se valuti questo progetto guardando la voce «API» del preventivo stai guardando la colonna sbagliata — il costo reale è il tuo tempo. Per lo stesso motivo l’indicizzazione iniziale va fatta in batch, come tutte le elaborazioni AI non interattive su WordPress.
Lo schema minimo, e i due campi che sembrano superflui
Una tabella custom, non i post meta: un vettore da 1.536 dimensioni serializzato in un meta_value è un modo elegante di rendere la ricerca inutilizzabile e wp_postmeta ingestibile.
CREATE TABLE wp_lr_embeddings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_id BIGINT UNSIGNED NOT NULL,
anchor VARCHAR(191) NOT NULL DEFAULT '',
model VARCHAR(64) NOT NULL,
content_hash CHAR(64) NOT NULL,
updated_at DATETIME NOT NULL,
embedding VECTOR(1536) NOT NULL,
UNIQUE KEY post_anchor (post_id, anchor),
VECTOR INDEX (embedding) M=8 DISTANCE=cosine
);
La parte vettoriale è quella descritta dalla documentazione MariaDB: tipo VECTOR(n), clausola VECTOR INDEX, distanza euclidean (default) o cosine, parametro M fra 3 e 200 — più alto significa indice più grande, SELECT e INSERT più lenti, risultati più accurati.
I due campi che sembrano burocrazia sono quelli che ti salvano dopo:
model: dice con cosa è stato calcolato ogni vettore. Senza, il giorno del cambio modello hai una tabella di numeri di provenienza ignota e nessun modo di sapere quali righe sono già state rifatte.content_hash: SHA-256 del testo che hai effettivamente mandato all’API. Se l’articolo viene salvato per correggere una virgola, l’hash non cambia e non spendi una chiamata. È la stessa logica con cui si evitano le richieste AI duplicate, applicata in scrittura.
La chiave primaria è surrogata e l’unicità sta su (post_id, anchor): anchor vuoto significa «un vettore per tutto il post», e il giorno che ti servirà spezzare le guide lunghe per h2 aggiungerai più righe per lo stesso post senza rifare la chiave primaria di una tabella già popolata.
Cosa mandare all’API: titolo, estratto e i primi due o tremila caratteri del corpo, non l’articolo intero. Un vettore per documento è una media, e più testo ci infili più quella media diventa generica. Il chunking per sezione è il passo successivo, non il primo.
Il vettore, in MariaDB, non è un array JSON: la colonna contiene float a 32 bit e si scrive con VEC_FromText() passando la rappresentazione testuale dell’array che ti restituisce l’API. Con il vincolo di unicità appena definito, l’upsert del worker è una riga:
REPLACE INTO wp_lr_embeddings
(post_id, anchor, model, content_hash, updated_at, embedding)
VALUES (?, '', 'gemini-embedding-001', ?, NOW(), VEC_FromText(?));
Il terzo segnaposto riceve una stringa come [0.0182,-0.0447,…], cioè wp_json_encode() del vettore normalizzato — sul perché «normalizzato» torno nella sezione sull’indicizzazione, ed è il passaggio che si dimentica più spesso. Con REPLACE la riga in conflitto viene cancellata e reinserita, quindi l’id cambia a ogni reindicizzazione: se ti serve stabile, usa INSERT … ON DUPLICATE KEY UPDATE.
La query che usa l’indice, e le due che lo buttano via
La documentazione MariaDB è esplicita al riguardo, e ignorarla è il modo più comune di ritrovarsi con una ricerca semantica corretta nei risultati e inutilizzabile nei tempi. L’ottimizzatore usa l’indice vettoriale solo quando l’ORDER BY è la chiamata letterale VEC_DISTANCE_*(colonna, vettore) — o il suo alias — in ordine crescente, insieme a un LIMIT. La forma giusta, con il vettore della query in una variabile di sessione per leggibilità:
SET @q = VEC_FromText('[0.0141,-0.0392, ... ]');
SELECT post_id, VEC_DISTANCE_COSINE(embedding, @q) AS distance
FROM wp_lr_embeddings
ORDER BY distance
LIMIT 10;
La variabile di sessione è solo per leggibilità: vive nella connessione, quindi se fra il SET e la SELECT il client si riconnette @q torna NULL e ottieni distanze NULL senza nessun errore. In produzione passa il vettore direttamente nella prepare.
Due schemi molto naturali fanno cadere tutto in una scansione completa della tabella.
Primo: avvolgere la distanza in un’espressione. Vuoi mostrare un punteggio di similarità invece di una distanza, scrivi ORDER BY 1 - VEC_DISTANCE_COSINE(...) e hai appena disattivato l’indice. Il punteggio si calcola in una query esterna, tenendo l’ORDER BY interno sulla distanza nuda:
SELECT t.post_id, 1 - t.distance AS score
FROM (
SELECT post_id, VEC_DISTANCE_COSINE(embedding, @q) AS distance
FROM wp_lr_embeddings
ORDER BY distance
LIMIT 10
) AS t
ORDER BY score DESC;
Secondo, e più insidioso: filtrare per soglia. Un WHERE VEC_DISTANCE(...) < 0.5 senza ORDER BY ... LIMIT è un predicato di intervallo che l’indice non può guidare: scansione completa. La forma corretta prende un top-K dall’indice e applica la soglia sopra:
SELECT t.post_id, t.distance FROM (
SELECT post_id, VEC_DISTANCE_COSINE(embedding, @q) AS distance
FROM wp_lr_embeddings
ORDER BY distance
LIMIT 100
) AS t
WHERE t.distance < 0.5
ORDER BY t.distance;
E qui c’è il dettaglio che la documentazione segnala e che in produzione morde: quel LIMIT è un tetto rigido su quante righe l’indice restituisce prima che la soglia venga applicata. Se le righe che soddisfano la soglia sono più del limite, le eccedenti vengono scartate in silenzio. Nessun errore, nessun avviso: risultati mancanti. Scegli il LIMIT con generosità e verifica il piano con EXPLAIN invece di fidarti.
Ultima nota utile: l’indice va interrogato con la funzione di distanza con cui è stato costruito — VEC_DISTANCE_EUCLIDEAN per euclidean, VEC_DISTANCE_COSINE per cosine, o la generica VEC_DISTANCE che si adatta al tipo di indice. Il prodotto scalare non c’è, ed è una scelta dichiarata: non è una vera misura di distanza (il match più vicino di un vettore non è necessariamente lui stesso) e altrove viene usato solo per velocità.
Indicizzare senza mettere l’editor in attesa
La tentazione è agganciare save_post, chiamare l’API dentro l’hook e chiudere la pratica. Non farlo: metti una chiamata di rete con latenza variabile dentro il salvataggio di un articolo, e il giorno che il fornitore risponde in cinque secondi il tuo cliente vede la rotella e ti scrive che «WordPress si è rotto».
Lo schema che regge è a coda:
- Su
save_postcalcoli l’hash del testo. Se coincide con quello in tabella, esci subito. - Se è diverso, marchi il post come «da reindicizzare» — una riga in tabella o un job in Action Scheduler, che su un sito con WooCommerce hai già installato.
- Un worker asincrono processa la coda a blocchi, chiama l’API, scrive il vettore. Gestisce il fallimento: riprova con backoff, e dopo n tentativi lascia il post senza vettore invece di riprovare in eterno.
- La ricerca frontend tratta l’assenza di vettore come un caso normale, non come un errore.
Anche la ricerca dell’utente va trasformata in vettore, quindi ogni ricerca è una chiamata API dentro la richiesta HTTP. Due accorgimenti obbligatori: timeout aggressivo (500-800 ms) con fallback immediato alla ricerca nativa di WordPress, e una cache delle query in un transient. Quanto convenga la cache lo dice la lista delle venti ricerche più frequenti che stai già registrando (vedi più sotto): coda corta e concentrata, la cache ti toglie quasi tutte le chiamate; coda lunga e piatta, smorza solo i picchi. È una misura, non un’assunzione. La chiave dell’API sta in wp-config.php, non nella tabella delle opzioni: il database lo copiano tutti.
Un dettaglio che alza la qualità a costo zero su gemini-embedding-001: il parametro task_type. Documenti con RETRIEVAL_DOCUMENT, query con RETRIEVAL_QUERY — sono due lati asimmetrici della stessa operazione e il modello lo sa. Su gemini-embedding-2 quel parametro non esiste: le istruzioni di task vanno nel prompt (task: search result | query: ..., title: ... | text: ...).
E qui arriva il passaggio che si dimentica, quello a cui rimandavo prima. Se chiedi meno di 3.072 dimensioni con output_dimensionality, il vettore troncato non è normalizzato: la documentazione Gemini scrive che con gemini-embedding-001 la normalizzazione va fatta a mano per qualsiasi dimensione diversa da 3.072, mentre gemini-embedding-2 rinormalizza da solo i vettori troncati. Su 001, solo il default a 3.072 dimensioni è sempre normalizzato.
Tradotto in codice del worker: prima di scrivere in tabella, dividi ogni componente per la norma euclidea del vettore.
$norm = sqrt( array_sum( array_map( fn( $v ) => $v * $v, $values ) ) );
if ( $norm > 0 ) {
$values = array_map( fn( $v ) => $v / $norm, $values );
}
Saltare questo passaggio non produce un errore, e quanto costa dipende dalla distanza che hai scelto. Con la distanza euclidea — il default di MariaDB, cioè quello che ti ritrovi se non scrivi DISTANCE=cosine — la lunghezza del vettore entra nel conto e i vicini restituiti sono sbagliati, senza che niente te lo segnali. Con la cosinusoidale non cambia nulla: è invariante di scala, e la documentazione MariaDB la definisce infatti fra vettori «not necessarily normalized». Normalizzi comunque, per tre ragioni: il default è euclideo e un ALTER o un collega distratto ti ci riportano, i vettori possono finire riusati altrove con il prodotto scalare, ed è la regola che il fornitore dichiara. Se preferisci non pensarci: 3.072 dimensioni e 12 KB per riga, oppure gemini-embedding-2, che se ne occupa lui.
Il costo che nessuno mette nel preventivo: cambiare modello
La documentazione Gemini lo dice senza giri di parole: gli spazi di embedding di gemini-embedding-001 e gemini-embedding-2 sono incompatibili. Non puoi confrontare un vettore prodotto da uno con un vettore prodotto dall’altro, e se migri devi rigenerare tutti i vettori esistenti. Non è una particolarità di Google: è come funzionano gli embedding. Ogni modello ha il suo spazio, e i numeri di due spazi diversi non si parlano.
La mia take, dopo aver visto abbastanza integrazioni AI andare in manutenzione: la colonna VECTOR non è la parte difficile di questo lavoro. La parte difficile è che l’indice non è tuo, è del modello. Il giorno che il fornitore ritira il modello, o che ne esce uno migliore, l’indice non vale più niente e la reindicizzazione completa è l’unica strada. Per questo model e content_hash stanno nello schema dal primo giorno: con quei due campi la migrazione è una query che ti dice quante righe sono da rifare (WHERE model <> 'nuovo-modello') e un worker che le rifà a batch mentre il sito continua a rispondere sui vecchi vettori. Senza, hai una tabella di float e nessun modo di sapere a metà migrazione dove eri arrivato.
Nota di scelta: gli esempi usano gemini-embedding-001 perché è il modello con task_type esplicito e con il codice in circolazione. Se parti oggi da zero valuta gemini-embedding-2: sull’archivio dell’esempio costa 5 centesimi in più a listino, due in batch, normalizza da solo i vettori troncati e ti toglie una delle tre trappole di questa guida.
Conseguenza pratica sulla scelta delle dimensioni. Entrambi i modelli Gemini emettono 3.072 dimensioni per default e sono addestrati con la tecnica Matryoshka, quindi il vettore si può troncare a una lunghezza minore senza perdere qualità in modo significativo — a patto di normalizzarlo, come sopra. La documentazione consiglia 768, 1.536 o 3.072. In una tabella MariaDB questa non è una scelta estetica: 3.072 float a 32 bit occupano 12.288 byte per riga contro i 6.144 di 1.536, e la dimensione è scritta nella definizione della colonna, quindi cambiarla è un ALTER TABLE con reindicizzazione completa. Scegli 1.536 in modo consapevole ora, non per caso.
Come capisci se è servita a qualcosa
Una ricerca semantica che «sembra più intelligente» non è un risultato. Prima di toccare il codice, registra tre numeri per due settimane — bastano una tabella e un hook sulla ricerca:
- Ricerche a zero risultati, in percentuale: la metrica principale, quella che la ricerca semantica dovrebbe schiacciare.
- Ricerche seguite da un clic su un risultato. Se cercano e non cliccano, i risultati sono fuori tema anche quando ci sono.
- Le venti query più frequenti, testuali. È anche il tuo piano editoriale gratuito: le domande del tuo pubblico, con le sue parole.
Poi accendi la ricerca semantica dietro un flag e confronta le stesse tre metriche. Se la quota di zero risultati non scende, il problema non era il motore: è che i contenuti cercati non li hai. È un esito possibile, e va saputo prima di scrivere il worker.
Cosa non fare
Due errori che ho visto costare più del progetto stesso.
Sostituire la ricerca con un chatbot. Sono due prodotti diversi: la ricerca porta a una pagina tua, già indicizzata, che il visitatore può salvare e condividere. Il chatbot genera testo, sposta il rischio sul contenuto e appesantisce la pagina. Se l’obiettivo è far trovare le cose, la ricerca semantica ha meno superficie di attacco e meno costi ricorrenti.
Installare un plugin «AI search» senza guardare dove finiscono i contenuti. Molti mandano tutto l’archivio a un servizio terzo e ci tengono l’indice: legittimo, ma la domanda diventa contrattuale — chi tratta i dati, dove, per quanto tempo. Se volevi il vettore in casa per una questione di controllo, il plugin non risolve il problema: lo sposta e lo nasconde.
Cosa fare, in ordine
SELECT VERSION();sul database del sito. Se non sei su MariaDB 11.8 o superiore (o Enterprise 11.4.5-3), fermati qui: parla con l’hosting o rimanda.- Registra le ricerche interne per due settimane: zero risultati, clic, top 20 query.
- Crea la tabella con
model,content_hashe la chiave unica su(post_id, anchor). Tre righe di schema in più oggi, una migrazione possibile domani. - Indicizza l’archivio in batch, non in tempo reale.
- Scrivi la query nella forma che usa l’indice, e verifica con
EXPLAINche lo stia usando davvero. - Accendila dietro un flag, con timeout e fallback alla ricerca nativa: un fornitore lento non deve diventare il tuo tempo di risposta.
- Confronta le tre metriche. Se non migliorano, il problema era editoriale.
La ricerca semantica è probabilmente l’intervento AI con il miglior rapporto fra utilità e rischio che puoi fare su WordPress oggi: nessun testo generato, nessuna allucinazione possibile, un effetto misurabile su un comportamento reale. Ora che il vettore sta nel database dei contenuti, l’alibi dell’infrastruttura troppo grande è caduto. Resta il lavoro, che è dove è sempre stato.
Fonti
- MariaDB — Vector Overview: disponibilità da Community Server 11.7 e GA da 11.8, Enterprise 11.4.5-3, sintassi
VECTOR/VECTOR INDEX, funzioni di distanza, uso efficiente dell’indice e trappole diORDER BY/soglia. - Google — Gemini API, Embeddings: incompatibilità fra gli spazi di
gemini-embedding-001egemini-embedding-2, dimensioni di default e troncamento Matryoshka,task_typee istruzioni di task. - Google — Gemini API, prezzi: 0,15 $ e 0,075 $ per milione di token per
gemini-embedding-001, 0,20 $ e 0,10 $ pergemini-embedding-2sul testo. - WordPress.org — Requirements: PHP 8.3 o superiore, MariaDB 10.11 o superiore oppure MySQL 8.0 o superiore.

