RAG en production : architecture pragmatique 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.
<?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<RetrievedChunk> $candidates
* @return list<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.
<?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 = <<<'PROMPT'
Tu es l'assistant documentaire de l'entreprise. Règles absolues :
1. Réponds uniquement à partir des extraits fournis dans <context>.
2. Si l'information n'est pas dans <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<RetrievedChunk> $chunks
*/
public function generate(string $query, array $chunks): GeneratedAnswer
{
$context = $this->buildContext($chunks);
$userContent = sprintf("<context>\n%s\n</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<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: ephemeralsur 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.2pour 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).
<?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<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 :
- Prompt caching Anthropic. Le system prompt et les instructions longues sont cachés, gain réel de 70 à 90%.
- 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.
- 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.