Lección 4 — Tester, évaluer, exploiter

Évaluer et enrichir la FAQ

6 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

Évaluer et enrichir la FAQ

flowchart LR
  G["golden.json : 9 couvertes + 4 hors périmètre"] --> R["1 appel d'embedding : rappel@3"]
  G --> A["agent.ask x 13"]
  A --> J["LLM-juge : fidélité"]
  A --> RF["refus corrects / faux refus"]
  J --> L["lire les NON FIDÈLE"]
  L --> I["corriger l'instruction"]
  I --> A
  A --> LOG["questions_non_couvertes.jsonl"]
  LOG --> RP["faq_rag.py rapport"]
  RP --> F["nouvelles entrées FAQ"]

1. Les tests sans clé

test_faq.py remplace les embeddings par un sac de mots haché et le modèle par des fonctions lambda. Il vérifie les garde-fous un par un :

files/test_faq.py (extrait)
def test_repondre_garde_fous(idx, tmp_path):
    log = tmp_path / "log.jsonl"
    q = "Combien coûte la livraison ?"
    bon = faq_rag.repondre(idx, q, lambda p: "4,90 euros, gratuit dès 60 euros [FAQ-02].", 0.3, log)
    assert not bon["refus"] and "FAQ-02" in bon["ids"]
    invente = faq_rag.repondre(idx, q, lambda p: "Gratuit pour tous [FAQ-99].", 0.3, log)
    assert invente["refus"] and invente["raison"] == "citation invalide"
    hors = faq_rag.repondre(idx, "tarte tatin pommes caramel", lambda p: 1 / 0, 0.5, log)
    assert hors["raison"] == "seuil"  # le modèle n'a même pas été appelé

Le faux modèle lambda p: 1 / 0 lèverait une erreur s'il était appelé : le test prouve que le seuil coupe avant la génération.

2. Les quatre métriques

MétriqueCalculCe qu'elle protège
rappel@3entrée attendue dans les 3 premiers résultats (avant seuil)la recherche trouve la bonne entrée
fidélitéjuge gemini-3.5-flash-lite : OUI si tout est soutenu par les entrées citéespas d'invention dans les réponses données
refus correctsrefus / questions hors périmètrepas de réponse inventée hors FAQ
faux refusrefus / questions couvertesl'assistant reste utile

Un réglage donne refus corrects = 1,0 et faux refus = 0,6. Que s'est-il probablement passé ?

3. Mesurer, lire, corriger

terminal
python -m pytest -q
python eval.py --calibrer     # 1 appel
python eval.py                # agent : environ 45 appels (13 x 2 pour l'agent, 9 jugements, embeddings)
python eval.py --chaine       # chaîne simple, moitié moins d'appels (non mesurée dans ce lab)

La première exécution réelle (instruction à 4 règles) a donné :

{"mode": "agent", "seuil": 0.71, "rappel@k": 1.0, "fidelite": 0.778, "refus_corrects": 1.0, "faux_refus": 0.0}

Deux réponses sur neuf ajoutaient une déduction absente de la FAQ, par exemple : « Les vélos sont livrés en 5 à 8 jours ouvrés […] [FAQ-01]. Il est donc tout à fait normal… ». Plausible, mais la FAQ ne le dit pas. D'où les règles 5 et 6 de l'instruction (pas de déduction, ignorer les résultats hors sujet).

La fidélité passe de 0,778 à 0,889 après modification de l'instruction. Que peut-on affirmer honnêtement ?

Résultat obtenu

Deuxième exécution réelle du 2026-10-15 (instruction à 6 règles, gemini-3.5-flash, seuil 0,71) :

$ python -m pytest -v   →   7 passed in 0.66s

$ python eval.py
[ok] Je n'ai toujours pas reçu mon vélo, c'est normal au bout de 6 jours ?
        -> Les vélos sont livrés en 5 à 8 jours ouvrés en France métropolitaine [FAQ-01].
[NON FIDÈLE] Vous expédiez à Genève ?
        -> Nous ne livrons pas en Suisse pour le moment [FAQ-03]. Nous livrons uniquement en Belgique
           et au Luxembourg [FAQ-03].
[ok] Le voyant de ma batterie reste éteint quand je la branche
        -> Vérifiez le chargeur sur une autre prise. Si le voyant reste éteint, ouvrez une demande de
           garantie avec le numéro de série gravé sous la batterie [FAQ-09].
...
[REFUS] Proposez-vous une assurance contre le vol ?
        -> Je ne sais pas. Je vous invite à poser votre question via notre formulaire de contact.
{"mode": "agent", "seuil": 0.71, "rappel@k": 1.0, "fidelite": 0.889, "refus_corrects": 1.0, "faux_refus": 0.0}

$ python faq_rag.py rapport
  1 x vendez vous des trottinettes électriques
  1 x proposez vous la location de vélos au mois
  1 x proposez vous une assurance contre le vol
  1 x quelle est la recette de la tarte tatin

Le juge a eu raison sur Genève : « uniquement en Belgique et au Luxembourg » est faux (la boutique livre aussi en France). La citation était valide, l'affirmation non : la vérification des citations ne remplace pas la mesure de fidélité.

4. Enrichir la FAQ

Le rapport regroupe les questions par texte normalisé (minuscules, ponctuation retirée) et les trie par fréquence. Chaque semaine :

  1. lire le haut du rapport, écarter le hors-sujet (tarte tatin) ;
  2. écrire une entrée pour les vraies demandes (FAQ-17 : « Proposez-vous la location ? ») ;
  3. relancer eval.py --calibrer : les questions passent de « hors périmètre » à « couverte » dans golden.json, et le seuil peut bouger.

Exercice : corriger le cas Genève

Proposez une modification de la FAQ (pas du code) qui rend la mauvaise réponse sur Genève moins probable, et dites comment vérifier que ça marche.

Voir la solution

Le mot « uniquement » vient d'une lecture trop rapide de FAQ-03, qui ne parle que de l'étranger. Rendez l'entrée explicite :

files/faq.json (FAQ-03 réécrite)
{"id": "FAQ-03", "categorie": "livraison", "question": "Livrez-vous à l'étranger (Belgique, Suisse) ?",
 "reponse": "En plus de la France métropolitaine, nous livrons en Belgique et au Luxembourg (7 à 10 jours ouvrés). Nous ne livrons pas en Suisse pour le moment."}

Vérification : FAQIndex voit que faq.json a changé (empreinte) et recalcule l'index ; relancez python eval.py --calibrer puis python eval.py, et lisez la ligne de Genève. Lancez-le deux ou trois fois : une seule exécution réussie ne prouve pas que le problème a disparu.

Je sais faire

  • Prouver par un test qu'un garde-fou coupe avant l'appel au modèle
  • Lire ensemble refus corrects et faux refus
  • Améliorer une instruction à partir des réponses jugées non fidèles, et rester prudent sur l'interprétation
  • Transformer le journal des questions non couvertes en nouvelles entrées de FAQ