Le problème : un CV ne répond pas aux questions
Un recruteur qui arrive sur un portfolio a des questions précises, et rarement le temps de lire trois pages pour y répondre : est-il disponible quand ? à Lyon ou ailleurs ? il a fait quoi exactement chez Eureden ? il connaît PyTorch ou seulement scikit-learn ? Un CV, aussi bien rangé soit-il, oblige à chercher.
D'où l'idée d'un assistant qui répond directement, à toute heure. Avec 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. C'est exactement le problème que résout le RAG (Retrieval Augmented Generation) : on ne demande pas au modèle de savoir, on lui donne les bons extraits et on lui demande de rédiger à partir de ceux-là.
Pourquoi pas LangChain et une base vectorielle
La réponse réflexe à « RAG » en 2026, c'est LangChain plus une base vectorielle managée. J'ai écarté cette voie pour trois raisons, et elles sont toutes liées au contexte réel du projet.
- 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 : moins de couches, moins de choses qui cassent, et un coût qui reste celui des seuls appels API.
Le vrai travail d'ingénierie ne s'est donc pas joué sur l'infrastructure, mais sur la qualité du retrieval : c'est là que se gagne ou se perd la pertinence d'un RAG.
L'architecture, de la question à la réponse
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.
Répartition du corpus : 20 chunks, 10 catégories
La taille des fiches compte autant que leur nombre. Trop long, le chunk noie l'information utile dans du bruit et le vecteur devient flou. Trop court, il perd son contexte. Les miennes tiennent dans une fourchette resserrée :
Taille des chunks, en caractères
Les embeddings sont calculés une fois pour toutes, hors ligne, par un script Python, puis sérialisés dans embeddings.json. À l'exécution, PHP charge ce fichier et ne fait plus qu'un seul appel réseau pour l'embedding de la question. Règle d'or respectée : le même modèle d'embedding à l'indexation et à la recherche, sinon les vecteurs ne vivent pas dans le même espace et la similarité ne veut plus rien dire.
Recherche hybride : le sens ne suffit pas
La recherche purement vectorielle a un angle mort bien connu : elle capture le sens, mais rate parfois le mot exact. 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. Inversement, « où a-t-il travaillé ? » ne contient aucun mot du corpus, mais correspond bien à la fiche Expérience.
D'où deux moteurs, exécutés en parallèle puis fusionnés :
« 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.
« 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.
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
Prendre les 4 meilleurs candidats bruts a un défaut : ils se ressemblent souvent. Sur une question à propos de mes projets, les 4 premiers chunks peuvent tous venir de la catégorie Projets et raconter la même chose sous trois angles. Le modèle reçoit alors une information redondante, et le budget de contexte est gaspillé.
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.
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 détails qui changent l'expérience réelle :
- 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-fou | Valeur | Ce qu'il protège |
|---|---|---|
| Limite par visiteur | 20 / 10 min | Empêche un envoi en boucle depuis une même adresse. |
| Plafond quotidien | 500 / jour | Borne le coût maximal d'une journée, tous visiteurs confondus. |
| Longueur de réponse | 400 tokens | Plafonne le coût par requête et garde des réponses lisibles. |
| Longueur de question | 500 caractères | Rejette les prompts massifs destinés à noyer les consignes. |
| Filtre de détournement | par motifs | Refuse les demandes de génération de code : l'assistant n'est pas un LLM gratuit. |
| Délais réseau | timeouts | Un fournisseur lent ne bloque pas indéfiniment un worker PHP. |
Côté données personnelles, deux principes. Les compteurs de limitation sont indexés sur une empreinte SHA-256 de l'adresse IP, jamais sur l'IP en clair. Et le recours à Mistral AI comme sous-traitant est annoncé directement dans l'en-tête du chatbot, avec le détail dans la politique de confidentialité. Un assistant qui parle de RGPD dans ses réponses se doit d'être exemplaire sur le sujet.
Ce que je ferais différemment à plus grande échelle
Cette architecture est taillée pour son contexte, et il faut savoir dire où elle cesserait de tenir.
- 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.
C'est le compromis que je défends : la sophistication doit se placer là où elle produit un effet mesurable, ici le retrieval hybride et le reranking, pas dans une pile d'outils que le volume de données ne justifie pas.
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.