Cas Utilisateur

Amélioration de la navigation utilisateur sur Bricozor.com avec Elasticsearch

Contexte & Problématique

En tant que développeur web chez Bricozor, site ecommerce spécialisé dans la vente en ligne de matériel de bricolage et de jardinage, j'ai été chargé de trouver des solutions techniques pour la refonte complète du site.

L'objectif de cette refonte était d'améliorer l'expérience d'achat de nos clients. Les utilisateurs n'ayant pas les mêmes approches lorsqu'il s'agit de la navigation, nous avons imaginé deux scénarios possibles : certains ont une idée précise de ce qu'ils recherchent, tandis que d'autres ont besoin d'être orienté dans leurs processus d'achat. À partir de ce constat, nous devions mettre en place, pour les premiers, un moteur de recherche plein texte pertinent, et, pour les seconds, une navigation à facettes performante. Les deux fonctionnalités peuvent se combiner et permettre à une recherche plein texte d'être redéfinie par l'intermédiaire de différentes facettes. À partir de ces besoins, notre problématique était la suivante : comment permettre à nos visiteurs de naviguer à travers notre catalogue de plus de 40 000 produits de manière performante et pertinente ?

Elasticsearch semblait être le candidat idéal car sa vocation première est de proposer un moteur de recherche plein texte pertinent. Grâce aux agrégations, il permet de constituer des facettes et offre ainsi à nos utilisateurs la possibilité d'affiner les résultats. La capacité d'Elasticsearch à s'adapter aux montées en charge, la grande richesse des fonctionnalités qu'il propose et le caractère open source du logiciel nous ont conduit à sauter le pas.

Test de la solution

Prise en main

Pour essayer Elasticsearch et mettre à l'épreuve ses capacités à répondre à nos besoins, nous nous sommes servis de Found Play qui sert de bac à sable pour expérimenter les requêtes. Found Play permet de découvrir les vastes fonctionnalités d'Elasticsearch.

Pour l'utiliser, il suffit de :

  • Représenter vos documents
  • Définir le mapping correspondant (si besoin, Elasticsearch définira lui même le mapping grâce au dynamic mapping)
  • Définir les analyseurs (si besoin)
  • Écrire les requêtes de recherche

Found Play s'occupe ensuite du reste : il crée les indexes avec les bons mappings et analyseurs.

found-play-bricozor.png

Performances

Sur la précédente version de notre site e­commerce, nous utilisions MySQL. Pour récupérer les produits d'une catégorie donnée avec leurs facettes correspondantes, nous devions effectuer deux requêtes : une pour chercher les produits et une autre pour constituer les facettes. Cela posait des problèmes de performance qui dépendaient directement de la complexité de nos requêtes, notamment du nombre de facettes et du nombre de filtres actifs impliqués. Nous attendions donc beaucoup d'Elasticsearch.

Sur une instance d'Elasticsearch tournant sur une machine de développement, nous avons indexé notre catalogue de plus de 40 000 produits. Lors des tests qui consistaient à effectuer plusieurs requêtes différentes et concurrentes, nous avons été bluffés par les performances : les temps de réponses sont de l'ordre du quasi-­instantané. 

Mise en place de l'index catalogue

Il est temps de passer à la pratique. Nous allons configurer notre index pour accueillir notre catalogue de produits de façon à remplir deux fonctions : offrir un moteur de recherche (avec autocomplétion) et proposer une navigation à facettes.

Mapping

Si le mapping dynamique d'Elasticsearch est utile pour indexer et requêter des documents sans attendre, spécifier explicitement la structure et le mode d'indexation des données est nécessaire pour les types de recherches que nous envisageons.

Voici un exemple de mapping simplifié pour le type “product” présent dans notre index catalog :

{
   "product": {
       "_source": {
           "enabled": true
       },
       "dynamic": "strict",
       "properties": {
           "full_title": {
               "type": "string",
               "fields": {
                   "french": {
                       "type": "string",
                       "analyzer": "french"
                   },
                   "suggestion": {
                       "type": "string",
                       "analyzer": "custom_suggestion"
                   },
                   "autocomplete": {
                       "type": "string",
                       "index_analyzer": "nGram_analyzer",
                       "search_analyzer": "whitespace_analyzer"
                   }
               }
           },
           "catalog_code": {
               "type": "string",
               "index": "not_analyzed"
           },
           "categories": {
               "type" : "string",
               "fields": {
                   "french": {
                       "type": "string",
                       "analyzer": "french"
                   },                   
             "untouched": {
                       "type": "string",
                       "index": "not_analyzed"
                   }
               }
           },
           "brand": {
               "type" : "string",
               "fields": {
                   "untouched": {
                       "type": "string",
                       "index": "not_analyzed"
                   }
               }
           },
           "description": {
               "type": "string",
               "fields": {
                   "french": {
                       "type": "string",
                       "analyzer": "french"
                   }
               }
           },
           "offers": {
               "type" : "string",
               "fields" : {
                   "untouched": {
                       "type": "string",
                       "index": "not_analyzed"
                   }
               }
           },
           "first_published_at": {
               "type": "date"
           }
       }
   }
}

Définir un mapping de type strict nous assure un contrôle sur les champs lors de l'indexation des documents.

Il est possible de définir plusieurs manières dont seront indexés les champs d'un document. Pour certains champs qui seront utilisés dans les agrégations (pour produire des facettes), nous avons besoin d'une variante not_analyzed du champ (le champ brand.untouched par exemple).

Pour la recherche plein texte et l'autocomplétion, nous avons besoin de plusieurs variantes du champ full_title car nous allons utiliser des analyseurs différents selon le type de recherche.

Des analyseurs personnalisés pour la recherche plein texte et l'autocomplétion

Nos données sont indexées et exploitées en langue française. Il nous est donc nécessaire d'utiliser les bons analyseurs pour qu'Elasticsearch prenne en compte les particularités de la langue lors de l'indexation des documents. La façon dont sont traités certains caractères accentués, les élisions, les pluriels des mots, la gestion des mots récurrents à faible importance, etc. est définie à travers les analyseurs de texte.

Pour qu'une recherche de “coulisse a billes” retourne aussi les documents ayant comme titre “coulisses à billes”, on effectue la recherche sur les champs full_title et full_title.french. Le champ full_title utilise l'analyseur standard qui sépare un texte en mots, tandis que l'analyseur french est défini de façon à tronquer les mots pour ne garder que le radical et enlever les accents. Cependant, le document qui aura un titre correspondant à la recherche sera considéré comme plus pertinent.

{
   "settings" : {
       "index" : {
           "analysis": {
               "filter": {
                   "nGram_filter": {
                       "type": "nGram",
                       "min_gram": 2,
                       "max_gram": 20,
                       "token_chars": [
                           "letter",
                           "digit",
                           "punctuation",
                           "symbol"
                       ]
                   }
               },
               "analyzer": {
                   "nGram_analyzer": {
                       "type": "custom",
                       "tokenizer": "whitespace",
                       "filter": [
                           "lowercase",
                           "asciifolding",
                           "nGram_filter"
                       ]
                   },
                   "whitespace_analyzer": {
                       "type": "custom",
                       "tokenizer": "whitespace",
                       "filter": [
                           "lowercase",
                           "asciifolding"
                       ]
                   }
               }
           }
       }
   }
}

L'autocomplétion s'effectue sur le champ full_title.autocomplete. Nous avons utilisé un analyseur personnalisé avec le filtre nGram afin que les mots soient découpés en plusieurs sous­-séquences de caractères. Cela permettra de remonter des résultats à partir d'un début de saisie textuelle. Cependant, nous voulons que toute la chaine saisie par l'utilisateur soit prise en compte dans son ensemble, nous définissons donc un analyseur à utiliser pour l'indexation et un autre à utiliser pour les requêtes :

"autocomplete":{
   "autocomplete": {
       "type": "string",
       "index_analyzer": "nGram_analyzer",
       "search_analyzer": "whitespace_analyzer"
   }
}

Navigation à facettes

Les agrégations d'Elasticsearch permettent de constituer des facettes. Elasticsearch propose une multitude d'agrégations différentes.

Une agrégation de type terms va permettre de définir une facette composée des différentes valeurs possibles d'un champ (par exemple, les différentes marques), tandis qu'une aggregation de type range servira à créer des intervalles pour, par exemple, permettre de filtrer par différents ensembles de prix.

Les agrégations sont produites selon le contexte de la recherche (query). Pour filtrer les résultats sans pour autant filtrer les agrégations, il faut utiliser “post_filter”. Il est cependant nécessaire d'appliquer aussi les filtres au niveau des différentes agrégations (exception faite de l'agrégation dont est issue le filtre si vous souhaitez permettre l'activation de plusieurs filtres par facette) afin que les résultats des agrégations reflètent les documents remontés.

Par exemple, dans la requête de recherche suivante, nous effectuons une recherche (query) sur la catégorie “Tondeuse” et nous filtrons (post_filter) les résultats par la marque “ALPINA”.

{
   "aggregations": {
       "brand": {
           "terms": {
               "field": "brand.untouched"
           }
       },
       "categories": {
           "aggs": {
               "categories_filtered": {
                   "terms": {
                       "field": "categories.untouched"
                   }
               }
           },
           "filter": {
               "term": {
                   "brand.untouched": "ALPINA"
               }
           }
       },
       "offers": {
           "aggs": {
               "offers_filtered": {
                   "terms": {
                       "field": "offers.untouched"
                   }
               }
           },
           "filter": {
               "term": {
                   "brand.untouched": "ALPINA"
               }
           }
       }
   },
   "post_filter": {
       "term": {
           "brand.untouched": "ALPINA"
       }
   },
   "from": 0,
   "query": {
        "filtered": {
            "filter": {
                 "term": {
                      "categories.untouched": "Tondeuse"
                  }
             }
        }
   },
   "size": 20
}

Nous filtrons les agrégations offers et categories avec la marque “ALPINA” afin qu'elles reflètent le résultat de la recherche.

facette-navigation-bricozor.jpg

Calcul du score de pertinence selon des facteurs personnalisés

Elasticsearch permet, grâce à l'API function_score, d'influencer le score de pertinence de chaque document. Ainsi, il nous est possible pour les recherches plein texte, de mettre en avant les produits qui sont en stock. Par ailleurs, dans la navigation par catégorie, nous avons pu définir un ordre de tri par popularité. Le nombre d'avis sur un produit, sa note moyenne, ses ventes sur une période donnée, etc. sont les critères qui, une fois pondérés, permettent de définir la notion de popularité.

pertinence-score-bricozor.jpg

Intégration du client Elasticsearch dans notre application

La partie backend de Bricozor est écrite en Python. Elasticsearch propose plusieurs clients pour différents langages. Nous avons utilisé celui qui se nomme elasticsearch­-py.

Elasticsearch tourne en production sur 1 cluster de 3 noeuds, répartis sur deux machines physiques, le troisième noeud servant d'arbitre afin d'éviter le souci de “Split brain”. Faire tourner Elasticsearch sur 3 noeuds nous permet d'avoir 2 réplicas des shards, ce qui nous assure une redondance des données.

Perspectives d'évolution

Les perspectives d'utilisation d'Elasticsearch et de son écosystème sont nombreuses. Nous envisageons, par exemple, d'utiliser les agrégations de type significant terms pour proposer de la recommandation produit, tout en exploitant le plugin Graph pour Kibana pour débusquer les relations entre les produits de notre catalogue.

Par ailleurs, la recherche inversée d'Elasticsearch (Percolator) nous permettra de mettre en place un système de notification d'évènements promotionnels sur des typologies de produits qui suscitent l'intérêt de nos clients.

Pour finir, en tant que développeurs, nous avons un besoin de visualiser d'éventuelles anomalies de nos applications et de nos serveurs. La Suite Elastic complète, qui amène Kibana, Logstash et Beats en plus d'Elasticsearch semble être intéressante. J'ai hâte d'essayer tout ça !


william-jautee-headshot.jpg

William Jautée est développeur web chez Bricozor depuis près de 4 ans. Il s'intéresse tout autant aux technologies front­end que back­end dans l'objectif d'améliorer l'expérience utilisateur.