
Agosto 2025, audit di stack su una community fan italiana verticale TV/serie, cinque anni di contenuti tra articoli redazionali e UGC, decine di migliaia di pezzi pubblicati e una mole di commenti che cresceva ogni stagione di messa in onda. Il sito girava su WordPress con un tema custom maturo, ma due cose non scalavano più: la performance mobile (LCP p75 attorno ai 5 secondi) e la search interna (default WordPress, full-text basico, gli utenti trovavano solo se sapevano già il titolo). Sei settimane di lavoro su due tracce parallele: ottimizzazione LCP classica e sostituzione search con Typesense + un layer di ricerca semantica via embeddings locali. Risultati: LCP mobile p75 a ~210ms, recall search su query concettuali da ~30% a ~85%. Vedi anche gli altri post di questa rubrica: Fieldwork.
Il cliente, anonimizzato
Una community fan italiana verticale TV/serie, cinque anni di vita pubblica, audience verticale solida, redazione mista: una piccola redazione professionale che produce schede e recensioni, e una base di utenti registrati che produce UGC (commenti, microrecensioni, thread di discussione episodio per episodio). Stack di partenza: WordPress su VM dedicata, MariaDB sullo stesso nodo, nessun Redis object cache, nginx davanti senza fastcgi cache, immagini servite in formati misti (JPEG/PNG, sporadicamente WebP), font caricati da CDN esterna con font-display: swap ma senza preload critico. Il nome non lo scriviamo: in Fieldwork il cliente lo citiamo solo con consenso esplicito scritto. Qui parliamo del lavoro.
Il problema, in sintesi
La frase del committente, parafrasata in fase di brief: “il sito è lento e la search trova solo le parole esatte. Gli utenti cercano riassumendo, non con i titoli”. Sotto la frase due dolori operativi distinti.
Dolore performance. Lighthouse field data lato mobile mostrava LCP p75 oltre i 5 secondi, page weight della home oltre i 4 MB, font Web caricati senza preload, immagini hero senza dimensioni esplicite (CLS p75 attorno a 0,18). Il TTFB mobile p75 era 1,4s: il backend stava sotto carico costante di crawler e bot, il MySQL lavorava su query non indicizzate del tema custom.
Dolore search. La search interna usava s= di WordPress, full-text base. Il pattern utente era: “episodio dove muore [personaggio]”, “stagione finale spoiler”. Risposta: zero risultati o risultati irrilevanti. Lo stesso utente apriva Google e cercava site:nostrodominio.it ..., perdendo il sito come strumento di scoperta dei suoi stessi contenuti. Il committente aveva già provato un plugin search di terze parti, scartato per qualità dei risultati e per costo unitario delle query.
L’output dell’audit era un piano a due tracce parallele, non un piano sequenziale: la performance e la search hanno team e risorse non sovrapposte, lasciarle in serie significava raddoppiare il calendario senza guadagno.
Traccia A: LCP optimization classica
Niente magia, igiene rigorosa.
Preload critico. La hero image della home e l’immagine featured della top story della categoria principale entrano in <link rel="preload" as="image"> direttamente nel <head>, generato server-side dal tema. Per le pagine articolo, l’immagine featured è preload con fetchpriority="high", le successive lazy nativo (loading="lazy").
Immagini WebP/AVIF + lazy nativo + dimensioni esplicite. Pipeline di conversione asincrona via Horizon: ogni nuova upload genera 4 varianti (AVIF, WebP, JPEG ottimizzato, fallback originale). Il template emette <picture> con sources ordinati AVIF → WebP → JPEG. Tutte le <img> con width e height espliciti, calcolati dal media manager al momento dell’upload. CLS sceso da 0,18 a 0,02.
Font system-stack. Eliminata la dipendenza dal CDN font esterno, font system-stack (system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial). Una scelta editoriale che il committente ha approvato dopo un test A/B di una settimana sul bounce rate (nessuna differenza statistica). Risparmio: due richieste blocking eliminate, ~120 KB su mobile in meno.
JS deferito. Tutti gli script non critici (analytics, social embed, consensi banner) caricati con defer e su requestIdleCallback dove possibile. Lo script del consenso banner riscritto in vanilla JS leggero (~3 KB minified) per non bloccare il render.
Cache layer. nginx fastcgi cache abilitato sul container WordPress: TTL 60s sugli archivi, bypass su cookie loggato, purge automatica via webhook su pubblicazione/edit. Redis object cache attivato per le query MySQL ricorrenti del tema. TTFB p75 sceso da 1,4s a 280ms in due settimane.
Traccia B: ricerca semantica via embeddings locali
La parte differenziante del progetto.
Architettura. Typesense come full-text engine canonico, una collection articles con i campi standard (title, body, excerpt, tags, author, date, url) e un campo aggiuntivo embedding di tipo float[] con index knn (cosine similarity). Davanti a Typesense un microservizio FastAPI in container Docker, esposto solo sulla rete privata del cliente, che calcola gli embeddings e li scrive nella collection.
Modello. Un sentence-transformer multilingue locale, modello open-source orientato a italiano + altre lingue romanze, dimensione vettore 768. Il modello gira sulla VM del microservizio (CPU only, nessuna GPU richiesta per la dimensione del corpus). Inferenza singola attorno ai 40-80ms per pezzo di lunghezza media: accettabile per indicizzazione in batch e per query a runtime.
Pipeline indicizzazione. Backfill in batch sulla coda Horizon: per ogni articolo, il microservizio chunka il body in segmenti di ~512 token, calcola un embedding per segmento, fa pooling medio sul documento, scrive il vettore in Typesense via REST. Backfill iniziale di tutto il corpus in 38 ore di compute, costo totale ~28 € (energia VM, modello locale, niente API esterne).
Query a runtime. La search frontend invia la query al backend Laravel; Laravel chiama il microservizio per calcolare l’embedding della query (~40ms), poi compone una richiesta Typesense con search ibrida: full-text classico (boost titolo 3x, tag 2x) + knn sull’embedding (k=50, alpha 0.5 sul mix con BM25). Risposta in ~80ms p95 sulla home page di search.
UI di feedback. Risultati raggruppati in tre lane: “match esatto” (BM25 alto), “concettuale” (knn alto, BM25 basso), “correlati” (knn medio). Permette all’utente di capire perché un risultato è apparso quando non contiene le parole della query. Il committente ha riportato un aumento netto di click su “concettuale” sulle settimane successive.
Cosa abbiamo scartato
Due alternative valutate e tagliate.
Full migration a Next.js. Per un blog comunitario maturo, costo non giustificato: rifare il tema, riformare la redazione sul nuovo CMS headless, gestire il SEO della migrazione. La performance ottenibile con WordPress + nginx fastcgi + igiene di rendering era già sufficiente. Il costo opportunità andava sulla search semantica, dove il delta percepito dall’utente è molto più alto.
Embedding via API esterne. Un provider commerciale di embeddings ridurrebbe il setup operativo, ma due controindicazioni: costo unitario alto sui volumi del backfill iniziale (~10x rispetto al locale ammortizzato), e privacy del corpus (commenti UGC, anche pubblici, restano dati del cliente, esposizione minima a terzi). Il modello locale ha vinto per autonomia e governance interna.
I numeri
| Metrica | Prima | Dopo |
|---|---|---|
| LCP mobile p75 | 5,0 s | ~210 ms |
| TTFB p75 (mobile) | 1,4 s | 280 ms |
| CLS p75 | 0,18 | 0,02 |
| Page weight homepage | 4,1 MB | 1,1 MB |
| Recall search su query concettuali (test set 200 query) | ~30% | ~85% |
| Tempo medio query Typesense p95 | n/a | ~80 ms |
| Costo embedding backfill (totale, una tantum) | n/a | ~28 € |
| Durata progetto | n/a | 6 settimane |
| Persone Romiltec coinvolte | n/a | 2 (un senior dev, Rocco sul DevOps e architettura) |
Il test set di 200 query “concettuali” è stato costruito con il committente: query reali estratte dai log di search degli ultimi 90 giorni che avevano dato zero o pochi risultati con il sistema legacy, validate manualmente con il risultato atteso da un editor della redazione. Recall calcolato su top-10 dei risultati restituiti.
Voce del cliente
“Il dato che mi colpisce non è la velocità, anche se la velocità si sente. È che gli utenti adesso trovano cose che prima non riuscivano a trovare. Lo vedo dai log: la query media è più lunga, le sessioni di search sono più approfondite, la pagina dei risultati ha un click-through più alto. Una redattrice mi ha detto che ha cercato un suo pezzo di tre anni fa con una frase a memoria e l’ha trovato al primo tentativo. Sul vecchio sito non sarebbe successo. La community sta scoprendo il proprio archivio.” — il referente di prodotto del cliente.
Cosa stiamo costruendo dopo
Due estensioni nel piano del trimestre successivo al go-live.
Filtro temporale per stagione/episodio. Le serie TV hanno una struttura cronologica naturale (stagione, episodio, data di messa in onda). Aggiungere campi facet su Typesense per stringere i risultati a “stagione 4”, “episodio finale”, “ultima settimana” e combinare con la query semantica. Lavoro a basso rischio, valore alto per la community.
Alerting su contenuti stale. Un job notturno calcola la similarity tra le top 100 query di search del giorno e gli articoli esistenti: se la similarity media è bassa, segnala alla redazione una “buca di copertura” da riempire. La search diventa input editoriale.
Box laterale
Stack: WordPress su VM dedicata, nginx fastcgi cache + Redis object cache, Typesense (full-text + knn su embedding), microservizio FastAPI in container Docker con sentence-transformer multilingue locale (CPU-only, dimensione vettore 768), Horizon su Redis per la pipeline di indicizzazione, AI Multisite come piattaforma orchestrante.
Durata progetto: 6 settimane (audit, due tracce in parallelo, cutover progressivo, monitor 30 giorni).
Team Romiltec: 1 senior dev sul codice (sia tema sia microservizio embedding), Rocco sul DevOps e architettura. Lato cliente: 1 referente di prodotto, 1 redattrice senior sul test set di validazione search.
