Comment réaliser et tester un analyzer Elasticsearch 7.2.0 avec Testcontainers ?

Introduction

Qu’est ce qu’un moteur de recherche ? Vous me répondrez, certainement, un moteur qui permet de chercher des choses. Mais quelles sont ces choses ? Des mots ? Des phrases ? Un contexte ? Quelle est la limite pour notre moteur de recherche ? Cette nuance est essentielle et se doit d’être testée.
Dans Elasticsearch, nous disposons d’un outil, les analyzers. Ils sont le coeur de notre moteur. Une fois configurés comme on le souhaite, ils redéfinissent la façon qu’aura le moteur d'interpréter les données. Ils permettent de découper un texte en différents tokens qui seront utilisés pour la recherche.
Nous verrons comment travailler les analyzers et SURTOUT comment les tester. Difficile de ne pas être sûr de comment notre moteur va chercher les éléments demandés.

Cet article s’adresse à des personnes ayant déjà manipulé Elasticsearch et qui souhaitent travailler la pertinence de celui-ci à travers les analyzers.

À la fin de l’article, vous saurez réaliser/tester des analyzers via Testcontainers. De plus, un Github est à votre disposition si vous souhaitez tester ou utiliser une partie du code. Voici le lien : https://github.com/contejulie/analyzer-test

Important : la première fois que vous lancerez les tests, cela vous prendra un peu de temps. Ce temps correspond notamment au téléchargement de l’image Elasticsearch nécessaire. De plus, il vous faudra obligatoirement Docker d’installé sur votre ordinateur.

Pourquoi ai-je écrit cet article ?

Dans le cadre d’un projet, le besoin du client était de réaliser plusieurs analyzers permettant de modifier l’indexation et la façon de rechercher les différentes données. Cet article abordera différents types de filtre comme un filtre de caractères au travers d’une regex et une élision.

Les technologies

Les technologies qui interviennent dans cet article sont Elasticsearch en version 7.2.0 (la plus récente lors de l’écriture de l’article), Java 1.8, Testcontainers, Maven et Docker. Il vous faudra impérativement Docker (testé en version 18.09.2) pour pouvoir effectuer des tests avec Testcontainers.

Définitions

  • Analyzer : C’est le coeur de notre moteur de recherche. Il est composé de divers filtres/séparateurs qui va permettre de travailler les données en amont de l’indexation et de la recherche. Il est constitué de tokenizers et de filtres. Cela permet de compartimenter un texte en phrase ou mots ou contexte et bien d’autres. Les compartiment créé sont appelés Tokens. Par exemple “tacos de Lyon” devient “tacos” “de” “lyon” sans aucun filtre.
  • Token : Élément qui résulte du découpage effectué par l’analyzer. C’est cet élément qui sera utilisé lors de la recherche. Par exemple, un texte “le 4” aura deux tokens tels que “le” et “4”.

Un analyzer possède une certaine anatomie qu’il convient de respecter. Il est constitué de :

  • Char_filter permettant de filtrer les caractères via une regex pour le pattern replace par exemple.
  • Tokenizer indiquant comment séparer les différents tokens. On peut utiliser le séparateur “-” où “amuse-bouche” deviendra “amuse” et “bouche”. Dans un autre cas, un espace servira de séparateur.
  • Token filters permet de modifier, ajouter, supprimer les tokens résultant de l’analyse d’un texte.

Un peu de vocabulaire : qu’est ce qu’une élision ? Une élision correspond à l’effacement d’une voyelle en fin de mot. Lors d’une élision, “le avion” devient “l’avion” par exemple.

Comment ça marche ?

Création du POM

Voici les différentes choses dont nous avons besoin pour arriver à tester nos analyzers :

<properties>
   <elasticsearch.version>7.2.0</elasticsearch.version>
</properties>

<dependencies>
   <dependency>
       <groupId>org.elasticsearch</groupId>
       <artifactId>elasticsearch</artifactId>
       <version>${elasticsearch.version}</version>
   </dependency>
   <dependency>
       <groupId>org.elasticsearch.client</groupId>
       <artifactId>elasticsearch-rest-high-level-client</artifactId>
       <version>${elasticsearch.version}</version>
   </dependency>
   <dependency>
       <groupId>org.assertj</groupId>
       <artifactId>assertj-core</artifactId>
       <version>3.12.2</version>
       <scope>test</scope>
   </dependency>
   <dependency>
       <groupId>org.testcontainers</groupId>
       <artifactId>elasticsearch</artifactId>
       <version>1.11.4</version>
       <scope>test</scope>
   </dependency>
</dependencies>

Il faut intégrer le elasticsearch-rest-high-level-client qui sera utilisé pour développer, en Java, la configuration et la communication avec Elasticsearch au niveau du client fourni.
Testcontainers va nous permettre de faire des tests d’intégration facilement en déployant de lui-même un dockerfile. Par ailleurs, il gère aussi le cycle de vie du container qu’il a lancé.
Enfin, Assertj est là pour faire nos assertions.

Testcontainers

Rentrons dans le coeur de notre sujet Testcontainers. Testcontainers est une librairie Java qui va nous permettre lancer divers containers comme une base de données ou autre. Ici, elle nous servira à lancer notre Elasticsearch sur lequel nous souhaitons faire des tests.

static void startElasticsearch() {
   ElasticsearchContainer elasticsearchContainer = new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:7.2.0");
   elasticsearchContainer.addExposedPorts(9200, 9300);
   elasticsearchContainer.start();
   configHostnameAndPortELS(elasticsearchContainer.getContainerIpAddress(), elasticsearchContainer.getMappedPort(9200));
}

Cette méthode va nous permettre de configurer notre Elasticsearch. La démarche de cette fonction est :

  • Création d’un ElasticsearchContainer
  • Exposition des ports Elasticsearch pour l’utiliser hors du container docker
  • Lancement du container
  • Configuration de l’URL pour communiquer avec Elasticsearch

Avec cela, Testcontainers a démarré le container basé sur l’image Elasticsearch.

Les tests d’analyzer

Les settings

Pour commencer, nous allons avoir besoin de déclarer des settings pour décrire nos analyzers. Voici à quoi ressemblent nos settings :

{
 "settings": {
   "index": {
     "analysis": {
       "analyzer": {
         "default": {
           "char_filter": [
             "remove_number"
           ],
           "tokenizer": "standard",
           "filter": [
             "lowercase",
             "french_elision"
           ]
         },
         "without_filter": {
           "tokenizer": "standard"
         }
       },
       "char_filter": {
         "remove_number": {
           "type": "pattern_replace",
           "pattern": "(\\D+)([0-9\\s\\.,]*)",
           "replacement": "$1",
           "flags": "CASE_INSENSITIVE"
         }
       },
       "filter": {
         "french_elision": {
           "type": "elision",
           "articles_case": true,
           "articles": [
             "l",
             "d"
           ]
         }
       }
     }
   }
 }
}

Dans ce fichier, nous avons la description de deux analyzers. Le premier analyzer ne contient rien si ce n’est le tokenizer standard. Il est là pour faire la comparaison avec le second.
Le second “default” décrit l’analyzer appliqué par défaut sur tous les champs. Il utilise le même tokenizer que le premier mais utilise différents filtres en plus . Il est composé d’un char_filter qui va permettre d’enlever tous les chiffres dans le texte analysé. Il utilise le tokenizer “standard”. Tous les mots seront séparés par un caractère spécial. Par exemple, “amuse-bouche” deviendra “amuse” et “bouche”. Il utilise les filtres lowercase (tous les textes seront analysés en minuscule) et french_elision. french_elision permet d’enlever toutes les élisions référencées. Nous ne renseignons que L et d pour l’exemple. Une phrase “je monte dans l’avion” sera coupée en différents tokens qui sont “je” “monte” “dans” “avion”. Le l’ de “l’avion” a été retiré.

Les dernières configurations

Tout d’abord, nous devons créer un index avec nos settings dans notre Elasticsearch.

static void createIndex() throws IOException {
   try (CloseableHttpClient httpclient = HttpClients.createDefault()) {
       HttpPut putAction = new HttpPut(getBaseUrlElasticsearch());
       putAction.setEntity(new InputStreamEntity(resolveSettingsDefinitionStream(), ContentType.APPLICATION_JSON));
       ResponseHandler<String> responseHandler = response -> {
           int status = response.getStatusLine().getStatusCode();
           HttpEntity entity = response.getEntity();
           System.out.println(EntityUtils.toString(entity));
           if (status < 200 || status >= 300) {
               throw new RuntimeException("Unexpected response status: " + status);
           }
           return "";
       };
       httpclient.execute(putAction, responseHandler);
   }
}

Le but de cette méthode est simple. Effectuer une requête Elasticsearch en intégrant les settings déclarés au dessus. Pour cela :

  • Création d’une requête HTTP PUT qui va permettre de créer et mettre à jour l’index en récupérant l’URL configurée avant.
  • Il faut donner les settings en paramètre de la requête en lui indiquant le chemin du fichier.
  • Il faut effectuer la requête en s’assurant que tout s’est bien passé.

Les tests

Le fichier de test pour réaliser les tests d’intégration Elasticsearch.

@Before
public void setup() throws IOException {
   TestUtils.startElasticsearch();
   TestUtils.createIndex();
}

Il faut commencer par appeler les fonctions décrites plus haut qui nous permettent de lancer un container Elasticsearch et de créer l’index et ses settings.

Nous aurons plusieurs tests qui intégreront la même logique. Il faudra donc créer une méthode qui reprendra le coeur des différents tests.

private void analyzerTestWithInput(String textToAnalyse, String[] expectedTokens, String analyzer) {
   ObjectMapper mapper = new ObjectMapper();
   try (CloseableHttpClient httpclient = HttpClients.createDefault()) {
       HttpPost postAction = new HttpPost(TestUtils.getBaseUrlElasticsearch() + "/_analyze");

       ObjectNode body = mapper.createObjectNode();
       body.put("analyzer", analyzer);
       body.put("text", textToAnalyse);

       postAction.setEntity(new StringEntity(body.toString(), ContentType.APPLICATION_JSON));
       ResponseHandler<String[]> responseHandler = response -> {
           int status = response.getStatusLine().getStatusCode();
           if (status < 200 || status >= 300) {
               throw new RuntimeException("Unexpected response status: " + status);
           }
           String responseBody = EntityUtils.toString(response.getEntity());
           Map map = mapper.readValue(responseBody, Map.class);
           List<Map> tokensMap = (List) (map.get("tokens"));
           return tokensMap.stream().map(t -> t.get("token")).toArray(String[]::new);
       };
       String[] tokens = httpclient.execute(postAction, responseHandler);

       assertThat(tokens).containsExactlyInAnyOrder(expectedTokens);
   } catch (IOException e) {
       e.printStackTrace();
   }
}

Cette méthode prend en entrée le texte à analyser, le texte attendu à l’analyse et le nom de l’anayzer. Elasticsearch met à disposition une API permettant de comprendre efficacement comment est analysé le texte avec l’analyzer stipulé. On crée un objet JSON qui aura:

  • Une propriété “analyzer” servant à indiquer l’analyzer utilisé.
  • Une propriété “text” qui sera analysé.
    Ensuite, il faut effectuer la requête et récupérer les tokens retournés par Elasticsearch. Et pour finir, il est nécessaire de comparer les divers tokens retournés et ceux que nous souhaitions en sortie.

Pour finir, voici un exemple de test réalisé :

@Test
public void testAnalyzerWithFilters() {
   String analyzer = "default";
   Map<String, String[]> cases = new HashMap<>();
   cases.put("confiture d'abricot", new String[]{"confiture", "abricot"});
   cases.put("chaussure 1999 6kg", new String[]{"chaussure", "kg"});

   cases.forEach((textToAnalyse, expectedTokens) -> analyzerTestWithInputAndAnalyzer(textToAnalyse, expectedTokens, analyzer));
}

Il conviendra d’utiliser l’analyzer default et de décrire les cas souhaités dans une map. Avec le filtre french_elision, “confiture d’abricot” sera découpé en deux tokens tels que “confiture” et “abricot”.

Conclusion

Je vous ai décrit une base générale de test d’intégration sur les analyzers. Vous pourrez la personnaliser selon vos use case. Il existe encore beaucoup d’autres filtres tel qu’un stemmer pour la langue de votre choix et bien d’autres encore que je vous laisse découvrir à travers la documentation.
Maintenant, vous savez faire des analyzers et vous savez les tester !

Le lien vers le Github de test: https://github.com/contejulie/analyzer-test

Les différents liens de documentation qui peuvent vous être utiles :