Aller au contenu principal
Retour au blog

Symfony AI : pipeline RAG complet avec PgVector et Claude Sonnet 4

Flavien Métivier29 juillet 202510 min

Ancrer un LLM dans ta base documentaire sans fine-tuning, c'est exactement le problème que résout le RAG — Retrieval-Augmented Generation. Le principe tient en trois étapes : avant de répondre, le modèle consulte les documents les plus pertinents via une recherche sémantique, les injecte dans son contexte, puis génère une réponse ancrée dans tes données réelles. L'implémenter proprement en PHP supposait jusqu'ici d'assembler des librairies tierces hétéroclites, un client HTTP maison et une bonne dose de colle artisanale. La semaine du 7 juillet 2025, l'équipe Symfony a changé la donne en lançant l'initiative Symfony AI : trois composants first-party — Platform, Store, Agent — qui couvrent l'ensemble du pipeline RAG directement dans l'écosystème Symfony, avec un bridge natif vers l'API Anthropic et Claude Sonnet 4. Ce tutoriel te guide de A à Z : indexation dans PgVector, embeddings via Voyage AI, retrieval sémantique avec le Store, génération augmentée avec l'Agent Bundle, et prompt caching pour garder la facture Anthropic sous contrôle — le tout en Symfony 7.3 et PHP 8.4, avec du code production-ready commenté.

La stack Symfony AI en un coup d'œil

L'initiative, annoncée sur symfony.com, regroupe les composants dans le mono-repo symfony/ai. L'architecture est volontairement en couches : chaque composant est utilisable indépendamment, mais les trois ensemble forment un pipeline RAG cohérent sans infrastructure tierce spécialisée. Platform parle aux LLMs, Store gère les vecteurs, Agent orchestre le tout dans ton application Symfony avec des services autowirables et une configuration déclarative en YAML — le tout dans la continuité des conventions Symfony que tu maîtrises déjà.

  • symfony/ai-platform — Abstraction HTTP qui parle à n'importe quel fournisseur LLM (Anthropic, OpenAI, Mistral…). Gère l'authentification, le streaming, la sérialisation des messages et les options spécifiques à chaque provider comme le prompt caching d'Anthropic.
  • symfony/ai-store — Couche d'accès aux vector stores (PgVector, Pinecone, Milvus…). Fournit une interface VectorStoreInterface standardisée, un objet TextDocument comme unité de stockage, et un SimilarityRetriever prêt à l'emploi pour la recherche cosinus.
  • symfony/ai-agent-bundle — Bundle Symfony qui câble Platform et Store ensemble. Expose un service Agent autowirable, un MessageBag pour construire les conversations, et des options de prompt caching transparentes via le bridge Anthropic.

Installer les composants en Symfony 7.3

Symfony 7.3 (sorti le 29 mai 2025) est le socle requis. Assure-toi d'avoir PHP 8.4 et une instance PostgreSQL avec l'extension pgvector compilée et activée — c'est elle qui stocke les vecteurs et exécute la recherche cosinus côté base de données. Pour les embeddings, on utilisera Voyage AI (acquis par Anthropic), dont les modèles sont le choix recommandé dans l'écosystème Anthropic pour alimenter un pipeline RAG avec Claude. Trois packages Composer suffisent à démarrer.

# Composants Symfony AI
composer require symfony/ai-platform symfony/ai-store symfony/ai-agent-bundle

# Adapter PgVector pour Doctrine
composer require pgvector/pgvector

# Variables d'environnement à ajouter dans .env
ANTHROPIC_API_KEY=sk-ant-api03-...
VOYAGE_API_KEY=pa-...
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16"

Configurer le bridge Anthropic avec Claude Sonnet 4

L'Agent Bundle expose une configuration déclarative en YAML. Deux providers sont nécessaires : anthropic pour la génération (Claude Sonnet 4), voyage pour les embeddings. Ce découplage est intentionnel — Anthropic ne fournit pas d'endpoint d'embeddings natif sur ses modèles Claude, mais Voyage AI produit des vecteurs denses de 1 024 dimensions optimisés pour la récupération sémantique. L'identifiant de modèle à utiliser est claude-sonnet-4-5, disponible depuis le 22 mai 2025.

# config/packages/ai.yaml
ai_platform:
    providers:
        anthropic:
            api_key: '%env(ANTHROPIC_API_KEY)%'
        voyage:
            api_key: '%env(VOYAGE_API_KEY)%'
    default_provider: anthropic

ai_store:
    stores:
        pgvector:
            type: pgvector
            dsn: '%env(DATABASE_URL)%'
            table: document_embeddings
            dimensions: 1024      # Dimension des vecteurs voyage-3

ai_agent:
    default_model: claude-sonnet-4-5
    max_tokens: 2048

Indexer tes documents dans le vector store

L'indexation se déroule en trois temps. D'abord, le chunking : découper le texte brut en fragments de taille raisonnable (autour de 500 tokens, soit ~2 000 caractères pour du français). L'overlap — chevauchement entre chunks consécutifs — est critique : il préserve le contexte aux frontières et évite de perdre une information clé qui tomberait à cheval sur deux chunks. Ensuite, la génération d'embedding pour chaque chunk via Voyage AI. Enfin, la persistance dans PgVector avec les métadonnées qui permettront de remonter à la source. Une commande Symfony Console est l'outil idéal : ré-exécutable à volonté, loguable, intégrable dans un pipeline CI à chaque mise à jour documentaire.

Besoin d'un expert Symfony ?

Réserver un appel
<?php
// src/Command/IndexDocumentsCommand.php

namespace App\Command;

use App\Repository\DocumentRepository;
use Symfony\AI\Platform\Bridge\Voyage\VoyageEmbeddingsProvider;
use Symfony\AI\Store\Document\TextDocument;
use Symfony\AI\Store\VectorStoreInterface;
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: 'app:index-documents',
    description: 'Indexe les documents dans le vector store PgVector',
)]
final class IndexDocumentsCommand extends Command
{
    public function __construct(
        private readonly DocumentRepository $repository,
        private readonly VoyageEmbeddingsProvider $embeddings,
        private readonly VectorStoreInterface $store,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $documents = $this->repository->findAll();
        $io->progressStart(count($documents));

        foreach ($documents as $document) {
            // ~500 tokens par chunk, overlap 200 caractères pour conserver le contexte aux frontières
            foreach ($this->chunk($document->getContent(), chunkSize: 2000, overlap: 200) as $i => $chunk) {
                // Génère le vecteur dense 1024D via Voyage AI
                $vector = $this->embeddings->embed($chunk, model: 'voyage-3');

                $this->store->add(new TextDocument(
                    id: sprintf('%s_chunk_%d', $document->getId(), $i),
                    content: $chunk,
                    metadata: [
                        'source_id' => $document->getId(),
                        'title'     => $document->getTitle(),
                        'chunk'     => $i,
                    ],
                    vector: $vector,
                ));
            }

            $io->progressAdvance();
        }

        $io->progressFinish();
        $io->success(sprintf('%d documents indexés avec succès.', count($documents)));

        return Command::SUCCESS;
    }

    /** @return string[] */
    private function chunk(string $text, int $chunkSize, int $overlap): array
    {
        $chunks = [];
        $offset = 0;
        $length = strlen($text);

        while ($offset < $length) {
            $chunks[] = substr($text, $offset, $chunkSize);
            $offset += $chunkSize - $overlap;  // avance avec chevauchement
        }

        return array_filter($chunks);
    }
}

Retrieval sémantique : interroger le Store

La recherche sémantique consiste à transformer la question de l'utilisateur en vecteur — avec le même modèle d'embedding qu'à l'indexation, impérativement — puis à trouver dans PgVector les chunks dont la distance cosinus est la plus faible. Le paramètre minScore est le levier principal de qualité : trop bas, tu injectes du bruit dans le contexte et Claude hallucine en combinant des informations non pertinentes ; trop élevé, tu risques de n'avoir aucun résultat. Un seuil de 0,75 est un bon point de départ à affiner sur ton corpus.

<?php
// src/Service/RagService.php — partie retrieval

namespace App\Service;

use Symfony\AI\Platform\Bridge\Voyage\VoyageEmbeddingsProvider;
use Symfony\AI\Store\Retriever\SimilarityRetriever;

final class RagService
{
    public function __construct(
        private readonly VoyageEmbeddingsProvider $embeddings,
        private readonly SimilarityRetriever $retriever,
        // ... Agent injecté dans la section suivante
    ) {}

    /** @return string[] Les contenus des chunks les plus pertinents */
    public function findContext(string $question): array
    {
        // Même modèle qu'à l'indexation — indispensable pour que les espaces vectoriels coïncident
        $queryVector = $this->embeddings->embed($question, model: 'voyage-3');

        $documents = $this->retriever->retrieve(
            vector: $queryVector,
            maxResults: 5,     // top-5 chunks les plus proches
            minScore: 0.75,   // seuil de pertinence à calibrer sur ton corpus
        );

        return array_map(fn($doc) => $doc->content, $documents);
    }
}

Générer la réponse augmentée avec l'Agent Bundle

L'Agent prend un MessageBag — liste ordonnée de messages système, utilisateur et assistant — et retourne la complétion de Claude. La construction du prompt suit une règle d'or : le system prompt pose les contraintes épistémiques (répondre uniquement depuis le contexte, refuser d'inventer), le contexte documentaire arrive en premier message utilisateur, et la question réelle en dernier. Ce découpage n'est pas anodin : il prépare le terrain pour le prompt caching détaillé dans la section suivante. Une temperature basse (0.1–0.3) ancre Claude dans le contexte plutôt que de le laisser extrapoler.

<?php
// src/Service/RagService.php — pipeline RAG complet

namespace App\Service;

use Symfony\AI\AgentBundle\Agent\Agent;
use Symfony\AI\AgentBundle\Message\Message;
use Symfony\AI\AgentBundle\Message\MessageBag;
use Symfony\AI\Platform\Bridge\Voyage\VoyageEmbeddingsProvider;
use Symfony\AI\Store\Retriever\SimilarityRetriever;

final class RagService
{
    private const SYSTEM_PROMPT = <<<'PROMPT'
    Tu es un assistant expert. Tu réponds uniquement en te basant sur le contexte documentaire fourni.
    Si la réponse n'est pas dans le contexte, indique-le explicitement — ne fabrique jamais d'information.
    Réponds de façon concise et structurée, en français.
    PROMPT;

    public function __construct(
        private readonly Agent $agent,
        private readonly VoyageEmbeddingsProvider $embeddings,
        private readonly SimilarityRetriever $retriever,
    ) {}

    public function query(string $question): string
    {
        // 1. Embedding de la question avec le même modèle qu'à l'indexation
        $queryVector = $this->embeddings->embed($question, model: 'voyage-3');

        // 2. Top-5 chunks au-dessus du seuil de pertinence
        $documents = $this->retriever->retrieve(
            vector: $queryVector,
            maxResults: 5,
            minScore: 0.75,
        );

        // 3. Assemblage du contexte — séparateur explicite entre les extraits
        $context = implode("\n\n---\n\n", array_map(
            fn($doc) => $doc->content,
            $documents,
        ));

        // 4. Construction du MessageBag
        $messages = new MessageBag(
            Message::system(self::SYSTEM_PROMPT),
            Message::user("Contexte documentaire :\n\n" . $context),
            Message::user($question),
        );

        // 5. Appel Claude Sonnet 4 — temperature basse pour coller au contexte
        $response = $this->agent->call(
            messages: $messages,
            options: [
                'model'       => 'claude-sonnet-4-5',
                'max_tokens'  => 1024,
                'temperature' => 0.2,
            ],
        );

        return $response->getContent();
    }
}

Prompt caching pour maîtriser les coûts Anthropic

À mesure que ta base documentaire grossit, le contexte RAG peut dépasser 4 000 tokens par requête. Sans optimisation, chaque appel Claude facture l'intégralité de ces tokens en input tokens. Le prompt caching d'Anthropic change l'équation : les tokens lus depuis le cache coûtent dix fois moins cher que les tokens standards (l'écriture dans le cache est facturée 1,25× le tarif normal, amortie dès la deuxième requête). Dans l'Agent Bundle, le flag cache: true sur un Message injecte automatiquement le header cache_control: {"type": "ephemeral"} dans l'appel Anthropic — sans aucune modification de ton code HTTP. Le cache est valide 5 minutes ; pour un assistant RAG avec un corpus stable, le system prompt et les chunks documentaires les plus fréquents sont réutilisés entre requêtes successives, ce qui peut représenter jusqu'à 90 % des tokens en entrée.

<?php
// src/Service/RagService.php — ajout du prompt caching

// System prompt stable → marqué en cache en priorité
$messages = new MessageBag(
    Message::system(self::SYSTEM_PROMPT, cache: true),
    // Contexte documentaire semi-stable → mis en cache également
    Message::user("Contexte documentaire :\n\n" . $context, cache: true),
    // Question de l'utilisateur → toujours dynamique, jamais en cache
    Message::user($question),
);

// À partir de la 2e requête dans la fenêtre de 5 min,
// le system prompt + contexte sont facturés 10× moins cher
$response = $this->agent->call(
    messages: $messages,
    options: [
        'model'       => 'claude-sonnet-4-5',
        'max_tokens'  => 1024,
        'temperature' => 0.2,
    ],
);

return $response->getContent();

Symfony AI apporte enfin à l'écosystème PHP une solution de première classe pour le RAG : typée, testable, configurable en YAML, et parfaitement intégrée dans le DI container Symfony. L'activation du prompt caching Anthropic n'est pas un détail — c'est ce qui rend l'architecture économiquement viable en production, où le volume de requêtes transforme chaque token économisé en coût réel récupéré. La prochaine étape naturelle est d'ajouter un reranker entre le retrieval et la génération pour affiner la pertinence des chunks sélectionnés, ou d'explorer le streaming de la réponse Claude via le bridge SSE de l'Agent Bundle — deux axes qui feront l'objet d'un article dédié.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.