Lesson 2 — Construire pas à pas

Index, seuil et journal

5 min read

This content is not translated yet. Showing the original version.

Index, seuil et journal

flowchart LR
  F["faq.json"] --> H["SHA-256 (FAQ + modèle + dimension)"]
  H --> C{"faq_index.npz à jour ?"}
  C -- "oui" --> V["vecteurs en cache"]
  C -- "non" --> E["16 embeddings, 1 appel"]
  E --> V
  Q["Question"] --> S["search : top-3 + scores"]
  V --> S
  S --> T{"score >= SEUIL ?"}
  T -- "aucun" --> J["log_non_couverte"]
  T -- "au moins un" --> OK["status ok + entrées gardées"]

1. Un index qui tient dans un fichier

Une entrée de FAQ est courte : inutile de la découper. Un morceau = une entrée, question et réponse ensemble (la question aide à retrouver, la réponse apporte les mots du client).

files/faq_rag.py
class FAQIndex:
    def __init__(self, faq_path=FAQ_PATH, index_path=INDEX_PATH, embed_fn=embed):
        self.embed_fn = embed_fn
        raw = Path(faq_path).read_bytes()
        self.entries = json.loads(raw)
        self.by_id = {e["id"]: e for e in self.entries}
        digest = hashlib.sha256(raw + f"{EMBED_MODEL}/{DIM}".encode()).hexdigest()
        cache = Path(index_path) if index_path else None
        if cache and cache.exists() and str(np.load(cache)["digest"]) == digest:
            self.vecs = np.load(cache)["vecs"]
        else:  # FAQ modifiée (ou modèle changé) : on recalcule tout, c'est petit
            self.vecs = embed_fn([f"{e['question']}\n{e['reponse']}" for e in self.entries])
            if cache:
                np.savez(cache, vecs=self.vecs, digest=digest)

L'empreinte inclut le nom du modèle et la dimension : changer EMBED_DIM invalide le cache automatiquement, au lieu de comparer des vecteurs incompatibles.

2. Rechercher avec un seuil

files/faq_rag.py
def rechercher(index, question, seuil=None, k=K, log_path=None):
    """Le cœur partagé par la chaîne simple et l'outil de l'agent."""
    seuil = SEUIL if seuil is None else seuil
    hits = index.search(question, k)
    gardes = [h for h in hits if h["score"] >= seuil]
    if not gardes:
        log_non_couverte(question, hits[0]["score"] if hits else None, log_path)
        return {"status": "hors_perimetre", "meilleur_score": hits[0]["score"] if hits else None}
    return {"status": "ok", "resultats": [
        {"id": h["id"], "question": h["question"], "reponse": h["reponse"], "score": h["score"]}
        for h in gardes]}

Le seuil ne garde pas seulement « oui ou non » : il filtre aussi les entrées faiblement liées, pour ne pas distraire le modèle. Le journal est un fichier JSONL (une ligne JSON par événement), facile à relire ou à charger dans un tableur :

files/faq_rag.py
def log_non_couverte(question, meilleur_score, path=None):
    with open(path or LOG_PATH, "a", encoding="utf-8") as f:
        f.write(json.dumps({"date": datetime.now(timezone.utc).isoformat(timespec="seconds"),
                            "question": question, "meilleur_score": meilleur_score},
                           ensure_ascii=False) + "\n")

Pourquoi les paramètres seuil et log_path valent None par défaut au lieu de SEUIL et LOG_PATH ?

3. Calibrer le seuil

Un seuil choisi « au feeling » est soit trop haut (faux refus), soit trop bas (hallucinations). On le calibre : on prend des questions de référence dont on sait si elles sont couvertes, on regarde leur meilleur score, et on cherche la valeur qui les sépare le mieux.

files/eval.py
def calibrer(golden, all_hits):
    best = [(h[0]["score"], bool(g["attendu"])) for g, h in zip(golden, all_hits)]
    scores = sorted({s for s, _ in best})
    candidats = [round((a + b) / 2, 3) for a, b in zip(scores, scores[1:])] or scores
    acc = lambda t: sum((s >= t) == couverte for s, couverte in best) / len(best)
    seuil = max(candidats, key=lambda t: (acc(t), -t))
    return seuil, acc(seuil), sorted(best, reverse=True)

Les candidats sont les milieux entre deux scores voisins : le seuil retenu laisse une marge des deux côtés au lieu de coller au score le plus bas d'une question couverte. À précision égale, on garde le plus bas, qui produit moins de faux refus. Le calcul des embeddings des 13 questions se fait en un seul appel.

La calibration donne 100 % de questions bien triées sur 13 questions. Que faut-il en conclure ?

Exercice : séparer calibration et test

Modifiez eval.py --calibrer pour calibrer sur les questions d'indice pair de golden.json et afficher la précision obtenue sur les questions d'indice impair.

Voir la solution
files/eval.py (dans main, branche --calibrer)
    if mode == "--calibrer":
        cal, test = golden[0::2], golden[1::2]
        seuil, acc_cal, _ = calibrer(cal, all_hits[0::2])
        bons = sum((h[0]["score"] >= seuil) == bool(g["attendu"])
                   for g, h in zip(test, all_hits[1::2]))
        print(f"seuil {seuil:.3f} : calibration {acc_cal:.0%}, test {bons / len(test):.0%}")
        return

Avec si peu de questions, l'écart entre les deux pourcentages est surtout du bruit ; l'habitude, elle, est la bonne. Sur un vrai SAV, tirez les questions de test des vrais tickets.

Résultat obtenu

Calibration réelle du 2026-10-15 (gemini-embedding-2, 768 dimensions) :

$ python eval.py --calibrer
  0.792  couverte
  0.784  couverte
  0.768  couverte
  0.762  couverte
  0.762  couverte
  0.761  couverte
  0.756  couverte
  0.752  couverte
  0.733  couverte
  0.696  HORS PÉRIMÈTRE
  0.690  HORS PÉRIMÈTRE
  0.654  HORS PÉRIMÈTRE
  0.559  HORS PÉRIMÈTRE
seuil conseillé : 0.714 (questions bien triées : 100%)

Je sais faire

  • Construire un index de FAQ avec un cache invalidé par empreinte
  • Filtrer les résultats par un seuil et journaliser les refus en JSONL
  • Calibrer un seuil sur des questions de référence et en connaître les limites
Next