Come potenziare la ricerca su WordPress con Elasticsearch

In alcuni casi avere un efficente motore di ricerca interno al sito puo’ essere fondamentale per aumentare l’engagement di un utente. Sopratutto gli e-commerce, oppure i siti con elevato volume di contenuti, rischiano di perdere l’utente qualora il tempo di ricerca di un determinato prodotto o soggetto si protragga per troppo tempo. L’utente non è per sua natura paziente, e pretende risposte precise ed immediate.

In questo articolo cercheremo di riassumere alcuni concetti fondamentali riguardanti il motore di ricerca di WordPress (e quindi di WooCommerce) ed una soluzione per migliorare notevolmente le prestazioni di ricerca utilizzando Elasticsearch, un motore di ricerca distribuito per tutti i tipi di dati basato su Apache Lucene.

1) La ricerca full-text su MySql
2) La ricerca su WordPress e Woocommerce
3) Installazione ambiente di test
4) Abilitazione del log delle slow query su MySql
5) Analisi di una query di ricerca su WordPress
6) La stessa query eseguita su Elasticsearch
7) Conclusioni


1. La ricerca full-text su MySql

La ricerca “full-text” (FTS) è la ricerca classica che facciamo, su Google o su qualsiasi altro motore di ricerca quando ricerchiamo una determinata parola o una frase. Questo tipo di ricerca è una tecnica per trovare documenti che possono non corrispondere esattamente ai criteri di ricerca.

Possiamo per esempio cercare le parole “Sole e Mare” e la FTS potrà restituire documenti che contengono sia esclusivamente ciascuna delle due sinole parole, “Sole” o “Mare”, oppure risultati che contengono entrambe le parole sia nellordine esatto che in un altro ordine, per esempio “Sole e Mare” oppure “Mare e Sole”.

Parlando tecnicamente, MySql supporta le ricerche di testo parziali utilizzando l’operatore “LIKE” e le regular expressions. Questo tipo di ricerca ha pero’ diversi limiti man mano che aumentano le dimesioni del campo di testo in cui la ricerca viene fatta, oppure aumenta il numero dei records nella tabella in cui si cerca.

I limiti principali sono:
1) una scarsa performance, perchè MySql deve cercare i termini in tutti i campi della tabella prima di restituire i risultati
2) una scarsa flessibilità nella ricerca, in quanto diventa difficile trovare risultati che escludano uno dei termini della ricerca (per esempio “Mare” ma non “Sole”)
3) l’impossibilità di avere un punteggio di rilevanza nei risultati ottenuti

A causa di queste limitazioni, MySql, a partire dalla versione 5.6 ha introdotto un indice apposito per le ricerche full-text.

2. La ricerca su WordPress e Woocommerce

Sfortunatamente la struttura del database MySql che arriva con l’installer di wordpress non utilizza le nuove funzionalità introdotte in MySQL. Nella tabella ‘wp_posts’ per esempio possiamo notare come l’indicizzazione usata sia quella standard, ossia la ‘BTREE’.

Senza stare ad addentrarsi nelle dinamiche del funzionamento di questo tipo di indice, si puo’ dire semplicemente che tale indice permette di effettuare delle ricerche con operatori di uguaglianza (= oppure <=>),operatori che diano risultati all’interno di un range (>, <, >=, <=, BETWEEN), oppure l’operatore LIKE.

Adesso viene pero’ il bello perchè andremo a strutturare un ambiente per misurare l’effettivo carico delle query di ricerca su WordPress ed esaminarne la struttura.

3. Installazione ambiente di test

Per poter effettuare il nostro benchmarch e capire quale sia veramente il costo delle query di ricerca su WordPress ed avere un termine di paragone con una query eseguita su Elasticsearch abbiamo:

1) Installato un’istanza di WordPress
Per eseguire questo passaggio in velocità abbiamo approfittato delle istanze ready to run sul marketplace di Digital Ocean. Per dare un po’ di brillantezza alla ricerca abbiamo optato per una configurazione con 2 CPU dedicate e 8GB di memoria.

2) Installato un’istanza di Elasticsearch
Utilizzando un container Docker su Amazon AWS, in questo caso la potenza dell’istanza è minima: una T2.small con 1CPU e 2GB di ram

3) Inserito una serie di dati dummy per poter fare ricerche full-text su un database consistente

Per utilizzare le api-rest di WordPress in maniera veloce, ma non sicura, abbiamo installato un plugin che permetta un’autenticazione di tipo Basic. Questa pratica è ovvimante sconsigliata all’utilizzo in produzione.

Lato Elasticsearch abbiamo creato un index che riporoducesse i campi full-text del database MySql di WordPress in modo da poter effettuare parallelamente la stessa ricerca sui due database.

Utilizzando il servizio api Loripsum abbiamo inserito 11.000 articoli sul nostro WordPress con titoli e contenuti random.

Questo il sorgente del file che ha effettuato il caricamento dei dati:

function wp_insert($title,$content){
$username = 'xxxxxxx';
$password = 'yyyyyyyyyyy';
$rest_api_url = "https://my.test.site/wp-json/wp/v2/posts";

$data_string = json_encode([
    'title'    => $title,
    'content'  => $content,
    'status'   => 'publish',
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $rest_api_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string);

curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'Content-Length: ' . strlen($data_string),
    'Authorization: Basic ' . base64_encode($username . ':' . $password),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$result = curl_exec($ch);

curl_close($ch);
return json_decode($result);
}
function dbg($s){
	echo "\n";
	print_r($s);
	echo "\n";
}
function es_insert($title,$content,$link){
	$topost = array(
		"link" => $link,
		"title" => $title,
		"description" => $content,
		"pubdate" => date("Y-m-d H:i:s"),
		"author" => "Admin"
	);
	$data = json_encode($topost,JSON_PRETTY_PRINT);
	$ch = curl_init();

	curl_setopt($ch, CURLOPT_URL, 'elasticearch.host.ip:9200/post/_doc/?pretty');
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
	curl_setopt($ch, CURLOPT_POST, 1);
	curl_setopt($ch, CURLOPT_POSTFIELDS, $data);

	$headers = array();
	$headers[] = 'Content-Type: application/json';
	curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

	$result = curl_exec($ch);
	if (curl_errno($ch)) {
	    echo 'Error:' . curl_error($ch);
	}
	curl_close($ch);
	return json_decode($result);
}

function li_get($wich){

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $wich);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        $output = curl_exec($ch);
        curl_close($ch);
        return $output;      
}


for($i=0;$i<10000;$i++){ $title = li_get("https://loripsum.net/api/1/short"); $incipit = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."; $title = str_replace($incipit, "", $title); $title = substr(trim($title),0,100); $title = strip_tags($title); $title = preg_replace('/\[.*\]/', '', $title); dbg($title); if($title == "") die('aaaaargh'); $content = li_get("https://loripsum.net/api/20/verylong/headers"); $wp = wp_insert($title,$content); if(isset($wp->link)){
		dbg($wp->link);
		$uno = strip_tags($content);
		$testo = preg_replace('/\[.*\]/', '', $uno);
		
		$es = es_insert($title,$testo,$wp->link);
		dbg($es);
	}
}

Proviamo a misurare il volume del database che abbiamo creato:

mysql> SELECT
    ->   TABLE_NAME AS `Table`,
    ->   ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024) AS `Size (MB)`
    -> FROM
    ->   information_schema.TABLES
    -> WHERE
    ->     TABLE_SCHEMA = "wordpress"
    ->   AND
    ->     TABLE_NAME = "wp_posts"
    -> ORDER BY
    ->   (DATA_LENGTH + INDEX_LENGTH)
    -> DESC;
+----------+-----------+
| Table    | Size (MB) |
+----------+-----------+
| wp_posts |       366 |
+----------+-----------+
1 row in set (0.01 sec)

Sono 366 Mb, nemmeno tanti…. 🙂

4. Abilitazione del log delle slow query su MySql

Per poter misurare correttamente il tempo di esecuzione delle query di ricerca di WordPress abbiamo ancora bisogno di istruire MySql affinchè scriva in un file di log i dettagli delle query la cui esecuzione superi un certo periodo di tempo.

Per fare questo andiamo ad aggiungere alcune istruzioni nel file di configurazione di MySql. Apriamo con un editor il file di configurazione:

nano /etc/mysql/mysql.conf.d/mysqld.cnf

ed aggiungiamo queste linee:

 slow_query_log         = 1
 slow_query_log_file    = /var/log/mysql/slow.log
 long_query_time = 1
 log-queries-not-using-indexes = ON

Poi creiamo il file di log e lo rendiamo scrivibile (quest’ultima istruzione è da evitare in produzione ma bisogna attribuire all’utente mysql la proprietà del file):

touch /var/log/mysql/slow.log
chmod 777 /var/log/mysql/slow.log

Ora facciamo ripartire MySql con la nuova configurazione:

/etc/init.d/mysql restart

5. Analisi di una query di ricerca su WordPress

Abbiamo ora tutti gli elementi per poter testare una query di ricerca su WordPress. Abbiamo 11.000 post riempiti di Lorem Ipsum, possiamo scegliere 2 termini a caso che sappiamo presenti in un articolo, inserirli nella form di ricerca e vedere come si comporta WordPress.

La ricerca che io ho scelto di fare è sui termini “Cicero cognoscere”. Eseguo la ricerca. Vado a leggere nel log delle slow query cosa è successo.

Questa è la query che WordPress ha fatto:

SELECT SQL_CALC_FOUND_ROWS  wp_posts.ID FROM wp_posts  WHERE 1=1  
AND (((wp_posts.post_title LIKE '%Cicero%') 
OR (wp_posts.post_excerpt LIKE '%Cicero%') 
OR (wp_posts.post_content LIKE '%Cicero%')) 
AND ((wp_posts.post_title LIKE '%cognoscere%') 
OR (wp_posts.post_excerpt LIKE '%cognoscere%') 
OR (wp_posts.post_content LIKE '%cognoscere%')))  
AND wp_posts.post_type IN ('post', 'page', 'attachment') 
AND (wp_posts.post_status = 'publish' OR wp_posts.post_author = 1 AND wp_posts.post_status = 'private')  
ORDER BY (CASE WHEN wp_posts.post_title LIKE '%Cicero cognoscere%' THEN 1 WHEN wp_posts.post_title LIKE '%Cicero%' AND wp_posts.post_title LIKE '%cognoscere%' THEN 2 WHEN wp_posts.post_title LIKE '%Cicero%' OR wp_posts.post_title LIKE '%cognoscere%' THEN 3 WHEN wp_posts.post_excerpt LIKE '%Cicero cognoscere%' THEN 4 WHEN wp_posts.post_content LIKE '%Cicero cognoscere%' THEN 5 ELSE 6 END), wp_posts.post_date DESC LIMIT 0, 10;

Ossia, WordPress ha cercato singolarmente le due parole richieste nei campi titolo, excerpt e content della tabella posts ove il tipo di post fosse un artcolo, una pagina o un attachment (ossia un immagine).

Li ha poi ordinati come importanza a seconda che nel titolo vi fosse la stringa completa o uno dei due termini e con lo stesso criterio a seguire e disponendoli a seconda della data di creazione in maniera discendente ed in numero di 10.

E poi:

# Query_time: 3.922342  Lock_time: 0.000138 Rows_sent: 10  Rows_examined: 12734

L’effettuazione della query ha preso 3,9 secondi per cercare in 12734 records.

Considerando la potenza della macchina ed il fatto che questa query fosse l’unica attività in corso in quel momento, la prestazione non sembra in effetti molto brillante. Altri test fatti su macchine meno performanti hanno dato, ovviamente, risultati decisamente peggiori.

Ma sopratutto il criterio lascia perplessi, in quanto come si puo’ osservare dagli id dei post ottenuti in risposta alla query

+-------+
| ID    |
+-------+
| 11465 |
| 11459 |
| 11452 |
| 11451 |
| 11443 |
| 11442 |
| 11437 |
| 11434 |
| 11432 |
| 11416 |
+-------+

sono stati selezionati esclusivamente post molto recenti, il che non è una garanzia per l’ottimizzazione del risultato di ricerca.

E’ necessario anche sottolineare il fatto che tutti i plugin che esistono per implementare diversi modi di ricerca per Worpress o WooCommerce possono migliorare il dettaglio del risultato ma non certo la prestazione che anzi, quasi sempre, peggiorano.

6. La stessa query eseguita su Elasticsearch

Andiamo ora ad implementare la ricerca verso Elasticsearch che, come abbiamo detto, risiede su un’altra macchina, non molto performante, su un altro cloud, le due macchine sono comunque nella stessa regione US-EAST.

Per amor di brevità prenderemo una scorciatoia, ovvio che questa implementazione dovrebbe essere fatta diversamente. Da notare che esistono dei plugin per integrare WordPress con Elasticsearch, ma, ahimè, non funzionano 😉

Creiamo quindi una pagina con slug /elasticsearch-search

Creiamo poi un template di pagina, lo chiamiamo “Elastic Page Search”. Dentro il template creiamo una form che abbia un input di testo per la rcerca e la action allo slug della nostra pagina. Inseriamo poi, dopo la form, questo codice:

if(isset($_POST['string']) && $_POST['string'] != ""){
$ch = curl_init();

curl_setopt($ch, CURLOPT_URL, 'my.elasticsearch.ip:9200/post/_search?pretty');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, "\n{\n  \"query\": {\n    \"multi_match\" : {\n      \"query\":    \"".$_POST['string']."\", \n      \"fields\": [ \"title^2\", \"description\" ] \n    }\n  },\n   \"fields\": [\"title\", \"link\"],\n   \"_source\": false\n}\n");

$headers = array();
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

$result = curl_exec($ch);
if (curl_errno($ch)) {
echo 'Error:' . curl_error($ch);
}
curl_close($ch);
}

$res = json_decode($result);
print_r($result);
			

Analizziamo in particolare il JSON con cui andiamo ad interrogare Elasticsearch:

{
  "query": {
    "multi_match" : {
      "query":    "Cicero cognoscere", 
      "fields": [ "title^2", "description" ] 
    }
  },
   "fields": ["title", "link"],
   "_source": false
}

Praticamente, noi cerchiamo la nostra frase nel titolo e nella descrizione del post, specificando che qual’ora venisse ritrovato nel titolo uno dei termini, questo verrebbe valutato doppio nell’attribuzione del ranking di Elasticsearch, e ci facciamo restituire solo il titolo e lo slug dell’articolo. Otteniamo questi risultati:

{
  "took" : 846,
  "timed_out" : false,
  "_shards" : {
    "total" : 3,
    "successful" : 3,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 876,
      "relation" : "eq"
    },
    "max_score" : 3.5842853,
    "hits" : [
      {
        "_index" : "post",
        "_type" : "_doc",
        "_id" : "KIuDyXYBaPVcgdaTjmHU",
        "_score" : 3.5842853,
        "fields" : {
          "link" : [
            "https://awswp.pipozzi.site/quae-diligentissime-contra-aristonem-dicuntur-a-chryippo-scrupulum-inquam-abeunti-contemnit/"
          ],
          "title" : [
            " Quae diligentissime contra Aristonem dicuntur a Chryippo. Scrupulum, inquam, abeunti; Contemnit "
          ]
        }
      },
      {
        "_index" : "post",
        "_type" : "_doc",
        "_id" : "8YuJyXYBaPVcgdaTLmEi",
        "_score" : 3.572124,
        "fields" : {
          "link" : [
            "https://awswp.pipozzi.site/post-enim-chrysippum-eum-non-sane-est-disputatum-duo-reges-constructio-interrete-nihil-illinc/"
          ],
          "title" : [
            " Post enim Chrysippum eum non sane est disputatum. Duo Reges: constructio interrete. Nihil illinc"
          ]
        }
      },

....

Traducendo i messaggi piu’ importanti che Elasticsearch ci manda, otteniamo che:
1) la query è stata eseguita in 0,846 secondi (“took” : 846)
2) sono stati trovati 876 documenti contenenti la nostra chiave (hits->total->value = 876)
3) Elasticsearch ci ha restituito 10 risultati ( avremmo potuto scegliere il numero) ordinati secondo il punteggio ( “_score” : 3.572124 ) che lui ha calcolato utilizzando il suo sofisticato sistema di indicizzazione.

7. Conclusioni

Possiamo concludere il nostro tutorial affermando che in tutte le web applications gestite con WordPress, e quindi con WooCommerce, in cui venga gestito un grande volume di dati, soppiantare il sistema di ricerca nativo con un sistema parallelo basato su Elasticsearch porta indiscutibilmente una serie di vantaggi fondamentali:

1) Esecuzione della ricerca con velocità almeno 4 volte superiore
2) Maggiore precisione nella ricerca
3) Maggiore consistenza dei risultati ottenuti grazie al sistema di ranking di Elasticsearch