Pourquoi le RAG et pas le fine-tuning ?

Un LLM a une date de coupure — il ne connaît pas votre documentation interne, vos tickets de support, vos contrats. Le fine-tuning injecte de la connaissance dans les poids du modèle, mais c'est coûteux, long, et vous devez recommencer à chaque mise à jour de vos données.

Le Retrieval-Augmented Generation adopte une approche différente : on récupère dynamiquement les passages pertinents au moment de la requête, on les injecte dans le prompt, et le LLM répond en s'appuyant sur ce contexte. Vos données restent dans votre base — aucun modèle à ré-entraîner.

Architecture

Pipeline en deux phases : ingestion (découpage, embedding, stockage) exécutée hors ligne, et retrieval + génération (recherche vectorielle, construction du prompt, appel LLM) sur chaque requête utilisateur.

pgvector : tout dans PostgreSQL

Plutôt qu'ajouter un service dédié (Pinecone, Qdrant, Weaviate), j'ai opté pour pgvector — une extension PostgreSQL qui ajoute un type vector et des index ANN (Approximate Nearest Neighbor). Même base, même infra, même backup.

SQL migrations/Version20260110_create_chunks.sql
-- Activer l'extension
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE document_chunk (
    id          UUID          PRIMARY KEY DEFAULT gen_random_uuid(),
    document_id UUID          NOT NULL REFERENCES document(id) ON DELETE CASCADE,
    content     TEXT          NOT NULL,
    embedding   vector(1536)  NOT NULL,   -- dimensions text-embedding-3-small
    chunk_index INT           NOT NULL,
    metadata    JSONB         NOT NULL DEFAULT ''
);

-- Index HNSW : meilleur rappel que IVFFlat, recommandé pour < 10M vecteurs
CREATE INDEX ON document_chunk
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

Pipeline d'ingestion

L'ingestion transforme un document brut en vecteurs stockables. Trois étapes : découpage en chunks, embedding via l'API OpenAI, persistance en base.

PHP src/Rag/Ingestion/DocumentIngester.php
final class DocumentIngester
{
    public function __construct(
        private readonly TextSplitter       $splitter,
        private readonly EmbeddingClient    $embeddings,
        private readonly ChunkRepository    $chunks,
        private readonly EntityManagerInterface $em,
    ) {}

    public function ingest(Document $document): void
    {
        // 1. Découper en chunks de ~512 tokens avec overlap de 64
        $texts = $this->splitter->split($document->getContent(), chunkSize: 512, overlap: 64);

        // 2. Embedder en batch (max 2048 inputs / appel OpenAI)
        $vectors = $this->embeddings->embedBatch($texts);

        // 3. Persister
        foreach ($vectors as $i => [$text, $vector]) {
            $chunk = new DocumentChunk(
                document:   $document,
                content:    $text,
                embedding:  $vector,
                chunkIndex: $i,
            );
            $this->em->persist($chunk);
        }

        $this->em->flush();
    }
}

Taille des chunks

512 tokens avec un overlap de 64 est un bon point de départ, mais pas une règle universelle. Des chunks trop courts perdent le contexte ; trop longs, ils noient le signal pertinent. Testez avec vos données réelles en mesurant le rappel sur un jeu d'évaluation.

Recherche vectorielle et construction du prompt

Côté retrieval, on embed la question de l'utilisateur puis on cherche les chunks les plus proches par similarité cosinus. pgvector s'en charge avec l'opérateur <=>.

PHP src/Rag/Retrieval/ChunkRepository.php
public function findSimilar(array $queryVector, int $limit = 5): array
{
    $vectorLiteral = '[' . implode(',', $queryVector) . ']';

    return $this->getEntityManager()->createNativeQuery(
        'SELECT id, content, metadata,
                1 - (embedding <=> :vec::vector) AS score
         FROM document_chunk
         ORDER BY embedding <=> :vec::vector
         LIMIT :limit',
        $rsm
    )
    ->setParameter('vec', $vectorLiteral)
    ->setParameter('limit', $limit)
    ->getResult();
}

Les chunks récupérés sont ensuite assemblés en contexte et injectés dans le prompt système avant l'appel au LLM.

PHP src/Rag/Generation/RagQueryHandler.php
public function answer(string $question): string
{
    // 1. Embedding de la question
    $queryVec = $this->embeddings->embed($question);

    // 2. Recherche des chunks pertinents
    $chunks = $this->repository->findSimilar($queryVec, limit: 5);

    // 3. Construire le contexte
    $context = implode("\n\n---\n\n", array_column($chunks, 'content'));

    // 4. Prompt avec contexte injecté
    $systemPrompt =  
        Tu es un assistant expert. Réponds uniquement à partir du contexte fourni.
        Si la réponse n'est pas dans le contexte, dis-le explicitement.

        Contexte :
        $context
         ;

    return $this->llm->chat($systemPrompt, $question);
}

Conclusion

pgvector dans PostgreSQL couvre 90% des besoins RAG sans infrastructure supplémentaire. Pour les 10% restants — milliards de vecteurs, latence sub-10ms, multi-tenancy massif — un service dédié se justifie. Mais commencer avec ce qu'on maîtrise déjà, c'est souvent la décision d'ingénierie la plus pragmatique.