SpazioCodice

Dans cet article, nous décrivons une approche pour combiner une recherche d'éléments connus avec une recherche en texte intégral régulière, en utilisant Apache Solr.

Apache Solr: orchestrer la recherche d’éléments connus et la recherche en texte intégral

Vous travaillez comme ingénieur de recherche pour XYZ Ltd, une entreprise qui vend des composants électriques. XYZ vous a fourni les journaux d’application des six derniers mois, ainsi que quelques exigences commerciales.

Deux types de clients, deux types d'exigences, deux types de recherche

L’analyse des journaux montre que XYZ a principalement deux types de clients : le premier groupe, les utilisateurs “experts” (par exemple, électriciens, revendeurs, magasins), dont les membres effectuent des recherches sur le système en utilisant des identifiants de produit, des codes (par exemple, SKU, codes de modèles, comme Y-M8GB, 140-213/A et ABD9881) ; il est clair, ou du moins cela semble être le cas, qu’ils savent déjà ce qu’ils veulent et ce qu’ils recherchent. Cependant, vous avez remarqué que de nombreuses recherches ne produisent aucun résultat. Après enquête, le problème semble être que les codes et identifiants sont difficiles à mémoriser : les requêtes utilisent de nombreuses formes disparates pour désigner le même produit. Par exemple :

  • y-m8gb (minuscule)
  • YM8GB (pas de délimiteurs)
  • YM-8GB (délimiteur au mauvais endroit)
  • Y/M8GB (délimiteur incorrect)
  • Y M8GB (espace à la place du délimiteur)
  • y M8/gb (combinaison des cas ci-dessus)

Ce type de scénario, où il n’y a qu’un seul document pertinent dans la collection, est généralement appelé “Recherche d’éléments connus” : notre premier objectif est de s’assurer que cette “intention d’identifiant produit” est satisfaite.

L’autre groupe de clients est constitué des utilisateurs finaux, comme vous et moi. N’étant pas familiers avec des spécifications de produits telles que des codes ou des numéros de modèles, le comportement ici est différent : ils effectuent une recherche par mots-clés classiques, en tentant de trouver des produits en entrant des termes représentant des noms, des marques et des fabricants. Voici donc le deuxième objectif, qui peut être résumé comme suit : les gens doivent pouvoir trouver des produits en entrant des requêtes en texte libre.

Comme vous pouvez l’imaginer, dans ce cas, les exigences de recherche sont différentes de l’autre scénario : l’accent ici est plus sur les “termes”, impliquant donc d’autres considérations sur l’analyse de texte à appliquer.

Tandis que le groupe d’experts recherche un seul et unique produit (nous sommes dans un scénario binaire : correspondre ou non), les besoins de l’autre groupe nécessitent que le système fournisse une liste de documents “pertinents” en fonction des termes entrés.

Une chose importante à noter avant de continuer : à des fins d’illustration, nous allons considérer ces deux requêtes/groupes d’utilisateurs comme disjoints : c’est-à-dire qu’un utilisateur donné appartient uniquement à l’un des groupes mentionnés, et non aux deux. Mieux encore, une requête donnée contiendra soit des identifiants de produits soit des termes, pas les deux.

Le groupe d'experts et la “Recherche d'éléments connus”

L’intention de “l’identifiant produit”, supposée implicite dans le comportement de requête de ce groupe, peut être capturée, tant au niveau de l’index qu’au niveau de la requête, en appliquant l’analyseur suivant, qui traite essentiellement la valeur entrante comme un tout, la normalise en minuscules, supprime tous les délimiteurs, puis regroupe tout en un seul jeton de sortie.

Dans le tableau suivant, vous pouvez voir l’analyseur en action avec quelques exemples :

Comme vous pouvez le voir, l’analyseur ne déclare pas d’attribut type car il est censé être appliqué à la fois au moment de l’indexation et de la requête. Cependant, il y a une différence dans la valeur entrante : lors de l’indexation, l’analyseur traite le contenu d’un champ (c’est-à-dire la valeur d’un champ du document entrant), tandis qu’au moment de la requête, la valeur qui passe par le pipeline est composée de un ou plusieurs termes entrés par l’utilisateur (une requête, brièvement).

Alors qu’au moment de l’indexation, tout fonctionne comme prévu, au moment de la requête, l’analyseur ci-dessus nécessite une fonctionnalité introduite dans Solr 6.5 : le paramètre “Split On Whitespace” [1]. Lorsqu’il est défini sur “false” (comme nous en avons besoin ici dans ce contexte), il empêche le texte de la requête entrante d’être séparé en unités distinctes lors de l’envoi à l’analyseur.

Avant Solr 6.5, nous ne disposions pas d’un tel contrôle, et les analyseurs recevaient des jetons “prédéfinis-par-les-espaces” ; en d’autres termes, l’unité de travail de l’analyse au moment de la requête était le terme unique : la chaîne d’analyse (y compris le tokenizer lui-même) était invoquée pour chaque terme généré par cette pré-tokenisation par espaces. Par conséquent, notre analyseur, au moment de la requête, ne pouvait pas fonctionner comme prévu : si nous prenons l’exemple #5 et #6 du tableau ci-dessus, vous pouvez voir que l’utilisateur a entré un espace. Avec le paramètre “Split on Whitespace” défini sur true (explicitement ou dans une version Solr < 6.5), la pré-tokenisation décrite ci-dessus produit deux jetons :

  • #5 = {“Y”, ”M8GB”}
  • #6 = {“y”, “M8/gb”}

Ainsi, notre analyseur recevrait 2 jetons (pour chaque cas), et il n’y aurait aucune correspondance avec le seul terme ym8gb stocké dans l’index. Donc, avant Solr 6.5, nous avions deux manières de traiter cette exigence :

  • Côté client : entourer toute la requête de guillemets, échapper les espaces avec “\”, ou les remplacer par un délimiteur comme “-”. Facile, mais cela nécessite un contrôle du code client, ce qui n’est pas toujours possible.
  • Côté Solr : appliquer à la requête entrante les mêmes transformations qu’au-dessus, mais cette fois au niveau du parser de requête. Facile, si vous connaissez bien les internals de Lucene / Solr. De plus, cela nécessite un contexte où vous avez l’autorisation d’installer des plugins personnalisés dans Solr. Un effet similaire pourrait aussi être obtenu en utilisant un UpdateRequestProcessor, qui créerait un nouveau champ avec la même valeur que l’original mais sans espaces.

Le groupe des utilisateurs finaux et la requête de recherche en texte intégral

Dans ce cas, nous sommes dans un contexte de recherche en texte intégral « classique », où l’analyse a identifié quelques champs cibles : les noms de produits et les marques.

Contrairement au scénario précédent, ici, nous n’avons pas de manière unique et déterministe de satisfaire l’exigence de recherche. Cela dépend de nombreux facteurs : le catalogue, la distribution des termes, l’expérience de l’implémenteur et les attentes des clients en matière d’expérience de recherche utilisateur. Toutes ces choses peuvent mener à des réponses différentes. À titre d’exemple, voici une option possible :

L’objectif ici n’est pas de se concentrer sur la conception du schéma en soi : l’important à souligner est que cette exigence nécessite une configuration complètement différente de celle de la « Recherche d’éléments connus » décrite précédemment.

Plus précisément, supposons que nous ayons adopté une approche « centrée sur les termes » pour satisfaire la deuxième exigence. Cette approche nécessite une valeur différente pour le paramètre « Split on Whitespace », qui doit être défini sur vrai dans ce cas.

Le paramètre « sow » peut être défini au niveau du SearchHandler, de sorte qu’il soit appliqué au moment de la requête. Il peut être déclaré dans le solrconfig.xml et, selon la configuration, il peut être remplacé en utilisant un paramètre de requête nommé (HTTP).

Une pré-tokenisation « split on whitespace » nous amène dans un scénario très différent de celui de la « Recherche d’éléments connus », où, en revanche, nous devrions être dans une recherche centrée sur les champs ; « devrions » est entre guillemets, car si, d’une part, nous utilisons effectivement une recherche centrée sur les champs, d’autre part, nous sommes dans un cas particulier où nous interrogeons un seul champ avec un seul terme de requête (le premier analyseur de ce post génère toujours un seul terme).

Implémentation : Où ?

Bien que l’on puisse penser que la première chose à faire est de combiner ces deux stratégies de requête différentes, avant cela, la question à laquelle nous devons répondre est où implémenter la solution ? Clairement, quelle que soit la méthode que nous déciderons de suivre, nous devrons implémenter un flux de travail (de recherche), qui peut être résumé dans le diagramme suivant :

Du côté de Solr, chaque tâche de « recherche » doit être exécutée dans un SearchHandler différent. Revenons donc à notre question : où voulons-nous implémenter ce flux de travail ? Nous avons trois options : en dehors de Solr, entre Solr et l’application, ou à l’intérieur de Solr.

Option #1 : Côté client

La première option consiste à implémenter le flux décrit ci-dessus dans l’application cliente. Cela suppose que vous ayez le contrôle nécessaire et les compétences en programmation sur ce côté. Si cette hypothèse est correcte, alors il est relativement facile de coder le flux de travail : vous pouvez choisir l’un des liens API client disponibles pour votre langage, puis implémenter la recherche conditionnelle double, comme illustré ci-dessus.

Avantages : facile à implémenter. Il nécessite des connaissances minimales en Solr (fonctionnelles).
Inconvénients : le flux de travail/la logique de recherche est déplacé du côté client. La programmation est requise, donc vous devez être dans un contexte où cela peut être fait et où le code de l’application cliente est sous votre contrôle.

Option #2 : Homme du milieu

Déplaçant les choses hors du domaine du client, une autre option populaire, qui peut encore être vue comme une alternative côté client (du point de vue de Solr), est un proxy/adaptateur/façade. Peu importe le nom que vous souhaitez donner à cette solution, il s’agit d’un nouveau module qui se situe entre l’application cliente et Solr ; il intercepte toutes les demandes et implémente la logique personnalisée en orchestrant les points de terminaison de recherche exposés dans Solr.

Étant un nouveau module, il présente plusieurs avantages :

  • il peut être codé dans votre langage préféré,
  • il est complètement découplé de l’application cliente et de Solr.

Mais pour la même raison, il présente aussi quelques inconvénients :

  • il doit être créé : conçu, implémenté, testé, installé et maintenu,
  • il constitue une nouvelle pièce dans votre système, ce qui augmente nécessairement la complexité générale de l’architecture,
  • Solr expose de nombreux services (indexation et recherche). Avec cette option, tous ces services devraient être proxyfiés, ce qui entraîne de nombreuses délégations inutiles (c’est-à-dire des services délégués qui n’ajoutent aucune valeur à la chaîne d’exécution).

Option #3 : Côté serveur (Solr)

La dernière option déplace l’implémentation du flux de travail (et la logique de recherche) à l’endroit où, à mon avis, elle devrait être : dans Solr.

Notez que cette option n’est généralement pas seulement un choix « philosophique » : si vous êtes un ingénieur de recherche, il est probable que vous soyez embauché pour concevoir, implémenter et affiner la « partie recherche du gâteau ». Cela signifie qu’il est parfaitement possible que, pour diverses raisons, vous deviez considérer l’application cliente comme un (sous)système externe, où vous n’avez aucun contrôle.

Le principal inconvénient de cette approche est que, comme vous pouvez l’imaginer, elle nécessite des compétences en programmation ainsi que des connaissances sur les internals de Solr.

Dans Solr, une requête de recherche est traitée par un SearchHandler, un composant chargé d’exécuter la logique associée à un point de terminaison de recherche donné. Dans notre exemple, nous aurions les handlers de recherche suivants correspondant aux deux exigences :

En plus de cela, nous aurions besoin d’un troisième composant, chargé d’orchestrer les deux handlers de recherche ci-dessus. Je vais appeler ce composant un “Composite Request Handler”.

Le handler composite fournirait également le point de terminaison de recherche public appelé par les clients. Une fois une requête reçue, le composite request handler implémente le flux de travail de recherche : il invoque tous les handlers qui composent sa chaîne, et s’arrête dès qu’un des cibles d’invocation produit le résultat attendu.

La configuration du composite handler ressemble à ceci :

				
					<requestHandler name="/search" class="io.sease.crh.CompositeRequestHandler">
    <str name="chain">/rh1,/rh2,/rh3</str>
	<str name="rules">eq1,gt0,always</str>
</requestHandler>
				
			

Côté client, cela ne nécessiterait qu’une seule requête car tout le flux de travail serait implémenté dans Solr à l’aide du composite request handler. En d’autres termes, en imaginant une interface graphique avec une barre de recherche, l’application cliente, lorsque le bouton de recherche est pressé, devrait récupérer le(s) terme(s) saisis par l’utilisateur et envoyer juste une requête (au point de terminaison du composite handler), quel que soit l’intention de l’utilisateur (c’est-à-dire quel que soit le groupe auquel l’utilisateur appartient).

Le composite request handler introduit dans cette section a déjà été implémenté ; vous pouvez le trouver dans notre compte Github ici.

Profitez-en, et comme d’habitude, tous les retours sont les bienvenus !

Share this post

Laisser un commentaire

En savoir plus sur SpazioCodice

Abonnez-vous pour poursuivre la lecture et avoir accès à l’ensemble des archives.

Poursuivre la lecture