Elasticsearch in produzione per Laravel: ricerca full-text su cataloghi di grandi dimensioni

Elasticsearch in produzione per Laravel: ricerca full-text su cataloghi di grandi dimensioni

Il 7 febbraio 2025 mi ha chiamato il responsabile IT di un distributore B2B padovano di ricambistica industriale - fatturato annuo di circa 14 milioni di euro, circa 900 rivenditori autenticati sulla piattaforma e-commerce proprietaria, un catalogo di 207.000 codici articolo distribuiti su 18 categorie tecniche e circa 4.500 sottocategorie. La piattaforma era Laravel 10 con MySQL 8.0, ospitata su un server Hetzner AX52 dedicato con configurazione standard LEMP. Il problema era concreto e misurabile: la ricerca nel catalogo - una funzione centrale per un buyer industriale che deve trovare un codice ricambio specifico fra duecentomila - impiegava mediamente 8 secondi per restituire risultati, con picchi oltre i 14 secondi per ricerche multi-parola e frequenti timeout HTTP 504 sotto carico nelle ore di punta del mattino (dalle 9:30 alle 11:30). Il team interno aveva già provato di tutto: aggiunta di indici fulltext MySQL, caching aggressivo via Redis, riscrittura del controller di ricerca con query builder ottimizzato. Nessuno di questi interventi aveva prodotto miglioramenti sostanziali, perché il problema era strutturale - MySQL fulltext con operatore LIKE '%parola%' non scala a catalogi di questa dimensione.

La prima proposta che il responsabile IT aveva ricevuto dal suo fornitore cloud principale era di migrare l'intero stack verso servizi AWS managed - Aurora MySQL per il database, OpenSearch per la ricerca, CloudFront per la CDN - con un preventivo totale di 68.000 euro di lavoro progettuale più 2.400 euro al mese di costi infrastrutturali ricorrenti, che equivalevano a quasi un quadruplicamento della bolletta cloud esistente. Mi ha contattato per un secondo parere prima di firmare un cambio di paradigma infrastrutturale di questa dimensione. Dopo una valutazione di quattro ore sull'architettura esistente, la diagnosi è stata chiara: il problema non era lo stack in sé, era esclusivamente la funzionalità di ricerca. Tutto il resto dell'applicazione girava bene sull'hardware esistente. In otto giornate di lavoro distribuite in quattro settimane abbiamo integrato un'istanza Elasticsearch 8.12 dedicata sullo stesso server esistente (aggiungendo 32 GB di RAM al noleggio, costo incrementale di 30 euro al mese), riscritto il layer di ricerca di Laravel con Laravel Scout e un driver custom Elasticsearch ottimizzato per il dominio specifico, costruito una pipeline di sincronizzazione incrementale che tiene allineato l'indice con il database in tempo reale. Il tempo medio di ricerca è sceso da 8 secondi a 40 millisecondi. La rilevanza dei risultati, misurata con una batteria di 150 query di test costruite dai buyer interni dell'azienda, è migliorata di un ordine di grandezza. Il costo totale dell'intervento è stato 6.800 euro consulenziali più 30 euro al mese incrementali - contro i 68.000 euro più 2.400/mese dell'alternativa AWS managed.

Questo articolo è il playbook operativo per integrare Elasticsearch con Laravel in contesti PMI italiane di medie dimensioni, basato sull'esperienza di circa 15 progetti simili negli ultimi sei anni. Il principio guida che applico sempre: Elasticsearch non è uno strumento generico per "farmi dare cose dal database più velocemente" - è uno strumento specializzato per ricerca full-text, relevance ranking e aggregazioni su grandi volumi di testo non strutturato. Applicato nel suo dominio corretto, è straordinario. Applicato fuori dominio (come replacement generico del database, come storage primario, come fonte di dati per dashboard operativi), genera più problemi di quanti ne risolva. La differenza la fa saper riconoscere quando serve davvero.

Perché MySQL fulltext smette di scalare su cataloghi oltre i 100.000 record per PMI italiane?

MySQL offre da decenni la funzionalità FULLTEXT INDEX per ricerca testuale su colonne VARCHAR e TEXT, documentata nel manuale ufficiale e molto usata nelle PMI italiane per ricerche relativamente semplici. Il limite strutturale di questa tecnologia emerge quando il catalogo supera i 100.000-200.000 record e le query di ricerca diventano multi-parola con ranking di rilevanza: l'indice fulltext MySQL non è progettato per calcolare scoring TF-IDF sofisticati, non supporta nativamente sinonimi multilingue, non gestisce in modo elegante l'analisi morfologica (stemming, lemmatizzazione, rimozione di stopword), non permette facet search né aggregazioni su metadata. Il risultato è che ogni query non banale richiede query MySQL sempre più complesse, con subquery e UNION multipli, e i tempi di risposta crescono linearmente con la dimensione del dataset.

Nel caso del cliente padovano, il codice originale del controller di ricerca era una query MySQL raw di circa 60 righe con tre LEFT JOIN, due subquery correlate per scoring manuale, e un LIMIT 50 OFFSET $offset per la paginazione. Su 207.000 prodotti, questa query impiegava circa 8.1 secondi medi, con EXPLAIN che mostrava full table scan su due delle tre tabelle coinvolte nonostante la presenza di indici fulltext. Il vero problema non era la query in sé - era che il buyer industriale cerca tipicamente stringhe come "o-ring NBR 70 shore A diametro 38mm", cinque concetti semantici che MySQL trattava come sei parole separate da cercare in LIKE '%parola%' indipendenti, senza comprensione del contesto. Un o-ring 38 NBR non ritornava risultati coerenti perché nessun prodotto aveva esattamente quella sequenza di parole consecutive nel nome - tutti i prodotti pertinenti avevano i termini distribuiti fra nome, codice, descrizione, attributi tecnici.

Elasticsearch risolve questi problemi non perché "è più veloce di MySQL", ma perché è un sistema diverso progettato per l'obiettivo specifico della ricerca full-text con ranking. Internamente costruisce indici inverted dove ogni termine del vocabolario punta alla lista di documenti che lo contengono con relative posizioni e frequenze, applica analisi morfologica configurabile al momento dell'indicizzazione, calcola scoring TF-IDF o BM25 con parametri tarabili, e permette aggregazioni e faceting in tempo reale. La documentazione ufficiale di Elastic sulla funzionalità full-text search descrive in dettaglio i tipi di query disponibili (match, multi_match, query_string, bool, dis_max) e quando usare ciascuno. Per un buyer industriale che cerca "o-ring NBR 70 shore A diametro 38mm", una query multi_match con boosting differenziato su nome prodotto (peso 3), codice articolo (peso 2), descrizione tecnica (peso 1), e attributi strutturati separati, restituisce in meno di 50 ms i 20 prodotti più rilevanti anche su catalogo di 200.000 record - un ordine di grandezza che MySQL fulltext non può avvicinare.

Se stai valutando un progetto di integrazione di ricerca full-text avanzata sul tuo catalogo e vuoi un'analisi preliminare indipendente che distingua fra soluzioni autogestite e offerte cloud managed, nel mio profilo professionale trovi il dettaglio dei progetti Elasticsearch che ho condotto in contesti PMI italiane, sempre con valutazione onesta del trade-off fra ROI reale e costo architetturale.

Architettura di integrazione Elasticsearch con Laravel: Scout come orchestratore di ricerca

Laravel offre nativamente un'astrazione per la ricerca full-text chiamata Laravel Scout, un pacchetto ufficiale mantenuto dal core team che si posiziona come interfaccia comune verso diversi motori di ricerca (Algolia, Meilisearch, database MySQL nativo, e con driver community Elasticsearch e OpenSearch). La documentazione ufficiale di Laravel Scout copre in dettaglio l'API del componente e i driver supportati. Nei miei progetti, preferisco usare Scout come orchestratore applicativo e scrivere un driver custom per Elasticsearch che sfrutti appieno le feature del motore, invece di usare i driver community che spesso sono forche abbandonate di versioni datate. Il pattern architetturale è questo: Scout espone un'API idiomatica Laravel (Product::search('o-ring NBR 70')->paginate(20)), il driver Elasticsearch custom traduce questa chiamata in una query DSL complessa con tutti gli optimizations specifici del dominio, Elasticsearch risponde con i document ranked per rilevanza, e il driver carica le istanze Eloquent dal MySQL a partire dagli ID ritornati da Elasticsearch.

La decisione architetturale importante è dove vive la ground truth dei dati. Nel design corretto che applico nelle PMI, MySQL resta il primary storage autoritativo - tutte le scritture passano da lì, tutte le transazioni business avvengono lì, tutti i backup preservano lo stato di MySQL. Elasticsearch è un secondary index popolato dal MySQL attraverso una pipeline di sincronizzazione. Questa scelta è critica: se Elasticsearch crasha, se l'indice si corrompe, se un deploy Elasticsearch va male, l'applicazione deve poter continuare a operare leggendo da MySQL (magari con ricerca degradata ma funzionante), e l'indice Elasticsearch deve poter essere ricostruito dal MySQL senza perdita di dati. Il contrario - usare Elasticsearch come primary storage - è una scelta architetturale possibile in contesti enterprise dedicati ma sconsigliata in PMI dove la complessità operativa di Elasticsearch come primary è troppo alta rispetto ai benefici.

Per il cliente padovano, la struttura dell'integrazione è stata questa. Un ProductElasticsearchDriver nel namespace App\Infrastructure\Search\Elasticsearch implementa l'interfaccia ScoutEngine di Laravel Scout, incapsula il client ufficiale elasticsearch/elasticsearch per PHP, traduce le chiamate di Scout in DSL Elasticsearch, e orchestra l'idratazione dei risultati. Un ProductIndexMapping statico definisce lo schema dell'indice Elasticsearch - analyzer personalizzati per italiano, field mapping tipato, settings di relevance tarati. Un ProductSearchListener ascolta gli eventi Eloquent saved e deleted sul model Product e aggiorna in modo incrementale l'indice Elasticsearch. Un comando Artisan search:reindex esegue un'indicizzazione completa da zero per deploy iniziali o recovery. Questa struttura è completamente allineata ai principi di architettura esagonale ports and adapters applicata a Laravel che ho descritto in un articolo dedicato, dove Elasticsearch è un adapter esterno accessibile dal dominio via una port pulita (l'interfaccia di Scout) senza accoppiamento diretto.

Mapping e analyzer per italiano: la parte che fa davvero la differenza in ricerca di rilevanza

Il momento in cui un'integrazione Elasticsearch si distingue fra mediocre ed eccellente è la definizione del mapping e degli analyzer dell'indice. Il mapping è la dichiarazione tipizzata di come ogni campo del documento deve essere interpretato da Elasticsearch - tipo di dato (text, keyword, integer, date), se è indicizzato o solo memorizzato, quale analyzer applica al tempo di indicizzazione e di ricerca. L'analyzer è la pipeline di trasformazioni che un testo attraversa per diventare termini ricercabili - tokenization, lowercasing, stemming, rimozione stopword, gestione dei sinonimi.

Per il dominio della ricambistica industriale italiana, ho definito un analyzer custom che combina tre elementi critici. Primo, un tokenizer che preserva i codici alfanumerici (NBR-70-SHORE-A) come singoli token e non li spezza su hyphen come farebbe il tokenizer standard. Secondo, uno stemmer italiano basato su italian_stemmer built-in di Elasticsearch, che normalizza le varianti morfologiche (anelli/anello, guarnizione/guarnizioni) evitando mancati match per pluralizzazione. Terzo, un synonym filter custom che mappa varianti tecniche comuni nel dominio della ricambistica - o-ring come sinonimo di anello, NBR come sinonimo di nitrile, EPDM come sinonimo di gomma sintetica. Il file di sinonimi era stato costruito in collaborazione con il responsabile prodotto del cliente, che aveva identificato circa 280 coppie di termini che venivano usati intercambiabilmente dai buyer. La definizione del mapping nel JSON di creazione dell'indice appare così:

{
  "settings": {
    "analysis": {
      "analyzer": {
        "ricambi_it_analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "italian_stop", "italian_stemmer", "ricambi_synonyms"]
        }
      },
      "filter": {
        "italian_stop": {"type": "stop", "stopwords": "_italian_"},
        "italian_stemmer": {"type": "stemmer", "language": "italian"},
        "ricambi_synonyms": {"type": "synonym_graph", "synonyms_path": "analysis/ricambi_synonyms.txt"}
      }
    }
  },
  "mappings": {
    "properties": {
      "codice_articolo": {"type": "keyword", "boost": 3.0},
      "nome": {"type": "text", "analyzer": "ricambi_it_analyzer", "boost": 2.5},
      "descrizione": {"type": "text", "analyzer": "ricambi_it_analyzer"},
      "attributi_tecnici": {"type": "text", "analyzer": "ricambi_it_analyzer"},
      "categoria_path": {"type": "keyword"},
      "prezzo_netto": {"type": "scaled_float", "scaling_factor": 100},
      "disponibilita": {"type": "integer"},
      "attivo": {"type": "boolean"}
    }
  }
}

Il boost differenziato sui campi è il pezzo che modifica radicalmente la rilevanza dei risultati: un match sul codice articolo conta tre volte di più di un match sulla descrizione, perché un buyer che digita un codice esatto vuole assolutamente trovare quel prodotto in prima posizione. La gestione delle varianti morfologiche è quella che fa sì che cercando "anelli" trovi anche "anello" e "guarnizione" trovi anche "guarnizioni", senza dover scrivere logica applicativa di normalizzazione.

Sincronizzazione incrementale tra MySQL e Elasticsearch: eventi Eloquent e queue asincrona

Il terzo pilastro tecnico dell'integrazione è la pipeline di sincronizzazione fra MySQL (ground truth) e Elasticsearch (indice secondario). L'obiettivo architetturale è che ogni modifica al catalogo in MySQL - creazione nuovo prodotto, modifica prezzo o disponibilità, disattivazione articolo - si rifletta su Elasticsearch entro pochi secondi, senza impattare la latenza della richiesta HTTP che ha generato la modifica. Il pattern giusto è asincronia via queue: gli eventi Eloquent catturano la modifica, accodano un job in coda Redis, un worker dedicato processa i job ed esegue la chiamata a Elasticsearch. Questo disaccoppiamento isola la performance del frontend dall'eventuale latenza o indisponibilità temporanea di Elasticsearch.

Il codice del listener sul cliente padovano si aggancia all'observer Eloquent tramite ProductObserver:

namespace App\Observers;

use App\Jobs\Search\IndexProductInElasticsearch;
use App\Jobs\Search\RemoveProductFromElasticsearch;
use App\Models\Product;

final class ProductObserver
{
    public function saved(Product $product): void
    {
        IndexProductInElasticsearch::dispatch($product->id)
            ->onQueue('search');
    }

    public function deleted(Product $product): void
    {
        RemoveProductFromElasticsearch::dispatch($product->id)
            ->onQueue('search');
    }
}

Il job IndexProductInElasticsearch ricarica il prodotto da MySQL (per avere lo stato più fresco in caso di modifiche multiple ravvicinate), serializza in JSON con mapping esplicito, e invia la richiesta index al cluster Elasticsearch. La coda dedicata search è processata da un worker Horizon separato che gira su due vCPU e può processare circa 2.000 indicizzazioni al minuto - più che sufficiente per il picco di aggiornamento massa che il cliente esegue ogni notte quando sincronizza il catalogo dal suo ERP esterno. Per gli aggiornamenti massa notturni, uso invece un pattern di bulk indexing con API _bulk di Elasticsearch che invia 500 documenti per chiamata - il reindex completo di 207.000 prodotti completa in meno di 4 minuti. La gestione dei job asincroni e delle code beneficia degli stessi pattern che ho descritto nel mio articolo sulla modernizzazione dei job in coda Laravel con Queue::fake e withFakeQueueInteractions su Laravel 12, che è il riferimento metodologico per rendere il layer di sincronizzazione testabile in modo affidabile.

Deployment e operatività di Elasticsearch su VPS singolo: configurazione pragmatica per PMI

Un tema operativo che molti tutorial sottovalutano è dove far girare Elasticsearch in un contesto PMI. La soluzione tradizionale enterprise è un cluster dedicato multi-nodo (minimo 3 nodi per alta disponibilità), ma in PMI con dataset sotto il mezzo milione di record e carichi di ricerca moderati, un singolo nodo Elasticsearch coresidente sullo stesso server applicativo è una scelta perfettamente ragionevole. I requisiti critici sono tre: RAM sufficiente (minimo 8 GB dedicati al processo Elasticsearch, con -Xms e -Xmx della JVM settati allo stesso valore), disco SSD/NVMe veloce per il data directory, e configurazione del heap size al 50% della RAM assegnata mai superiore a 32 GB per evitare problemi di compressed ordinary object pointers della JVM.

Sul cliente padovano, Elasticsearch 8.12 gira sullo stesso AX52 dell'applicazione Laravel, con 32 GB di RAM aggiuntivi portati a 96 GB totali del server. Di questi, 8 GB sono assegnati al heap JVM di Elasticsearch, 24 GB restano filesystem cache per il directory dei data dell'indice (cruciale per la velocità di ricerca), 16 GB per PHP-FPM worker pool, 16 GB per MySQL InnoDB buffer pool, 8 GB per Redis e sistema. L'isolamento fra i servizi è garantito da systemd con limit espliciti di memoria e CPU shares per ciascun servizio, evitando che un picco di uno consumi risorse dell'altro. Per il backup dell'indice Elasticsearch uso il meccanismo nativo di snapshot repository su Hetzner Storage Box, con snapshot incrementali ogni 6 ore e full snapshot settimanale. In caso di corruzione dell'indice, il ripristino da snapshot richiede tra 15 e 40 minuti in funzione della dimensione dei dati - tempo accettabile per un servizio di ricerca in PMI, durante il quale l'applicazione ripiega automaticamente sulla ricerca MySQL tradizionale (degradata ma funzionante) grazie all'architettura a doppio storage.

Il risultato finale dell'intervento sul cliente padovano, al termine delle otto giornate di lavoro in quattro settimane, è stato il seguente. Tempo medio di ricerca catalogo sceso da 8,1 secondi a 41 millisecondi (riduzione del 99,5%). Precisione top-20 misurata sulle 150 query di test dei buyer interni migliorata dal 34% al 91%. Timeout HTTP 504 in ore di punta scesi da 40-60 occorrenze/giornata a zero. Utilizzo CPU medio del server sceso dal 65% al 22% nelle ore di punta, perché MySQL non doveva più sostenere le ricerche di catalogo. Costo totale: 6.800 euro consulenziali più 30 euro/mese incrementali per la RAM aggiuntiva del server. Alternativa AWS managed evitata: 68.000 euro progettuali più 2.400 euro/mese ricorrenti. ROI stimato dal cliente sulla prima annualità: oltre 28 considerando la non-migrazione, senza contare gli aumenti di conversion rate e retention dei buyer che nei tre mesi successivi all'introduzione hanno portato un +16% sulle transazioni concluded per sessione di ricerca.

Se gestisci una piattaforma e-commerce o un catalogo B2B con volumi di prodotto sopra i 50.000-100.000 articoli e ti trovi con tempi di ricerca che iniziano a degradare, o stai valutando preventivi di migrazione cloud managed significativi per risolvere il problema, vale quasi sempre la pena fermarsi e valutare un'integrazione Elasticsearch auto-ospitata prima di cambiare paradigma infrastrutturale. Nel 80% dei casi PMI italiane che ho analizzato negli ultimi cinque anni, la soluzione ingegnerizzata su infrastruttura esistente produce gli stessi benefici funzionali a costo 5-10 volte inferiore rispetto alle offerte cloud managed. Se vuoi confrontarti su una valutazione tecnica del tuo caso specifico, contattami per una consulenza preliminare: in una mezza giornata di analisi guidata produciamo insieme una misurazione realistica delle dimensioni del tuo catalogo e delle caratteristiche del tuo traffico di ricerca, un confronto economico a 36 mesi fra alternative architetturali praticabili (Elasticsearch self-hosted, Meilisearch come alternativa più leggera, OpenSearch AWS managed, Algolia SaaS), e una decisione informata su quale soluzione è davvero appropriata per la scala del tuo progetto senza mode tecnologiche né preventivi sovradimensionati.

Ultima modifica: