Un tutoriel Elasticsearch pour les développeurs .NET

Publié: 2022-03-11

Un développeur .NET doit-il utiliser Elasticsearch dans ses projets ? Bien qu'Elasticsearch soit construit sur Java, je pense qu'il offre de nombreuses raisons pour lesquelles Elasticsearch vaut le coup pour la recherche en texte intégral pour n'importe quel projet.

Elasticsearch, en tant que technologie, a parcouru un long chemin au cours des dernières années. Non seulement cela donne l'impression que la recherche en texte intégral est magique, mais il offre d'autres fonctionnalités sophistiquées, telles que la saisie semi-automatique de texte, les pipelines d'agrégation, etc.

Si l'idée d'introduire un service basé sur Java dans votre écosystème .NET vous met mal à l'aise, ne vous inquiétez pas, car une fois que vous aurez installé et configuré Elasticsearch, vous passerez la plupart de votre temps avec l'un des packages .NET les plus cool. là : NID.

Dans cet article, vous apprendrez comment vous pouvez utiliser l'incroyable solution de moteur de recherche, Elasticsearch, dans vos projets .NET.

Installation et configuration

L'installation d'Elasticsearch dans votre environnement de développement revient à télécharger Elasticsearch et, éventuellement, Kibana.

Une fois décompressé, un fichier bat comme celui-ci est pratique :

 cd "D:\elastic\elasticsearch-5.2.2\bin" start elasticsearch.bat cd "D:\elastic\kibana-5.0.0-windows-x86\bin" start kibana.bat exit

Après avoir démarré les deux services, vous pouvez toujours vérifier le serveur Kibana local (généralement disponible sur http://localhost:5601), jouer avec les index et les types, et effectuer une recherche en utilisant JSON pur, comme décrit en détail ici.

Le premier pas

En tant que développeur minutieux et bon, avec un soutien et une compréhension complets de la part de la direction, vous commencez par ajouter un projet de test unitaire et à écrire un SearchService avec une couverture de code d'au moins 90 %.

La première étape consiste clairement à configurer le fichier app.config pour fournir une sorte de chaîne de connexion pour le serveur Elasticsearch.

Ce qui est cool avec Elasticsearch, c'est qu'il est entièrement gratuit. Mais, je conseillerais toujours d'utiliser le service Elastic Cloud fourni par Elastic.co. Le service hébergé rend toute la maintenance et la configuration assez faciles. De plus, vous disposez de deux semaines d'essai gratuit, ce qui devrait être plus que suffisant pour essayer tous les exemples ici !

Puisqu'ici nous exécutons localement, une clé de configuration comme celle-ci devrait faire :

 <add key="Search-Uri" value="http://localhost:9200" />

L'installation d'Elasticsearch s'exécute sur le port 9200 par défaut, mais vous pouvez le modifier si vous le souhaitez.

ElasticClient et le package NEST

ElasticClient est un gentil petit gars qui fera la plupart du travail pour nous, et il est livré avec le package NEST.

Installons d'abord le package.

Pour configurer le client, quelque chose comme ceci peut être utilisé :

 var node = new Uri(ConfigurationManager.AppSettings["Search-Uri"]); var settings = new ConnectionSettings(node); settings.ThrowExceptions(alwaysThrow: true); // I like exceptions settings.PrettyJson(); // Good for DEBUG var client = new ElasticClient(settings);

Indexation et mappage

Pour pouvoir rechercher quelque chose, nous devons stocker certaines données dans ES. Le terme utilisé est « indexation ».

Le terme « mappage » est utilisé pour mapper nos données dans la base de données avec des objets qui seront sérialisés et stockés dans Elasticsearch. Nous utiliserons Entity Framework (EF) dans ce didacticiel.

Généralement, lorsque vous utilisez Elasticsearch, vous recherchez probablement une solution de moteur de recherche à l'échelle du site. Vous utiliserez soit une sorte de flux ou de résumé, soit une recherche de type Google qui renvoie tous les résultats de diverses entités, telles que les utilisateurs, les entrées de blog, les produits, les catégories, les événements, etc.

Il ne s'agira probablement pas seulement d'une table ou d'une entité dans votre base de données, mais plutôt d'agréger diverses données et peut-être d'extraire ou de dériver des propriétés communes telles que le titre, la description, la date, l'auteur/propriétaire, la photo, etc. Une autre chose est que vous ne le ferez probablement pas en une seule requête, mais si vous utilisez un ORM, vous devrez écrire une requête distincte pour chacune de ces entrées de blog, utilisateurs, produits, catégories, événements ou autre chose.

J'ai structuré mes projets en créant un index pour chaque "gros" type, par exemple, article de blog ou produit. Certains types Elasticsearch peuvent ensuite être ajoutés pour des types plus spécifiques qui relèveraient du même index. Par exemple, si un article peut être une histoire, un article vidéo ou un podcast, il figurerait toujours dans l'index "article", mais nous aurions ces quatre types dans cet index. Cependant, il est toujours probable qu'il s'agisse de la même requête dans la base de données.

Gardez à l'esprit que vous avez besoin d'au moins un type pour chaque index, probablement un type qui porte le même nom que l'index.

Pour mapper vos entités, vous souhaiterez créer des classes supplémentaires. J'utilise généralement la classe DocumentSearchItemBase , dont chacune des classes spécialisées héritera BlogPostSearchItem , ProductSearchItem , etc.

J'aime avoir des expressions de mappeur dans ces classes. Je peux toujours modifier les expressions si nécessaire sur la route.

Dans l'un de mes premiers projets avec Elasticsearch, j'ai écrit une assez grande classe SearchService avec des mappages et une indexation effectués avec de belles et longues instructions de commutation : pour chaque type d'entité que je veux lancer dans Elasticsearch, il y avait un commutateur et une requête avec mappage qui fait ça.

Cependant, tout au long du processus, j'ai appris que ce n'était pas la meilleure façon, du moins pas pour moi.

Une solution plus élégante consiste à avoir une sorte de classe IndexDefinition intelligente et une classe de définition d'index spécifique pour chaque index. De cette façon, ma classe IndexDefinition de base peut stocker une liste de tous les index disponibles et certaines méthodes d'assistance telles que les analyseurs requis et les rapports d'état, tandis que les classes dérivées spécifiques à l'index gèrent l'interrogation de la base de données et le mappage des données pour chaque index spécifiquement. Ceci est particulièrement utile lorsque vous devez ajouter une entité supplémentaire à ES ultérieurement. Cela revient à ajouter une autre classe SomeIndexDefinition qui hérite de IndexDefinition et vous oblige à implémenter quelques méthodes qui interrogent les données que vous souhaitez dans votre index.

Le discours d'Elasticsearch

Au cœur de tout ce que vous pouvez faire avec Elasticsearch se trouve son langage de requête. Idéalement, tout ce dont vous avez besoin pour pouvoir communiquer avec Elasticsearch est de savoir comment construire un objet de requête.

Dans les coulisses, Elasticsearch expose ses fonctionnalités en tant qu'API basée sur JSON sur HTTP.

Bien que l'API elle-même et la structure de l'objet de requête soient assez intuitives, la gestion de nombreux scénarios réels peut toujours être un problème.

Généralement, une demande de recherche à Elasticsearch nécessite les informations suivantes :

  • Quel index et quels types sont recherchés

  • Informations sur la pagination (combien d'éléments à ignorer et combien d'éléments à renvoyer)

  • Une sélection de type concret (lorsque vous faites une agrégation, comme nous sommes sur le point de le faire ici)

  • La requête elle-même

  • Définition de la surbrillance (Elasticsearch peut automatiquement surligner les résultats si nous le souhaitons)

Par exemple, vous souhaiterez peut-être implémenter une fonction de recherche dans laquelle seuls certains utilisateurs peuvent voir le contenu premium de votre site, ou vous souhaiterez peut-être que certains contenus ne soient visibles que par les "amis" de ses auteurs, etc.

Être capable de construire l'objet de requête est au cœur des solutions à ces problèmes, et cela peut vraiment être un problème lorsque l'on essaie de couvrir de nombreux scénarios.

De tout ce qui précède, le plus important et le plus difficile à configurer est, naturellement, le segment de requête - et ici, nous nous concentrerons principalement sur cela.

Les requêtes sont des constructions récursives combinées de BoolQuery et d'autres requêtes, telles que MatchPhraseQuery , TermsQuery , DateRangeQuery et ExistsQuery . C'était suffisant pour répondre à toutes les exigences de base et devrait être bon pour commencer.

Une requête MultiMatch est assez importante car elle nous permet de spécifier les champs sur lesquels nous voulons faire la recherche et de peaufiner un peu plus les résultats, ce sur quoi nous reviendrons plus tard.

Une MatchPhraseQuery peut filtrer les résultats en fonction de ce qui serait une clé étrangère dans les bases de données SQL conventionnelles ou des valeurs statiques telles que les énumérations, par exemple, lors de la correspondance des résultats par auteur spécifique ( AuthorId ) ou de la correspondance avec tous les articles publics ( ContentPrivacy=Public ).

TermsQuery serait traduit par "in" dans le langage SQL conventionnel. Par exemple, il peut renvoyer tous les articles écrits par l'un des amis de l'utilisateur ou obtenir des produits exclusivement auprès d'un ensemble fixe de marchands. Comme avec SQL, il ne faut pas abuser de cela et mettre 10 000 membres dans ce tableau car cela aura un impact sur les performances, mais il gère généralement assez bien des quantités raisonnables.

DateRangeQuery est auto-documenté.

ExistsQuery est intéressant : il permet d'ignorer ou de renvoyer des documents qui n'ont pas de champ spécifique.

Ceux-ci, lorsqu'ils sont combinés avec BoolQuery , vous permettent de définir une logique de filtrage complexe.

Pensez à un site de blog, par exemple, où les articles de blog peuvent avoir un champ AvailableFrom qui indique quand ils doivent devenir visibles.

Si nous appliquons un filtre comme AvailableFrom <= Now , nous n'obtiendrons pas de documents qui n'ont pas du tout ce champ particulier (nous agrégeons des données, et certains documents peuvent ne pas avoir ce champ défini). Pour résoudre le problème, vous devez combiner ExistsQuery avec DateRangeQuery et l'envelopper dans BoolQuery avec la condition qu'au moins un élément de BoolQuery soit rempli. Quelque chose comme ça:

 BoolQuery Should (at least one of the following conditions should be fulfilled) DateRangeQuery with AvailableFrom condition Negated ExistsQuery for field AvailableFrom

La négation des requêtes n'est pas une tâche aussi simple et prête à l'emploi. Mais avec l'aide de BoolQuery , il est néanmoins possible :

 BoolQuery MustNot ExistsQuery

Automatisation et tests

Pour faciliter les choses, la méthode recommandée est définitivement d'écrire des tests au fur et à mesure.

De cette façon, vous pourrez expérimenter plus efficacement et, plus important encore, vous vous assurerez que toute nouvelle modification que vous introduisez (comme des filtres plus complexes) ne cassera pas la fonctionnalité existante. Je ne voulais pas explicitement dire «tests unitaires», car je ne suis pas fan de se moquer de quelque chose comme le moteur d'Elasticsearch - la simulation ne sera presque jamais une approximation réaliste de la façon dont ES se comporte réellement - donc, cela pourrait être des tests d'intégration, si vous êtes un fan de terminologie.

Exemples concrets

Une fois que tout le travail de base est fait avec l'indexation, la cartographie et le filtrage, nous sommes maintenant prêts pour la partie la plus intéressante : peaufiner les paramètres de recherche pour obtenir de meilleurs résultats.

Dans mon dernier projet, j'ai utilisé Elasticsearch pour fournir un flux utilisateur : tout le contenu agrégé à un seul endroit, classé par date de création et recherche en texte intégral avec certaines des options. Le flux lui-même est assez simple ; assurez-vous simplement qu'il y a un champ de date quelque part dans vos données et commandez par ce champ.

La recherche, en revanche, ne fonctionnera pas étonnamment bien hors de la boîte. C'est parce que, naturellement, Elasticsearch ne peut pas savoir quelles sont les choses importantes dans vos données. Disons que nous avons des données qui (entre autres champs) ont des champs Title , Tags (tableau) et Body . Le champ du corps peut être du contenu HTML (pour rendre les choses un peu plus réalistes).

Fautes d'orthographe

L'exigence : Notre recherche doit renvoyer des résultats même si des fautes d'orthographe se produisent ou si la fin du mot est différente. Par exemple, s'il y a un article avec le titre "Des choses magnifiques que vous pouvez faire avec une cuillère en bois", lorsque je recherche "chose" ou "bois", je voudrais toujours obtenir une correspondance.

Pour faire face à cela, nous devrons nous familiariser avec les analyseurs, les tokenizers, les filtres de caractères et les filtres de jetons. Ce sont les transformations qui sont appliquées au moment de l'indexation.

  • Les analyseurs doivent être définis. Cela peut être défini par index.

  • Les analyseurs peuvent être appliqués à certains champs de nos documents. Cela peut être fait en utilisant des attributs ou une API fluide. Dans notre exemple, nous utilisons des attributs.

  • Les analyseurs sont une combinaison de filtres, de filtres de caractères et de tokenizers.

Pour répondre à l'exigence (correspondance partielle des mots), nous allons créer l'analyseur "autocomplete", qui consiste en :

  • Un filtre de mots vides en anglais : le filtre qui supprime tous les mots courants en anglais, tels que "et" ou "le".

  • Filtre Trim : supprime l'espace blanc autour de chaque jeton

  • Filtre minuscule : convertit tous les caractères en minuscules. Cela ne signifie pas que lorsque nous récupérons nos données, elles seront converties en minuscules, mais permettent plutôt une recherche invariante à la casse.

  • Tokenizer Edge-n-gram : ce tokenizer nous permet d'avoir des correspondances partielles. Par exemple, si nous avons une phrase «Ma grand-mère a une chaise en bois», lorsque nous recherchons le terme «bois», nous aimerions toujours obtenir un résultat sur cette phrase. Ce que fait edge-n-gram, c'est stocker "woo", "wood", "woode" et "wooden" afin que toute correspondance partielle de mot avec au moins trois lettres soit trouvée. Les paramètres MinGram et MaxGram définissent le nombre minimum et maximum de caractères à stocker. Dans notre cas, nous aurons un minimum de trois et un maximum de 15 lettres.

Dans la section suivante, tous ceux-ci sont liés ensemble :

 analysis.Analyzers(a => a .Custom("autocomplete", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .Tokenizer("autocomplete") ) .Tokenizers(tdesc => tdesc .EdgeNGram("autocomplete", e => e .MinGram(3) .MaxGram(15) .TokenChars(TokenChar.Letter, TokenChar.Digit) ) ) .TokenFilters(f => f .Stop("eng_stopwords", lang => lang .StopWords("_english_") ) );

Et, quand on veut utiliser cet analyseur, on doit juste annoter les champs qu'on veut comme ceci :

 public class SearchItemDocumentBase { ... [Text(Analyzer = "autocomplete", Name = nameof(Title))] public string Title { get; set; } ... }

Examinons maintenant quelques exemples qui démontrent des exigences assez courantes dans presque toutes les applications avec beaucoup de contenu.

Nettoyage HTML

L'exigence : Certains de nos champs peuvent contenir du texte HTML.

Naturellement, vous ne voudriez pas que la recherche de "section" renvoie quelque chose comme "<section>…</section>" ou "body" renvoyant l'élément HTML "<body>". Pour éviter cela, lors de l'indexation, nous supprimerons le HTML et ne laisserons que le contenu à l'intérieur.

Heureusement, vous n'êtes pas le premier à avoir ce problème. Elasticsearch est livré avec un filtre de caractères utile pour cela :

 analysis.Analyzers(a => a .Custom("html_stripper", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .CharFilters("html_strip") .Tokenizer("autocomplete") )

Et pour l'appliquer :

 [Text(Analyzer = "html_stripper", Name = nameof(HtmlText))] public string HtmlText { get; set; }

Champs importants

L'exigence : les correspondances dans un titre doivent être plus importantes que les correspondances dans le contenu.

Heureusement, Elasticsearch propose des stratégies pour booster les résultats si la correspondance se produit dans un domaine ou dans l'autre. Cela se fait dans la construction de la requête de recherche en utilisant l'option boost :

 const int titleBoost = 15; .Query(qx => qx.MultiMatch(m => m .Query(searchRequest.Query.ToLower()) .Fields(ff => ff .Field(f => f.Title, boost: titleBoost) .Field(f => f.Summary) ... ) .Type(TextQueryType.BestFields) ) && filteringQuery)

Comme vous pouvez le voir, la requête MultiMatch est très utile dans des situations comme celle-ci, et des situations comme celle-ci ne sont pas si rares du tout ! Souvent, certains domaines sont plus importants et d'autres non, ce mécanisme nous permet d'en tenir compte.

Il n'est pas toujours facile de définir immédiatement des valeurs de boost. Vous devrez jouer un peu avec cela pour obtenir les résultats souhaités.

Prioriser les articles

L'exigence : Certains articles sont plus importants que d'autres. Soit l'auteur est plus important, soit l'article lui-même a plus de likes/partages/upvotes/etc. Les articles les plus importants devraient être mieux classés.

Elasticsearch nous permet d'implémenter notre fonction de notation, et nous la simplifions de manière à définir un champ "Importance", qui est une valeur double - dans notre cas, supérieure à 1. Vous pouvez définir votre propre fonction/facteur d'importance et l'appliquer De même. Vous pouvez définir plusieurs modes de boost et de score, selon ce qui vous convient le mieux. Celui-ci a bien fonctionné pour nous:

 .Query(q => q .FunctionScore(fsc => fsc .BoostMode(FunctionBoostMode.Multiply) .ScoreMode(FunctionScoreMode.Sum) .Functions(f => f .FieldValueFactor(b => b .Field(nameof(SearchItemDocumentBase.Rating)) .Missing(0.7) .Modifier(FieldValueFactorModifier.None) ) ) .Query(qx => qx.MultiMatch(m => m .Query(searchRequest.Query.ToLower()) .Fields(ff => ff ... ) .Type(TextQueryType.BestFields) ) && filteringQuery) ) )

Chaque film a une note, et nous avons déduit la note de l'acteur par la moyenne des notes des films dans lesquels ils ont été castés (méthode pas très scientifique). Nous avons réduit cette note à une valeur double dans l'intervalle [0,1].

Correspondances de mots complets

L'exigence : les correspondances de mots complets doivent être mieux classées.

À l'heure actuelle, nous obtenons d'assez bons résultats pour nos recherches, mais vous remarquerez peut-être que certains résultats contenant des correspondances partielles peuvent être mieux classés que les correspondances exactes. Pour faire face à cela, nous avons ajouté un champ supplémentaire dans notre document nommé "Mots clés" qui n'utilise pas d'analyseur de saisie semi-automatique, mais utilise à la place un tokenizer de mots clés et fournit un facteur de boost pour pousser les résultats de correspondance exacte plus haut.

Ce champ ne correspondra que si le mot exact correspond. Il ne correspondra pas à "bois" pour "bois" comme le fait l'analyseur de saisie semi-automatique.

Emballer

Cet article aurait dû vous donner un aperçu de la configuration d'Elasticsearch dans votre projet .NET et, avec un peu d'effort, fournir une fonctionnalité de recherche agréable partout.

La courbe d'apprentissage peut être un peu raide, mais cela en vaut la peine, surtout lorsque vous le peaufinez correctement et que vous commencez à obtenir d'excellents résultats de recherche.

Rappelez-vous toujours d'ajouter des cas de test approfondis avec les résultats attendus pour vous assurer de ne pas trop gâcher les paramètres lors de l'introduction de modifications et de la manipulation.

Le code complet de cet article est disponible sur GitHub et utilise des données extraites de la base de données TMDB pour montrer comment les résultats de recherche s'améliorent à chaque étape.