De l’algorithme à la solution : un exemple d’exploitation d’un algorithme d’intelligence artificielle (4/5)

Construire une solution de recherche sémantique avec un modèle de plongement de texte.

Dans l’article précédent, nous avons vu que nous avions à disposition les outils nécessaires pour bâtir une solution de recherche de texte qui tient compte du sens du texte :

  • Un modèle de plongement de texte : l’ ”Universal Sentence Encoder” (USE) qui calcule une représentation vectorielle d’un texte, cette représentation respecte la proximité sémantique (similarité) des textes entre eux.
  • Un moyen pour déterminer la similarité entre deux textes : la similarité cosinus calculée entre leur représentation vectorielle respective.

On a donc les ingrédients de base pour construire une solution capable d’effectuer des recherches dans un corpus de textes :

  • Pour chaque texte du corpus, on calcule sa représentation vectorielle avec l’USE et on l’associe au texte.
  • L’utilisateur fournit le texte de sa requête.
  • Le système calcule la représentation vectorielle de cette requête et restitue les textes dont la représentation vectorielle est la plus similaire à la représentation de la requête. Plus précisément, on présentera à l’utilisateur les textes par ordre de similarité décroissante (similarité de 1 à -1), afin de lui afficher les textes les plus pertinents en premier.

Nous avons maintenant connaissance du processus technique que nous voulons mettre en œuvre et on peut commencer à s’intéresser à l’implémentation pratique, comme promis à la fin du précédent article.

Les algorithmes USE sont disponibles en Python via la librairie Tensorflow. Par conséquent, Python sera notre langage de programmation. 

Parlons du stockage. Dans l’article introductif de cette série, on a évoqué Elasticsearch. Dans les solutions d’entreprise, ce logiciel est devenu un standard de fait et depuis sa version 7, le support des vecteurs de grandes dimensions a été ajouté. Concrètement, cela veut dire que l’on peut créer des champs d’un nouveau type (dense vector1) qui pourront contenir la représentation vectorielle du texte.

Elasticsearch, en bon moteur de recherche qu’il est, utilise une notion de pertinence (ou scoring) pour présenter à l’utilisateur les résultats d’une requête en affichant les plus pertinents en premier. Parce qu’il se veut un outil flexible, Elasticsearch nous permet de créer nos propres fonctions de scoring : c’est là que nous allons placer notre logique qui utilise la similarité des vecteurs représentant les textes.

Avant de rentrer dans le dur avec du code, voici un synoptique de la solution que l’on peut construire.

Expliquons le schéma : lors de l’indexation des documents, chaque texte est encodé sous forme d’un vecteur. Cet encodage utilise l’algorithme USE. Le texte et son encodage sont stockés dans un index Elasticsearch.
Lorsqu’un utilisateur effectue une requête, le texte de sa requête est lui aussi encodé par l’USE. Elasticsearch va effectuer un scoring des documents de l’index en utilisant la similarité cosinus. Les résultats sont retournés à l’utilisateur, ordonnés par score décroissant.

Les mains dans le cambouis enfin !!!

Une petite remarque en préambule sur le code qui sera présenté ici : on a choisi de favoriser la concision. Ne vous formalisez donc pas trop si le code ne vous paraît pas optimal. 

Pour le code Python, nous aurons besoin de quelques librairies que vous aurez pris soin de mettre dans le fichier requirement.txt de votre projet :

tensorflow_hub
tensorflow_text>=2.0.0rc02
elasticsearch~=7.8.0

Encodage avec Tensorflow

Pour encoder un texte, le code est concis :

# text_encoder.py
import tensorflow_hub as hub ❶
import tensorflow_text ❷

model = hub.load("https://tfhub.dev/google/universal-sentence-encoder-multilingual/3") ❸

def encode(input):
    return model(input) ❹

❶: donne accès à la librairie des modèles “sur étagère” fournie par Tensorflow.
❷: il est nécessaire d’inclure tensorflow_text pour que l’encodage fonctionne.
❸: on récupère le modèle depuis le hub grâce à son url.
❹: c’est ici que se fait tout le travail… simple non ?

On peut alors utiliser ce code ainsi (dans un shell ipython par exemple) pour encoder des textes :

import text_encoder

text_encoder.encode(["ceci est un test", "ceci est un autre test"])

<tf.Tensor: shape=(2, 512), dtype=float32, numpy=
array([[ 0.05874003,  0.04935631,  0.00190662, ..., -0.02494125,
        -0.06902733, -0.07291851],
       [ 0.01348238,  0.05375661,  0.00291965, ..., -0.04038788,
        -0.05852002, -0.06328191]], dtype=float32)>

On obtient bien en sortie pour chaque texte un vecteur de dimension 512 qui correspond à l’encodage du texte. Le résultat de l’opération d’encodage est un tenseur, une structure de données propre à Tensorflow. Il s’agit d’un tableau multidimensionnel. Pour en savoir plus, voir ici : https://www.tensorflow.org/guide/tensor

Installer Elasticsearch

Afin de disposer d’un moteur Elasticsearch, pour les premiers tests, le plus simple est d’utiliser docker.

docker run -p 9200:9200 -e node.name=es01 -e cluster.name=es-docker-cluster -e cluster.initial_master_nodes=es01 docker.elastic.co/elasticsearch/elasticsearch:7.8.1

Le paramètre -p 9200:9200 permet d’exposer le port tcp 9200 en dehors du conteneur docker afin d’interagir avec Elasticsearch. Il est nécessaire de fournir d’autres paramètres spécifiques à Elasticsearch pour qu’il puisse démarrer.
On peut vérifier que tout fonctionne correctement dans un shell :

curl -XGET http://localhost:9200

La réponse doit ressembler à ça :

{
  "name" : "es01",
  "cluster_name" : "es-docker-cluster",
  "cluster_uuid" : "yC93p3FgTGeKlLRUHhajJA",
  "version" : {
    "number" : "7.8.1",
...
  },
  "tagline" : "You Know, for Search"
}

Indexer des textes

Question vocabulaire, pour Elasticsearch, stocker des données signifie les indexer dans un… index. On ne va pas détailler ici ce qu’est Elasticsearch, mais si vous voulez en savoir plus, vous pouvez lire les documentations introductives ici : https://www.elastic.co/guide/en/elasticsearch/reference/current/elasticsearch-intro.html

Avant d’insérer des données, il est nécessaire de définir la structure de l’index dans lequel seront stockées ces données. Dans notre cas, on aura besoin de stocker deux informations : le texte et son encodage. Ceci va se traduire pour Elasticsearch par deux champs : 

  • ‘content’ de type ‘text’
  • ‘embedding’ de type ‘dense_vector’

En terminologie Elasticsearch, la structure de l’index s’appelle un ‘mappping’.

En python, cela peut se traduire ainsi:

# es_indexor.py
from elasticsearch import Elasticsearch

def build_index(client=Elasticsearch(), index="text_embedding"): 
     index_definition = { 
         "mappings": { 
             "properties":{ 
                 "content": {"type": "text"}, 
                 "embedding": {"type": "dense_vector", "dims": 512} 
             } 
         } 
     } 
     client.indices.create(index, index_definition)

Il suffit alors d’exécuter cette fonction avec ses paramètres par défaut dans ipython par exemple : 

import es_indexor
build_index()

On peut vérifier alors que l’index a bien été créé avec ce mapping.

curl -XGET http://localhost:9200/text_embedding/_mappings

Dans la réponse on retrouve bien le mapping défini.

{
  "text_embedding": {
    "mappings": {
      "properties": {
        "embedding": {
          "type": "dense_vector",
          "dims": 512
        },
        "content": {
          "type": "text"
        }
      }
    }
  }
}

Maintenant que la structure de l’index est définie, nous pouvons indexer nos données.

# es_indexor.py
…
import text_encoder 

def index_text(text,client=Elasticsearch(), index="text_embedding"):
    embedding = text_encoder.encode(text).numpy()
    client.index(index=index, body={"content":text,"embedding":embedding})

Rechercher des textes

Lors de la recherche, Elasticsearch attribue à chaque document un score et retourne les documents ayant le score le plus élevé. Dans notre cas, nous devons nous même définir la façon dont le score est calculé. Elasticsearch nous permet de le faire grâce à la fonctionnalité “Script score query” (documentée ici: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-script-score-query.html ).

Comme on l’a vu précédemment, on a besoin d’une fonction de similarité (similarité cosinus) pour calculer le score. Ça tombe bien: Elasticsearch fournit cette fonction. Le corps de la fonction de scoring s’écrit simplement comme ceci:

cosineSimilarity(query_vector, doc_vector) + 1.0
# query_vector: encodage vecteur du texte de la requête
# doc_vector: encodage vecteur du texte du document

On ajoute 1.0 à la similarité cosinus car Elasticsearch impose que les fonctions de scoring retournent un résultat positif et la fonction similarité cosinus en elle-même retourne un résultat compris entre -1 et 1.

La requête à Elasticsearch s’écrit donc ainsi : 

import text_encoder
from elasticsearch import Elasticsearch

def search_similar_docs(query, index="my_index", client=Elasticsearch()):
    query_vector = text_encoder.encode(query) ❶
    script_query = {
       "script_score": {
           "query": {"match_all": {}}, ❹
           "script": {
               "source": "cosineSimilarity(params.q_vector, embedding) + 1.0", ❸
               "params": {"q_vector": query_vector} ❷
           }}}
    return client.search(index=index, body={"query": script_query})

❶: On obtient l’encodage en vecteur du texte de requête
❷: Cet encodage est injecté dans le code qui sera envoyé à Elasticsearch sous la forme d’un paramètre
❸: L’encodage du texte de requête est utilisé dans la fonction de scoring en conjonction avec le champ ‘embedding’ du document dont on calcule le score
❹: La fonctionnalité ‘script score query’ requiert une requête permettant de filtrer les documents dont on va calculer le score. Ici on ne filtre pas…. On en reparle plus loin

Quelques résultats

Pour expérimenter, nous avons utilisé le dataset para_crawl dans sa version en_fr. Il correspond nos contraintes d’expérimentation: 

  • Il est multilingue anglais et français.
  • Il contient suffisamment de données: plus de 30 millions de phrases.
  • Il ne demande pas d’investissement d’ingénierie pour l’intégrer, car il est disponible directement dans Tensorflow

Nous nous sommes restreints à 4 millions de phrases, le temps d’ingestion étant déjà significatif: compter environ 2 heures pour un tel dataset sur une machine de développement standard.

Voici quelques exemples:

RequêteRéponsesSimilarité
aspirineLes aspirines vont faire partir la douleur temporairement, mais pas le problème.1.5953267
Aspirin is the biggest painkiller of choice.1.5835499
les médicaments pour les migraines1.4927158
medicines for migraine headaches1.4838736
Gibson et Fender3Jonboat Pour sûr ! John Rickard m’a dit après ce concert qu’il est notre troisième guitariste, notre Steve Gaines. Et laissez mois vous dire qu’il peut jouer tout aussi en douceur sur une stratocaster qu’avec une pedal steel1.3820316
Jonboat Hell yes! John Rickard said to me after that show he’s our third picker, our Steve Gaines, and he wanted in – And let me tell you, he can rip it up just as sweetly on a Stratocaster as that pedal steel.1.3327684
The BENROD-ELECTRO Plexi Rock 18 Watts amp head version, just like the Breaking Blues combo, gathers the best of 2 great classics designed by Marshall ® Amplification ® – the Bluesbreaker and the JTM45, on separate channels!1.2892777
Accordée avec précision, cette guitare délivre un son parfait ce qui en fait une excellente introduction aux instruments de musique.1.2845232
linus torvaldsIf you look around in our community, you’ll find that almost everywhere, the institutions are calling the system Linux. You know, reporters mostly call it Linux. It’s not right, but they do. 1.2777085
Le noyau Linux est appelé une technologie centrale de la décennie, en fonction de la populaire publication américaine InfoWorld. Malgré sa sortie du noyau dans le lointain 1991, que dans l’année.1.266908

Victoire ! …ou pas

Ça y est ! Au vu des résultats nous avons une solution fonctionnelle. Cette solution est-elle pour autant satisfaisante ? 

Comme d’habitude en ingénierie, la réponse est : “ça dépend”. 

En regardant de plus près le code proposé, le paramètre “query” ne fait aucun filtrage effectif. Autrement dit, tous les documents de l’index sont pris en compte pour le calcul de score. 

En conséquence, plus il y a de documents dans l’index, plus cela va prendre du temps. Ce temps peut impacter significativement les temps de réponses pour vos utilisateurs. C’est à eux de juger si c’est acceptable ou non. 

Également, un ensemble de tests de performances “réalistes” pourront aider à prendre des décisions objectives.

Pour donner quelques idées réalistes, suite à des expérimentations sur le dataset para_crawl, avec 4 millions de documents stockés, les temps de réponse (sur une machine de développement) sont de l’ordre de plusieurs dizaines de secondes! Certes sur un cluster de production, on aura de meilleures performances, mais on restera dans ces ordres de grandeur.
Si votre corpus de documents est de taille modeste (et qu’il le reste), il se peut que les utilisateurs ne soient pas impactés : fin de partie, tout va bien.

Dans le cas contraire, il va être nécessaire de faire mieux. Il y a plusieurs pistes à explorer :

  • Augmenter les ressources matérielles allouées au cluster Elasticsearch : pas très éco-compatible, nécessite d’avoir une direction financière conciliante 😉 , mais c’est un compromis possible, peut-être temporairement pour passer un cap de charge. Elasticsearch étant un système distribué, il vous permet nativement d’absorber une plus grande charge en ajoutant des nœuds au cluster.
  • Ne prendre en compte que des documents « intéressants » pour l’utilisateur avant de les scorer. Autrement dit, on va filtrer les documents avant le scoring : c’est une approche particulièrement pertinente dans le sens où on prend en compte les aspects métier et par conséquent on a une bonne opportunité de coller aux besoins des utilisateurs. Par exemple, on pourra restreindre la recherche à une plage de date, une catégorie, … pour peu que ces informations (des métadonnées) soient associées aux textes qu’on manipule. 
  • Si aucune des solutions précédentes n’est applicable, est-ce pour autant qu’on doit capituler ? Heureusement non ! mais comme rien n’est gratuit, il va falloir réfléchir un peu… la suite au prochain épisode !

(1) On dit qu’un vecteur est dense lorsque la plupart de ses coordonnées sont différentes de 0, par opposition aux “vecteurs creux” (ou sparse vectors) qui ont une grande partie de leurs composantes égales à 0.

(2) Disclaimer: au moment où ce code a été testé, il n’a pas été possible de le faire fonctionner sous Windows. Une des librairies utilisées (tensorflow_text) n’est pas disponible sur cette plateforme. Donc amis windowsiens, vous avez deux alternatives: utiliser docker pour windows ou … un autre OS 🙁

(3) Amis zicos, j’espère que cet exemple pourra vous convaincre que le modèle est capable de manipuler les concepts avec une certaine aisance… Pour les non initiés, Gibson et Fender sont deux marques de guitares qui ont marqué (voire forgé) l’histoire du rock. Encore plus fort, la deuxième réponse à la requête parle de la Stratocaster, un des modèles phares de Fender. Et la troisième réponse parle des amplis Marshal, outil indispensable pour envoyer du bon gros son!

2 réflexions sur “De l’algorithme à la solution : un exemple d’exploitation d’un algorithme d’intelligence artificielle (4/5)

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

%d blogueurs aiment cette page :