Le RAG (retrieval-augmented generation) consiste à injecter dans le prompt d'un LLM des extraits de documents pertinents, récupérés au moment de la question, plutôt que de compter sur ce que le modèle a mémorisé à l'entraînement. Cet article détaille quand préférer cette approche à un appel d'outil direct ; celui-ci couvre en profondeur le stockage des vecteurs et la recherche par similarité en PHP pur. Ici, on assemble les pièces qui manquent autour : découper les documents, générer les embeddings, et surtout construire le prompt final — la partie la moins documentée alors que c'est elle qui détermine si les réponses sont bonnes ou non.

Document Chunks découpage + overlap Embeddings stockés (indexation) Question embeddée à son tour Chunks proches top-k par cosine similarity Prompt augmenté question + contexte LLM réponse
Le découpage et l'assemblage du prompt pèsent plus sur la qualité finale que le choix du modèle de génération.

Étape 1 — découper les documents

Un chunk trop long dilue la pertinence de la recherche (le vecteur moyenne trop de sujets différents) ; trop court, il perd le contexte nécessaire pour être compris isolément. Un découpage par paragraphes avec chevauchement (overlap) évite de couper une idée en deux au pire endroit :

function chunk(string $text, int $maxChars = 800, int $overlap = 100): array {
    $paragraphs = preg_split('/\n{2,}/', trim($text));
    $chunks = [];
    $current = '';
    foreach ($paragraphs as $p) {
        if (strlen($current) + strlen($p) > $maxChars && $current !== '') {
            $chunks[] = $current;
            $current = substr($current, -$overlap) . "\n\n" . $p; // chevauchement
        } else {
            $current .= ($current === '' ? '' : "\n\n") . $p;
        }
    }
    if ($current !== '') $chunks[] = $current;
    return $chunks;
}

Étape 2 — générer et stocker les embeddings

Chaque chunk passe par un appel d'embedding (même principe que l'article sur le stockage sans base vectorielle, qui détaille la table et la fonction cosine()) :

interface EmbeddingClient {
    public function embed(string $text): array; // vecteur de floats
}

function indexDocument(PDO $pdo, EmbeddingClient $client, int $docId, string $text): void {
    $stmt = $pdo->prepare(
        'INSERT INTO doc_embedding (doc_id, chunk_text, vector) VALUES (?, ?, ?)'
    );
    foreach (chunk($text) as $c) {
        $stmt->execute([$docId, $c, json_encode($client->embed($c))]);
    }
}

Noter la colonne chunk_text, absente du schéma minimal de l'article précédent : pour construire le prompt final, il faut le texte du chunk, pas seulement son vecteur — la recherche se fait sur les vecteurs, mais c'est le texte qui part au LLM.

Étape 3 — assembler le prompt augmenté

C'est l'étape qui fait vraiment la différence. Trois règles qui évitent le plus de déceptions en pratique : séparer clairement le contexte de la question, numéroter les sources pour permettre la citation, et dire explicitement au modèle de ne pas répondre si le contexte ne contient pas l'information.

function buildPrompt(string $question, array $chunks): string {
    $context = '';
    foreach ($chunks as $i => $c) {
        $context .= "[Source " . ($i + 1) . "]\n" . $c['chunk_text'] . "\n\n";
    }
    return <<<PROMPT
Réponds à la question en te basant uniquement sur le contexte ci-dessous.
Cite la source entre crochets, ex. [Source 2]. Si le contexte ne permet pas
de répondre, dis-le explicitement plutôt que d'inventer.

Contexte :
{$context}
Question : {$question}
PROMPT;
}

Étape 4 — chercher puis générer

La boucle complète, recherche puis génération, avec le même LlmClient que celui défini dans cet article (une interface complete(string $prompt): string, pour pouvoir le doubler en test) :

function answer(PDO $pdo, EmbeddingClient $embedder, LlmClient $llm, string $question): string {
    $q = $embedder->embed($question);
    $rows = $pdo->query('SELECT chunk_text, vector FROM doc_embedding')->fetchAll();
    usort($rows, fn($x, $y) =>
        cosine($q, json_decode($y['vector'], true))
        <=> cosine($q, json_decode($x['vector'], true))
    );
    $top = array_slice($rows, 0, 4);
    return $llm->complete(buildPrompt($question, $top));
}
Taille de chunkEffet
~200 caractèresrecherche très précise, mais contexte souvent trop pauvre pour répondre seul
~800 caractèresbon compromis par défaut pour de la documentation technique
> 2000 caractèresvecteur dilué, la recherche rapporte des chunks moins pertinents
Le réglage par défaut (800 caractères, overlap 100) convient à la majorité des cas — mesurer avant d'optimiser plus loin.

Règle

Le goulot d'étranglement d'un RAG qui déçoit n'est presque jamais le modèle de génération : c'est un découpage trop grossier ou un prompt qui ne force pas le modèle à s'en tenir au contexte fourni. Avant de changer de modèle, vérifier d'abord ce que la recherche a réellement remonté.

FAQ

Faut-il réindexer tous les documents à chaque changement ?

Non, seulement les documents modifiés ou ajoutés — l'indexation est incrémentale, par document, pas globale.

Pourquoi dire au modèle de ne pas inventer si le contexte est insuffisant ?

Sans cette consigne explicite, un LLM a tendance à compléter avec ses connaissances générales plutôt que d'admettre que le contexte fourni ne suffit pas — ce qui réintroduit le risque qu'un RAG est censé réduire.

Combien de chunks faut-il injecter dans le prompt ?

Il n'y a pas de nombre universel : 3 à 5 chunks pertinents couvrent la plupart des cas, au-delà le contexte devient bruyant et dilue l'attention du modèle sur l'information utile.