Come funziona davvero la ricerca a faccette in Elasticsearch (e dove si rompe)

di Luca Cavallini Guide tecniche
Come funziona davvero la ricerca a faccette in Elasticsearch (e dove si rompe)

Una faccetta sono due domande, non una

Una pagina di ricerca a faccette pone due domande diverse nello stesso momento. La prima è ovvia: quali documenti corrispondono alla ricerca in corso e ai filtri già applicati. La seconda viene posta una volta per ogni valore di filtro disponibile: quanti documenti corrisponderebbero se anche quel valore fosse selezionato. La casella "Rosso (12)" è la risposta alla seconda domanda, calcolata mentre si risponde alla prima.

Gran parte di ciò che rende difficile la ricerca a faccette, in Elasticsearch come altrove, nasce da questa seconda domanda. Ottenere l'elenco dei documenti che corrispondono non è complicato. Ottenere un conteggio onesto accanto a ogni valore di ogni filtro, a ogni caricamento di pagina, senza che il tempo di risposta esploda al crescere del numero di faccette, è il vero problema di ingegneria.

Il mattone di base: un'aggregazione terms su un insieme filtrato

Elasticsearch risponde a entrambe le domande con una sola richiesta. La clausola di query restringe l'insieme dei documenti (corrispondenza full text più i filtri già attivi), e un insieme di aggregazioni collegate a quella stessa query calcola, per ogni campo di faccetta, quanti documenti di quell'insieme filtrato rientrano in ciascun valore. Un'aggregazione terms su un campo color restituisce un bucket per ogni valore distinto con il suo numero di documenti; un'aggregazione range o histogram fa lo stesso per i campi numerici come il prezzo. Tutto avviene nella stessa esecuzione di query, sullo stesso insieme filtrato, in un solo viaggio di andata e ritorno.

È il punto facile da perdere se hai usato solo SQL: un'aggregazione, qui, non è una seconda query sul risultato della prima. Gira sui doc values e sui postings dell'indice durante la stessa richiesta, ed è per questo che aggiungere altre venti faccette a una pagina non significa venti scansioni complete in più dei documenti corrispondenti. Significa venti calcoli di bucket in più su dati che il motore doveva comunque toccare per rispondere alla query.

Contesto filter, non contesto query

Le faccette, e i filtri applicati cliccandoci sopra, dovrebbero quasi sempre stare nel contesto filter, non nel contesto query. Un filtro term su brand: acme non ha bisogno di calcolare un punteggio di pertinenza, ha bisogno di una risposta sì o no, ed Elasticsearch mette in cache le clausole di filtro in modo molto più aggressivo delle clausole di query con punteggio. Costruire una pagina di ricerca a faccette soltanto con clausole bool must tutte con punteggio è un modo comune di renderla più lenta del necessario senza alcun vantaggio: nessuno ordina una pagina categoria per "pertinenza rispetto al filtro Rosso". I filtri vanno in filter, la ricerca full text va in must o in una query match dedicata, e questa separazione non è estetica: cambia ciò che viene messo in cache e ciò che riceve un punteggio.

La trappola della selezione multipla

Il dettaglio che rompe più implementazioni di ricerca a faccette di qualunque altro è questo: quando calcoli il conteggio di una faccetta, devi escludere il filtro di quella stessa faccetta dall'insieme di documenti su cui conti, mantenendo attivi tutti gli altri filtri. Altrimenti ogni faccetta su cui hai già filtrato mostrerà un conteggio pari al numero di risultati attualmente a schermo, perché stai contando l'intersezione con una condizione già applicata per intero. Clicca "Rosso", e ogni altro valore di "Colore" mostrerebbe a torto lo stesso numero del totale, invece di quanti prodotti rossi e di quel valore esistono davvero.

La correzione abituale è costruire un'aggregazione per ogni campo di faccetta, ciascuna avvolta nella propria aggregazione filter che riapplica tutti i filtri attivi tranne quello di quel campo, tutte allo stesso livello sotto la stessa query principale. In concreto: la query di base porta già la ricerca full text e tutti i filtri che non sono in corso di conteggio, e l'aggregazione di ogni faccetta aggiunge gli altri filtri selezionati, meno il proprio. Significa più aggregazioni per richiesta, non più richieste, ed è la parte che vale la pena di testare esplicitamente invece di approvarla a occhio, perché una pagina può sembrare perfetta in una demo con tre prodotti e mostrare in silenzio conteggi sbagliati appena si caricano dati veri, con intersezioni vere.

C'è una seconda decisione, collegata alla prima, che conviene prendere deliberatamente: che cosa significa un gruppo di faccette quando più valori sotto la stessa etichetta vengono selezionati insieme. Se un negozio raggruppa "Cremisi", "Scarlatto" e "Bordeaux" sotto un unico filtro "Rosso", selezionare due di queste sottovoci significa "ha questo E quello" oppure "ha questo O quello"? Un conteggio che risponde alla domanda E mentre il clic applica la domanda O (o il contrario) è la causa più frequente di un filtro che mostra un numero e ne restituisce un altro. I due vanno calcolati esattamente con la stessa logica, il che di solito significa trattare fin dall'inizio il gruppo come un'unica condizione di unione, invece di sommare conteggi per valore che possono sovrapporsi o contare due volte lo stesso prodotto.

Perché tutto crolla senza un motore di aggregazione, con un numero reale

La ragione d'essere dei motori di ricerca a faccette, invece di fare tutto in SQL, si vede chiaramente non appena si prova a ottenere conteggi esatti per valore su un database relazionale su larga scala. Su un catalogo di prova da 200.000 prodotti che usava il motore di ricerca integrato di PrestaShop, limitato al database, con circa 2.250 gruppi di attributi e caratteristiche, calcolare un conteggio esatto "quanti risultati per valore" con una query COUNT(DISTINCT ...) per faccetta faceva impiegare 43 secondi a una pagina categoria con un filtro attivo. Disattivare i conteggi esatti per quelle faccette (mostrare l'elenco dei valori senza numero) ha riportato la stessa pagina a poco meno di 12 secondi, il che mostra quanta parte del costo iniziale dipendeva dal puro conteggio, e non dalla ricerca in sé.

Un errore affine, emerso durante una revisione interna del codice di un modulo di ricerca a faccette, consisteva nel calcolare l'intervallo di una faccetta numerica (per uno slider "Prezzo" o "Peso") caricando tutti i valori corrispondenti nella memoria dell'applicazione e calcolando lì minimo e massimo, invece di chiedere al database o al motore di ricerca di farlo. Su un catalogo in cui questo riguardava circa due milioni di righe, quel solo passaggio richiedeva 11 secondi e 965 MB di memoria per richiesta, abbastanza su certe configurazioni da esaurire il limite di memoria di un hosting condiviso e restituire una pagina di errore al posto di uno slider. Spostare lo stesso calcolo in un GROUP BY del database, o nell'aggregazione range del motore di ricerca, lo ha riportato a una query normale e veloce, perché è proprio il tipo di calcolo aggregato che questi sistemi sono progettati per fare in un solo passaggio su dati indicizzati, e non nel codice applicativo a posteriori.

I due numeri descrivono lo stesso errore di fondo a livelli diversi: chiedere "quanti, per ogni valore possibile" una volta per valore invece che una volta per tutti i valori insieme. Il framework di aggregazione di Elasticsearch esiste proprio per rispondere a questa domanda in un solo passaggio su dati già indicizzati. È questo il suo vero valore, indipendentemente dal ranking di pertinenza full text, ed è per questo che la navigazione a faccette è uno dei motivi più frequenti per adottare Elasticsearch, anche quando la ricerca in sé non ha bisogno di funzioni di pertinenza avanzate.

Una breve checklist prima di mettere online una ricerca a faccette

  • I filtri vanno nel contesto filter, il full text nel contesto query. Non calcolare un punteggio per una casella da spuntare.
  • L'aggregazione di conteggio di ogni faccetta deve escludere il filtro attivo di quella faccetta mantenendo tutti gli altri, altrimenti le faccette a selezione multipla mentiranno appena due filtri verranno usati insieme.
  • Decidi fin dall'inizio, e testa esplicitamente, se un gruppo di valori sotto la stessa etichetta funziona in E o in O, e fai condividere esattamente la stessa logica al conteggio e al clic.
  • Limita la dimensione delle aggregazioni e tieni d'occhio doc_count_error_upper_bound sui campi ad alta cardinalità; un'aggregazione terms senza limite che restituisce migliaia di bucket è un problema di prestazioni a sé, indipendente dalla questione della selezione multipla di cui sopra.
  • Non calcolare mai i numeri di una faccetta caricando righe grezze nella memoria dell'applicazione. Se ti sorprendi a scrivere un ciclo sui risultati di una query per costruire un conteggio o un intervallo, quel ciclo appartiene all'aggregazione, non al tuo codice.

Quando Elasticsearch non è la risposta

Niente di tutto questo significa che ogni catalogo abbia bisogno di Elasticsearch. Un negozio con qualche migliaio di prodotti e una manciata di faccette noterà raramente la differenza che porta un motore di ricerca dedicato, e far girare un cluster ha un suo costo di gestione: memoria, disco, e qualcuno che sappia gestire la reindicizzazione quando lo schema cambia. I numeri qui sopra cominciano a contare solo quando il numero di faccette, la dimensione del catalogo, o entrambi, diventano abbastanza grandi perché un conteggio ingenuo si misuri in secondi invece che in millisecondi. Se non sei a quel punto, una query di database ben ottimizzata è una scelta del tutto ragionevole, e la checklist qui sopra vale anche lì: la questione E/O e la trappola della selezione multipla sono lo stesso problema, qualunque sia il motore che risponde.

In sintesi

La ricerca a faccette non è una query, è una query più un'aggregazione per ogni filtro, e quasi tutti i bug reali nascono da una deviazione da questo modello: dare un punteggio a ciò che dovrebbe essere un filtro, contare una faccetta tenendo conto della sua stessa condizione già applicata, o rispondere al conteggio e al clic con due definizioni diverse di un gruppo. Il framework di aggregazione di Elasticsearch risolve bene il problema di scala, in un solo passaggio su dati indicizzati invece di una query per ogni valore di faccetta, ed è la ragione concreta per cui i numeri di 43 e 11 secondi qui sopra sono comparsi su un motore limitato al database e non su uno progettato per questo. Facciamo questo lavoro ogni giorno in cll_essearch, il nostro modulo di ricerca a faccette per PrestaShop, sia sul driver nativo sia su Elasticsearch, OpenSearch e Meilisearch (vedi la guida all'installazione), se vuoi vedere la stessa logica applicata a un negozio online invece che a un indice grezzo.

Ricerca Elasticsearch & Navigazione a FaccetteRicerca velocissima e tollerante ai refusi con navigazione a faccette con pannello filtri modale, con motore…99,99 €Vedi il prodotto

Lascia un commento

I commenti sono moderati prima della pubblicazione.

← Blog

Caricamento in corso ...
Torna all'inizio