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.
É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 chunk | Effet |
|---|---|
| ~200 caractères | recherche très précise, mais contexte souvent trop pauvre pour répondre seul |
| ~800 caractères | bon compromis par défaut pour de la documentation technique |
| > 2000 caractères | vecteur dilué, la recherche rapporte des chunks moins pertinents |
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.