Lunover Engineering Notes

Ce qu’on a appris en construisant notre propre widget de support (chat avec votre contenu)

Un teardown technique de ce qui compte vraiment quand on construit un widget de support : modes d’échec de la retrieval, navigation “docs comme des fichiers”, cache, contrôle d’accès et garde-fous pour des réponses fiables.

June 18, 2025By LunoverWork with us

Ce qu’on a appris en construisant notre propre widget de support (chat avec votre contenu)

Nous avons construit notre propre widget de support : une bulle de chat à intégrer sur un site pour que les visiteurs posent des questions et obtiennent des réponses à partir de votre contenu (docs, base de connaissance, pages services, politiques). Si vous en avez déjà livré un, vous savez que la promesse est simple et la réalité, beaucoup moins. Les problèmes que vous rencontrez sont rarement des « problèmes de modèle ». Ce sont des problèmes d’ingénierie :
  • la retrieval casse de manière prévisible
  • la latence détruit la confiance avant même le premier token
  • le contrôle d’accès est facile à rater
  • l’injection de prompt devient un « empoisonnement du contenu »
  • les réponses doivent être auditables, sinon vous créez un risque support
Ce billet est un teardown détaillé : architecture, modèle de données, stratégie de retrieval, design des outils, cache, RBAC, patterns UI, et garde-fous qui ont rendu notre widget fiable.

Ce qu’on construisait (exigences et contraintes)

On a défini quelques exigences non négociables dès le départ :
  • Fast first token : l’utilisateur doit voir une réponse rapidement, même si l’assistant doit « chercher » en arrière-plan.
  • Réponses ancrées : les réponses doivent être supportées par le contenu que l’on expédie réellement.
  • Read-only par défaut : l’assistant ne doit pas modifier le contenu.
  • Multi-locale : notre site est localisé ; l’assistant ne doit pas mélanger les langues au hasard.
  • Escalade sûre : quand il ne peut pas répondre, il doit orienter vers le contact/support sans halluciner.
À partir de ça, on a dérivé une définition simple du succès :
L’assistant doit se comporter comme un ingénieur prudent qui lit notre site, pas comme un écrivain créatif qui improvise.

Un modèle mental qui marche : « support bot = search + navigation + synthèse »

Un bon assistant support fait trois choses, dans cet ordre :
  1. Search : identifier des pages/sections candidates.
  2. Navigate : ouvrir des pages, scanner les titres, suivre des liens, affiner la recherche.
  3. Synthesize : produire une réponse courte et correcte, avec des pointeurs vers les sources utilisées.
La plupart des implémentations ne font que (1) et (3). Elles sautent (2), et c’est là que la précision s’effondre.

Leçon 1 : un RAG “chunks only” échoue quand la réponse s’étale sur plusieurs pages

La retrieval Top-K de chunks casse toujours de la même manière :
  • la réponse est répartie sur deux pages
  • la « syntaxe exacte » est dans un bloc de code qui n’a pas remonté
  • la bonne réponse est sur une page avec un faible recouvrement lexical
  • la question est trop vague, donc le match embedding est « proche »
Quand le modèle ne voit que quelques chunks, il peut paraître confiant tout en étant incomplet. L’expérience utilisateur ressemble à :
  • le ton est bon
  • les détails sont faux
  • aucune manière de vérifier
Notre conclusion : la retrieval ne suffit pas. Il faut de l’exploration.

Leçon 2 : donner à l’assistant des primitives déterministes (docs comme des fichiers)

Les humains ne répondent pas à une question doc en lisant cinq paragraphes pris au hasard. On :
  • find la page
  • l’open
  • scanne les titres
  • cherche des tokens exacts
  • suit des liens internes
  • répète jusqu’à pouvoir prouver la réponse
On a donc façonné l’interface d’outils de l’assistant comme un mini filesystem au-dessus de notre contenu :
  • chaque page est un « fichier »
  • les dossiers représentent des sections (ou des segments de chemin d’URL)
  • l’assistant peut ls, find, cat et grep
Ce n’est pas un gimmick. Ça donne au modèle des primitives :
  • auditables (vous pouvez logguer les appels d’outils)
  • déterministes (même requête, mêmes lectures)
  • scalables (il peut explorer sans que vous codiez des flows à la main)

Architecture (la version la plus simple qui marche)

À haut niveau, on s’est retrouvé avec trois couches :
Browser widget
  -> /api/support-chat (stream)
      -> Retrieval layer (lexical + vector)
      -> Tool layer ("filesystem" over content)
      -> Answer synthesis (grounded prompt + citations)
      -> Observability (logs + traces + eval hooks)
Le point clé : on traite la « lecture de notre contenu » comme un outil, pas comme un effet secondaire de la retrieval.

Modèle de données : pages, chunks et un arbre de chemins

On a volontairement gardé une représentation du contenu très simple :
  • page : URL canonique/slug + titre + locale + visibilité + lastmod + plan de titres
  • chunk : { page, chunk_index, text, embedding, tokens, hash }
  • path_tree : { "services/seo": { isPublic: true, groups: [] }, ... }
Pourquoi un arbre de chemins ?
  • l’assistant a besoin d’une carte de « ce qui existe »
  • le contrôle d’accès devient structurel (on prune les chemins avant la session)
  • ls/find devient rapide et cacheable

Pipeline d’ingestion : transformer un site en surface de connaissance fiable

C’est ce qui a pris le plus de temps. « Indexer vos docs » paraît simple jusqu’à ce qu’on voie à quoi ressemble du vrai contenu :
  • copy marketing + composants
  • MDX et titres qui ne se mappent pas proprement à l’HTML
  • pages de navigation qui répètent des blocs
  • variantes de locale qui divergent partiellement
Notre pipeline d’ingestion fait :
  1. Discover pages : sitemap + liste de routes connues.
  2. Fetch rendered HTML : ce que les utilisateurs et les crawlers voient vraiment.
  3. Extract main content : retirer nav, footers, bannières cookies, UI répétée.
  4. Normalize : compacter les espaces, retirer les query strings de tracking des liens.
  5. Segment :
    • un enregistrement au niveau page pour navigation et citations
    • des enregistrements au niveau chunk pour la retrieval
  6. Annotate :
    • locale
    • visibilité (public, client-only, internal)
    • outline de titres
    • liens internes sortants
Leçon principale : indexer le site rendu, pas seulement le repo source, sinon vous manquez ce que l’utilisateur voit réellement et vous finissez par citer du contenu qui n’existe pas en production.

Retrieval : combiner lexical et vector avant de « demander au modèle »

On ne fait pas confiance à une seule stratégie de retrieval. On fait :
  • recherche lexicale pour tokens exacts (identifiants, acronymes, codes d’erreur)
  • recherche vectorielle pour match sémantique (questions vagues)
Puis on fusionne les candidats et on applique quelques checks :
  • rejeter les pages qui ne correspondent pas à la locale utilisateur (sauf demande explicite)
  • préférer les pages canoniques aux pages tag / listings dupliqués
  • préférer les pages avec un bon recouvrement entre titres et termes de la requête
Seulement ensuite, on laisse l’assistant ouvrir des pages et synthétiser.

Design des outils : ce que l’assistant peut faire (et ce qu’il ne peut pas)

On a implémenté une surface d’outils petite et stricte : Autorisé :
  • lister des dossiers : ls /services
  • chercher des chemins : find -name "billing"
  • lire une page entière : cat /services/seo
  • chercher dans le contenu : grep -ri "canonical" /
Interdit :
  • écrire ou éditer du contenu
  • fetcher des URLs arbitraires
  • faire des appels réseau arbitraires
Le gain sécurité est énorme : ça transforme « l’injection de prompt » en problème de qualité de contenu, pas en compromission système.

Latence : un vrai filesystem est trop lent pour un chat interactif

Si vous démarrez un sandbox/container par session pour fournir un vrai filesystem :
  • le cold start devient visible
  • le chat donne l’impression d’être cassé
  • vous êtes tenté d’ajouter des complexités type warm pools
Dans un widget support, l’utilisateur est face à l’UI. Vous voulez des outils instantanés. On a donc virtualisé les opérations filesystem au-dessus de l’index :
  • ls et find se résolvent via l’arbre de chemins en cache
  • cat recompose la page depuis les chunks stockés (triés par chunk_index)
  • les résultats sont cachés par session (et en partie globalement, quand c’est safe)
L’assistant a l’illusion d’un shell sur des fichiers, mais il n’y a aucun fichier réel.

Mettre en cache ce qui se répète : arbre, pages et « cibles de grep »

Mettre en cache des « réponses » est fragile, parce que les questions varient. Ce qui se répète dans les vraies conversations :
  • lister les mêmes sections
  • ouvrir les mêmes 5-10 pages importantes
  • greper les mêmes tokens
On a donc caché :
  • l’arbre de chemins
  • les pages reconstruites
  • des candidats de grep récents
Ça a compté plus que d’optimiser des embeddings au micro niveau, parce que ça améliore la vitesse des follow-ups et réduit les boucles « searching… ».

RBAC : le contrôle d’accès doit être structurel, pas prompt-based

Si certaines docs ne sont pas publiques (brouillons, notes internes, docs client-only), vous ne pouvez pas vous appuyer sur le prompting. On a appliqué le RBAC avant le moindre tool call :
  • construire un arbre de chemins scoped à l’utilisateur
  • pruner tout ce qu’il ne peut pas accéder
  • appliquer le même filtre à chaque requête et lecture de page
Si l’assistant ne peut pas ls un fichier, il ne peut pas le cat, et il ne peut pas le citer. C’est le seul modèle mental qui tient sous pression.

Garde-fous : comment on a arrêté les réponses confiantes mais fausses

Quelques règles ont réduit fortement les mauvaises réponses :

Règle 1 : pas de preuve, pas de réponse

Si l’assistant ne trouve pas de contenu support avec les outils, il doit :
  • dire qu’il n’a pas pu vérifier
  • montrer ce qu’il a vérifié (pages ou sections)
  • proposer une prochaine étape (contact/support)

Règle 2 : préférer citer plutôt que paraphraser pour les détails critiques

Quand la réponse dépend de la formulation exacte (exigences, limitations, mentions légales) :
  • citer la/les phrase(s) pertinente(s) depuis la page lue
  • garder la synthèse minimale

Règle 3 : être strict sur la locale et les pages canoniques

Si l’utilisateur est sur une route de locale, l’assistant doit :
  • préférer le contenu de cette locale
  • éviter de mélanger les langues dans une seule réponse
  • revenir à la locale par défaut uniquement si la page de la locale n’existe pas

Le « problème grep » : la killer feature a besoin d’un plan en deux phases

Un grep récursif naïf est lent s’il lit tout sur le réseau. On a utilisé une approche en deux phases :
  1. coarse filter via l’index (quelles pages pourraient contenir le token)
  2. fine filter en mémoire sur le texte page en cache pour extraire les matches exacts + contexte
Ça donne l’impression que l’assistant cherche comme un dev, pas qu’il devine comme un chatbot.

UX du widget : l’UI fait ou défait la confiance

On a livré plusieurs itérations UI avant que le widget paraisse fiable. Ce qui a le plus compté :
  • streaming de tokens avec un layout stable (éviter le reflow “jumpy”)
  • états clairs :
    • « Searching… »
    • « Reading page… »
    • « Answering… »
  • citations courtes dans la conversation :
    • « From: /services/seo »
    • « From: /legal/privacy »
  • fallbacks qui ne ressemblent pas à un échec :
    • « J’ai vérifié X et Y mais je n’ai pas pu confirmer ; voici comment nous contacter. »
Ça se combine bien avec une UI qui rend le “travail” visible. Les utilisateurs pardonnent beaucoup plus un bot qui dit « je n’ai pas pu confirmer » qu’un bot qui invente une réponse confiante.

Observability : logger les lectures, pas seulement les tokens

Pour améliorer la qualité, il faut savoir ce qui s’est passé. On a loggué :
  • tool calls (chemins listés/lus/recherchés)
  • quelles pages ont servi de preuves
  • longueur de réponse et buckets de latence
  • taux de « could not verify »
  • taux d’escalade (clics contact)
Ça permet de répondre à des questions concrètes :
  • quelles pages manquent d’informations importantes ?
  • quelles questions ne trouvent jamais de preuve ?
  • quelles pages créent de la confusion et doivent être restructurées ?

Une checklist pragmatique (ce qu’on referait)

Si vous construisez un widget support qui chatte avec votre contenu, on commencerait par :
  • indexer le site rendu et stocker des enregistrements au niveau page
  • combiner retrieval lexicale + vectorielle
  • ajouter une couche d’exploration (files + grep)
  • pruner le contenu via RBAC avant le début des sessions
  • garder les outils read-only par défaut
  • ajouter une règle « pas de preuve, pas de réponse »
  • rendre “searching/reading/answering” visible dans l’UI
  • logguer les tool calls pour débugger les échecs
Si vous voulez qu’on vous aide à implémenter ça (widget + indexation + architecture sûre) :