Skip to main content

Leçons tirées de l’amélioration de la recherche en texte intégral chez Snyk avec Elasticsearch

Écrit par
Headshot of Sergey Vasilkov

Sergey Vasilkov

blog banner snyk preview early access

4 novembre 2021

0 minutes de lecture

Elasticsearch est un moteur de recherche open source très populaire. Grâce à sa rapidité en temps réel et à son API robuste, il est souvent choisi par les développeurs qui souhaitent ajouter des fonctionnalités de recherche en texte intégral à leurs projets. En plus d’être largement adopté, c’est aussi le moteur vers lequel nous transférons actuellement la fonctionnalité issues de nos rapports Snyk ! Une fois que tout sera bien configuré pour les issues, nous commencerons à utiliser Elasticsearch dans d’autres sections de nos rapports.

Tableau de bord des problèmes Snyk répertoriant les vulnérabilités de sécurité, les licences, les scores, les identifiants CVE, les projets concernés et le niveau de maturité des exploits

Elasticsearch est un outil puissant, mais il peut aussi sembler complexe au premier abord (à moins de déjà connaître les moteurs de recherche). Comme je viens de beaucoup apprendre sur cet outil en le mettant en œuvre chez Snyk, je me suis dit que j’allais vous transmettre quelques connaissances, de développeur à développeur !

Parfois, un analyseur standard ne suffit pas

Elasticsearch reste rapide en traitant différemment les différents types de données. Parmi les nombreux types de champs, Elasticsearch propose les champs de texte, conçus pour le contenu textuel (c’est-à-dire les chaînes de caractères). Pour rendre les informations stockées dans ces champs consultables, Elasticsearch analyse le texte à l’ingestion, le convertit en jetons (termes), puis enregistre ces jetons et d’autres informations utiles, comme leur longueur et leur position, dans l’index. Par défaut, il utilise un analyseur standard pour l’analyse du texte.

Extrait de la documentation officielle : « L’analyseur standard prend en charge la plupart des langues naturelles et des cas d’utilisation dès son installation. Si vous choisissez d’utiliser l’analyseur standard tel quel, aucune configuration supplémentaire n’est nécessaire. »

Dans les cas simples, l’analyse standard peut suffire. Voyons comment elle fonctionne : la commande suivante permet d’afficher l’analyse générée pour n’importe quel texte fourni.

POST _analyze
{
  "analyzer": "standard",
  "text": "Regular Expression Denial of Service (ReDoS)"
}

And the result will look like:

{
  "tokens" : [
    {
      "token" : "regular",
      "start_offset" : 0,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "expression",
      "start_offset" : 8,
      "end_offset" : 18,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "denial",
      "start_offset" : 19,
      "end_offset" : 25,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "of",
      "start_offset" : 26,
      "end_offset" : 28,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "service",
      "start_offset" : 29,
      "end_offset" : 36,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "redos",
      "start_offset" : 38,
      "end_offset" : 43,
      "type" : "<ALPHANUM>",
      "position" : 5
    }
  ]
}

On voit ici précisément ce que fait l’analyseur standard : il découpe le texte en jetons et génère des informations pour chaque segment afin de les stocker dans l’index.

Rechercher dans le texte indexé

Une fois le texte indexé, nous pouvons tester la recherche en texte intégral avec un analyseur standard. Créons un document d’exemple avec deux champs : title et description. Elasticsearch mappera automatiquement le document et détectera les champs de texte.

PUT text-search-index/_doc/1
{
  "title": "Regular Expression Denial of Service (ReDoS)",
  "description": "The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren'\''t very intuitive and can ultimately end up making it easy for attackers to take your site down."
}

Recherchons maintenant « express » à l’aide de la requête suivante :

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "express"
      }
    }
  }
}

Aucun document ne sera renvoyé, même si le titre contient « expression ». C’est normal (du moins, je m’y attendais, car j’ai déjà rencontré ce problème !) : l’analyseur n’a pas créé de jeton « express », comme on peut le voir dans les jetons générés ci-dessus.

Si nous remplaçons la requête par « expression », le document sera renvoyé :

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "expression"
      }
    }
  }
}

Il existe des requêtes plus coûteuses qui permettent de contourner ce problème de correspondance des jetons, comme les requêtes floues et wildcard, mais elles consomment beaucoup de ressources. Plutôt que de multiplier les cycles CPU pour résoudre le problème, nous devons donc revoir notre analyseur.

Il est clair qu’un analyseur standard ne suffit pas si nous voulons obtenir des résultats plus pertinents et plus flexibles. Pour atteindre cet objectif, nous allons configurer un analyseur personnalisé. Nos critères sont les suivants :

  • Utiliser des requêtes plus courtes

  • Effectuer des recherches sans tenir compte de la casse

Pour y parvenir, nous allons procéder comme suit :

  1. Créer un analyseur personnalisé

  2. Configurer un meilleur tokeniseur adapté à notre cas d’utilisation

  3. Activer le nouvel analyseur personnalisé pour les champs sélectionnés dans l’index Elasticsearch

Mieux comprendre les analyseurs

Avec l’analyseur standard, nous nous sommes facilité la tâche en évitant d’avoir à comprendre précisément son fonctionnement. Maintenant que nous créons un analyseur personnalisé, nous devons comprendre ce qui se passe pendant l’analyse du texte :

  1. Les filtres de caractères (facultatifs) sont appliqués au texte à analyser pour en supprimer certains caractères.

  2. Un tokeniseur découpe le texte en jetons ou en termes. Il peut le faire de différentes façons, par exemple en séparant les éléments à partir des espaces ou des lettres.

  3. Les filtres de jetons (facultatifs) appliquent d’autres transformations aux jetons, comme les convertir en minuscules, en supprimer certains, et bien plus encore.

Un analyseur combine des tokeniseurs et des filtres. Il analyse le texte pour le préparer à la recherche. Chaque analyseur doit comporter un seul tokeniseur, mais il peut utiliser autant de filtres des deux types que nécessaire. L’ordre des opérations, lui, ne change jamais : filtre de caractères > tokeniseur > filtre de jetons.

Créer notre premier analyseur personnalisé

Comme nous l’avons vu, tous les champs de texte utilisent par défaut l’analyseur standard. Elasticsearch propose un large choix d’analyseurs intégrés, utilisables dans n’importe quel index sans configuration supplémentaire. Vous pouvez aussi créer les vôtres. Pour notre cas d’utilisation, nous allons créer un analyseur personnalisé.

Les analyseurs peuvent être configurés à différents niveaux : index, champ ou requête. Nous allons créer un analyseur personnalisé et l’associer à un champ. Voici comment le configurer. Remarque :les configurations sont enregistrées dans les paramètres de l’index :

PUT text-search-index
{
  "settings":{
     "analysis":{
        "analyzer":{
           "my_analyzer":{
              "type":"custom",
              "tokenizer":"standard"
           }
        }
     }
  },
  "mappings":{
      "properties":{
         "title": {
            "type":"text",
            "analyzer":"my_analyzer"
         },
         "text": {
           "type": "text"
         }
      }
   }
}

Voici ce que nous avons fait :

  1. Un nouvel analyseur personnalisé nommé my_analyzer est défini pour l’index

  2. my_analyzer utilise le tokeniseur standard et le filtre lowercase

  3. my_analyzer est activé comme analyseur de la propriété title dans le mapping de l’index

Cet exemple est assez simple et ne modifie pas le comportement de l’analyseur standard. Nous pouvons maintenant l’ajuster pour permettre des recherches insensibles à la casse avec des mots incomplets.

Choisir et configurer un meilleur tokeniseur

Dans notre exemple précédent, nous avons utilisé le tokeniseur standard. Il est maintenant temps de le remplacer par edge_ngram, qui découpe les jetons selon leur longueur (et non selon les espaces). Si vous vous demandez pourquoi j’utilise edge_ngram plutôt que stemmer, poursuivez votre lecture !

...
"analysis":{
  "analyzer":{
    "my_analyzer":{
      "type":"custom",
      "tokenizer":"my_tokenizer"
    }
  },
  "tokenizer": {
    "my_tokenizer": {
      "type": "edge_ngram",
      "min": 3,
      "max": 8,
      "token_chars": ["letter", "digit"]
    }
  }
}
...

Voici ce que nous avons fait :

  1. my_tokenizer utilise le tokeniseur edge_ngram

  2. Il crée des jetons de 3 à 8 caractères

  3. Les jetons ne contiennent que des lettres et des chiffres

Choisir et configurer un meilleur filtre de jetons

L’analyseur que nous avons créé permet désormais de rechercher des mots incomplets de 3 à 8 caractères. Il ne reste plus qu’à rendre la recherche insensible à la casse. Pour cela, nous pouvons ajouter un filtre de jetons lowercase adapté à my_analyzer :

...
"analysis":{
  "analyzer":{
    "my_analyzer":{
      "type":"custom",
      "tokenizer":"my_tokenizer",
      "filter":[
        "lowercase"
      ]
    }
  },
  "tokenizer": {
    "my_tokenizer": {
      "type": "edge_ngram",
      "min": 3,
      "max": 8,
      "token_chars": ["letter", "digit"]
    }
  }
}
...

Résultat final

Après toutes ces modifications, nous sommes prêts à créer un nouvel index avec un analyseur personnalisé. Avant de changer l’analyseur, notez que son paramètre ne peut pas être modifié pour les champs existants à l’aide de l’API de mise à jour du mapping.

Voici à quoi ressembleront les paramètres et les mappings de notre index de test de base :

PUT text-search-index
{
  "settings":{
    "analysis":{
      "analyzer":{
        "my_analyzer":{
          "type":"custom",
          "tokenizer":"my_tokenizer",
          "filter":[
            "lowercase"
          ]
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "edge_ngram",
          "min": 3,
          "max": 8,
          "token_chars": ["letter", "digit"]
        }
      }
    }
  },
  "mappings":{
    "properties":{
      "title": {
        "type":"text",
        "analyzer":"my_analyzer"
      },
      "text": {
        "type": "text"
      }
    }
  }
}

Nous pouvons maintenant ajouter un document et lancer différentes requêtes pour vérifier que la recherche fonctionne comme prévu :

PUT text-search-index/_doc/1
{
  "title": "Regular Expression Denial of Service (ReDoS)",
  "description": "The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren'\''t very intuitive and can ultimately end up making it easy for attackers to take your site down."
}

Rechercher les titres contenant « express » ou d’autres jetons incomplets, sans tenir compte de la casse :

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "express" // try: EXPRESS, exp, expression
      }
    }
  }
}

Notez que nous n’avons pas configuré d’analyseur personnalisé pour la propriété text de notre index : la recherche y utilise donc toujours le bon vieil analyseur standard.

Et ensuite ?

Dans cet article, nous avons configuré un analyseur personnalisé pour rendre la recherche de texte dans Elasticsearch plus flexible, et présenté quelques paramètres et configurations de base. Elasticsearch offre un large choix d’analyseurs intégrés, de tokeniseurs, de filtres, de normaliseurs, de paramètres de recherche avec exclusion de termes, et bien plus encore.

En les combinant, nous pouvons obtenir d’excellents résultats de recherche personnalisés. Si vous découvrez Elasticsearch, j’espère que cet article vous aura été utile. Restez à l’écoute : nous avons prévu de vous en dire plus sur notre parcours autour de la recherche. Au prochain épisode : les agrégations ! (Vous pouvez compter dessus…)

Si vous souhaitez rejoindre Snyk et créer d’excellents outils de sécurité pour les développeurs, découvrez nos postes ouverts en ingénierie.