Toutes les veilles

Cas technique / RAG & LLM

Un RAG en production, sans base vectorielle.

L'assistant en bas à droite répond aux questions sur mon parcours. Je l'ai conçu en PHP pour un hébergement mutualisé, avec un index JSON de 535 Ko, sans base vectorielle, framework d'orchestration ni serveur Node. Cette page détaille son architecture et mes choix techniques.

Le problème : un CV ne répond pas aux questions

Un recruteur peut chercher une information précise : ma disponibilité, ma mobilité, mon travail chez Eureden ou les bibliothèques que j'utilise. Un CV oblige à parcourir plusieurs sections pour trouver la réponse.

J'ai donc construit un assistant capable de répondre directement, à toute heure. Je lui impose une contrainte non négociable : il ne doit rien inventer sur mon parcours. Un modèle de langage seul hallucine des diplômes et des dates. Le RAG (Retrieval Augmented Generation) répond précisément à ce risque : le modèle n'a pas à savoir. Il reçoit les bons extraits et rédige sa réponse à partir de ceux-là.

Pourquoi pas LangChain et une base vectorielle

J'ai écarté LangChain et les bases vectorielles managées après avoir regardé ce dont ce projet avait réellement besoin.

  • La contrainte d'hébergement : le site tourne sur un hébergement mutualisé, PHP et fichiers statiques, sans Node ni service persistant. Une base vectorielle imposait un service externe de plus.
  • La taille du corpus : mon profil tient en 20 chunks. Un index vectoriel approché (HNSW, IVF) sert à éviter de comparer la question à des millions de vecteurs. Sur 20, la recherche exhaustive est instantanée. Sortir une base vectorielle ici, c'est de la sur-ingénierie.
  • La dépendance : je préfère ici garder peu de couches, donc peu de points de panne, et limiter le coût aux appels API.

J'ai préféré consacrer le travail à la qualité du retrieval. C'est là que se joue la pertinence des réponses.

L'architecture, de la question à la réponse

Question du visiteur 500 caractères maximum, historique des 6 derniers messages
Embedding de la question mistral-embed, vecteur de 1024 dimensions
Recherche hybride BM25 (mots exacts) fusionné avec cosinus (sens), sur les 20 chunks
Reranking MMR 10 candidats re-triés en 4 chunks pertinents et non redondants
Construction du prompt Consignes système, 4 chunks préfixés de leur catégorie, historique
Génération streamée mistral-small-latest, 400 tokens maximum, affichée au fil de l'eau

Le pipeline complet, exécuté à chaque question

Indexer un profil : 20 chunks, 10 catégories

Le corpus est écrit à la main, en fiches courtes, une idée par fiche. Chaque chunk porte une catégorie en métadonnée, réinjectée dans le prompt : le modèle sait ainsi qu'un extrait parle de formation ou de projets, ce qui l'aide à structurer sa réponse.

NOMBRE DE CHUNKS Compétences 4 Projets 4 Formation 3 Présentation 2 Expérience 2 Poste recherché 1 Soft skills 1 Langues 1 Centres d'intérêt 1 Contact 1 1 2 3 4

Répartition du corpus : 20 chunks, 10 catégories

La taille des fiches compte autant que leur nombre. Un chunk trop long noie l'information utile et produit un vecteur flou. Trop court, il perd son contexte. J'ai donc gardé une fourchette resserrée :

78 car.
chunk le plus court
207 car.
médiane
552 car.
chunk le plus long

Taille des chunks, en caractères

Un script Python calcule les embeddings hors ligne, puis les enregistre dans embeddings.json. À l'exécution, PHP charge ce fichier et effectue un seul appel réseau pour l'embedding de la question. L'indexation et la recherche utilisent le même modèle d'embedding, afin de comparer des vecteurs placés dans le même espace.

Recherche hybride : le sens ne suffit pas

La recherche purement vectorielle capture le sens, mais elle rate parfois le mot exact. C'est un angle mort gênant pour ce corpus. Si l'on demande « c'est quoi Digicrop ? », le mot Digicrop n'a pas de voisin sémantique utile, il faut le retrouver littéralement. À l'inverse, « où a-t-il travaillé ? » ne contient aucun mot du corpus, alors que la question correspond bien à la fiche Expérience.

Le système exécute donc deux moteurs en parallèle, puis fusionne leurs résultats :

BM25 · mots exacts

« c'est quoi Digicrop ? »

Pondère les mots rares (IDF), sature les répétitions (k1 = 1,5) et normalise par la longueur du chunk (b = 0,75). Retrouve le terme littéral.

Cosinus · sens

« où a-t-il travaillé ? »

Compare le vecteur de la question à ceux des chunks. Retrouve la bonne fiche même sans aucun mot en commun.

Fusion des deux scores

Un chunk bien classé par l'un ou par l'autre remonte. Les 10 meilleurs candidats passent à l'étape suivante.

Recherche hybride : deux signaux complémentaires, une seule liste de candidats

Le reranking MMR : éviter de répéter la même chose

Garder simplement les 4 meilleurs candidats bruts ne me convenait pas : ils se ressemblent souvent. Sur une question à propos de mes projets, les 4 premiers chunks peuvent tous venir de la catégorie Projets et répéter la même information sous trois angles. Le modèle reçoit alors du contenu redondant et gaspille son budget de contexte.

Le MMR (Maximal Marginal Relevance) corrige cela : il sélectionne les chunks un par un, en pénalisant à chaque tour ceux qui ressemblent trop à ceux déjà retenus. Le paramètre lambda règle le curseur entre pertinence pure et diversité. Le mien est à 0,8 : nettement orienté pertinence, avec juste ce qu'il faut de diversité pour éviter les doublons.

Top 4 brut, sans MMR
Projets · Digicrop Projets · Digicrop (variante) Projets · MilkCast Projets · MLOps
Après MMR, lambda = 0,8
Projets · Digicrop Compétences · ML Expérience · Eureden Projets · MLOps

Illustration du principe : à gauche quatre extraits redondants, à droite une vue complète du sujet

Génération : cadrer le modèle

Les 4 chunks retenus sont assemblés dans un prompt système d'environ 2 850 caractères (à peu près 700 tokens) qui fixe les règles : répondre uniquement à partir des extraits fournis, dire clairement quand l'information n'y est pas, rester dans le périmètre du parcours professionnel, et respecter les conventions typographiques du site.

Deux mécanismes complètent la génération :

  • La mémoire de conversation : une fenêtre glissante des 6 derniers messages est renvoyée à chaque tour. C'est ce qui permet de comprendre « et à Lyon ? » juste après une question sur la disponibilité.
  • Le streaming : la réponse de Mistral arrive en flux (SSE), qui est réémis vers le navigateur et affiché au fil de l'eau. La compression gzip est désactivée pour cette réponse, sinon le tampon casse le streaming. Le premier mot s'affiche vite, l'attente perçue s'effondre.

Les garde-fous : un service public, donc exposé

Un chatbot ouvert sur Internet et branché sur une API payante, c'est une facture potentielle laissée à la discrétion des visiteurs. Plusieurs limites encadrent le service.

Garde-fouValeurCe qu'il protège
Limite par visiteur20 / 10 minEmpêche un envoi en boucle depuis une même adresse.
Plafond quotidien500 / jourBorne le coût maximal d'une journée, tous visiteurs confondus.
Longueur de réponse400 tokensPlafonne le coût par requête et garde des réponses lisibles.
Longueur de question500 caractèresRejette les prompts massifs destinés à noyer les consignes.
Filtre de détournementpar motifsRefuse les demandes de génération de code : l'assistant n'est pas un LLM gratuit.
Délais réseautimeoutsUn fournisseur lent ne bloque pas indéfiniment un worker PHP.

Les compteurs de limitation utilisent une empreinte SHA-256 de l'adresse IP, jamais l'adresse en clair. L'en-tête du chatbot mentionne aussi Mistral AI comme sous-traitant, avec davantage de détails dans la politique de confidentialité.

Ce que je ferais différemment à plus grande échelle

Cette architecture me paraît juste pour ce contexte précis. Elle a aussi une limite nette : elle ne tiendrait pas telle quelle à une autre échelle.

  • Au delà de quelques milliers de chunks, la recherche exhaustive en PHP et le chargement intégral du JSON deviendraient coûteux : il faudrait un véritable index vectoriel (HNSW) et un service dédié.
  • Le corpus est écrit à la main, ce qui garantit sa qualité mais ne passe pas à l'échelle. Sur un corpus documentaire, il faudrait un découpage automatique avec recouvrement, et surtout une évaluation du retrieval (recall@k) pour régler la taille des chunks autrement qu'au jugé.
  • Il n'y a pas encore de jeu d'évaluation : une vingtaine de questions de référence avec les chunks attendus permettrait de mesurer objectivement l'effet du lambda du MMR ou du poids relatif de BM25, au lieu de s'en remettre à l'impression.

J'ai dimensionné l'architecture pour le volume actuel du corpus. Le retrieval hybride et le reranking apportent un gain mesurable, sans imposer une infrastructure plus lourde.

Essayez-le. L'assistant est en bas à droite de cette page. Posez-lui une question sur mon parcours, mes projets ou le poste que je recherche. S'il ne sait pas, il vous le dira, et c'est exactement ce qu'on lui demande.