Publier des données structurées via un LLM

Le traitement de données non structurées est une problématique récurrente sur de nombreux systèmes d’information. L’approche classiquement adoptée pour adresser cette difficulté consiste à mettre en place un ensemble de prétraitements que l’on regroupe sous le terme général de normalisation des données. Ces traitements qui demeurent accessibles pour des données faiblement structurées peuvent se révéler particulièrement coûteux sur des données non structurées. Le traitement des données issues de textes en langage naturel est un exemple de ces traitements coûteux. Mais l’émergence récente des Large Language Models (ou LLM) ouvre des perspectives qui permettent de reconsidérer le niveau de difficulté d’une telle normalisation. Dans cet article, nous étudierons dans quelle mesure un LLM peut répondre à cet objectif et comment il est possible de l’intégrer dans un développement spécifique.

Déploiement d’un LLM

Dans la mesure où l’objectif est d’intégrer un LLM dans un développement spécifique, l’approche proposée dans cet article consiste à déployer un LLM local. Pour ce faire, nous pouvons nous appuyer sur Ollama et sur un modèle simple tel que Mistral 7B (le “7B” faisant ici référence à ses 7,3 milliards de paramètres). Développée initialement pour supporter l’inférence du modèle Llama de Méta, la solution libre Ollama peut être vue comme le Docker des LLM. Elle permet en effet d’exécuter de nombreux LLM, dont les plus populaires du marché, par exemple le modèle Mistral 7B.

Pour déployer un modèle Mistral 7B localement, nous allons nous appuyer sur un Ollama conteneurisé, déployé au sein d’un WSL. La première étape consiste à télécharger et exécuter Ollama avec la commande suivante :

docker run -d -v ollama:~/.ollama -p 11434:11434 --name ollama ollama/ollama

Cette commande instancie une image Ollama et expose son port de communication 11434. Pour tester le chargement d’un LLM Mistral 7B, on se connectera en interactif au conteneur Docker pour lancer le modèle.

docker exec -it ollama bash
ollama run mistral
>>> Send a message (/? for help)

Une question simple va nous permettre de vérifier que le modèle est opérationnel.

>>> Qu'est-ce que Ippon Technologies ?
 Ippon Technologies est une entreprise française de conseil et d'innovation spécialisée dans la digitalisation des entreprises. Elle offre des services en ingénierie logicielle, design UX/UI, développement web et mobile, ainsi que devops et cloud services. En plus de cela, Ippon Technologies fournit également des solutions pour l'analyse et le traitement des données, ainsi que des solutions de marketing digital.

Avant de passer à l’intégration du LLM, nous pouvons également vérifier qu’il répond correctement sur son port 11434 en exécutant la requête curl suivante :

curl http://localhost:11434/api/generate -d '{
  "model": "mistral",
  "prompt": "En quelle année a été créée Ippon Technologies ?",
  "stream": false
}'

{"model":"mistral","created_at":"2024-09-19T14:02:56.04657194Z","response":"2002 est l'année où Ippon Technologies a été créée.","done":true,"done_reason":"stop","context":[3,29473,2386,1294,3990,3000,2878,1032,7934,15559,2878,1083,1355,1034,7540,9544,2318,4,1027,29518,29502,29508,29550,1702,1073,29510,1598,2878,11877,1083,1355,1034,7540,9544,1032,7934,15559,2878,29491],"total_duration":5355601081,"load_duration":5232904,"prompt_eval_count":20,"prompt_eval_duration":1719998000,"eval_count":21,"eval_duration":3581952000}

Le diagramme suivant résume le déploiement proposé ci-dessus :

Note : la configuration proposée ci-dessus est destinée à une expérimentation et ne saurait être utilisée dans un contexte de production.

Intégration du LLM

Notre modèle étant opérationnel, il convient maintenant de l’intégrer dans un développement spécifique. Pour ce faire, nous allons réaliser un simple Web Service avec SparkJava et Langchain4J.

  • Langchain4J est la version Java de la librairie Langchain utilisée par les développeurs Python pour intégrer des LLM dans leurs solutions. Dans notre cas, nous utiliserons la version 0.34.0 (publiée le 5 septembre 2024) des modules langchain4j.core et langchain4j.local-ai.
  • SparkJava est un framework permettant d’exposer simplement des Web Services ; on privilégiera ici la simplicité de ce framework à la richesse fonctionnelle d’alternatives telles que SpringBoot. Pour cet article, nous utiliserons la version 2.9.4 du 10 juillet 2022.

En guise d’étape préliminaire, partons sur un simple service d’écho exposé par un endpoint POST et basé sur SparkJava.

package fr.ippon.llm;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import spark.Spark;

/**
 * LocalLlm is a simple example of how to use the LangChain4J library to create
 * a conversional agent.
 */
public class LocalLlm {

    /** Local LLM logger. */
    private static final Logger LOGGER = LoggerFactory.getLogger(LocalLlm.class);

    public static void main(String[] args) {
        LOGGER.info("Starting Local LLM");
        Spark.post("/query", (req, res) -> {
            String query = req.body();
            return query;
        });
    }
}

Après avoir lancé ce service, il est possible de le tester avec une simple requête :

curl -X POST localhost:4567/query -d 'Hello!'
Hello!

Il est maintenant temps d’instancier un client pour interagir avec notre LLM avec Langchain4J. Pour ce faire, on crée un modèle de langage local en utilisant la classe LocalAiChatModel fournie par le module langchain4j.local-ai. Le ChatLanguageModel ainsi obtenu est utilisé pour créer une instance de ConversationalChain qui va permettre de dialoguer avec le modèle local publié par Ollama. Le contenu de la méthode main devient alors :

LOGGER.info("Starting Local LLM");
ChatLanguageModel model = LocalAiChatModel.builder()
    .baseUrl("http://localhost:11434/v1")
    .modelName("mistral")
    .temperature(0.7)
    .timeout(Duration.ofMinutes(10))
    .build();
ConversationalChain chain = ConversationalChain.builder()
    .chatLanguageModel(model)
    .build();

LOGGER.info("Exposing /query endpoint");
Spark.post("/query", (req, res) -> {
    String query = req.body();
    return chain.execute(query);
});

Note : la température indiquée lors de la création du modèle est un paramètre, variant entre 0 et 2, caractérisant le déterminisme de ses réponses (une valeur élevée pouvant conduire à des résultats plus aléatoires et moins reproductibles).

Il est alors possible de dialoguer avec le LLM exposé par Ollama sur le port 11434 au travers de notre service exposé sur le port 4567.

curl -X POST localhost:4567/query -d 'En quelle année a été créée Ippon Technologies ?'
2002 est l'année où Ippon Technologies a été fondée.

A ce stade, notre modèle Mistral 7B est déployé localement et exposé au travers d’une API spécifique. Il nous reste à compléter sa base de connaissances avec les données non structurées que nous souhaitons exploiter.

Personnalisation de contenu

Pour ajuster la base de connaissances d’un LLM afin de personnaliser ses réponses, il existe deux approches :

  • Le “fine-tuning” consiste à partir d’un modèle de langage existant et à poursuivre le processus d’apprentissage avec des données complémentaires afin de produire un modèle étendu. Dans ce cas, les connaissances spécifiques font directement partie du LLM. Cette approche, assez efficace sur les résultats produits, présente l’inconvénient de nécessiter des ressources matérielles importantes pour procéder à cet apprentissage complémentaire.
  • La “retrieval augmented generation”, ou RAG pour les intimes, est une technique consistant à produire une représentation vectorielle de la base de connaissances spécifiques et à identifier les vecteurs pertinents à chaque requête du modèle pour en compléter le contexte. Cette approche, bien que moins “intégrée”, propose un résultat satisfaisant en mobilisant moins de ressources matérielles à l’apprentissage.

Dans le cadre de cet article, c’est l’approche “retrieval augmented generation” qui sera suivie. Alors, c’est parti, it’s rag-time !

L’approche RAG peut être considérée en deux temps :

  1. Traitement de la base de connaissances. Lors de cette étape, les documents à inclure dans la base de connaissances sont lus et découpés en morceaux (ou “segments”) de taille configurable et se recouvrant partiellement. Chaque segment issu de ce découpage est transformé en données vectorielles appelées “embeddings”. Ces données vectorielles sont alors stockées dans un espace stockage qui peut être persistant ou non.
  2. Enrichissement du contexte des requêtes. Lorsqu’une requête est émise vers le LLM, un pré-traitement lui est appliqué pour en produire une représentation vectorielle. Cette représentation est comparée aux vecteurs issus de l’apprentissage afin d’identifier les segments de texte pertinents. Ces segments sont alors intégrés au contexte de la requête qui est effectivement adressée au LLM.

Le diagramme suivant présente l’orchestration de ces différentes étapes.

Pour implémenter ce mécanisme en utilisant Langchain4j, un nouveau module va faire son entrée : langchain4j-easy-rag. La librairie Langchain4j propose des classes qui facilitent la mise en œuvre de l’étape d’apprentissage ; dans notre cas, étant donné que la volumétrie des données d’entrée est faible, on optera pour un stockage des “embeddings” en mémoire. Pour des données plus volumineuses, on aurait pu opter pour un support persistant, par exemple une base de données vectorielle telle que Chroma. Le calcul des embeddings à partir des documents présents dans un dossier local est réalisé comme suit :

LOGGER.info(String.format("Parsing documments from : %s", directoryPath));
List<Document> documents = FileSystemDocumentLoader.loadDocuments(directoryPath);
InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
EmbeddingStoreIngestor.ingest(documents, embeddingStore);

Pour exploiter le contenu de ce “store” lors du traitement d’une requête, il est nécessaire de remplacer l’instance de ConversationalChain par une instance de ConversationalRetrievalChain qui sera connectée au store via un EmbeddingStoreContentRetriever.

ConversationalRetrievalChain chain = ConversationalRetrievalChain.builder()
    .chatLanguageModel(model)
    .contentRetriever(new EmbeddingStoreContentRetriever(embeddingStore, null))
    .build();

Désormais, notre Web Service est prêt à découvrir nos données et à répondre aux questions qui s’y rapportent. Pour illustrer ces capacités, déposons un export PDF du catalogue de formations Ippon 2024. Et interrogeons notre modèle pour voir ce qu’il sait en dire :

curl -X POST localhost:4567/query -d 'Est-ce que le catalogue Ippon propose une formation sur Terraform ?'
 Oui, le catalogue Ippon propose une formation sur Terraform. Il s'agit de la formation "Terraform Advanced" qui est la suite logique de la formation "Terraform Intermediate". Cette formation aborde la gestion en pipeline de terraform, des credentials ainsi que des tests en Infrastructure as Code (IaC). Elle revient sur les outils phares du moment et les bonnes pratiques liées à terraform. Les pré-requis sont d'avoir suivi la formation "Terraform Intermediate" ou déjà avoir une bonne expérience avec terraform en production. Cette formation peut aussi se donner en externe chez nos clients.

La réponse proposée est assez complète si on regarde la description de la formation “Terraform Advanced” du catalogue dont la capture ci-dessous met en évidence les informations restituées par le LLM.

On peut néanmoins se questionner sur la raison pour laquelle le LLM n’a pas proposé de contenu sur la formation “Terraform Intermediate” qui est également présente au catalogue. Une raison possible est que cette autre formation rappelle le mot clé “Terraform” moins souvent (3 occurrences contre 7) :

Exposition d’API dédiées

Avec un LLM étendu selon une approche RAG, il est désormais possible d’envisager d’exploiter des données non structurées. Pour ce faire, on va ajouter un nouvel endpoint à notre Web Service pour traduire une requête technique en langage naturel à destination du LLM. En reprenant l’exemple du catalogue de formation Ippon, nous pourrions exposer un service GET permettant de récupérer l’ensemble des formations du catalogue avec, pour chaque formation, le niveau de difficulté et la liste des profils auxquels il s’adresse. L’implémentation de cet endpoint est la suivante :

Spark.get("/formations", (req, res) -> {
    String query = """
        Donne moi la liste de toutes les formations du catalogue
        Ippon au format JSON. Pour chaque formation, donne moi le nom
        ('name' dans le JSON), le niveau d'expérience requis ('level' 
        dans le JSON) et la liste des profils concernés ('profiles'
        dans le JSON).
        """;
    return chain.execute(query);
});

Une interrogation de cet endpoint est proposée ci-dessous :

curl localhost:4567/formations
 ```
[
    {
        "name": "La DatAcademy",
        "level": "Beginner",
        "profiles": ["Analyst", "Data Engineer", "Data Scientist"]
    },
    {
        "name": "Spark Formation",
        "level": "Beginner",
        "profiles": ["Developer", "Data Engineer", "Data Scientist", "Tech Lead"]
    },
    {
        "name": "Design #1: Comprendre l'utilisateur et ses besoins",
        "level": "Beginner",
        "profiles": ["All non-designers"]
    },
    {
        "name": "Design #2: S’inspirer et cocréer",
        "level": "Intermediate",
        "pre_requis": ["Design #1"],
        "profiles": ["All non-designers"]
    },
    {
        "name": "Design #3: Prototyper et collaborer sur Figma",
        "level": "Advanced",
        "profiles": ["All non-designers"]
    },
    {
        "name": "Terraform Advanced",
        "level": "Advanced",
        "profiles": ["Terraform Maintainer"]
    }
]
```

Cette réponse soulève plusieurs remarques :

  • La liste des formations proposée est incomplète. On retrouve ici un biais déjà observé sur l’exemple précédent dans lequel le LLM avait focalisé son attention sur la formation “Terraform Advanced” en ignorant la formation “Terraform Intermediate”.
  • Le niveau des formations “Design #2” et “Design #3” n’est pas le bon. Cette hallucination est probablement liée à un perception de gradation du niveau de difficulté selon le “numéro” de la formation, principe qui est souvent adopté dans des contenus similaires au catalogue de formation Ippon sur lequel le LLM a peut-être été entraîné.
  • Les valeurs retenues pour “level” et “profiles” sont en anglais dans la réponse alors qu’elles sont en français dans le contenu d’origine. Même si le prompt proposé est en français, le choix de nommage pour les attributs du JSON ont probablement induit une réponse en anglais là où la traduction était directe ; on notera en effet que le nom des formations a été préservé en français.

Enfin, il est important de souligner que les temps de réponse du LLM via ses endpoints restent assez élevés (souvent au-delà de deux minutes) avec la configuration matérielle utilisée pour mener les tests en local. En effet, seules les ressources CPU (4 cœurs) ont été utilisées par le LLM pour restituer ses réponses. La mise en œuvre de GPU ou l’adjonction de ressources CPU supplémentaires permettrait de réduire les temps de réponse pour des usages interactifs.

Conclusions

Le niveau de maturité des technologies liées aux LLM ouvre des perspectives d’utilisation dans des contextes de développement d’applications spécifiques. Le déploiement d’un LLM via Ollama et l’intégration de son API via Langchain permettent en effet de produire des résultats intéressants. Cela étant, nous avons mis en évidence quelques limitations de l’approche qu’il convient d’adresser en fonction du contexte d’usage :

  • Les temps de réponse du LLM peuvent être un sérieux obstacle pour des cas d’usage interactifs (c’est moins problématique pour des traitements en arrière plan) ; le dimensionnement de l’infrastructure est alors à considérer avec soin.
  • La complétude et l’exactitude des réponses sont conditionnées par la taille du modèle choisi (rappelons que le modèle 7B utilisé pour notre exemple, bien qu’efficient, est loin d’être le plus performant sur le marché) et par la base d’apprentissage (qui dans notre cas était limitée à un seul document).

Pour aller au-delà des limitations relevées dans cet article, plusieurs pistes sont à considérer :

  • Jouer sur la température du modèle pour systématiser les réponses et réduire le risque d’hallucination.
  • Affiner le contexte d’interrogation du modèle en enrichissant le prompt avec des directives supplémentaires (par exemple celle consistant à demander au modèle de fournir des données en français malgré des noms de propriétés anglaises).
  • Adopter un modèle avec davantage de paramètres pour produire des réponses plus pertinentes.
  • Approvisionner une configuration matérielle plus conséquente, idéalement avec une capacité GPU, pour obtenir des réponses plus rapides et supporter des modèles plus larges.

Toutes ces optimisations ne garantiront pas une fiabilité absolue des réponses d’un modèle qui reste fondamentalement probabiliste. La caractérisation du niveau de fiabilité (et plus précisément du niveau de “faultlessness” tel que défini par l’ISO 25010) attendu des composants logiciels pour lesquels l’intégration d’un LLM est envisagée doit donc être appréciée avec soin.

Les LLM, et l’écosystème associé qui est aujourd’hui parvenu à un bon niveau de maturité, complètent désormais la boîte à outils du développeur. Mais il ne faut pas y voir une balle en argent et garder à l’esprit leurs atouts et leurs limites lorsqu’il s’agit de les comparer avec des solutions historiques, qui viennent avec leurs avantages en termes de fiabilité, de performance ou d’empreinte environnementale.