Recherche de similarités textuelles dans Elasticsearch grâce aux champs vectoriels | Elastic Blog
Technique

Recherche de similarités textuelles grâce aux champs vectoriels

Depuis la naissance de ce qui était à l'origine un moteur de recherche de recettes culinaires, Elasticsearch est conçu pour des recherches full-text rapides et performantes. De ce fait, l'amélioration de la recherche textuelle a toujours joué un rôle important, qui nous a motivés dans notre travail sur les vecteurs. Dans Elasticsearch 7.0, nous avions introduit des types de champs expérimentaux pour les vecteurs hautement dimensionnels. La version 7.3 permet désormais d'exploiter ces vecteurs pour le scoring des documents.

Cet article de blog se penche sur une méthode appelée "recherche de similarités textuelles". Dans ce type recherche, l'utilisateur saisit une courte requête en texte libre, et les documents sont classés en fonction de leur similarité avec la requête. La similarité textuelle peut s'avérer utile dans un grand nombre de cas d'utilisation :

  • Réponse aux questions : grâce à une collection de questions fréquentes, trouvez des questions similaires à celle que l'utilisateur a saisie.
  • Recherche d'articles : à partir d'une collection d'articles de recherche, renvoyez les articles dont le titre est étroitement lié à la requête de l'utilisateur.
  • Recherche d'images : dans un ensemble de données d'images avec légende, trouvez les images présentant une légende similaire à la description qu'en fait l'utilisateur.

Une approche simple de la recherche de similarités consisterait à classer les documents en fonction du nombre de mots qu'ils ont en commun avec la requête. Mais il peut arriver que des documents qui ne présentent que très peu de mots en commun avec une requête soient néanmoins similaires à celle-ci. Une approche plus fiable de la similarité prendrait aussi en compte le contenu syntaxique et sémantique.

Les spécialistes du traitement automatique du langage naturel (NLP) ont développé une méthode appelée plongement lexical, ou word embedding, qui permet d'encoder les mots et les phrases en tant que vecteurs numériques. Ces représentations vectorielles sont conçues pour capturer le contenu linguistique du texte et peuvent servir à évaluer la similarité existant entre une requête et un document.

Cet article explique comment utiliser les plongements textuels (ou text embeddings) et les nouveaux types de champs dense_vector d'Elasticsearch pour la recherche de similarités. Nous commencerons par un aperçu des différentes techniques de plongement, puis nous étudierons un prototype simple de recherche de similarités avec Elasticsearch.

Les plongements textuels (ou text embeddings), qu'est-ce que c'est ?

Penchons-nous sur les différents types de plongements textuels et sur ce qui les différencie des méthodes de recherche classiques.

Plongements lexicaux (word embeddings)

Un modèle de plongement lexical représente un mot sous forme de vecteur numérique dense. Ces vecteurs visent à capturer les propriétés sémantiques du mot : les mots présentant des vecteurs proches doivent donc aussi présenter une signification sémantique similaire. Dans un plongement lexical efficace, les directions de l'espace vectoriel sont liées à différents aspects de la signification du mot. Par exemple, le vecteur du mot "Canada" peut être proche du mot "France" dans une direction et proche du mot "Toronto" dans une autre.

Voilà un bon moment que les spécialistes du traitement automatique du langage naturel et de la recherche s'intéressent à la représentation vectorielle des mots. Ces dernières années, nous avons assisté à un regain d'intérêt pour les plongements lexicaux, les réseaux neuronaux permettant de repenser bon nombre de tâches traditionnelles. On a ainsi vu naître quelques algorithmes de plongement lexical performants, comme word2vec et GloVe. Ces approches s'appuient sur de grandes collections de textes et étudient le contexte dans lequel apparaît chaque mot, afin de déterminer sa représentation vectorielle :

  • Le modèle Skip-gram de word2vec entraîne un réseau de neurones, afin qu'il prédise les mots contextuels entourant un mot dans une phrase. Les pondérations internes du réseau donnent les plongements lexicaux.
  • Dans GloVe, la similarité des mots dépend de la fréquence de leur apparition avec d'autres mots contextuels. L'algorithme entraîne un modèle linéaire simple en fonction du nombre de concomitances.

Bon nombre de groupes de recherche distribuent des modèles pré-entraînés sur des corpus textuels volumineux, tels que Wikipedia ou Common Crawl, ce qui facilite leur téléchargement et leur application à des tâches en aval. Bien que les versions pré-entraînées soient souvent exploitées directement, il peut être utile d'adapter le modèle à l'ensemble de données et à la tâche cibles concernés. Pour se faire, on exécute souvent une étape de légers ajustements sur le modèle pré-entraîné.

Les plongements lexicaux s'avèrent assez fiables et efficaces, et on les utilise maintenant fréquemment en lieu et place des tokens dans les tâches de traitement automatique du langage naturel, comme la traduction automatique et la classification des sentiments.

Plongements de phrases (sentence embeddings)

Depuis peu, les chercheurs s'intéressent de près à des techniques de plongement qui ne représentent plus seulement les mots, mais aussi de plus longues parties de texte. Les approches les plus courantes s'appuient sur des architectures de réseaux neuronaux complexes et intègrent parfois des données libellées durant l'entraînement, afin de faciliter l'acquisition d'informations sémantiques.

Une fois entraînés, les modèles sont capables, à partir d'une phrase, de produire un vecteur pour chaque mot en contexte, ainsi qu'un vecteur pour toute la phrase. Tout comme le plongement lexical, de nombreux modèles proposent des versions pré-entraînées, épargnant ainsi à l'utilisateur le coûteux processus d'entraînement. Car si ce dernier peut se montrer très gourmand en ressources, l'invocation du modèle est bien plus légère : en effet, les modèles de plongements de phrases sont généralement suffisamment rapides pour être exploités dans des applications en temps réel.

Entre autres techniques de plongement de phrases, citons InferSent, Universal Sentence Encoder, ELMo, ou encore BERT. Les chercheurs travaillent activement à l'amélioration des plongements lexicaux et de phrases, et d'autres modèles fiables vraiment probablement le jour.

Comparaison avec les approches de recherche classiques

Dans les systèmes classiques de récupération d'informations, pour représenter le texte par un vecteur numérique, on affecte souvent une dimension à chaque mot du vocabulaire. Le vecteur associé à une partie du texte est alors fonction du nombre de fois où chaque terme apparaît dans le vocabulaire. On appelle souvent cette façon de représenter le texte "sac de mots" (en anglais, "bag of words"), car il s'agit simplement de compter les occurrences des mots, sans tenir compte de la structure de la phrase.

Il existe d'importantes différences entre les plongements textuels et les représentations vectorielles classiques :

  • Les vecteurs encodés sont denses et relativement peu dimensionnels, comprenant souvent entre 100 et 1 000 dimensions. A contrario, les vecteurs de sacs de mots sont épars et peuvent comprendre plus de 50 000 dimensions. Les algorithmes de plongement encodent le texte dans un espace de moindre dimension lors de la modélisation de sa signification sémantique. Idéalement, les mots et expressions synonymes sont associés à une représentation similaire dans le nouvel espace vectoriel.
  • Les plongements de phrases peuvent tenir compte de l'ordre des mots au moment de définir la représentation vectorielle. Par exemple, l'expression anglaise "tune in" sera probablement représentée par un vecteur très différent de celui associé à "in tune".
  • En pratique, les plongements de phrases sont souvent difficiles à généraliser à de longs extraits de texte. Ils servent habituellement à représenter de courts paragraphes.

Exploiter les plongements pour la recherche de similarités

Imaginons que nous ayons une importante collection de questions et de réponses. Lorsqu'un utilisateur pose une question, notre objectif est de récupérer la question la plus similaire de notre collection, afin de l'aider à trouver une réponse.

Nous pouvons exploiter les plongements textuels pour récupérer ces questions similaires :

  • Lors de l'indexation, chaque question est exécutée dans un modèle de plongement de phrases, afin de produire un vecteur numérique.
  • Lorsqu'un utilisateur saisit une requête, elle est exécutée dans le même modèle de plongement de phrases pour produire un vecteur. Pour classer les réponses, nous calculons la similarité vectorielle qui existe entre chaque question et le vecteur de la requête. On utilise souvent la similarité cosinus pour comparer les vecteurs de plongement.

Ce référentiel, vous fournit un exemple simple, qui explique comment exploiter les plongements dans Elasticsearch. Le script principal indexe environ 20 000 questions à partir de l'ensemble de données StackOverflow, puis autorise l'utilisateur à saisir des requêtes en texte libre, qui sont comparées à l'ensemble de données.

Avant d'examiner chaque partie du script en détail, voyons d'abord quelques exemples de résultats. Dans bien des cas, cette méthode permet de repérer des similarités, même en l'absence de chevauchement lexical important entre la requête et la question indexée :

  • "zipping up files" renvoie "Compressing / Decompressing Folders & Files"
  • "determine if something is an IP" renvoie "How do you tell whether a string is an IP or a hostname"
  • "translate bytes to doubles" renvoie "Convert Bytes to Floating Point Numbers in Python"

Détails de la mise en œuvre

Le script commence par le téléchargement et la création du modèle de plongement dans TensorFlow. Nous avons opté pour le module Universal Sentence Encoder de Google, mais vous pouvez choisir de nombreuses autre méthodes de plongement. Le script exploite le modèle de plongement tel quel, sans aucun entraînement ni réglage supplémentaire.

Nous créons ensuite l'index Elasticsearch, qui comprend des mappings pour le titre de la question, les balises, ainsi que le titre de la question encodé comme vecteur :

"mappings": {
  "properties": {
    "title": {
      "type": "text"
    },
    "title_vector": {
      "type": "dense_vector",
      "dims": 512
    }
    "tags": {
      "type": "keyword"
    },
    ...
  }
}

Dans le mapping associé à dense_vector, nous devons spécifier le nombre de dimensions que contiendront les vecteurs. Au moment d'indexer le champ title_vector, Elasticsearch vérifiera qu'il présente bien le nombre de dimensions spécifiées dans le mapping.

Pour indexer des documents, nous exécutons le titre de la question dans le modèle de plongement, afin d'obtenir un tableau numérique. Celui-ci est ensuite ajouté au document dans le champ title_vector.

Lorsqu'un utilisateur saisit une requête, le texte est d'abord exécuté dans le même modèle de plongement, puis stocké dans le paramètre query_vector. À compter de la version 7.3, Elasticsearch intègre une fonction cosineSimilarity (similarité cosinus) dans son langage de script natif. Pour classer les questions en fonction de leur similarité avec la requête de l'utilisateur, nous utilisons une requête script_score :

{
  "script_score": {
    "query": {"match_all": {}},
    "script": {
      "source": "cosineSimilarity(params.query_vector, doc['title_vector']) + 1.0",
      "params": {"query_vector": query_vector}
    }
  }
}

Nous veillons à transmettre le vecteur de requête en tant que paramètre de script, ce qui nous évite de recompiler le script à chaque nouvelle requête. Elasticsearch n'autorisant pas les scores négatifs, il est nécessaire d'en ajouter un à la similarité cosinus.

Limitations importantes

La requête script_score est conçue pour traiter une requête restrictive comme un argument, et modifier les scores des documents qu'elle renvoie. Nous avons toutefois prévu une requête match_all : le script est donc exécuté sur tous les documents de l'index. C'est actuellement une limitation de la similarité vectorielle dans Elasticsearch – les vecteurs peuvent servir à attribuer des scores aux documents, mais pas à l'étape de récupération initiale. La prise en charge de la récupération basée sur la similarité vectorielle fait partie des points importants sur lesquels nous travaillons sans relâche.

Pour éviter de balayer l'ensemble des documents et conserver de bonnes performances, vous pouvez remplacer la requête match_all par une requête plus sélective. Le choix de la requête dépendra des cas d'utilisation.

Malgré les exemples encourageants que nous venons de voir, il est important de garder à l'esprit que les résultats peuvent parfois être bruyants et contre-intuitifs. Par exemple, "zipping up files" affecte aussi des scores élevés à "Partial .csproj Files" et "How to avoid .pyc files?". Et lorsque la méthode renvoie des résultats surprenants, la solution au problème n'est pas toujours évidente : la signification de chaque composant vectoriel est souvent obscure et ne correspond pas à un concept interprétable. À cet égard, il est souvent plus facile de savoir pourquoi un document enregistre un bon classement avec les techniques classiques de scoring basées sur le chevauchement lexical.

Pour conclure

Les techniques de plongement sont un excellent moyen de capturer le contenu linguistique d'un extrait de texte. Grâce à l'indexation des plongements et du scoring en fonction de la distance vectorielle, nous sommes en mesure de classer les documents en nous appuyant sur une notion de similarité qui dépasse le chevauchement lexical.

Et nous comptons bien aller plus loin, avec le lancement de nouvelles fonctionnalités qui s'appuieront sur le type de champ vectoriel. L'exploitation des vecteurs est un domaine tout en nuances, qui nous tient à cœur. Et comme toujours, nous serions ravis d'entendre parler de vos cas d'utilisation et de votre expérience sur Github et nos forums de discussion. N'hésitez pas à nous en faire part !