Une mise à jour de dépendance, une idée de feature, et l'envie d'en tirer un billet. Sauf qu'au bout d'une soixantaine de billets publiés, deux questions se posent avant même la première ligne : est-ce que je n'ai pas déjà écrit la même chose ? Et est-ce que je ne disais pas l'inverse il y a trois mois ?
Ce billet raconte comment j'ai fini par répondre à ces deux questions avec un RAG monté sur mon propre corpus. On va voir ce qu'est un RAG sans le folklore, pourquoi le mien aide à écrire au lieu d'écrire à ma place, et comment il tient en désossant le montage canonique de l'écosystème Symfony AI.
Un RAG, c'est chercher avant de générer
Sur le même sujet
Faire tourner des LLM gratuitement avec Ollama
Derrière l'acronyme (Retrieval-Augmented Generation), l'idée tient en une phrase : avant de demander quoi que ce soit à un modèle, on cherche dans un corpus les documents pertinents, et on les lui met sous les yeux. Le modèle répond avec ces documents comme matière première, au lieu de puiser dans sa mémoire d'entraînement. Moins d'improvisation, des sources citables.
Concrètement, ça se joue en deux moments. À l'indexation, chaque document est transformé en vecteur (un embedding : une liste de nombres qui capture le sens du texte) et rangé dans un store vectoriel. À la requête, la question subit le même traitement, on cherche ses voisins les plus proches dans le store, et les documents retrouvés partent dans le prompt.
L'écosystème Symfony AI fournit tout ce qu'il faut pour ce montage, et sa démo officielle (symfony/ai-demo) l'assemble sur le blog de Symfony. Quatre couches déclaratives, lisibles dans config/packages/ai.yaml :
# symfony/ai-demo, config/packages/ai.yaml (extrait, vérifié le 2026-07-11)
store:
postgres:
symfony_blog:
dbal_connection: 'doctrine.dbal.default_connection'
table_name: 'symfony_blog'
distance: 'cosine'
indexer:
blog:
loader: 'Symfony\AI\Store\Document\Loader\RssFeedLoader'
transformers:
- 'Symfony\AI\Store\Document\Transformer\TextSplitTransformer' # chunking
vectorizer: 'ai.vectorizer.openai'
store: 'ai.store.postgres.symfony_blog'
retriever:
blog:
store: 'ai.store.postgres.symfony_blog'
vectorizer: 'ai.vectorizer.openai'Un store pgvector, un vectorizer qui calcule les embeddings côté PHP, un indexer qui charge le flux RSS et découpe les billets en morceaux (le chunking), un retriever. Au-dessus, un agent conversationnel équipé d'un outil de recherche par similarité, avec une consigne stricte : ne répondre qu'avec ce que l'outil retourne, et citer ses sources.
C'est propre, c'est documenté, ça tourne. Et c'est un chatbot.
Le RAG que tout le monde monte, et surtout pourquoi je n'en voulais pas !
Quand on dit « RAG » en 2026, on pense à ça : un visiteur pose une question, le système répond en s'appuyant sur le contenu du site. J'ai sérieusement envisagé ce montage pour le blog, avant de le ranger dans un ADR daté du 3 juillet 2026, pour trois raisons qui n'ont rien de théorique.
La première est comptable. Chaque question d'un visiteur déclenche un appel au modèle, donc une facture qui suit le trafic. Sur un blog perso, payer l'inférence de chaque curieux qui teste « raconte-moi une blague » est un choix de vie discutable.
La seconde pèse plus lourd : une réponse hallucinée serait signée par mon domaine. Le lecteur ne fait pas la différence entre « le blog dit » et « le chatbot du blog a improvisé ». Ma crédibilité est l'actif principal du site, et je la confierais à un générateur de texte face à des inconnus.
La troisième dépasse mon serveur : l'impact environnemental d'un dispositif d'inférence ouvert à tous les passants. Une technologie qui coûte ce qu'elle coûte en énergie n'est pas faite, à mon sens, pour amuser la galerie ou faire le signe savant. La question du bénéfice rendu se pose avant celle du montage.
Restait un cas que ni la doc ni la démo ne montrent. Je me suis demandé ce qu'un RAG apporterait vraiment ici. La réponse a fini par pointer ailleurs : le manque n'était pas côté lecteur. Les deux questions du début (est-ce que je me répète ? est-ce que je me contredis ?) sont exactement ce qu'un retrieval sait faire. Le corpus publié contient déjà les réponses ; il fallait juste le brancher sur le moment où j'écris.
Ce choix rejoint une position plus large : je préfère l'IA pour chercher que pour rédiger. Déléguer ma documentation technique à un générateur, c'est mettre en jeu ma réputation et, à force, ma capacité à faire le travail moi-même. Et l'enthousiasme ambiant ne prouve rien : 97 % des responsables data interrogés reconnaissent peiner à démontrer le ROI de leurs projets d'IA générative (étude CDO Insights 2025, Informatica, janvier 2025). D'où un outil qui cherche, souligne et se tait.
Une mémoire pour l'auteur
Le résultat tient en deux boutons dans l'éditeur de billets de l'admin.
Pour creuser
WebMCP : exposer un site aux agents
Le premier, « Billets liés (corpus) », prend le brouillon en cours (même pas sauvegardé : l'éditeur expose son état en continu) et le compare aux billets publiés. En retour, des cartes : titre, pourcentage de proximité, extrait, date, lien. Zéro appel à un modèle génératif : c'est de la recherche sémantique pure, et elle répond à la première question. Si un billet ressort à 80 %, soit je m'apprête à le réécrire, soit je tiens un lien interne tout trouvé.
Le second, « Détecter un revirement », va plus loin. Les cinq billets les plus proches sont relus par le modèle avec une mission : trouver une position que le brouillon inverse. Chaque revirement détecté s'affiche en carte, la phrase du brouillon face à la phrase du billet passé, avec sa date de publication et une explication. En quelque sorte un correcteur orthographique pour mes opinions : il souligne, il ne corrige pas.
Il ne corrige pas, et c'est un choix de design. Aucun des deux boutons n'écrit dans le contenu. Soit j'assume le revirement dans le texte (« je défendais l'inverse ici, voilà ce qui a changé »), soit il devient la matière d'un futur postmortem. L'outil informe l'auteur ; l'auteur reste seul à écrire.
Le même retrieval est aussi exposé en outil MCP read-only, pour interroger le corpus depuis n'importe quel client compatible pendant les phases de travail qui ne se passent pas dans l'admin. Côté coût : le premier bouton ne consomme au pire qu'un embedding de requête, le second un appel génératif par clic, dans un budget global partagé de 20 appels par minute pour toutes les features IA de l'admin. Un geste ponctuel avant relecture, pas un compagnon qui commente chaque frappe.
L'architecture par soustraction
Voilà pour l'usage. L'architecture, maintenant : elle s'explique mieux par ce qu'elle n'a pas. Face au montage canonique de la démo, couche par couche :
| Couche canonique | La démo | Ce blog | Pourquoi |
|---|---|---|---|
| Store | pgvector dédié | l'index Meilisearch existant | il servait déjà la recherche et la reco |
| Vectorizer | embeddings calculés en PHP | le moteur appelle l'embedder lui-même au push | zéro plomberie vectorielle applicative |
| Indexer + chunking | loader RSS + TextSplitTransformer | un template d'indexation tronqué | on cherche un billet, pas un paragraphe |
| Agent + tool-calling | le modèle boucle sur un outil | pipeline déterministe retrieve → generate | coût borné, latence bornée |
Le blog avait déjà un moteur de recherche, Meilisearch, qui indexait les billets pour la page de recherche et la recommandation. Or Meilisearch sait gérer un embedder déclaré en configuration : au push d'un document, c'est lui qui appelle l'API d'embedding, stocke le vecteur et sert les requêtes de similarité. Le store, le vectorizer et l'indexer du montage canonique existaient déjà chez moi, à condition d'accepter leur forme :
Pour aller plus loin
Recommandations de contenu avec Meilisearch
// MeilisearchIndexConfigurator : l'embedder est déclaré côté Meilisearch,
// qui appelle l'API d'embedding lui-même au push. Le template est un
// chunking du pauvre : titre, description, début du corps.
'documentTemplate' => '{{ doc.title }}. {{ doc.seoDescription }} {{ doc.blocksText | truncate: 2000 }}',Le retrieval a deux modes, selon que le billet de référence est déjà indexé ou encore à l'état de brouillon :
// PostCandidatesSearch. Billet publié : similarité doc-à-doc sur les
// vecteurs stockés (zéro embedding à la volée). Brouillon : recherche
// hybride sur le texte courant, avec un seuil de pertinence.
$params = [
'filter' => 'status = "online"',
'rankingScoreThreshold' => 0.5,
'hybrid' => ['embedder' => 'gemini', 'semanticRatio' => 0.9],
];Et l'agent ? Supprimé aussi. Un agent décide lui-même quand appeler ses outils, ce qui rend le coût et la latence imprévisibles. Ici le pipeline est écrit en dur : chercher les candidats, puis un seul appel génératif avec une sortie structurée, puis vérifier. S'il n'y a aucun candidat, l'appel n'a pas lieu. De l'écosystème Symfony AI, il reste le composant Platform, qui porte cet appel et l'embedding de requête. Quatre couches sur cinq faisaient doublon avec l'existant ; je les ai dévissées.
Cette frugalité a un prix, et autant l'écrire ici que le laisser deviner. Trois angles morts, assumés. L'embedding d'un billet ne voit que ses 2 000 premiers caractères : un revirement enterré au caractère 6 000 d'un billet fleuve échappe au radar. La similarité rapproche des sujets, pas des positions : contredire un principe énoncé dans un billet au thème éloigné passera entre les mailles. Et le brouillon est résumé à ses 1 000 premiers caractères pour la requête. Quant aux chiffres de qualité du retrieval, il n'y en a pas encore : la validation sur brouillons réels est la prochaine étape, et ce billet n'ira pas plus loin que ce qu'il a mesuré, c'est-à-dire rien.
Des citations vérifiées au caractère près
La partie dont je suis le plus content ne se voit pas dans l'interface. Le détecteur de revirement affiche des citations : une phrase de mon brouillon, une phrase d'un billet publié. Or un modèle génératif adore « améliorer » une citation en la reformulant. Une fausse citation attribuée à un billet publié, vérifiable par n'importe quel lecteur, c'est le même problème de crédibilité que le chatbot écarté plus haut, en plus sournois.
La consigne du prompt exige des citations mot pour mot. Mais une consigne reste un vœu : c'est le code qui tranche. Chaque citation retournée est vérifiée par substring contre son texte source, après normalisation de ce qu'un modèle abîme volontiers (apostrophes typographiques, espaces insécables, guillemets d'encadrement, casse). Ce qui ne matche pas est jeté, silencieusement :
// ContradictionSanitizer (extrait) : une citation reformulée est rejetée.
if (!str_contains($draftHaystack, $draftClaim)) {
continue;
}
$postHaystack = $this->normalize($candidate->post->title . "\n" . $candidate->post->plainTextContent);
if (!str_contains($postHaystack, $pastClaim)) {
continue;
}Mieux vaut zéro revirement affiché qu'une citation inventée. La démo officielle confie cette garantie à une phrase de system prompt ; ici la phrase existe aussi, mais elle a un huissier.
Le mot de la fin
Le réflexe de l'industrie, le mien compris, est d'empiler : un store vectoriel, un agent, une couche d'orchestration, et le RAG est monté. J'en ai la preuve horodatée : l'idée de ce billet, notée le 2 juillet dans mon backlog, s'appelait encore « pgvector + Symfony AI Store ». La stack complète, avant la question du besoin. Neuf jours plus tard, aucune de ces briques n'avait survécu. La question qui m'a rendu service était inverse : combien de couches est-ce que je peux retirer avant que ça cesse de marcher ? Réponse pour ce besoin précis : presque toutes.
Et puis il y a ce que ce montage dit du reste. Un corpus de blog est une trace publique de ce qu'on a pensé, mois après mois. Le relire à la main est impossible ; ne pas le relire, c'est s'exposer à se contredire devant témoins. L'outillage minimal pour tenir cette promesse existait déjà dans ma stack : il suffisait de le pointer vers moi.
Reste une certitude inconfortable : ce détecteur finira par me prendre en flagrant délit. Une carte rouge, une phrase de 2025 face à une phrase de la veille, et l'obligation d'en faire quelque chose. Ce jour-là, vous lirez le postmortem ici même. La seule question ouverte, c'est combien de billets je tiendrai d'ici là.
Une coquille, une erreur dans ce billet ? Signale-la-moi.