YOUR DIGITAL HUB
← Retour au blog

RAG en production : architecture pragmatique avec pgvector, Claude et Symfony

· 14 min de lecture
Visuel de couverture — RAG en production avec pgvector, Claude et Symfony

La promesse et la réalité du RAG

Retrieval-Augmented Generation : on récupère les passages pertinents d'un corpus, on les fournit à un LLM, on obtient une réponse en langage naturel avec des sources. Sur le papier, c'est magique. En production, c'est un projet d'ingénierie sérieux.

Les premières démos RAG construites en 2023 avec LangChain et Pinecone sont restées en POC chez 80% des équipes que nous avons auditées en 2025. Les raisons reviennent toujours : chunking mal fait, embeddings génériques sur un corpus métier, pas de reranking, prompts creux, coûts qui explosent, zéro évaluation quantitative.

Cet article présente l'architecture que nous mettons en production depuis deux ans : Symfony en front, PostgreSQL + pgvector pour le stockage, Claude 4.5 Sonnet en génération, embeddings OpenAI, reranking Cohere. Le code, le schéma SQL, les coûts réels.

Architecture cible

Le pipeline RAG pragmatique se décompose en sept étapes.

 ┌─────────────┐     ┌──────────┐     ┌───────────┐     ┌──────────┐
 │  Documents  │────▶│ Chunking │────▶│ Embedding │────▶│ Storage  │
 │  (PDF, MD,  │     │  smart   │     │ OpenAI    │     │ pgvector │
 │   HTML)     │     │          │     │           │     │  + meta  │
 └─────────────┘     └──────────┘     └───────────┘     └──────────┘
                                                              │
                                                              ▼
 ┌─────────────┐     ┌──────────┐     ┌───────────┐     ┌──────────┐
 │  Réponse    │◀────│  Claude  │◀────│ Reranking │◀────│ Retrieval│
 │  + sources  │     │   4.5    │     │  Cohere   │     │  HNSW    │
 └─────────────┘     └──────────┘     └───────────┘     └──────────┘
  • Ingestion : extraction du texte depuis les formats source.
  • Chunking : découpage en passages contextuellement cohérents.
  • Embedding : transformation en vecteurs denses.
  • Storage : persistence dans PostgreSQL + pgvector.
  • Retrieval : récupération des k candidats les plus proches par similarité cosinus.
  • Reranking : tri des candidats par un modèle plus précis.
  • Génération : envoi du contexte final à Claude, composition du prompt.

pgvector plutôt que Pinecone, Qdrant, Weaviate

Le choix de la vector DB est souvent surdimensionné. pgvector 0.8 (sortie fin 2024) couvre la majorité des besoins avec une simplicité opérationnelle imbattable.

Critère pgvector 0.8 Pinecone Qdrant Weaviate
Volume jusqu'à 5M vecteurs 1536-dim sur 1 instance 16 Go Illimité Illimité Illimité
Latence p95 HNSW 20 à 80 ms 30 à 100 ms 10 à 50 ms 40 à 150 ms
Transactions ACID avec métier Oui, natif Non Non Non
Backup unifié avec reste DB Oui Non Séparé Séparé
SQL standard Oui API propriétaire API propriétaire GraphQL / REST
Coût mensuel (5M vecteurs) Coût PG existant 70 à 200 USD 50 à 150 USD 80 USD
Courbe d'apprentissage Nulle si équipe PG Moyenne Moyenne Forte

Notre règle : pgvector par défaut, basculer vers Qdrant si on dépasse 50M vecteurs ou si le filtrage pré-requête devient complexe (requêtes hybrides filtrage dense + structuré). Pinecone reste pertinent pour une équipe qui veut zéro ops.

Schéma PostgreSQL

Le schéma que nous déployons sur la majorité des RAG Symfony. PostgreSQL 16 ou 17, extension pgvector 0.8 activée.

-- Activation de l'extension
CREATE EXTENSION IF NOT EXISTS vector;

-- Table source des documents
CREATE TABLE document (
    id           BIGSERIAL PRIMARY KEY,
    tenant_id    BIGINT NOT NULL,
    external_id  TEXT NOT NULL,
    title        TEXT NOT NULL,
    source_type  TEXT NOT NULL,               -- 'pdf', 'md', 'confluence', etc.
    source_url   TEXT,
    checksum     TEXT NOT NULL,               -- sha256 du fichier source
    raw_bytes    INTEGER NOT NULL,
    language     TEXT NOT NULL DEFAULT 'fr',
    ingested_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at   TIMESTAMPTZ,
    UNIQUE (tenant_id, external_id)
);
CREATE INDEX idx_document_tenant ON document (tenant_id) WHERE deleted_at IS NULL;

-- Table des chunks avec leur embedding
CREATE TABLE document_chunk (
    id                BIGSERIAL PRIMARY KEY,
    document_id       BIGINT NOT NULL REFERENCES document(id) ON DELETE CASCADE,
    tenant_id         BIGINT NOT NULL,
    chunk_index       INTEGER NOT NULL,
    content           TEXT NOT NULL,
    content_tokens    INTEGER NOT NULL,
    heading_path      TEXT,                   -- 'Section 2 > Sous-section A'
    embedding         vector(3072) NOT NULL,  -- text-embedding-3-large = 3072
    embedding_model   TEXT NOT NULL,          -- pour bascule future
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (document_id, chunk_index)
);

-- Index HNSW : équilibre vitesse / précision / mémoire
CREATE INDEX idx_chunk_embedding_hnsw
    ON document_chunk
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

-- Index pour filtrage par tenant (critique en multi-tenant)
CREATE INDEX idx_chunk_tenant ON document_chunk (tenant_id);

-- Trace des requêtes pour observabilité et évaluation
CREATE TABLE rag_query (
    id                BIGSERIAL PRIMARY KEY,
    tenant_id         BIGINT NOT NULL,
    user_id           BIGINT,
    query_text        TEXT NOT NULL,
    retrieved_ids     BIGINT[] NOT NULL,
    reranked_ids      BIGINT[] NOT NULL,
    final_prompt      TEXT NOT NULL,
    answer            TEXT NOT NULL,
    model             TEXT NOT NULL,
    prompt_tokens     INTEGER NOT NULL,
    completion_tokens INTEGER NOT NULL,
    cost_cents        INTEGER NOT NULL,
    latency_ms        INTEGER NOT NULL,
    rating            SMALLINT,               -- feedback utilisateur 1..5
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_rag_query_tenant_date ON rag_query (tenant_id, created_at DESC);

Le paramètre m = 16 de HNSW donne un bon équilibre sur la majorité des corpus. Monter à m = 32 si la précision est insuffisante (coût : +50% mémoire). ef_construction = 64 est suffisant pour des corpus sous 5 millions de vecteurs.

Important : l'index HNSW est construit en mémoire. Pour une table avec 5 millions de vecteurs 3072-dim, compter environ 60 Go de RAM. Bien dimensionner l'instance PostgreSQL, ou utiliser une dimension plus petite (text-embedding-3-small = 1536, -80% RAM et -60% coût).

Service d'embedding en Symfony

Le client d'embedding est une dépendance injectable qui isole le provider. Le même projet peut switcher OpenAI, Voyage AI, Cohere ou un modèle self-hosted sans toucher à la logique métier.

<?php
declare(strict_types=1);

namespace App\Rag\Embedding;

use Symfony\Contracts\HttpClient\HttpClientInterface;

interface EmbeddingClient
{
    /**
     * @param list<string> $texts
     * @return list<list<float>>
     */
    public function embedBatch(array $texts): array;

    public function dimensions(): int;

    public function modelId(): string;
}

final class OpenAIEmbeddingClient implements EmbeddingClient
{
    public function __construct(
        private readonly HttpClientInterface $client,
        #[\SensitiveParameter] private readonly string $apiKey,
        private readonly string $model = 'text-embedding-3-large',
        private readonly int $dimensions = 3072,
    ) {}

    public function embedBatch(array $texts): array
    {
        if ($texts === []) {
            return [];
        }
        if (count($texts) > 2048) {
            throw new \InvalidArgumentException('OpenAI embeds max 2048 inputs per call.');
        }
        $response = $this->client->request('POST', 'https://api.openai.com/v1/embeddings', [
            'headers' => [
                'Authorization' => 'Bearer ' . $this->apiKey,
                'Content-Type' => 'application/json',
            ],
            'json' => [
                'model' => $this->model,
                'input' => $texts,
                'encoding_format' => 'float',
            ],
            'timeout' => 60,
        ]);
        $payload = $response->toArray();
        /** @var list<list<float>> $vectors */
        $vectors = array_map(
            static fn (array $d) => $d['embedding'],
            $payload['data'],
        );
        return $vectors;
    }

    public function dimensions(): int
    {
        return $this->dimensions;
    }

    public function modelId(): string
    {
        return $this->model;
    }
}

Le chunking est l'étape la plus sous-estimée. Nous utilisons une approche hiérarchique : découpage par titre de section d'abord, puis par paragraphe, puis par taille maximale de 800 tokens avec 100 tokens de chevauchement. Le heading_path est préservé dans la métadonnée, crucial pour la composition finale du prompt.

Service de retrieval avec Doctrine + pgvector

Doctrine n'a pas de support natif pour le type vector. Nous travaillons en DBAL brut sur cette couche, ce qui garde les perfs optimales.

<?php
declare(strict_types=1);

namespace App\Rag\Retrieval;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;

final class PgVectorRetriever
{
    public function __construct(
        private readonly Connection $conn,
    ) {}

    /**
     * @param list<float> $queryVector
     * @return list<RetrievedChunk>
     */
    public function topK(array $queryVector, int $tenantId, int $k = 40): array
    {
        $vectorLiteral = $this->toPgvectorLiteral($queryVector);
        $sql = <<<SQL
            SELECT
                dc.id,
                dc.document_id,
                dc.content,
                dc.heading_path,
                d.title           AS document_title,
                d.source_url      AS document_url,
                1 - (dc.embedding <=> :q) AS similarity
            FROM document_chunk dc
            INNER JOIN document d ON d.id = dc.document_id
            WHERE dc.tenant_id = :tenant
              AND d.deleted_at IS NULL
            ORDER BY dc.embedding <=> :q
            LIMIT :k
        SQL;
        $rows = $this->conn->executeQuery($sql, [
            'q' => $vectorLiteral,
            'tenant' => $tenantId,
            'k' => $k,
        ], [
            'q' => ParameterType::STRING,
            'tenant' => ParameterType::INTEGER,
            'k' => ParameterType::INTEGER,
        ])->fetchAllAssociative();

        return array_map(
            static fn (array $r) => new RetrievedChunk(
                id: (int) $r['id'],
                documentId: (int) $r['document_id'],
                content: $r['content'],
                headingPath: $r['heading_path'] ?? null,
                documentTitle: $r['document_title'],
                documentUrl: $r['document_url'] ?? null,
                similarity: (float) $r['similarity'],
            ),
            $rows,
        );
    }

    /**
     * @param list<float> $vector
     */
    private function toPgvectorLiteral(array $vector): string
    {
        return '[' . implode(',', array_map(fn (float $f) => sprintf('%.8f', $f), $vector)) . ']';
    }
}

final readonly class RetrievedChunk
{
    public function __construct(
        public int $id,
        public int $documentId,
        public string $content,
        public ?string $headingPath,
        public string $documentTitle,
        public ?string $documentUrl,
        public float $similarity,
    ) {}
}

L'opérateur <=> de pgvector calcule la distance cosinus. Pour augmenter la précision de l'index HNSW sur une requête particulière, on peut faire SET LOCAL hnsw.ef_search = 80; avant la requête (défaut 40).

Le filtrage par tenant_id en WHERE est primordial en multi-tenant. L'index HNSW fait une pré-sélection sans filtre, puis PostgreSQL applique le WHERE. Pour des volumes importants, envisager la partition par tenant.

Reranking avec Cohere

Le retrieval vectoriel seul récupère 30 à 50 candidats. Le reranking les trie plus finement avec un cross-encoder qui voit la requête et le passage ensemble. Gain typique : +15 à +25% de precision@5.

&#x3C;?php
declare(strict_types=1);

namespace App\Rag\Reranking;

use App\Rag\Retrieval\RetrievedChunk;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class CohereReranker
{
    public function __construct(
        private readonly HttpClientInterface $client,
        #[\SensitiveParameter] private readonly string $apiKey,
        private readonly string $model = 'rerank-v3.5',
    ) {}

    /**
     * @param list&#x3C;RetrievedChunk> $candidates
     * @return list&#x3C;RetrievedChunk>
     */
    public function rerank(string $query, array $candidates, int $topN = 5): array
    {
        if ($candidates === []) {
            return [];
        }
        $response = $this->client->request('POST', 'https://api.cohere.com/v2/rerank', [
            'headers' => [
                'Authorization' => 'Bearer ' . $this->apiKey,
                'Content-Type' => 'application/json',
            ],
            'json' => [
                'model' => $this->model,
                'query' => $query,
                'documents' => array_map(
                    static fn (RetrievedChunk $c) => $c->content,
                    $candidates,
                ),
                'top_n' => $topN,
            ],
            'timeout' => 30,
        ]);
        $payload = $response->toArray();
        $ordered = [];
        foreach ($payload['results'] as $result) {
            $idx = (int) $result['index'];
            $ordered[] = $candidates[$idx];
        }
        return $ordered;
    }
}

Alternative self-hosted : BAAI/bge-reranker-large via un endpoint vLLM ou TEI (Text Embeddings Inference). Coût marginal plus faible au-dessus de 200 000 requêtes par mois.

Composition du prompt et appel à Claude

Le prompt est la pièce critique. Un RAG avec un excellent retrieval mais un prompt paresseux produit des hallucinations.

&#x3C;?php
declare(strict_types=1);

namespace App\Rag\Generation;

use App\Rag\Retrieval\RetrievedChunk;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ClaudeGenerator
{
    private const SYSTEM_PROMPT = &#x3C;&#x3C;&#x3C;'PROMPT'
    Tu es l'assistant documentaire de l'entreprise. Règles absolues :
    1. Réponds uniquement à partir des extraits fournis dans &#x3C;context>.
    2. Si l'information n'est pas dans &#x3C;context>, dis-le explicitement ("Je ne trouve pas cette information dans la documentation.").
    3. Cite les sources par leur numéro entre crochets, exemple : [1], [2].
    4. Réponds en français, sauf si la question est posée dans une autre langue.
    5. Sois concis et factuel. Pas de spéculation, pas de remplissage.
    PROMPT;

    public function __construct(
        private readonly HttpClientInterface $client,
        #[\SensitiveParameter] private readonly string $apiKey,
        private readonly string $model = 'claude-sonnet-4-5',
    ) {}

    /**
     * @param list&#x3C;RetrievedChunk> $chunks
     */
    public function generate(string $query, array $chunks): GeneratedAnswer
    {
        $context = $this->buildContext($chunks);
        $userContent = sprintf("&#x3C;context>\n%s\n&#x3C;/context>\n\nQuestion : %s", $context, $query);

        $response = $this->client->request('POST', 'https://api.anthropic.com/v1/messages', [
            'headers' => [
                'x-api-key' => $this->apiKey,
                'anthropic-version' => '2023-06-01',
                'Content-Type' => 'application/json',
            ],
            'json' => [
                'model' => $this->model,
                'max_tokens' => 1024,
                'temperature' => 0.2,
                'system' => [[
                    'type' => 'text',
                    'text' => self::SYSTEM_PROMPT,
                    'cache_control' => ['type' => 'ephemeral'],
                ]],
                'messages' => [[
                    'role' => 'user',
                    'content' => $userContent,
                ]],
            ],
            'timeout' => 60,
        ]);
        $payload = $response->toArray();

        return new GeneratedAnswer(
            text: $payload['content'][0]['text'],
            promptTokens: (int) ($payload['usage']['input_tokens'] ?? 0),
            completionTokens: (int) ($payload['usage']['output_tokens'] ?? 0),
            cachedPromptTokens: (int) ($payload['usage']['cache_read_input_tokens'] ?? 0),
            model: $payload['model'],
        );
    }

    /**
     * @param list&#x3C;RetrievedChunk> $chunks
     */
    private function buildContext(array $chunks): string
    {
        $parts = [];
        foreach ($chunks as $i => $chunk) {
            $heading = $chunk->headingPath ? ' > ' . $chunk->headingPath : '';
            $parts[] = sprintf(
                "[%d] Source : %s%s\n%s",
                $i + 1,
                $chunk->documentTitle,
                $heading,
                $chunk->content,
            );
        }
        return implode("\n\n---\n\n", $parts);
    }
}

final readonly class GeneratedAnswer
{
    public function __construct(
        public string $text,
        public int $promptTokens,
        public int $completionTokens,
        public int $cachedPromptTokens,
        public string $model,
    ) {}
}

Remarques importantes :

  • cache_control: ephemeral sur le system prompt active le prompt caching natif d'Anthropic. Sur les appels suivants dans les 5 minutes, les tokens du system sont facturés 90% moins cher. Gain massif sur les RAG à fort trafic.
  • temperature: 0.2 pour des réponses déterministes. Monter si on veut plus de créativité narrative, mais au prix de plus d'hallucinations.
  • La consigne de citer [1], [2] permet de matérialiser les sources dans l'UI et de laisser l'utilisateur vérifier.

Observabilité : Langfuse et dashboards

Sans observabilité, un RAG en production devient une boîte noire coûteuse. Nous instrumentons chaque requête avec Langfuse (open-source, self-host possible).

&#x3C;?php
declare(strict_types=1);

namespace App\Rag;

use Symfony\Contracts\HttpClient\HttpClientInterface;

final class LangfuseTracer
{
    public function __construct(
        private readonly HttpClientInterface $client,
        #[\SensitiveParameter] private readonly string $publicKey,
        #[\SensitiveParameter] private readonly string $secretKey,
        private readonly string $host = 'https://cloud.langfuse.com',
    ) {}

    /**
     * @param array&#x3C;string, mixed> $metadata
     */
    public function trace(string $name, array $metadata): void
    {
        $this->client->request('POST', $this->host . '/api/public/ingestion', [
            'auth_basic' => [$this->publicKey, $this->secretKey],
            'json' => [
                'batch' => [[
                    'id' => bin2hex(random_bytes(8)),
                    'type' => 'trace-create',
                    'timestamp' => gmdate('Y-m-d\TH:i:s.v\Z'),
                    'body' => ['name' => $name, 'metadata' => $metadata],
                ]],
            ],
            'timeout' => 5,
        ]);
    }
}

Dashboards à brancher systématiquement :

  • Coût par tenant par jour (alerte à 120% de la médiane des 7 derniers jours).
  • Latence p50, p95, p99 du pipeline complet.
  • Taux de hit du prompt cache.
  • Score de satisfaction utilisateur (1 à 5) par feature.
  • Distribution des scores de similarité du top-1 (indicateur de drift corpus).

Pour l'évaluation qualitative, nous lançons en CI une suite d'évaluations avec Ragas sur un dataset annoté de 100 à 200 questions-réponses. Les métriques Ragas surveillées : context_precision, context_recall, faithfulness, answer_relevancy. Échec du build si l'une descend sous 0.75.

Coûts réels observés

Métriques compilées sur 4 RAG en production dans notre portefeuille, corpus de 30 000 à 80 000 documents, 10 000 à 60 000 requêtes par mois.

Poste Coût unitaire 2026 Volume mensuel typique Coût mensuel
Embeddings initiaux (3072-dim) 0,13 USD / 1M tokens 50M tokens (ingest initial) ~6 USD (one-shot)
Embeddings incrémentaux 0,13 USD / 1M tokens 5M tokens ~0,65 USD
Stockage pgvector (PG managed) Coût PG existant +15 Go 0 à 50 USD
Reranking Cohere 2 USD / 1000 recherches 30 000 requêtes 60 USD
Génération Claude 4.5 Sonnet 3 USD / 1M input, 15 USD / 1M output Prompts 2k tokens, réponses 400 tokens, 30k requêtes 270 USD
Prompt caching (-90% sur cache hit) - ~85% de cache hit Économie de ~170 USD
Observabilité Langfuse Self-host ou 29 USD / mois cloud - 0 à 29 USD
Total opérationnel ~150 à 230 USD / mois

Le montant est contenu à condition d'appliquer trois optimisations systématiquement :

  1. Prompt caching Anthropic. Le system prompt et les instructions longues sont cachés, gain réel de 70 à 90%.
  2. Choix du modèle par requête. Les classifications simples basculent vers Claude Haiku (5 à 10 fois moins cher). Seul le Q&A complexe va sur Sonnet.
  3. Batch API pour les workflows non temps-réel (rapports, enrichissement) : -50% sur le prix.

Les six pièges classiques

Sur une vingtaine de RAG audités en 2025, les erreurs qui tuent les POC reviennent inlassablement.

  • Chunking trop grossier. Des chunks de 2000 tokens diluent l'information pertinente. Cibler 500 à 1000 tokens, avec chevauchement de 10 à 15%.
  • Métadonnées absentes. Pas de heading_path, pas de date, pas de type de document. Le LLM ne peut ni citer ni pondérer.
  • Pas de reranking. Le retrieval seul donne 30% de bruit dans le top-10. Sans reranking, le LLM hallucine.
  • Système prompt creux. "Tu es un assistant utile" ne suffit pas. Interdire explicitement la réponse hors contexte, exiger la citation des sources, fixer le ton.
  • Pas d'évaluation quantitative. Sans Ragas ou dataset annoté, on ne peut pas détecter les régressions sur changement de modèle ou de prompt.
  • Coûts non monitorés. Un utilisateur automatisé peut faire x100 le trafic habituel en une nuit. Rate limiting par tenant obligatoire dès le jour 1.

Conclusion

Un RAG en production n'est pas une démo LangChain. C'est un pipeline d'ingénierie avec ses couches de qualité, d'observabilité, de sécurité et de coûts. La bonne nouvelle : avec pgvector, Symfony et Claude, le socle technique est accessible, maintenable sur dix ans, et économiquement viable pour une entreprise moyenne.

Pour un cadrage RAG sur vos données métier, une mise en production ou une optimisation de coûts sur un POC existant, écrivez-nous à contact@your-digital-hub.com ou découvrez notre expertise Intelligence artificielle.