Voici mon 1er article qui va traiter d'IA dans son côté applicatif.

L'IA c'est comme le développement web, il y a plusieurs métiers, plusieurs façons de l'appréhender. Et c'était pas simple de commencer par quelque chose qui avait du sens à mes yeux.

J'ai choisi de commencer par le système R.A.G (Retrieval Augmented Generation) autrement dit Génération Assistée par Recherche.

Ce type d'application c'est quelque chose qui va nous permettre de voir ensemble plusieurs thèmes :

  • Le principe d'un R.A.G
  • Les solutions existantes
  • On monte son propre R.A.G
  • On optimise la pipeline

Je ne suis pas un expert de l'IA, j'utilise comme tout le monde des agents de code, mais l'IA ce n'est pas que ça, c'est bien plus et c'est passionnant quand on gratte un peu la surface.

J'ai fait quelques benchmarks pour mon usage et essayé d'avoir des métriques simples (notamment pour moi car je ne suis pas expert dans ce domaine et ça peut être vite compliqué). Elles sont parlantes, on va parler de durée de génération, de coût réel (car oui, malheureusement ce n'est pas gratuit si on veut faire des choses sympa).

1. Le principe d'un R.A.G

C'est un gros corpus de connaissances basé sur le sens des textes qu'il sauvegarde.

Un peu comme un énorme Wikipédia, mais quand on recherche quelque chose, c'est par rapport au sens de la phrase et pas juste à la correspondance alphabétique.

Pour ça on va avoir plusieurs étapes :

  • Construction du R.A.G (Chunking & Embedding)
  • Interrogation du R.A.G (Prompting & Matching)

1.1 Construction d'un R.A.G

Ce système c'est donc un ensemble de documents, généralement du texte, des process écrits, des normes etc … Pour le construire, on va devoir envoyer nos fichiers (généralement des PDFs mais on pourrait aussi envoyer une description d'image …) à une petite interface applicative pour construire notre corpus.

1.1.1 Le Chunking

Cette interface va chercher à diviser nos PDFs en chunks. Si tu as déjà joué à Minecraft ce terme t'est probablement familier.

C'est ce qu'on appelle le Chunking.

Un chunk dans Minecraft
Un chunk dans Minecraft

Un chunk c'est un "morceau". Dans Minecraft c'était 16x16 blocs sur 256 de haut.

Plus un chunk est gros plus il dilue le contexte. Plus un chunk est petit plus il y a de risque que ton texte soit coupé en plein milieu.

Pourquoi on fait des chunk ? Car on va faire ce qu'on appelle dans le jargon du R.A.G de l'Embedding (Vectorisation).

Là on va devoir faire un peu de mathématique. (j'étais une quiche, donc on n'ira pas loin promis !).

1.1.2 L'Embedding

On va transformer notre chunk, en vecteurs mathématiques.

giphy

Pour ceux qui comme moi dormaient en cours de math, un vecteur c'est ça :

Un vecteur mathématique
(Ne fuis pas s'il te plaît !!!)

Ça peut paraître barbare mais on a de la chance, on ne fait pas les calculs et les transformations nous-mêmes, ce sera le rôle de notre IA !

L'image du dessus c'est un vecteur à 5 dimensions. Chaque dimension représente quelque chose.

Là on est purement dans les maths géométriques, mais ce qui nous intéresse c'est le cas de nos chunks. Et avec du concret c'est plus facile.

Pour l'exemple, voici un mot qu'on découpe en trois chunks (ça n'a pas de sens mais c'est pour vulgariser). Sa représentation vectorielle en 5 dimensions donnera ça.

Embedding

Si on place ensuite ce mot ("ordinateur") dans une grille de représentation vectorielle avec d'autres mots on obtient ça : 

Représentation 3D d'un groupe de vecteurs

"Ordinateur" de manière sémantique est très proche de "logiciel".

L'ensemble des mots ont l'air proches mais c'est un graphique en 3D, or ici on a 5 dimensions, impossible à représenter sur une image.

Bien évidemment j'ai vulgarisé pas mal ici. Ça n'aurait aucun sens de diviser en 3 chunks un simple "mot" qui est déjà beaucoup plus petit qu'un chunk. Mais le principe est là, on coupe des textes en morceaux (paragraphes, phrases ….) qu'on vient vectoriser pour ensuite les trier.

En vérité, aucune dimension ne correspond à un thème précis qu'on pourrait nommer. Le sens est réparti sur toutes à la fois, et c'est la position globale de la phrase, pas la valeur d'un axe isolé, qui le rapproche de ses voisins.

Mais voila l'idée de l'embedding : transformer nos chunks en vecteurs afin de les classer sémantiquement.

Notre application, une fois terminée de fabriquer ses chunks, va envoyer l'ensemble de sa production à une IA spécialisée en embedding (qui a été entraînée pour trier les inputs en vecteurs à n dimensions), qui va nous renvoyer un tableau de vecteurs qu'on va stocker de notre côté.

C'est durant cette étape qu'on va se rendre compte de la puissance d'un GPU face à un CPU. Un embedding, en fait, ce sont des montagnes de multiplications à faire toutes en même temps. Et c'est là que le duel se joue : un CPU a quelques cœurs très costauds, mais ils travaillent sur des opérations en série. Le GPU, lui, aligne des milliers de petits cœurs qui calculent en parallèle. Pour ce genre de tâche répétitive et massive, c'est le jour et la nuit.

CPU vs GPU

Sur cette étape d'embedding, je vais utiliser le modèle bge-m3 pour vectoriser.

Il faut bien comprendre qu'un modèle d'IA spécialisée en vectorisation ne produira pas le même résultat qu'une autre IA spécialisée tout autant.

Chaque IA a ses propres paramètres, son propre entraînement.

Si vous commencez à faire un R.A.G avec un modèle spécifique, vous devrez le garder pour toujours ou alors décider de repasser les chunks un à un dans un autre modèle pour refaire les vecteurs.

C'est donc bien de se renseigner sur le modèle qu'on veut utiliser.

J'utilise https://huggingface.co/ pour ça et c'est top car il référence plein de modèles.

Si on va sur la page de notre modèle, on voit qu'il gère 1024 dimensions et supporte plus de 100 langues dont le français. C'est top.

Pour un usage personnel, à moins d'avoir de grosses quantités de données, la place ne sera pas un problème. Mais il faut savoir qu'il existe des modèles d'embedding de type Matryoshka qui permettent de ne sélectionner que les dimensions de surface pour gagner en place.

Ce tableau de vecteurs on le stocke dans une base de données qu'on appelle "vectorielle". On appelle ça aussi un Store.

Il y en a plusieurs à notre disposition :

  • ChromaDB
  • Qdrants
  • PostgreSQL + l'extension PGvector.

Cette base de données, c'est notre corpus de connaissances.

1.2 Interrogation du R.A.G

1.2.1 Le Prompting (Aller)

Je ne sais pas vraiment si ce terme est déjà utilisé dans l'univers des R.A.G mais je trouve que ça va bien avec la définition.

On a hydraté notre base de données, on va pouvoir l'interroger.

Comment ? Via un système de chat (comme n'importe quel agent conversationnel) afin de récolter la question (le prompt) de l'utilisateur.

1.2.2 Le Matching

On va envoyer le prompt de l'utilisateur récolté par l'agent et l'envoyer dans bge-m3 pour récupérer le vecteur correspondant à sa question.

Notre base de données vectorielle va quant à elle trouver le match le plus pertinent parmi l'ensemble du corpus.

Concrètement, notre question devient un vecteur, et Qdrant part le comparer à tous les chunks stockés pour trouver ses voisins les plus proches, ceux qui pointent dans la même direction (souviens-toi de notre grille du début, où "ordinateur" collait à "logiciel"). Les 3 plus proches gagnent le ticket et partent nourrir la réponse de l'agent. C'est là toute la magie du "Retrieval" : on ne cherche pas des mots, on cherche du sens.

Une fois le vecteur le plus pertinent trouvé, l'agent conversationnel va répondre à l'utilisateur avec le contenu récupéré.

2. Les solutions existantes

Comme dans tous les domaines, on retrouve les RAG as a Service qui permettent de ne rien configurer par nous-mêmes. On upload, on interroge et c'est fini. Ça coûte et on n'est pas maître de notre store.

Les géants ont aussi leur option :

  • Google avec VertexAI
  • AWS avec Bedrock
  • OVH avec PostgreSQL et PGvector

Ce sont des solutions qui coûtent cher, plutôt étudiées pour de grosses entreprises. Moi je suis pauvre, j'aime la souveraineté et le but c'est d'apprendre, alors on va tout faire nous-mêmes.

Pareil pour les modèles, on va héberger tout ça en local parce que pourquoi pas et qu'on veut apprendre !

3. On monte notre R.A.G

Pour les plus curieux, voici mon code sur Github.

On a vu dès le début qu'un R.A.G c'est un ensemble de services qui forment une pipeline :

  • Une interface applicative pour envoyer les documents et faire du chunking
  • Un modèle d'embedding (ici bge-m3)
  • Une store (base de données vectorielle)
  • Un modèle conversationnel pour gérer le prompt et la réponse (qwen2.5)
  • Un chatbot

Pour les PDF, je suis parti sur 4 documents du RGPD. Un RAG qui me permet d'avoir des infos sur le RGPD, c'est intéressant, ça me permettra plus tard de pouvoir me référer aux textes et d'avoir des réponses pertinentes. Voici leurs titres :

nos pdfs

Ils sont stockés à la racine de l'app dans /pdfs .

Si tu te demandes pourquoi le RGPD, je t'invite à lire mon article, j'ai un amour pour ce sujet !

L'objectif va être de lancer tout ça en local et voir pourquoi les GPUs sont si importants dans les modèles d'IA.

3.1 Préparation des documents

On s'occupe de notre parser de PDF, celui qui va réaliser les chunks.

Pour ça on va juste créer une petite application Symfony, ce sera notre point central.

composer create-project symfony/skeleton:"8.1.*" rag .

Puis on installe un parser de pdf : composer require smalot/pdfparser .

On va créer notre commande de chunking et d'embedding.

3.1.1 Le Chunking

#[AsCommand(name: 'app:index-pdfs', description: 'Lit et découpe les PDF')]
class PDFParserCommand extends Command
{
    public function __construct(private HttpClientInterface $http)
    {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
				
        $parser = new Parser();
        $files = scandir(__DIR__ . '/../../pdfs');
        $files = array_slice($files, 2);
        $pointId = 0;
        
        // On boucle sur nos fichiers
        foreach ($files as $file) {
            $io->info(sprintf('Traitement de %s en cours', $file));
            $pdf = $parser->parseFile(__DIR__ . '/../../pdfs/' . $file);
            $text = $pdf->getText();

            $io->note(sprintf('Texte extrait : %d caractères', strlen($text)));

            $chunks = $this->chunk($text);

            $io->success(sprintf('%d chunks produits', count($chunks)));
						
						// ...
          }
    }   
    
    // On découpe en morceau
    private function chunk(string $text, int $size = 800, int $overflow = 150): array
    {
        $text = $this->cleanUTF8($text);
        $chunks = [];
        $pas = $size - $overflow;
        $length = mb_strlen($text);
        for ($i = 0; $i < $length; $i += $pas) {
            $chunk = trim(mb_substr($text, $i, $size));
            if ($chunk !== '') {
                $chunks[] = $chunk;
            }
        }
        return $chunks;
    }

		// On nettoye le texte des caractères spécifiques ASCII
    private function cleanUTF8(string $text): string
    {
        $text = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
        return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
    }
}

 

Voilà à quoi ressemble un chunk : 

chunk d'un PDF

Je suis parti sur une taille de chunk de 800 ($size = 800) avec une zone commune entre les chunks de 150 ($overflow = 150).

Pourquoi 150 ? C'est une valeur arbitraire, il faut faire des tests pour trouver le meilleur compromis.

Plus on agrandit l'overflow, plus les zones communes entre les chunks sont grandes, moins on rate de contexte mais plus on duplique aussi.

En réduisant l'overflow, on prend le risque que des paragraphes se coupent en plein milieu et le chunk suivant perdra le contexte du début de phrase.

On a maintenant nos chunks.

chunks réalisés

Il nous reste maintenant plus qu'à les vectoriser.

3.1.2 L'Embedding

Pour ça, il faut qu'on installe notre modèle bge-m3 avec Ollama.

Pour ceux qui n'ont jamais fait d'IA, Ollama c'est l'interface entre notre modèle et nous (ou notre matériel). C'est lui qui va nous permettre de télécharger nos modèles et de les utiliser via une API Rest.

Le mieux c'est de faire un docker compose avec nos services :

services:
  app:
    build:
      context: .
      args:
        PUID: 1000
        PGID: 1000
    volumes:
      - .:/var/www/html
    ports:
      - "8000:80"
    networks:
      - rag

  ollama:
    image: ollama/ollama
    restart: unless-stopped
    volumes:
      - ollama_data:/root/.ollama
    ports:
      - "11434:11434"
    networks:
      - rag
      
volumes:
  ollama_data:

networks:
  rag:
    driver: bridge

 

Si tu n'es pas à l'aise avec docker et docker compose, tu peux faire un tour sur mon article qui en parle, il n'y a pas besoin d'être un expert en docker pour comprendre la suite.

On build et on télécharge notre modèle :

docker compose up -d --build
docker compose exec -i ollama

Ollama récupère le modèle IA

De cette manière, notre serveur Ollama et notre application sont sur le même serveur.

On va pouvoir faire la suite dans notre commande !

foreach ($files as $file) {
    $io->info(sprintf('Traitement de %s en cours', $file));
    $pdf = $parser->parseFile(__DIR__ . '/../../pdfs/' . $file);
    $text = $pdf->getText();

    $io->note(sprintf('Texte extrait : %d caractères', strlen($text)));

    $chunks = $this->chunk($text);

    $io->success(sprintf('%d chunks produits', count($chunks)));
		
		// On reprend ici !
		$points = [];
		
    $tEmbedStart = microtime(true);
    foreach ($chunks as $i => $chunk) {
        
        // On vectorise
        $vecteur = $this->http->request('POST', 'http://ollama:11434/api/embed', [
            'json' => ['model' => 'bge-m3', 'input' => $chunk],
        ])->toArray()['embeddings'][0];

        // On prépare pour envoyer à Qdrant
        $points[] = [
            'id' => $pointId++,
            'vector' => $vecteur,
            'payload' => [
                'texte' => $chunk,
                'source' => $file,
                'chunk' => $i,
            ],
        ];
    }
    $embedSeconds = microtime(true) - $tEmbedStart;
    
    // ...
}

 

Ici on commence par initialiser $points qui va regrouper l'ensemble des vecteurs de nos chunks.

Donc 1 vecteur = 1 chunk.

On boucle dans nos chunks pour appeler notre modèle d'embedding afin qu'il nous retourne notre vecteur pour notre chunk.

Pour appeler notre IA, comme tu peux le voir c'est un appel http sur /api/embed .

ici la doc d'Ollama sur l'endpoint.

$tEmbedStart et $embedSeconds me permettent de collecter des métriques pour le benchmark.

3.1.3 Le Stockage

On a nos chunks, on a nos vecteurs. Il ne nous reste plus qu'à les stocker.

Pour ça on va utiliser une base de données vectorielle comme on l'a vu dans la théorie.

On va utiliser Qdrant qui nous permet de garder ça local.

Il s'installe aussi via docker, pratique pour notre stack.

# ...
qdrant:
  image: qdrant/qdrant
  restart: unless-stopped
  volumes:
    - qdrant_data:/qdrant/storage
  ports:
    - "6333:6333"
  networks:
    - rag
    
 volumes:
  ollama_data:
  qdrant_data:

On rebuild la pipeline : docker compose up -d --build .

Une fois relancée, on se rend dans la base de données depuis la WebUI à l'adresse suivante : http://localhost:6333/dashboard .

On s'attaque à notre collection :

Dashboard Qdrant

Création de la collection

Ce qui va nous intéresser ici, c'est la dernière partie dans "Vector configuration" :

  • dimension → 1024 (c'est le nombre de dimensions que gère notre bge-m3)
  • Cosine → le type de similarité que l'on va chercher (bge-m3 utilise le cosinus)

Il y a plusieurs types de similarité dans Qdrant et dans les modèles d'embedding.

Sans trop rentrer dans les détails, mais chaque modèle peut utiliser un système différent par rapport à un autre modèle pour classer les vecteurs.

Les différentes types de similarités des vecteurs

  • Le cosinus va calculer l'angle entre les vecteurs
  • L'euclidienne va comparer la distance en ligne droite entre les vecteurs
  • Le produit scalaire va prendre l'angle et la distance
  • Manhattan se rapproche de l'euclidienne

OK n'aie pas peur, l'important c'est de savoir surtout qu'un modèle fonctionnant en un type de similarité, doit avoir sa base de données dans la même configuration.

Par conséquent, ici on confirme cosinus.

On a à la fin de la configuration, notre collection, de 0 points pour l'instant.

Notre collections de chunks vectorisés

Qdrant nous met à disposition des tutos, notamment "quickstart" (/dashboard#/tutorial/quickstart) qui nous permet de créer la collection directement via l'API, mais aussi comment remplir.

Pour remplir notre collection, il faut aller sur l'endpoint /collections/rgpd/points .

Continuons notre commande Symfony pour remplir cette collection.

// ...
		$tUpsertStart = microtime(true);
		$this->http->request('PUT', 'http://qdrant:6333/collections/rgpd/points', [
		    'json' => ['points' => $points],
		]);
		$upsertSeconds = microtime(true) - $tUpsertStart;
		
		$io->success(sprintf(
		    '%s : %d chunks embbedés en %.1f s (%.2f chunks/s)',
		    $file, count($points), $embedSeconds, count($points) / max($embedSeconds, 0.001)
		));
		
		$this->logIndexMetriques($file, strlen($text), $nbPages, count($chunks), $embedSeconds, $upsertSeconds);
}

On relance notre commande, et voici ce qu'on obtient :

Un point d'un vecteur

Un point est donc un chunk caractérisé par :

  • son texte (le chunk)
  • sa source (le nom du fichier)
  • le numéro du chunk

Pour matérialiser un vecteur, on peut cliquer sur "Open Graph". Chaque chunk est subdivisé lui aussi en points plus ou moins éloignés.

Représentation 3D d'un vecteur

Ça ne nous apprend pas grand-chose de plus si ce n'est la manière dont sont rangés les chunks eux-mêmes subdivisés.

Durant la commande, on remarque que j'ai fait un foreach de fichier par fichier, puis un foreach de chunk par chunk.

Ce n'est pas une méthode très performante, mais c'est pour le benchmark, afin de voir par la suite comment optimiser tout ça.

Sur mon PC, je n'ai pas de GPU. Pendant l'exécution de la commande voici la consommation en RAM.

Ram pendant l'embedding

Et en CPU.

CPU pendant l'embedding

Je ne faisais rien d'autre à ce moment-là donc je n'étais pas gêné, mais c'était limite.

Au niveau des métriques récupérées par Symfony, voilà un graphique plus parlant, par rapport aux 5 fichiers.

Benchmark de comparaison des différents documents sur l'embedding

Prenons un peu de temps pour analyser les métriques.

On a donc 4 documents, divisés chacun en un nombre de chunks.

On voit que le nombre de pages n'influe en rien sur le temps de traitement car le modèle ne traite pas des pages mais des chunks. La première impression est déroutante. Moins de pages devrait signifier moins de temps de traitement ? Alors oui, mais avec des limites.

Pour anonymisation.pdf je n'ai que 10 pages, donc forcément le gap entre lui et les autres fait qu'il sera toujours en tête. Mais pour les autres, on va se fier plutôt au débit de chunks par seconde (second graph).

Plus il y a de chunks, plus le PC prend du temps à traiter. Le plus long ici c'est recommandations.pdf et pourtant c'est lui qui a le moins de pages du trio.

Plus le traitement dure dans le temps, plus le processeur chauffe, plus il se bride, c'est ce qu'on appelle le thermal throttling. C'est le CPU qui se préserve pour continuer sa tâche.

Mon CPU pendant l'indexation

On va voir tout de suite comment optimiser l'indexation.

On va traiter l'ensemble des chunks en parallèle :

// Ancienne version, traitement chunk par chunk
$points = [];
$tEmbedStart = microtime(true);
foreach ($chunks as $i => $chunk) {
    $vecteur = $this->http->request('POST', 'http://ollama:11434/api/embed', [
        'json' => ['model' => 'bge-m3', 'input' => $chunk],
    ])->toArray()['embeddings'][0];
    $points[] = [
        'id' => $pointId++,
        'vector' => $vecteur,
        'payload' => [
            'texte' => $chunk,
            'source' => $file,
            'chunk' => $i,
        ],
    ];
}
$tUpsertStart = microtime(true);
$this->http->request('PUT', 'http://qdrant:6333/collections/rgpd/points', [
    'json' => ['points' => $points],
]);
$upsertSeconds = microtime(true) - $tUpsertStart;

// Nouvelle version, traitement en batch
$points = [];
$chunkIndex = 0;
$tEmbedStart = microtime(true);


$requests = [];
foreach (array_chunk($chunks, 32) as $batch) {
    $requests[] = [
        'batch'    => $batch,
        'response' => $this->http->request('POST', 'http://ollama:11434/api/embed', [
            'json' => ['model' => 'bge-m3', 'input' => $batch],
        ]),
    ];
}

foreach ($requests as ['batch' => $batch, 'response' => $response]) {
    $vectors = $response->toArray()['embeddings'];
    foreach ($batch as $j => $chunk) {
        $points[] = [
            'id' => $pointId++,
            'vector' => $vectors[$j],
            'payload' => [
                'texte' => $chunk,
                'source' => $file,
                'chunk' => $chunkIndex++,
            ],
        ];
    }
}
// ... Suite communue aux deux tests

Voilà notre optimisation. On parallélise les requêtes par 32 chunks ce qui fait que notre traitement dure moins longtemps. En utilisation de RAM, c'est 2 Go de moins, c'est plutôt bien.

Ram optimisée pendant l'embedding

De cette manière, en relançant la commande, on obtient ça en métriques.

Benchmark de comparaison des différentes méthodes d'embedding

On voit clairement une nette amélioration de notre traitement par lot plutôt qu'à l'unité. 

Malheureusement le CPU nous refait encore le coup du thermal throttling. On ne pourra jamais y échapper. Plus le temps de traitement sera long, plus le CPU va souffrir.

3.2 Matching et Prompting

Dans cette partie, maintenant que nos documents sont vectorisés et stockés dans Qdrant, on va chercher à les interroger via une interface web type "ChatGPT".

Pour ça, quelques ajustements côté Symfony, il faut respecter les standards de OpenWebUI.

De quoi avons-nous besoin ? Il faut que la webUI puisse connaître les modèles à sa disposition et qu'elle puisse envoyer le prompt de l'utilisateur à Symfony.

On va tout regrouper dans un RAGController.php qui sera un controller avec des routes. Classique de Symfony.

On va tout de suite faire un endpoint de "reconnaissance" qui va présenter les modèles présents dans Ollama, à OpenWebUI :

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class RAGController extends AbstractController
{
    public function __construct(private HttpClientInterface $http)
    {
    } 

    #[Route('/v1/models', name: 'openai_models', methods: ['GET'])]
    public function models(): JsonResponse
    {
        $data = [['id' => 'rag-pdf', 'object' => 'model', 'owned_by' => 'moi']]; // tous les PDF
        return $this->json(['object' => 'list', 'data' => $data]);
    }
}

$data permettra à l'utilisateur de OpenWebUI de choisir notre modèle rag-pdf. Ici il ne fera référence qu'à un seul modèle, mais si notre système en avait plusieurs, on pourrait y faire référence ici.

Le second endpoint important est celui-ci : /chat/completions .

    #[Route('/v1/chat/completions', name: 'openai_chat', methods: ['POST'])]
    public function chat(Request $request): JsonResponse
    {
		    // Etape 1 : on récupère la question
        $payload = json_decode($request->getContent(), true);

        $messages = $payload['messages'] ?? [];
        $question = '';
        foreach (array_reverse($messages) as $m) {
            if (($m['role'] ?? '') === 'user') {
                $question = $m['content'];
                break;
            }
        }

		    // Etape 2 : on vectorise cette question
        $embed = $this->http->request('POST', 'http://ollama:11434/api/embed', [
            'json' => ['model' => 'bge-m3', 'input' => $question],
        ])->toArray();
        $vecteur = $embed['embeddings'][0];

				// Etape 3 : on fait le matching
        $res = $this->http->request('POST', 'http://qdrant:6333/collections/rgpd/points/query', [
            'json' => ['query' => $vecteur, 'limit' => 3, 'with_payload' => true],
        ])->toArray();
        $tSearch = microtime(true);

				// Etape 4 : on retourne la réponse via l'agent
        $context = '';
        foreach ($res['result']['points'] as $point) {
            $context .= '- ' . $point['payload']['texte'] . "\n\n";
        }
			
        $prompt = <<<TXT
    Tu réponds UNIQUEMENT à partir du context ci-dessous.
    Si la réponse ne s'y trouve pas, dis-le.

context :
$context

Question : $question
TXT;
				
        $generation = $this->http->request('POST', 'http://ollama:11434/api/chat', [
            'json' => [
                'model' => 'qwen2.5:3b',
                'messages' => [['role' => 'user', 'content' => $prompt]],
                'stream' => false,
                'options' => [
                    'num_predict' => 300,
                    'num_ctx' => 2048,
                ],
            ],
            'timeout' => 300,
        ])->toArray();

        $reponse = $generation['message']['content'];

        return $this->json([
            'id' => 'chatcmpl-' . uniqid(),
            'object' => 'chat.completion',
            'created' => time(),
            'model' => $payload['model'] ?? 'rag-pdf',
            'choices' => [[
                'index' => 0,
                'message' => ['role' => 'assistant', 'content' => $reponse],
                'finish_reason' => 'stop',
            ]],
            'usage' => ['prompt_tokens' => 0, 'completion_tokens' => 0, 'total_tokens' => 0],
        ]);
    }

Dans ce code comme tu peux le voir il y a quatre grandes étapes :

  • La récupération de la question
  • La vectorisation de cette question
  • Le matching de la question dans le store
  • Le retour de la réponse via Qwen.

La récupération et la vectorisation c'est quelque chose de similaire à la partie d'Embedding car elle fonctionne de la même manière.

Le matching c'est juste une comparaison via l'API de Qdrant.

Et la réponse de l'agent, on lui fournit un prompt généraliste, la question et il se charge de reformuler.

Rien de sorcier en somme. On va connecter l'interface OpenWebUI !

Pour ça, rien de compliqué, OpenWebUI nous fournit une solution open source pour avoir une interface similaire via docker :

// ...
open-webui:
  image: ghcr.io/open-webui/open-webui:main
  restart: unless-stopped
  ports:
    - "3000:8080"
  environment:
    OPENAI_API_BASE_URL: "http://app:80/v1"
    OPENAI_API_KEY: "sk-rag"
    ENABLE_OLLAMA_API: "false"
  volumes:
    - openwebui_data:/app/backend/data
  depends_on:
    - app
  networks:
    - rag

volumes:
  ollama_data:
  qdrant_data:
  openwebui_data:

networks:
  rag:
    driver: bridge

On rebuild notre pipeline.

Sur le PC, je vais avoir besoin du modèle qwen2.5:3b pour faire office d'interface entre moi et le R.A.G. On va le récupérer depuis Ollama : docker compose exec -ti ollama ollama pull qwen2.5 .

Ollama récupère Qwen2.5

Une fois notre pipeline reconstituée et pimpée de openai-webui, on se rend sur le port 3000 de notre localhost.

On tombe sur une page de création de compte. Une fois créé, on est redirigé vers une interface très similaire à ChatGPT.

Prompt de recherche du R.A.G

Et là comme tu peux le voir, le modèle est déjà disponible grâce à l'endpoint /model créé plus tôt.

Avec le docker-compose OpenWebUI arrive à gérer l'autodécouverte des modèles et des endpoints, si jamais ça ne fonctionne pas, tu te rends ici.

Menu de OpenWebUI

Paramètrage de OpenWebUI

Tu devras y mettre l'url de ton app Symfony (si elle est dans le même réseau docker, ça roule).

On va maintenant pouvoir tester une question : "C'est quoi l'anonymisation ?"

Si ta pipeline est correctement configurée, au bout d'un certain temps, l'agent retourne une réponse correcte. Si elle n'est pas correcte, ça peut provenir de l'étape de chunking qui n'est pas assez précise (trop d'overflow ou une taille de chunk trop importante) et si tu n'as pas de réponse du tout, il faut vérifier les logs de ton app, d'Ollama et d'OpenWebUI, tout est marqué dedans s'il y a un problème de réseau entre les trois.

De mon côté, quand je pose une question en local, mon CPU et ma RAM font un gros travail.

Ci-dessous les métriques.

Avec un qwen2.5:3b j'étais déjà en PLS, alors j'ai tenté le modèle à 7b de paramètres, mon PC a crashé.

Benchmark d'analyse de Qwen2.5 en local

La phase de préremplissage c'est la phase la plus longue, c'est dans celle-là que l'IA va essayer de lire le prompt et comprendre l'intention.

3.3 Conclusion du R.A.G sur PC

Pour la science et l'expérience, c'était sympa de monter soi-même un R.A.G sur son propre PC.

Je partais clairement avec un désavantage : je n'ai aucun GPU.

Donc forcément c'était long, autant sur l'embedding que sur l'agent conversationnel.

Le plus long est bien sûr l'embedding car on envoie un paquet de chunks que ce soit en batch ou à l'unité, le PC a envie de boire des litres d'eau pour se rafraîchir (pas forcément la meilleure idée que j'ai eue durant cette canicule …).

Là j'ai utilisé Symfony sans vraiment l'avoir utilisé car je n'ai pas utilisé SymfonyAI, je voulais voir ce que c'était de tout faire et de tout comprendre mais évidemment que si c'était à refaire, j'installerais le bundle sans hésitation.

Je ne cache pas que j'étais un peu déçu de ne pas pouvoir l'utiliser de façon optimale mais j'étais averti, l'IA et les PCs sans GPU, c'est frustration assurée. Mais j'avais envie de voir pour pouvoir comparer avec un vrai GPU.

4. Optimisation avec un GPU A10

4.1 Installation et galères des pilotes

Pour comparer, direction le Cloud Computing. Chez moi c'est OVH donc c'est parti pour s'amuser pendant quelques heures !

Tarif OVH GPU computing

C'est un des GPU les moins chers à l'heure. Et pour s'amuser, c'est un bon rapport qualité prix quand on voit le prix d'une A10 neuve !

Prix d'une A10 sur internet

Donc me voilà l'heureux possesseur d'une A10 pendant 3 heures.

J'installe docker, je sécurise l'accès SSH avec UFW, je mets à jour les paquets.

Et là quelque chose de nouveau à gérer, les drivers du GPU bien sûr !

Je le note ici parce que ça m'a fait galérer un moment mais pour ceux que ça intéresse, voici les commandes pour installer les drivers :

Mémo d'installation drivers Nvidia + compatibilité docker

 

curl -fsSL https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2604/x86_64/cuda-keyring_1.1-1_all.deb -o /tmp/cuda-keyring.deb
dpkg -i /tmp/cuda-keyring.deb
apt-get update && apt-get install -y
nvidia-driver-550-server-open
reboot

// On installe nvidia-ctk pour docker
  curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
  | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
  | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
  | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
apt-get install -y nvidia-container-toolkit

sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

// On check la liste des GPU installés
nvidia-smi
+-----------------------------------------------------------------------------------------+                                                                                                                         
| NVIDIA-SMI 580.159.03             Driver Version: 580.159.03     CUDA Version: 13.0     |                                                                                                                         
+-----------------------------------------+------------------------+----------------------+                                                                                                                         
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |                                                                                                                         
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |                                                                                                                         
|                                         |                        |               MIG M. |                                                                                                                         
|=========================================+========================+======================|                                                                                                                         
|   0  NVIDIA A10                     Off |   00000000:00:07.0 Off |                    0 |                                                                                                                         
|  0%   43C    P0             51W /  150W |       0MiB /  23028MiB |      0%      Default |                                                                                                                         
|                                         |                        |                  N/A |                                                                                                                         
+-----------------------------------------+------------------------+----------------------+                                                                                                                         
                                                                                                                                                                                                                   
+-----------------------------------------------------------------------------------------+                                                                                                                         
| Processes:                                                                              |                                                                                                                         
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |                                                                                                                         
|        ID   ID                                                               Usage      |                                                                                                                         
|=========================================================================================|                                                                                                                         
|  No running processes found                                                             | 

 

On change dans le compose.yaml  Ollama pour ajuster sur le GPU :

# ...
ollama:
  image: ollama/ollama
  restart: unless-stopped
  deploy:
    resources:
      reservations:
        devices:
          - driver: nvidia
            count: all
            capabilities: [gpu]
# ...

Et là c'est bon ! On est comme à la maison !

Pour le test j'ai installé dans Ollama qwen2.5:3b mais aussi qwen2.5:7b .

4.2 Embedding

Quand j'ai lancé la commande app:index-pdfs j'ai halluciné. L'impression d'être dans le faucon millenium !

Vitesse lumière !
Vitesse lumière !

Voici la comparaison entre mon embedding local et mon embedding cloud :

Benchmark de comparaison entre un embedding local et un embedding GPU Cloud

C'est en fait juste pas comparable. On voit bien que ce genre d'outil n'est pas du tout fait pour être utilisé sur un CPU. Il gère très mal ce type de calcul, il fait du thermal throttle, bref ingérable comparé à un GPU.

GPU durant l'indexation

4.3 Matching et Agent

J'ai posé la même question qu'en local "C'est quoi l'anonymisation" pour essayer de respecter les conditions du test et voilà les résultats en Cloud entre les deux versions de qwen2.5 3b et 7b.

Benchmark de comparaison entre Qwen2.5:3b et Qwen2.5:7b sur le cloud

On voit ici que ce n'est pas parce qu'on prend un modèle plus récent avec plus de paramètres qu'on aura une réponse plus rapide. Au contraire, on voit que qwen2.5:7b a mis plus de temps (bon ok 2s c'est relatif) et a répondu avec plus de tokens tandis que 3b est allé à l'essentiel !

Et maintenant dernière métrique, R.A.G local VS cloud :

Benchmark de comparaison entre un Qwen2.5:3b local et sur cloud

Évidemment sans surprise, mon i13 est largué avec ses 43s de souffrance contre 5s pour l'A10.

Quand le GPU impose sa puissance
Quand le GPU travaille

5. La conclusion

Achète un GPU ! 😝

Non, ce n'est pas si simple comme conclusion, l'A10 par exemple n'est pas un GPU à refroidissement passif (elle n'a pas de ventilateur), elle nécessite d'être dans un datacenter avec une solution de refroidissement industrielle.

On pourrait partir sur un GPU type RTX 5090 qui possède de bonnes performances en IA, mais déjà sur un laptop c'est cher, c'est lourd, ça chauffe et c'est bruyant. Donc il faut une workstation, mais vu le prix il va falloir trouver un vrai sujet d'exploitation (je parle de quelqu'un qui n'est pas en entreprise et qui doit investir de sa poche).

Pour quelqu'un qui veut juste s'amuser, clairement la location en Cloud Computing, c'est simple et relativement pas cher (tous les providers offrent des crédits d'essai en plus).

Pour un usage personnel et souverain c'est plus complexe. Une location longue durée d'une A10 c'est 500€/mois (tarif à la parution de l'article). Donc déjà, est-ce que le projet en vaut la peine, c'est un vrai sujet de discussion. Mais en tout cas, un R.A.G en local, c'est impossible sans GPU c'est une certitude. J'ai été bluffé par la vitesse de cette carte.

Je n'ai pas encore trouvé de réponse à mes questions à propos de la souveraineté et du contrôle de mes données quand il s'agit d'IA mais pour apprendre clairement je ne vais pas acheter un GPU next gen !