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
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. 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 :
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 :
« 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
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.
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-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. |
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.