· 10 min read

Construire un RAG en PHP avec la doc de Symfony, LLPhant et OpenAI

Dans cet article, nous allons voir comment créer un chatbot ayant la capacité de répondre à des questions sur la documentation de Symfony en mettant en place un RAG (Retrieval Augmented Generation) avec LLPhant et OpenAI dans un projet Symfony.

Dans cet article, nous allons voir comment créer un chatbot ayant la capacité de répondre à des questions sur la documentation de Symfony en mettant en place un RAG (Retrieval Augmented Generation) avec LLPhant et OpenAI dans un projet Symfony.

Construire un RAG en PHP avec la doc de Symfony, LLPhant et OpenAI

Objectif

Dans cet article, nous allons voir comment créer un chatbot ayant la capacité de répondre à des questions sur la documentation de Symfony en mettant en place un RAG (Retrieval Augmented Generation) avec LLPhant et OpenAI dans un projet Symfony.

Nous allons nous appuyer sur l’incroyable librairie LLPhant et OpenAI pour créer notre chatbot !

Les concepts

Disclaimer : Je ne suis pas un expert en IA, ni en mathématiques. Je reste donc volontairement assez en surface sur les concepts pour ne pas dire -trop- de bêtises.

Mes excuses aux véritables experts du sujet pour mes approximations.

Le RAG

Le RAG (Retrieval Augmented Generation) est un modèle d’architecture qui permet de répondre à des questions (en autre) en 2 étapes :

  1. On récupère un ensemble de documents qui peuvent répondre à la question
  2. On génère une réponse à partir de ces documents

La mise en place d’un RAG est très intéressante car elle permet de rendre GPT-4 bien plus spécialisé et donc plus performant.

Sa mise en place necessite 3 étapes :

  1. Générer des embeddings pour chaque document
  2. Stocker les embeddings
  3. Utiliser les embeddings pour répondre aux questions

Les vecteurs

Dans ce tutoriel, nous allons manipuler le principe des Vecteurs.

En substance, et comme je ne suis pas du tout un expert du sujet, un vecteur est une représentation mathématique d’un objet. Par exemple, un vecteur peut représenter un mot, une phrase, une image, etc.

Le principe est de pouvoir comparer des vecteurs entre eux pour déterminer leur similarité.

Dans notre contexte, nous allons comparer des vecteurs représentant des questions avec des vecteurs représentant des documents pour déterminer si un document peut répondre ou éclairer la réponse à une question.

Embeddings et chunks

Les embeddings sont une forme de vecteurs. Nous allons découper notre documentation Symfony en petits morceaux, des chunks, et nous allons générer un embedding pour chaque chunk.

Pour découper la documentation en petits morceaux, la librairie LLPhant nous propose, nous le verrons plus tard, tout un tas d’outils pour nous faciliter la tâche (c’est à dire découper les documents en chunks de manière automatique).

Une fois notre documentation découpée, nous allons transformer chaque chunck en embeddings.

LLPant nous permet de faire un aller-retour avec OpenAI pour obtenir les embeddings de chaque morceau de documentation.

Enfin, nous stockerons ces embeddings dans un fichier JSON.

Le stockage des vecteurs/embeddings

Vous l’aurez compris, nous allons avoir besoin de stocker un grand nombre d’embeddings. Pour ce tutoriel, nous allons utiliser un stockage dans le FileSystem pour des raisons de simplicité de la démonstration.

LLPhant nous propose également d’autres solutions de stockage bien plus performantes sur de gros volumes de données, comme par exemple PostgreSQL, Redis, etc.

Prérequis

Pour suivre cet article, vous devez avoir installé sur votre machine :

  • PHP 8.2^
  • Composer
  • Symfony CLI

Vous devez également avoir un compte sur OpenAI pour obtenir une clé d’API.

La documentation Symfony

La documentation Symfony est disponible sur GitHub à l’adresse suivante : https://github.com/symfony/symfony-docs.

Attention : Pour les besoins de ce tutoriel, et pour limiter les coûts et la durée, nous allons nous contenter d’utiliser le fichier best_practices.rst pour la construction de notre chatbot.

Libre à vous d’utiliser la totalité de la documentation Symfony, mais je vous conseille de bien analyser les coûts de cette opération avant de vous lancer (je pense que l’opération reste relativement abordable, mais je n’ai pas fait le calcul).

Créer un projet Symfony

Au moment de la rédaction de cet article (14/01/2024), LLPhant n’est pas compatible avec Symfony 7, j’utilise donc Symfony 6.4. Vérifiez la compatibilité de LLPhant avec votre version de Symfony avant de continuer.

  • Commencez par créer un nouveau projet Symfony :
symfony new SymfonyDocBot --webapp
  • installons ensuite LLPhant :
composer require theodo-group/llphant

Génération des embeddings

Nous allons maintenant générer les embeddings de notre documentation Symfony et les stocker dans notre filesystem.

Configuration de l’API OpenAI

  • Ajoutons la variable d’environnement OPENAI_API_KEY dans le fichier .env et copions le vers le fichier .env.local :
echo "OPENAI_API_KEY=sk-xxxxxx" >> .env
cp .env .env.local
  • Ajoutons notre véritable clé d’API OpenAI dans le fichier .env.local.

Paramétrage de la clé dans les Env Vars System

Cette étape est nécessaire pour que LLPhant puisse récupérer la clé d’API OpenAI, il ne la récupère pas automatiquement depuis le fichier .env.local.

export OPENAI_API_KEY=sk-xxxxxx

Création d’une commande Symfony pour générer les embeddings

Pour simplifier la lecture de cet article, nous ne crérons pas de `Service“ Symfony pour générer les embeddings, nous allons directement créer le code dans la commande Symfony.

  • Déponsons le fichier best_practices.rst dans le dossier public de notre projet Symfony.

  • Créons ensuite une commande Symfony qui va nous permettre de générer les embeddings de notre documentation Symfony :

symfony console make:command GenerateEmbeddings

Lire le fichier best_practices.rst

Nous allons utiliser la classe FileDataReader fournie par LLPhant pour lire le fichier best_practices.rst.

  • Modifions le fichier GenerateEmbeddingsCommand.php :
<?php

namespace App\Command;

use LLPhant\Embeddings\DataReader\FileDataReader;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'GenerateEmbeddings',
    description: 'Génère les embeddings de vos données',
)]
class GenerateEmbeddingsCommand extends Command
{
    public function __construct()
    {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $io->title("Hello ! Nous allons générer les embeddings de vos données.");

        $dataReader = new FileDataReader(__DIR__ . '/../../public/best_practices.rst');
        $documents = $dataReader->getDocuments();

        return Command::SUCCESS;
    }
}

Découper la documentation en petits morceaux : les chunks

Nous utilisons la classe DocumentSplitter fournie par LLPhant pour découper notre documentation en morceaux de 500 caractères.

  • Modifions le fichier GenerateEmbeddingsCommand.php :
// ...
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $io->title("Hello ! Nous allons générer les embeddings de vos données.");

        $dataReader = new FileDataReader(__DIR__ . '/../../public/best_practices.rst');
        $documents = $dataReader->getDocuments();
        
        $splittedDocuments = DocumentSplitter::splitDocuments($documents, 500);
        
        return Command::SUCCESS;
    }
// ...

Générer les embeddings

Nous utilisons la classe OpenAIEmbeddingGenerator fournie par LLPhant pour générer les embeddings de chaque morceau de documentation.

Attention, cette étape peut prendre du temps, en effet, cette étape va faire un aller-retour avec l’API OpenAI pour générer les embeddings. Cette action est facturée par OpenAI.

Pour infos, cette étape de génération des embeddings utilise le modèle text-embedding-ada-002 d’OpenAI.

// ...
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $io->title("Hello ! Nous allons générer les embeddings de vos données.");

        $dataReader = new FileDataReader(__DIR__ . '/../../public/best_practices.rst');
        $documents = $dataReader->getDocuments();
        
        $splittedDocuments = DocumentSplitter::splitDocuments($documents, 500);

        $embeddingGenerator = new OpenAIEmbeddingGenerator();
        $embeddedDocuments = $embeddingGenerator->embedDocuments($splittedDocuments);

        return Command::SUCCESS;
    }
// ...

Stocker les embeddings dans le filesystem

Nous utilisons la classe FileSystemVectorStore fournie par LLPhant pour stocker les embeddings dans le filesystem (mais d’autres solutions plus performante sont possibles pour de plus gros volume de données, comme une base de données PostgreSQL, Redis, etc.).

// ...
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $io->title("Hello ! Nous allons générer les embeddings de vos données.");

        $dataReader = new FileDataReader(__DIR__ . '/../../public/best_practices.rst');
        $documents = $dataReader->getDocuments();
        
        $splittedDocuments = DocumentSplitter::splitDocuments($documents, 500);

        $embeddingGenerator = new OpenAIEmbeddingGenerator();
        $embeddedDocuments = $embeddingGenerator->embedDocuments($splittedDocuments);

        $vectorStore = new FileSystemVectorStore();
        $vectorStore->addDocuments($embeddedDocuments);

        return Command::SUCCESS;
    }
// ...

Ajouter du feedback à l’utilisateur

Améliorons un peu notre commande en ajoutant des messages à l’utilisateur pour lui indiquer l’avancement de la commande.

Oui, je sais, il nous faudrait gérer les erreurs, mais pour les besoins de ce tutoriel, nous allons nous contenter de ce code.

Voici le code complet de la commande :

<?php

namespace App\Command;

use Doctrine\ORM\EntityManagerInterface;
use LLPhant\Embeddings\DataReader\FileDataReader;
use LLPhant\Embeddings\DocumentSplitter\DocumentSplitter;
use LLPhant\Embeddings\EmbeddingGenerator\OpenAIEmbeddingGenerator;
use LLPhant\Embeddings\VectorStores\FileSystem\FileSystemVectorStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'GenerateEmbeddings',
    description: 'Génère les embeddings de vos données',
)]
class GenerateEmbeddingsCommand extends Command
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager
    )
    {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $io->title("Hello ! Nous allons générer les embeddings de vos données.");

        $io->section("Lecture des données");
        $dataReader = new FileDataReader(__DIR__ . '/../../public/best_practices.rst');
        $documents = $dataReader->getDocuments();
        $io->success("Les données ont été lues avec succès, et ".count($documents)." documents ont été trouvés.");
        
        $io->section("Découpage des documents");
        $splittedDocuments = DocumentSplitter::splitDocuments($documents, 500);
        $io->success("Les documents ont été découpés avec succès en ".count($splittedDocuments)." documents de 500 mots maximum.");
        
        $io->section("Génération des embeddings");
        $embeddingGenerator = new OpenAIEmbeddingGenerator();
        $embeddedDocuments = $embeddingGenerator->embedDocuments($splittedDocuments);
        $io->success("Les embeddings ont été générés avec succès.");

        $io->section("Sauvegarde des embeddings");
        $vectorStore = new FileSystemVectorStore();
        $vectorStore->addDocuments($embeddedDocuments);
        $io->success("Les embeddings ont été sauvegardés avec succès.");
        
        $io->success("Les embeddings ont été générés avec succès et stockés dans le fichier documents-vectorStore.json");

        return Command::SUCCESS;
    }
}

Créer un “chatbot” avec LLPhant

Maintenant que nous avons généré les embeddings de notre documentation Symfony, nous allons pouvoir créer notre chatbot avec LLPhant.

Pour des raisons de simplicité, nous allons coder la logique du chatbot dans le controller Symfony. Dans un cas réel, il serait préférable de créer un service Symfony.

Un peu de style avec PicoCSS

  • On vide le fichier assets/styles/app.css

  • On ajoute PicoCSS dans le fichier templates/base.html.twig :

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>{% block title %}Welcome!{% endblock %}</title>
        <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>">
    </head>
    <body>
        <main class="container">
            {% block body %}{% endblock %}
        </main>
    </body>
</html>

Création d’un controller Symfony

  • Créons un controller Symfony :
symfony console make:controller ChatbotController
  • Modifier le fichier pour qu’il réponde à la route / :
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class ChatbotController extends AbstractController
{
    #[Route('/', name: 'app_chatbot')]
    public function index(): Response
    {
        return $this->render('chatbot/index.html.twig', [
            'controller_name' => 'ChatbotController',
        ]);
    }
}

Création d’un formulaire Symfony

  • Créons un formulaire Symfony :
symfony console make:form ChatbotType
  • Modifier le fichier pour qu’il ressemble à ça :
<?php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ChatbotType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('question', TextType::class, [
                'label' => 'Question',
                'attr' => [
                    'placeholder' => 'Posez votre question',
                ],
            ])
            ->add('submit', SubmitType::class, [
                'label' => 'Poser la question',
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            // Configure your form options here
        ]);
    }
}

Utilisation du formulaire dans le controller

  • Utilisons le formulaire dans le controller :
<?php

namespace App\Controller;

use App\Form\ChatbotType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class ChatbotController extends AbstractController
{
    #[Route('/', name: 'app_chatbot')]
    public function index(Request $request): Response
    {
        $form = $this->createForm(ChatbotType::class);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            dd($form->getData());
        }

        return $this->render('chatbot/index.html.twig', [
            'form' => $form,
        ]);
    }
}
  • Adaptons le template Twig templates/chatbot/index.html.twig pour afficher le formulaire :
{% extends 'base.html.twig' %}

{% block title %}Hello ChatbotController!{% endblock %}

{% block body %}
    <h1>Hello ChatbotController!</h1>

    {{ form(form) }}
{% endblock %}

Traitement de la question et recherche de la réponse

Nous allons maintenant traiter la question posée par l’utilisateur. Ce processus se déroule en 3 étapes :

  1. Nous allons comparer la question avec les embeddings de la documentation Symfony stockés dans le fichier documents-vectorStore.json
  2. Nous transmettons le contexte (nos embeddings) et notre question à OpenAI pour obtenir une réponse la plus pertinente possible
  3. Nous affichons la réponse à l’utilisateur

LLPhant va grandement nous faciliter la tâche pour ces 3 étapes.

Comparer la question avec les embeddings, obtenir le contexte et transmettre le contexte et la question à OpenAI

  • Modifions le controller pour qu’il ressemble à ça :
// ...
        if ($form->isSubmitted() && $form->isValid()) {
            $question = $form->getData()['question'];

            $vectorStore = new FileSystemVectorStore('../documents-vectorStore.json');
            $embeddingGenerator = new OpenAIEmbeddingGenerator();

            $qa = new QuestionAnswering(
                $vectorStore,
                $embeddingGenerator,
                new OpenAIChat()
            );

            $answer = $qa->answerQuestion($question);

            return $this->render('chatbot/index.html.twig', [
                'form' => $form,
                'answer' => $answer,
            ]);
        }
// ...

Afficher la réponse à l’utilisateur

  • Modifions le template Twig templates/chatbot/index.html.twig pour afficher la réponse :
{% extends 'base.html.twig' %}

{% block title %}Hello ChatbotController!{% endblock %}

{% block body %}

<h1>SymfonyDocBot</h1>

<article>

    {{ form(form) }}

    {{ answer ?? '' }}

</article>

{% endblock %}

Améliorations possibles

Pour que l’expérience utilisateur soit plus agréable, il faudrait ajouter un peu de JavaScript pour que la réponse s’affiche sans recharger la page (avec HTMX par exemple), ajouter un loader pendant le chargement de la réponse, etc.

Dans le cadre de ce tutoriel, nous n’irons pas plus loin, je pense que vous avez les éléments les plus important pour mettre en place un RAG en PHP, permettant de répondre à des questions sur la documentation Symfony, dans une application Symfony avec LLPhant et OpenAI !

Conclusion

Nous avons vu comment créer un chatbot avec la documentation de Symfony, LLPhant et OpenAI en quelques étapes seulement, en utilisant le principe du RAG (Retrieval Augmented Generation).

Avec cette approche, nous pouvons créer un chatbot pour n’importe quel type de documentation, et même pour n’importe quel type de données, et cela nous permet de rendre GPT-4 bien plus spécialisé et donc plus performant.

A vous d’imaginez les possibilités ! Elle sont infinies !

Back to Blog