درس 2 — Construire

RAG sur la FAQ et les CGV

7 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

RAG sur la FAQ et les CGV

Une cliente écrit « c'est gratuit l'envoi pour 95 balles ? ». La FAQ dit « La livraison est offerte dès 80 € d'achat ». Aucun mot en commun, hormis « 95 » qui n'apparaît nulle part. Un agent qui répond de mémoire inventera un seuil ; un moteur par mots-clés renverra la page de la boutique physique. Il faut retrouver le bon passage par le sens, et obliger l'agent à ne répondre qu'à partir de lui.

Le mécanisme

RAG (Retrieval-Augmented Generation, génération augmentée par la recherche) : avant de répondre, l'agent appelle un outil qui cherche les passages pertinents ; le modèle rédige à partir de ces passages. Ici la recherche se fait par embeddings : chaque passage est transformé en vecteur de 768 nombres par gemini-embedding-2, la question aussi, et on garde les passages dont le vecteur est le plus proche (produit scalaire de vecteurs normalisés, c'est-à-dire similarité cosinus).

flowchart LR
  F["faq.md + cgv.md"] -->|"découpage par section ##"| P["29 passages"]
  P -->|"title: … | text: …"| E["gemini-embedding-2 (768 dims)"]
  E --> I[("index.json en cache")]
  Q["Question du client"] -->|"task: search result | query: …"| E2["embedding de la question"]
  E2 --> S["similarité avec les 29 vecteurs"]
  I --> S
  S --> K["3 meilleurs passages + source + titre"]
  K --> A["Agent : répond UNIQUEMENT à partir des passages"]

Trois choix concrets :

ChoixPourquoi
Un passage = une section ##la FAQ et les CGV sont déjà découpées par sujet, chaque section fait moins de 150 mots ; un découpage par taille fixe couperait des réponses en deux
Préfixes title: … | text: … et task: search result | query: …gemini-embedding-2 encode la tâche dans le texte ; document et requête n'ont pas le même format
Repli BM25 sans cléles tests tournent hors réseau ; BM25 est un classement par mots-clés pondérés

Mise en œuvre

Le découpage tient en une boucle :

files/support/kb.py (extrait)
def decouper(chemin: Path, source: str) -> list[Passage]:
    """Une section « ## » = un passage."""
    passages, titre, lignes = [], None, []
    for ligne in chemin.read_text(encoding="utf-8").splitlines():
        if ligne.startswith("## "):
            if titre:
                passages.append(Passage(source, titre, " ".join(lignes).strip()))
            titre, lignes = ligne[3:].strip(), []
        elif titre and ligne.strip():
            lignes.append(ligne.strip())

L'appel au modèle d'embeddings passe une types.Content par texte : une liste de chaînes brutes produirait un seul vecteur agrégé pour tout le lot. Sur Vertex AI, ce modèle n'accepte qu'un contenu par requête, d'où le lot de 1.

files/support/kb.py (extrait)
lot = 1 if getattr(client, "vertexai", False) else 20
contents = [types.Content(parts=[types.Part(text=t)]) for t in textes[i:i + lot]]
r = client.models.embed_content(model=model, contents=contents,
                                config=types.EmbedContentConfig(output_dimensionality=DIM))

L'index est mis en cache dans var/index.json, avec une clé dérivée du contenu : modifier une ligne de la FAQ le reconstruit, relancer l'application ne coûte rien.

L'outil exposé à l'agent est volontairement mince ; la contrainte « réponds uniquement à partir des passages » est dans la consigne (règle 2 de agent.py) :

files/support/tools.py (extrait)
def rechercher_aide(question: str) -> dict:
    """Cherche dans la FAQ et les conditions générales de vente de Lin & Lumière.

    Args:
        question: la question du client, reformulée de façon autonome.
    """
    return {"passages": kb.chercher(question, k=3)}

La docstring n'est pas décorative : ADK l'envoie au modèle comme description de l'outil. « Reformulée de façon autonome » pousse le modèle à écrire « frais de livraison pour 95 € » plutôt que « et pour ça ? ».

Résultat obtenu

Comparaison mesurée le 2026-10-15 sur six formulations familières (premier passage retourné) :

Question du clientEmbeddingsBM25
Je peux renvoyer un drap que j'ai déjà lavé ?Comment faire un retour ?Comment entretenir le linge en lin lavé ?
c'est gratuit l'envoi pour 95 balles ?Combien coûte la livraison ?Avez-vous une boutique physique ?
mon paquet est tout cabosséMon colis est arrivé abîmé, que faire ?Mon colis est arrivé abîmé, que faire ?
vous gardez mes messages combien de temps ?Article 10 : Données personnellesCombien coûte la livraison ?
J'habite à Genève, ça marche ?Livrez-vous à l'étranger ?(score nul)
je change d'avis après achat, j'ai combien de joursComment faire un retour ?Combien coûte la livraison ?

Embeddings : 6 sur 6 pertinents, environ 350 ms par requête (un appel réseau). BM25 : 1 sur 6. Le repli BM25 sert aux tests, pas à la production.

Ce qui casse en production

  • La FAQ change, l'index non. Symptôme : l'agent cite l'ancien tarif. Parade : clé de cache calculée sur le contenu des passages, pas sur la date du fichier.
  • Démarrage lent sur Cloud Run. Le conteneur n'a pas de disque persistant : l'index est recalculé à chaque démarrage d'instance. Mesuré sur Vertex AI : 29 passages en 6,5 s, un appel par passage. Parade : construire l'index au moment du docker build, ou le stocker dans un bucket.
  • Le modèle répond hors passages. Symptôme : une réponse plausible mais absente de la FAQ. Parade : consigne explicite, plus un cas d'évaluation dont la réponse n'est pas dans la base (leçon 7).
  • Une question touche deux sections. « Retour d'un article brodé » relève de la FAQ et de l'article 6 des CGV. Parade : k = 3 passages, pas 1.

La règle de décision

Gardez ce RAG minimal (fichier, embeddings, produit scalaire en mémoire) tant que la base tient en quelques centaines de passages : 29 vecteurs de 768 dimensions occupent moins de 100 Ko. Passez à un moteur géré (Vertex AI Search, RAG Engine) quand les documents changent sans vous, quand il faut des droits d'accès par document, ou au-delà de quelques dizaines de milliers de passages.

Pratique

Vous ajoutez à la FAQ une section « Livrez-vous en Corse ? ». Le lendemain, l'agent répond encore « nous ne livrons pas les DOM-TOM » à cette question. Que vérifiez-vous d'abord ?

Une question ne trouve aucun passage pertinent (score maximal faible). Quel comportement voulez-vous ?

Exercice. Ajoutez à data/faq.md une section « Puis-je payer en bons d'achat de mon comité d'entreprise ? » (réponse : oui, les chèques cadeaux « Lin & Lumière » distribués par les CE sont acceptés). Relancez make run, posez la question dans le widget sous une forme familière, et vérifiez dans les journaux que l'index a été reconstruit.

Voir la solution

Au démarrage, le journal affiche index construit : 30 passages en … s (au lieu de 29) : la clé de cache a changé parce que le contenu a changé. Une question comme « le CE m'a filé des bons, ça passe chez vous ? » doit déclencher rechercher_aide et renvoyer la nouvelle section en tête. Si le journal affiche toujours 29 passages, le titre ne commence pas par ## (deux dièses et une espace).

  • Je sais pourquoi un passage = une section, et quand ce choix ne tient plus
  • Je sais appeler gemini-embedding-2 avec une Content par texte et les préfixes de tâche
  • Je sais pourquoi l'index doit être construit avant le premier client sur Cloud Run
التالي