17min.

Construire un chatbot spécialisé sur vos données grâce à l’IA générative et PHP

Utilisée pour rédiger n’importe quel type de contenus, pour faire des résumés ou encore intégrée à nos outils de développement, l’IA, et en particulier l’IA générative, a trouvé sa place dans bon nombre de secteurs. Basée sur des LLM entraînés sur des quantités astronomiques de documents divers et variés, ces IA sont capables de comprendre et répondre à la plupart des questions qui leur sont posées. Ce qui ne les empêche cependant pas de donner des réponses erronées ou imprécises dans beaucoup de situations.

Prenons l’exemple de notre projet Castor et sa documentation en ligne et demandons à ChatGPT le rôle de la fonction run() :

ChatGPT nous parle de Castor

Même s’il comprend à peu près de quoi nous parlons, l’explication et l’exemple fournis ne sont pas corrects : la fonction line() n’existe tout simplement pas. ChatGPT n’a pas été entraîné spécifiquement sur la documentation de Castor mais a malgré tout « improvisé » une réponse.

Heureusement, il existe un moyen de forcer ce genre d’IA à être plus pertinente sur des domaines précis, sans avoir à dépenser du temps, ni d’argent à entraîner nos propres modèles de LLM. C’est notamment le principe de fonctionnement des Retrieval-Augmented Generation, abrégés RAG.

Section intitulée qu-est-ce-qu-un-ragQu’est-ce qu’un RAG ?

Bien qu’un modèle de langage (LLM) soit entraîné sur une vaste quantité de documents, il ne peut jamais être constamment à jour ni pertinent sur tous les sujets pour plusieurs raisons :

  • son entraînement prend souvent du temps ;
  • les documents en ligne (sur lesquels ils sont entraînés) changent régulièrement ;
  • le modèle n’a pas accès à toutes les informations du monde (et heureusement).

Dans cet article, nous utiliserons la documentation de Castor comme exemple. Mais imaginez avoir une IA capable de répondre à vos questions sur n’importe quel outil open source ou encore sur votre documentation interne du fonctionnement de votre entreprise.

C’est là qu’intervient le concept de RAG (Retrieval-Augmented Generation). Il permet de découper le processus de génération de réponses en deux étapes distinctes :

Les deux étapes d'un RAG

Section intitulée la-recuperationLa récupération 🔎

Lorsque l’utilisateur va saisir un message, nous allons nous occuper nous-mêmes de rechercher les documents pertinents pour lui répondre. Cette recherche pourrait se faire en faisant une simple recherche de mots-clefs dans notre ensemble de documents, par exemple avec Elasticsearch. Mais cela reste assez basique et les résultats peu satisfaisants. À la place, nous allons effectuer une recherche vectorielle dans notre corpus de documents. Cette idée peut faire peur au premier abord mais elle n’est pas très compliquée à appréhender. Voyons maintenant comment cela fonctionne.

Dans un premier temps, nous allons transformer chaque document en vecteurs. Ce vecteur est un objet mathématique qui permet de représenter le contexte et la sémantique du document en question. Ainsi, deux documents qui parlent d’un sujet assez similaire auront des vecteurs assez proches dans l’espace vectoriel rattaché.

Exemple de représentation de documents dans un espace vectoriel à 3 dimensions

Exemple de représentation de documents dans un espace vectoriel à trois dimensions.

Dans cet exemple simplifié, on voit que les deux chapitres de la documentation à propos des fonctions run() et capture() sont assez proches, et plutôt éloignés du chapitre concernant l’utilisation et la souscription aux événements de Castor. Ici, nous avons trois dimensions, mais dans la réalité, nous utiliserons plutôt des vecteurs avec des milliers de dimensions, afin d’être capable de capturer une sémantique la plus précise possible pour chaque document.

Si vous n’avez jamais fait de RAG, vous vous demandez sûrement comment obtenir un vecteur à partir d’un simple texte ? La réponse est plutôt simple et presque décevante : on va demander à une IA ! Il existe des modèles spécialement entraînés pour capturer la sémantique d’un texte et en extraire le vecteur correspondant. En anglais, cette représentation d’une valeur (textuelle, image ou autre) dans un format adapté (ici un vecteur) à un traitement par un algorithme s’appelle « embedding ».

Une fois nos vecteurs calculés et stockés pour chaque document, nous pouvons nous occuper des entrées de l’utilisateur. Chaque fois qu’il interagit avec notre RAG, nous allons appliquer la même logique de vectorisation à son message. Avec ce nouveau vecteur, nous serons capables de faire une recherche vectorielle parmis tous nos documents afin de déterminer ceux les plus proches de la question de l’utilisateur.

Section intitulée la-generationLa génération ✍️

Une fois que nous avons identifié les documents les plus pertinents, nous demanderons à notre LLM préféré de formuler une réponse pour l’utilisateur. Mais cette fois, nous allons lui fournir nous-mêmes les documents/connaissances sur lesquels il doit s’appuyer.

Cerise sur le gâteau, nous pouvons même lui demander :

  • de s’appuyer uniquement sur ces contenus fournis en ignorant son éventuelle connaissance interne ;
  • d’ajouter dans la réponse le lien correspondant au contenu, permettant à l’utilisateur de vérifier les sources.

La démo fonctionnelle à la fin de l'article

Mais assez de théorie pour l’instant. Il est temps de passer à la pratique et de voir comment construire concrètement l’exemple que nous venons d’évoquer.

Section intitulée mise-en-pratiqueMise en pratique

Nous allons chercher à créer un chatbot qui sera capable de répondre à n’importe quelle question à propos d’un site web qu’il aura auparavant ingéré. Pour ce faire, nous allons créer une application Symfony dans un projet dockerisé. Pour gagner du temps, nous nous baserons sur un template docker-starter pour nous consacrer sur l’essentiel.

Tout le code de cette démo se trouve dans un repository open source, n’hésitez pas à y jeter un œil pour approfondir cet article.

Section intitulée la-base-de-donnees-vectorielleLa base de données vectorielle 🐘

Pour stocker nos documents texte et leurs représentations vectorielles, tout en permettant une recherche vectorielle efficace, nous allons utiliser… PostgreSQL ! Plus précisément, PostgreSQL associé à l’extension pgvector, qui ajoute les fonctionnalités nécessaires pour effectuer des recherches basées sur la similarité vectorielle. Dans notre docker-compose.yml, on peut utiliser directement l’image pgvector/pgvector qui ajoute uniquement l’extension pgvector à l’image officielle de PostgreSQL :

# …
services:
    # …

    postgres:
        image: pgvector/pgvector:pg16
        # …

Section intitulée decoupage-des-pages-en-documentsDécoupage des pages en documents ✂️

Dans cette démo, j’ai choisi de crawler le site de la documentation au lieu de récupérer directement les documents markdown car je souhaitais créer un chatbot capable d’ingérer n’importe quel site, et pas uniquement une documentation markdown en particulier.

Plutôt que de créer un seul document pour chaque page, j’ai décidé que chaque page serait séparée en plusieurs sections. L’idée est d’avoir des documents plus granulaires pour aiguiller au mieux notre IA plutôt qu’un contenu conséquent qui risquerait de la perturber. Chaque fois qu’un titre sera détecté dans la page, une nouvelle section sera stockée en base de données. Dans l’idéal, chaque titre aura un id servant d’ancre afin que les URLs des sources soient plus précises pour l’utilisateur (c’est le souvent le cas avec les documentations générées depuis du markdown ou équivalent).

Imaginons la structure d’une page à l’URL /foobar :

<h1 id="lorem">Lorem</h1>
      <p>Ipsum dolor sit amet</p>

      <h2 id="consectetur">Consectetur</h2>
          <p>Adipiscing elit</p>

          <h3 id="sed">Sed</h3>
            <p>Do eiusmod tempor</p>

          <h3 id="incididunt">Incididunt</h3>
            <p>Ut labore et</p>

      <h2 id="dolore">Dolore</h2>
          <p>Magna aliqua</p>

Cela nous donnera les cinq documents suivants :

url: /foobar#lorem
title: Lorem
content: Ipsum dolor sit amet

—

url: /foobar#consectetur
title: Consectetur
content: Adipiscing elit

—

url: /foobar#sed
title: Sed
content: Do eiusmod tempor

—

url: /foobar#incididunt
title: Incididunt
content: Ut labore et

—

url: /foobar#dolore
title: Dolore
content: Magna aliqua

Voici un exemple simple de parsing du HTML et de découpage du contenu en fonction des titres à l’aide du DomCrawler de Symfony :

use Symfony\Component\DomCrawler\Crawler as DomCrawler;

class DocumentExtractor
{
    /** @return Document[] */
    public function extract(string $url, string $html): array
    {
        $crawler = new DomCrawler($html);
        $crawler = $crawler->filter('h1, h2, h3, h4, h5, h6');

        // Iterate over the h1-h6 titles
        foreach ($crawler as $node) {
            $documentUrl = $url;

            // Attach an anchor to the URL if title has an id
            if ($node instanceof \DOMElement && $node->hasAttribute('id')) {
                $documentUrl .= '#' . $node->getAttribute('id');
            }

            $title = $this->cleanContent($node->textContent);
            $content = '';
            $contentNode = $node->nextSibling;

            // Parse the content until the next title
            while ($contentNode && !$this->isTitle($contentNode)) {
                $content .= $contentNode->ownerDocument->saveHTML($contentNode);
                $contentNode = $contentNode->nextSibling;
            }

            $documents[] = new Document(
                $documentUrl,
                $title,
                $this->cleanContent($content),
            );
        }

        return $documents;
    }

    // …
}

Section intitulée crawl-du-siteCrawl du site 🕸️

Pour crawler le site, nous utiliserons le package spatie/crawler, qui, grâce à son utilisation du design pattern Observer, nous permet de nous brancher facilement dans le crawl et analyser chaque page, une par une :

use Spatie\Crawler\Crawler as SpatieCrawler;
use Spatie\Crawler\CrawlProfiles\CrawlInternalUrls;

class Crawler
{
    public function __construct(private readonly Observer $observer) {}

    /** @return Document[]  */
    public function crawl(string $url): array
    {
        $this->observer->reset();

        SpatieCrawler::create()
            ->setCrawlObserver($this->observer)
            ->setCrawlProfile(new CrawlInternalUrls($url))
            ->acceptNofollowLinks()
            ->setMaximumDepth(1000)
            ->startCrawling($url)
        ;

        return $this->observer->getDocuments();
    }
}

Et voici la définition de notre Observer, qui sera chargée d’appeler le DocumentExtractor définit plus tôt :

use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use Spatie\Crawler\CrawlObservers\CrawlObserver;
use Symfony\Contracts\Service\ResetInterface;

class Observer extends CrawlObserver implements ResetInterface
{
    /** @var Document[] */
    public array $documents = [];

    public function __construct(
        private readonly DocumentExtractor $extractor,
        private readonly LoggerInterface $logger,
    ) {
    }

    public function reset()
    {
        $this->documents = [];
    }

    public function crawled(
        UriInterface $url,
        ResponseInterface $response,
        ?UriInterface $foundOnUrl = null,
        ?string $linkText = null,
    ): void {
        $html = (string) $response->getBody();

        if (!$html) {
            return;
        }

        $stringUrl = (string) $url;
        $this->documents = array_merge($this->documents, $this->extractor->extract($stringUrl, $html));
    }

    public function crawlFailed(
        UriInterface $url,
        RequestException $requestException,
        ?UriInterface $foundOnUrl = null,
        ?string $linkText = null,
    ): void {
        $this->logger->error('Could not crawl this url.', [
            'url' => (string) $url,
            'exception' => $requestException->getMessage(),
        ]);
    }

    public function finishedCrawling(): void {}
}

Maintenant que l’on a récupéré tous nos documents, nous pouvons passer à l’étape d’après : calculer leur représentation vectorielle.

Section intitulée vectorisation-des-documentsVectorisation des documents ➗

C’est ici que nous commençons à mettre les mains dans le camboui. Comment faire pour obtenir les « embeddings », c’est-à -dire, les vecteurs correspondant à nos documents ? Il faut savoir que la plupart des services / modèles d’IA générative proposent directement des outils pour calculer nos embeddings.

OpenAI ne fait pas exception et propose une API dédiée. Rien de bien sorcier finalement, on se contentera d’appeler ce endpoint en lui passant le texte à vectoriser :

class OpenAIClient
{
    public function __construct(
        #[Autowire('%env(OPENAI_API_KEY)%')]
        private readonly string $apiKey,
        private readonly HttpClientInterface $client,
    ) {
    }

    /** @return array<float> */
    public function getEmbeddings(string $content): array
    {
        $data = $this->call('/v1/embeddings', [
            'model' => 'text-embedding-3-small', // "small" model will produce vectors of 1536 dimensions
            'input' => $content,
        ]);

        if (!($data['data'][0]['embedding'] ?? false)) {
            throw new \RuntimeException('Could not get embeddings from OpenAI response.');
        }

        return $data['data'][0]['embedding'];
    }

    private function call(string $endpoint, array $data): array
    {
        $response = $this->client->request('POST', "https://api.openai.com{$endpoint}", [
            'headers' => [
                'Authorization' => "Bearer {$this->apiKey}",
            ],
            'json' => $data,
        ]);

        return $response->toArray();
    }
}

Pour chaque document, on appellera cet endpoint en passant le contenu du document à vectoriser. On récupère ainsi, pour chacun, un vecteur qui n’est finalement qu’un tableau de float. En utilisant le modèle text-embedding-3-small d’OpenAI, la taille du vecteur sera de 1536 dimensions. Il est tout à fait possible de changer de modèle. Cependant, plus un modèle retourne de gros vecteurs, plus il sera précis, mais plus il coûtera cher côté OpenAI et plus il demandera de place à stocker dans notre base de données.

Si vous regardez le code de la démo, vous verrez que j’ai ajouté une couche de cache sur tous les appels à OpenAI. Comme l’API n’est pas gratuite, cela permet d’éviter de consommer des crédits quand les contenus à vectoriser ne changent pas entre les crawls.

Section intitulée stockage-des-documents-et-leurs-vecteursStockage des documents et leurs vecteurs 💾

J’ai choisi d’utiliser le package partITech/doctrine-pgvector qui intègre dans Doctrine tout ce dont on a besoin pour tirer parti de l’extension pgvector.

Pour stocker nos vecteurs, nous aurons besoin d’utiliser le type vector. Il faut donc l’ajouter dans la configuration DBAL de Doctrine pour pouvoir l’utiliser dans notre entité :

# config/packages/doctrine.yaml

doctrine:
    dbal:
        # …

        types:
            vector: Partitech\DoctrinePgVector\Type\VectorType

Nous pouvons maintenant définir notre document avec tous les champs nécessaires. Pour le champ embeddings, il faudra spécifier la taille attendue de notre vecteur, soit dans notre cas 1536 :

#[ORM\Entity()]
class Document
{
    public const VECTOR_LENGTH = 1536;

    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'NONE')]
    #[ORM\Column(type: 'guid', nullable: false)]
    public readonly string $id;

    /** @var float[] */
    #[ORM\Column(type: 'vector', length: self::VECTOR_LENGTH)]
    public array $embeddings;

    public function __construct(
        #[ORM\Column(type: 'text')]
        public readonly string $url,

        #[ORM\Column(type: 'text')]
        public readonly string $title,

        #[ORM\Column(type: 'text')]
        public readonly string $content,
    ) {
        $this->id = \uuid_create();
    }
}

Voilà à quoi ressemble nos documents et leurs vecteurs dans notre base de données :

Nos documents et leurs vecteurs stockés en base

Nos documents étant maintenant stockés en base, nous pouvons nous attaquer à la recherche de proximité sémantique de nos documents avec un message saisi par l’utilisateur.

Section intitulée recherche-vectorielleRecherche vectorielle 🔎

Vous vous souvenez du schéma en début d’article illustrant trois vecteurs dans un espace à trois dimensions ? C’est maintenant que nous allons mesurer à quel point ces vecteurs sont similaires entre eux. Heureusement, pas besoin de réinventer la roue pour y parvenir : les mathématiques nous fournissent déjà les outils nécessaires. Dans ce cas, nous utiliserons la similarité cosinus, qui calcule le cosinus de l’angle entre deux vecteurs. Inutile de plonger trop profondément dans les détails mathématiques : l’extension pgvector nous offre directement une fonction pratique pour cela, 'cosine_similarity'.

L’extension Doctrine pgvector supporte cette fonction, nous n’aurons qu’à l’enregistrer dans la configuration de Doctrine :

# config/packages/doctrine.yaml

doctrine:
    orm:
        # …

        dql:
            string_functions:
                cosine_similarity: Partitech\DoctrinePgVector\Query\CosineSimilarity

Nous utiliserons la fonction cosine_similarity() dans la section ORDER BY, qui nous permettra de trier les documents par ordre de similarité par rapport à la représentation vectorielle du message de l’utilisateur. Il suffira de passer le message vectorisé (le tableau de nombre flottants $embeddings) de l’utilisateur à la méthode findNearest() du DocumentRepository que nous allons créer :

use App\Entity\Document;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/** @extends ServiceEntityRepository<Document> */
class DocumentRepository extends ServiceEntityRepository
{
    // …

    /**
     * @param float[] $embeddings
     *
     * @return Document[]
     */
    public function findNearest(array $embeddings): array
    {
        return $this->createQueryBuilder('s')
            ->orderBy('cosine_similarity(s.embeddings, :embeddings)', 'DESC')
            ->setParameter('embeddings', $embeddings, 'vector')
            ->setMaxResults(5)
            ->getQuery()
            ->getResult()
        ;
    }
}

Une fois que l’on a trouvé les cinq documents les plus similaires à ce qu’a demandé notre utilisateur, il ne nous reste qu’à demander à notre AI de générer une réponse en s’appuyant sur ces documents en question.

Section intitulée generation-de-la-reponse-a-l-utilisateurGénération de la réponse à l’utilisateur 💬

La plupart des modèles d’IA générative fournissent une API permettant de configurer le prompt qui sera envoyé à l’IA pour lui expliquer ce que l’on attend d’elle. Le format attendu de la réponse, le ton à employer mais surtout les informations sur lesquelles s’appuyer pour répondre à l’utilisateur.

Plus tôt, nous avons utilisé OpenAI pour le calcul des embeddings. Par simplicité, c’est donc avec ce service que nous continuerons pour la partie génération de la réponse. Ici nous utiliserons l’endpoint /v1/chat/completions. On enverra à cet endpoint 2 choses :

  • le modèle à employer (on utilisera simplement gpt-4o qui est disponible à l’heure où j’écris cet article) ;
  • les « messages » qui permettront au modèle de comprendre la situation et d’y répondre.

OpenAI supporte plusieurs type de « messages » :

  • les messages system, qui permettent d’envoyer des prompt et de donner tout type d’indications notre modèle pour l’aiguiller dans sa génération ;
  • les messages user qui contiendront, comme leur nom l’indique, les messages saisi par l’utilisateur ;
  • les messages assistant qui contiendront les réponses précédentes générées par notre assistant, ce qui donnera une espèce d’historique / de mémoire à l’assistant afin d’offrir une conversation plus naturelle (pas besoin de tout re-préciser à chaque message, l’assistant se souviendra du contexte) ;
  • les messages tool qui fourniront des fonctions / outils que l’IA pourra appeler pour récupérer plus d’informations si nécessaire, faire des requêtes à une API, etc. (nous n’en aurons toutefois pas besoin dans le cadre de cet article).

Afin de conserver l’historique et être capable de la redonner au LLM, nous stockerons tous les messages (utilisateurs et réponses de l’IA) dans une entité Message. Ainsi, chaque fois que nous demanderons à notre IA une réponse, en plus des documents pertinents, nous lui enverrons l’historique de la conversation ($historyMessages dans l’exemple suivant).

class OpenAIClient
{
    // …

    /**
     * @param Document[] $documents
     */
    public function getAnswer(array $documents, array $historyMessages): string
    {
        // Send prompt to the IA in a `system` message

        $prompt = "Notre prompt. Définit plus bas dans l'article";

        $messages = [
            [
                'role' => 'system',
                'content' => $prompt,
            ],
        ];

        // Send most similar documents in a new `system` message

        $relevantInformation = 'Relevant information: \n';
        foreach ($documents as $document) {
            $relevantInformation .= json_encode([
                    'title' => $document->title,
                    'content' => $document->content,
                    'url' => $document->url,
                ]) . "\n";
        }

        $messages[] = [
            'role' => 'system',
            'content' => $relevantInformation,
        ];

        // Send conversation history to give IA some context about previous messages

        foreach ($historyMessages as $message) {
            $messages[] = [
                'role' => $message->isMe ? 'user' : 'assistant',
                'content' => $message->content,
            ];
        }

        $data = $this->call('/v1/chat/completions', [
            'model' => 'gpt-4o',
            'messages' => $messages,
        ]);

        if (!($data['choices'][0]['message']['content'] ?? false)) {
            throw new \RuntimeException('Could not get completion from OpenAI response.');
        }

        return $data['choices'][0]['message']['content'];
    }

Concentrons-nous sur notre prompt, car c’est finalement lui qui va définir le cœur même de notre application. Pour tirer pleinement parti de notre RAG, nous allons pouvoir demander à l’IA de s’appuyer sur les informations/documents que nous aurons trouvés par nous-mêmes, ainsi que de répondre de manière concise, en utilisant les exemples présents dans notre documentation. Autre valeur ajoutée, nous allons pouvoir lui demander de sourcer ses réponses, afin que l’utilisateur puisse vérifier les dires de notre assistant. Voici un exemple de prompt utilisé dans notre POC :

You are a friendly chatbot.
You respond in a concise, technically credible tone (but do not hesitate to add examples if needed).
You only use information from the provided information.
Please add the link of the relevant documents to the end of your response (do not invent url, only use the one we provided).

OpenAI nous renverra ainsi la réponse à donner à l’utilisateur. Le message généré par OpenAI utilise le format markdown, ce qui nous permettra par la suite de le mettre en forme dans notre application :

  • exemples de code avec les balises code et pre et la coloration syntaxique qui va bien pour chaque langage ;
  • liens ;
  • sémantiques avec les balises em, b, etc.

Le prompt ci-dessus est très certainement perfectible. La création de prompts adaptés est d’ailleurs une compétence à part entière, que l’on retrouvera souvent sous le terme de « prompt engineering ». Mais nous n’irons pas plus loin ici, car là n’est pas le but de cet article. En revanche, il est désormais temps de brancher ensemble les différentes sections que nous avons vu afin de créer notre POC.

Section intitulée le-pocLe POC 🛠️

La partie crawl, découpages des pages en documents, vectorisation de chacun d’entre eux et stockage en base de données se fera dans une commande Symfony classique :

use App\Crawl\Crawler;
use App\Entity\Document;
use App\OpenAI\Client;
use Doctrine\ORM\EntityManagerInterface;
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;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

#[AsCommand(name: 'app:crawl', description: 'Crawl the website to extract content')]
class CrawlCommand extends Command
{
    public function __construct(
        private readonly Crawler $crawler,
        private readonly Client $client,
        private readonly EntityManagerInterface $entityManager,
        #[Autowire('%env(DOCUMENTATION_URL)%')]
        private readonly string $documentationUrl,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $url = $this->documentationUrl;

        if (false === filter_var($url, \FILTER_VALIDATE_URL)) {
            $io->error('Invalid url.');

            return Command::FAILURE;
        }

        $io->info('Crawling the website.');

        $documents = $this->crawler->crawl($url);

        $io->note(sprintf('Found %d documents.', \count($documents)));

        $io->info('Extracting embeddings.');

        foreach ($documents as $document) {
            $embeddings = $this->client->getEmbeddings($document->content);

            $document->setEmbeddings($embeddings);
        }

        $io->info('Persisting data.');

        foreach ($documents as $document) {
            $this->entityManager->persist($document);
        }

        $this->entityManager->flush();

        $io->success('Finished crawling this website.');

        return Command::SUCCESS;
    }
}

Il faudra lancer cette commande une première fois, avant toute interaction avec le front, pour avoir les documents disponibles en base de données.

En parlant du front, c’est justement le moment de s’en occuper. Et on va se faire plaisir et utiliser les dernières fonctionnalités de Symfony :

  • AssetMapper et ImportMap pour construire la stack front sans besoin de configurer node ni webpack
  • Symfony UX et un Twig component pour avoir un minimum d’interactivité sans avoir besoin d’écrire de JS.

On ajoutera également le package league/commonmark pour la conversion markdown vers html des messages de l’IA et nous en ferons une extension Twig pour l’utiliser facilement dans notre component :

use League\CommonMark\MarkdownConverter;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

class MarkdownExtension extends AbstractExtension
{
    public function __construct(
        private readonly MarkdownConverter $markdownConverter,
    ) {
    }

    public function getFilters(): array
    {
        return [
            new TwigFilter('markdown', $this->markdownConverter->convert(...), ['is_safe' => ['html']]),
        ];
    }
}

On branche ensuite toute la partie formulaire, affichage de la conversation, recherche des documents et génération de la réponse utilisateur dans un twig component. Voici ce qu’il se passe dans le traitement du formulaire du chat :

// On récupère le message de l'utilisateur
$this->submitForm();
$userPrompt = $this->getForm()->get('userPrompt')->getData();

// On le stocke dans l'historique de la conversation

$message = new Message($userPrompt, true);
$this->entityManager->persist($message);
$this->entityManager->flush();

// On vectorise ce message

$embeddings = $this->client->getEmbeddings($userPrompt);

// Puis on cherche les documents les plus proches sémantiquement

$documents = $this->documentRepository->findNearest($embeddings);

// On demande à l'IA de générer notre réponse en fonction de la conversation

$messages = $this->messageRepository->findLatest();
$answer = $this->client->getAnswer($documents, $messages);

// On stocke la réponses dans la conversation

$message = new Message($answer, false);
$this->entityManager->persist($message);
$this->entityManager->flush();

$this->resetForm();

// On peut maintenant afficher la conversation
// …

Avec bootstrap et un peu de CSS custom, on arrive à un POC qui ressemble à ça ✨ (oui, vous l’aurez deviné, je ne suis pas développeur frontend) :

Le POC fonctionnel

Et voilà qui conclut cet article et notre démo. Le POC fonctionne même s’il pourrait être amélioré sur de très nombreux points : gestion d’utilisateurs et conversation propre à chacun, micro interactions dans le navigateur, possibilité de laisser à l’utilisateur d’analyser le site de son choix, etc 🤓.

Section intitulée conclusionConclusion

À travers cet article, nous avons vu ce qu’il se cachait derrière le concept de RAG et comment cela fonctionnait. Nous avons pu découvrir les différentes notions que ceux-ci impliquent, comme les espaces vectoriels, les embeddings ou encore la similarité cosinus.

Mais surtout, nous avons pu voir comment nous pouvions créer un RAG nous-mêmes, sous la forme d’un chatbot permettant de répondre à des questions sur un corpus documentaire bien à nous. Je rappelle que le code source du POC est disponible publiquement si jamais vous vouliez le tester par vous-même ou explorer plus en profondeur son fonctionnement.

Dans cette démonstration, nous n’avons utilisé que les outils fournis par OpenAI. L’objectif était surtout de simplifier l’article en utilisant un service « clé en main » qui ne demande quasi rien d’autre que d’appeler une API pour obtenir les données dont nous avions besoin. Cependant, bien que cette société ait aidé à la démocratisation de l’IA auprès du grand public, notamment avec son outil ChatGPT, elle est loin d’être irréprochable. On peut d’ailleurs s’interroger sur les dangers de lui fournir tout le contenu de nos documents, potentiellement privés si l’on travaille sur une documentation interne par exemple.

Le domaine de l’IA a connu un essor considérable ces dernières années, et il est désormais plus simple que jamais de faire tourner nos propres modèles en local pour éviter toute fuite de données, comme par exemple Ollama. N’hésitez pas à laisser un commentaire si vous seriez intéressé par un prochain article qui utiliserait un modèle local à la place de ceux d’OpenAI.

Section intitulée sourcesSources

Quelques sources supplémentaires utilisées pour mes recherches et la rédaction de cet article et son POC :

Commentaires et discussions

Nos articles sur le même sujet