Comment fonctionne vraiment la recherche à facettes dans Elasticsearch (et où elle casse)
Une facette, c'est deux questions, pas une
Une page de recherche à facettes pose deux questions différentes en même temps. La première est évidente : quels documents correspondent à la recherche en cours et aux filtres déjà appliqués. La seconde est posée une fois pour chaque valeur de filtre disponible : combien de documents correspondraient si cette valeur était aussi sélectionnée. La case « Rouge (12) » est la réponse à la seconde question, calculée pendant qu'on répond à la première.
L'essentiel de ce qui rend la recherche à facettes difficile, dans Elasticsearch comme ailleurs, vient de cette seconde question. Obtenir la liste des documents correspondants n'est pas compliqué. Obtenir un comptage honnête à côté de chaque valeur de chaque filtre, à chaque chargement de page, sans que le temps de réponse explose à mesure que le nombre de facettes augmente, voilà le vrai problème d'ingénierie.
La brique de base : une agrégation terms sur un ensemble filtré
Elasticsearch répond aux deux questions avec une seule requête. La clause de requête restreint l'ensemble des documents (correspondance plein texte plus les filtres déjà actifs), et un ensemble d'agrégations rattachées à cette même requête calcule, pour chaque champ de facette, combien de documents de cet ensemble filtré tombent dans chaque valeur. Une agrégation terms sur un champ color renvoie un bucket par valeur distincte avec son nombre de documents ; une agrégation range ou histogram fait de même pour les champs numériques comme le prix. Tout cela se passe dans la même exécution de requête, sur le même ensemble filtré, en un seul aller-retour.
C'est le point facile à manquer si vous n'avez utilisé que SQL : une agrégation, ici, n'est pas une seconde requête sur le résultat de la première. Elle s'exécute sur les doc values et les postings de l'index pendant la même requête, et c'est pourquoi ajouter vingt facettes de plus à une page ne signifie pas vingt parcours complets de plus des documents correspondants. Cela signifie vingt calculs de buckets de plus sur des données que le moteur devait de toute façon toucher pour répondre à la requête.
Contexte filter, pas contexte query
Les facettes, et les filtres appliqués en cliquant dessus, devraient presque toujours se trouver en contexte filter, pas en contexte query. Un filtre term sur brand: acme n'a pas besoin de calculer un score de pertinence, il a besoin d'une réponse oui ou non, et Elasticsearch met en cache les clauses de filtre de façon bien plus agressive que les clauses de requête avec score. Construire une page de recherche à facettes uniquement avec des clauses bool must qui sont toutes scorées est une manière courante de la rendre plus lente que nécessaire sans aucun bénéfice : personne ne trie une page catégorie par « pertinence par rapport au filtre Rouge ». Les filtres vont dans filter, la recherche plein texte va dans must ou dans une requête match dédiée, et cette séparation n'est pas cosmétique : elle change ce qui est mis en cache et ce qui est scoré.
Le piège de la multi-sélection
Le détail qui casse plus d'implémentations de recherche à facettes que tout autre est celui-ci : quand vous calculez le comptage d'une facette, vous devez exclure le filtre de cette facette elle-même de l'ensemble de documents sur lequel vous comptez, tout en conservant tous les autres filtres actifs. Sinon, chaque facette sur laquelle vous avez déjà filtré affichera un comptage égal au nombre de résultats actuellement à l'écran, parce que vous comptez l'intersection avec une condition déjà entièrement appliquée. Cliquez sur « Rouge », et chaque autre valeur de « Couleur » afficherait alors à tort le même chiffre que le total, au lieu du nombre de produits rouges et de cette valeur qui existent réellement.
La correction habituelle consiste à construire une agrégation par champ de facette, chacune enveloppée dans sa propre agrégation filter qui réapplique tous les filtres actifs sauf celui de ce champ, toutes au même niveau sous la même requête principale. Concrètement : la requête de base porte déjà la recherche plein texte et tous les filtres qui ne sont pas en cours de comptage, et l'agrégation de chaque facette rajoute les autres filtres sélectionnés, moins le sien. Cela fait plus d'agrégations par requête, pas plus de requêtes, et c'est la partie qu'il vaut la peine de tester explicitement plutôt que de la valider à l'œil, parce qu'une page peut sembler parfaitement correcte en démonstration avec trois produits et afficher silencieusement de faux comptages dès que de vraies données, avec de vrais recoupements, sont chargées.
Il y a une seconde décision, liée à la première, qu'il vaut mieux prendre délibérément : ce que signifie un groupe de facettes quand plusieurs valeurs sous le même libellé sont sélectionnées ensemble. Si une boutique regroupe « Cramoisi », « Écarlate » et « Bordeaux » sous un seul filtre « Rouge », sélectionner deux de ces sous-valeurs signifie-t-il « a ceci ET cela » ou « a ceci OU cela » ? Un comptage qui répond à la question ET alors que le clic applique la question OU (ou l'inverse) est la cause la plus fréquente d'un filtre qui affiche un chiffre et en renvoie un autre. Les deux doivent être calculés avec exactement la même logique, ce qui revient généralement à traiter le groupe dès le départ comme une seule condition d'union, plutôt que d'additionner des comptages par valeur qui peuvent se recouper ou compter deux fois le même produit.
Pourquoi tout s'effondre sans moteur d'agrégation, chiffre réel à l'appui
La raison d'être des moteurs de recherche à facettes, plutôt que de tout faire en SQL, apparaît clairement dès qu'on essaie d'obtenir des comptages exacts par valeur sur une base de données relationnelle à grande échelle. Sur un catalogue de test de 200 000 produits utilisant le moteur de recherche intégré de PrestaShop, limité à la base de données, avec environ 2 250 groupes d'attributs et de caractéristiques, calculer un comptage exact « combien de résultats par valeur » avec une requête COUNT(DISTINCT ...) par facette faisait prendre 43 secondes à une page catégorie avec un filtre actif. Désactiver les comptages exacts pour ces facettes (afficher la liste des valeurs sans chiffre) a ramené la même page à un peu moins de 12 secondes, ce qui montre quelle part du coût initial relevait du pur comptage, et non de la recherche elle-même.
Une erreur voisine, relevée lors d'une revue de code interne d'un module de recherche à facettes, consistait à calculer l'intervalle d'une facette numérique (pour un curseur « Prix » ou « Poids ») en chargeant toutes les valeurs correspondantes dans la mémoire de l'application et en calculant le minimum et le maximum à cet endroit, au lieu de demander à la base de données ou au moteur de recherche de le faire. Sur un catalogue où cela représentait environ deux millions de lignes, cette seule étape prenait 11 secondes et 965 Mo de mémoire par requête, ce qui suffisait sur certaines configurations à épuiser la limite de mémoire d'un hébergement mutualisé et à renvoyer une page d'erreur au lieu d'un curseur. Déplacer le même calcul dans un GROUP BY de la base de données, ou dans l'agrégation range du moteur de recherche, en a refait une requête normale et rapide, parce que c'est précisément le type de calcul agrégé que ces systèmes sont conçus pour faire en une seule passe sur des données indexées, et non dans le code applicatif après coup.
Les deux chiffres décrivent la même erreur de fond à des niveaux différents : demander « combien, pour chaque valeur possible » une fois par valeur au lieu d'une fois pour toutes les valeurs ensemble. Le framework d'agrégation d'Elasticsearch existe précisément pour répondre à cette question en une seule passe sur des données déjà indexées. C'est là sa vraie valeur, indépendamment du classement par pertinence plein texte, et c'est pourquoi la navigation à facettes est l'une des raisons les plus fréquentes d'adopter Elasticsearch, même quand la recherche elle-même n'a pas besoin de fonctions de pertinence avancées.
Une courte check-list avant de mettre en ligne une recherche à facettes
- Les filtres vont en contexte filter, le plein texte en contexte query. Ne calculez pas de score pour une case à cocher.
- L'agrégation de comptage de chaque facette doit exclure le filtre actif de cette facette tout en conservant tous les autres, sinon les facettes en multi-sélection mentiront dès que deux filtres seront utilisés ensemble.
- Décidez dès le départ, et testez explicitement, si un groupe de valeurs sous un même libellé fonctionne en ET ou en OU, et faites partager exactement la même logique au comptage et au clic.
- Limitez la taille des agrégations et surveillez
doc_count_error_upper_boundsur les champs à forte cardinalité ; une agrégation terms sans limite qui renvoie des milliers de buckets est un problème de performance à part entière, indépendant de la question de la multi-sélection ci-dessus. - Ne calculez jamais les chiffres d'une facette en chargeant des lignes brutes dans la mémoire de l'application. Si vous vous surprenez à écrire une boucle sur les résultats d'une requête pour construire un comptage ou un intervalle, cette boucle a sa place dans l'agrégation, pas dans votre code.
Quand Elasticsearch n'est pas la réponse
Rien de tout cela ne signifie que chaque catalogue a besoin d'Elasticsearch. Une boutique de quelques milliers de produits avec une poignée de facettes remarquera rarement la différence qu'apporte un moteur de recherche dédié, et faire tourner un cluster a son propre coût d'exploitation : mémoire, disque, et quelqu'un qui sait gérer la réindexation quand le schéma change. Les chiffres ci-dessus ne commencent à compter que lorsque le nombre de facettes, la taille du catalogue, ou les deux, deviennent assez grands pour qu'un comptage naïf se mesure en secondes plutôt qu'en millisecondes. Si vous n'en êtes pas là, une requête de base de données bien optimisée est un choix parfaitement raisonnable, et la check-list ci-dessus s'y applique aussi : la question ET/OU et le piège de la multi-sélection sont le même problème, quel que soit le moteur qui répond.
En bref
La recherche à facettes n'est pas une requête, c'est une requête plus une agrégation par filtre, et presque tous les bugs réels viennent d'une entorse à ce modèle : scorer ce qui devrait être un filtre, compter une facette en tenant compte de sa propre condition déjà appliquée, ou répondre au comptage et au clic avec deux définitions différentes d'un groupe. Le framework d'agrégation d'Elasticsearch règle proprement le problème d'échelle, en une seule passe sur des données indexées au lieu d'une requête par valeur de facette, et c'est la raison concrète pour laquelle les chiffres de 43 et 11 secondes ci-dessus sont apparus sur un moteur limité à la base de données et non sur un moteur conçu pour cela. Nous faisons ce travail au quotidien dans cll_essearch, notre module de recherche à facettes pour PrestaShop, sur le driver natif comme sur Elasticsearch, OpenSearch et Meilisearch (voir le guide d'installation), si vous voulez voir la même logique appliquée à une boutique en ligne plutôt qu'à un index brut.
Recherche Elasticsearch et Navigation à FacettesRecherche ultra-rapide, tolérante aux fautes de frappe, avec navigation à facettes moderne en panneau modal, a…99,99 €Voir le produit