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 :
| Choix | Pourquoi |
|---|---|
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 :
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.
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) :
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 client | Embeddings | BM25 |
|---|---|---|
| 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 personnelles | Combien 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 jours | Comment 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 ?
L'index est calculé au démarrage. Si le service n'a pas redémarré depuis la modification, il cherche dans l'ancien index. La température ne change pas les passages retrouvés ; la dimension n'empêche pas de retrouver une nouvelle section.
Une question ne trouve aucun passage pertinent (score maximal faible). Quel comportement voulez-vous ?
Une boutique engage sa responsabilité sur ses conditions de vente : une réponse inventée sur un délai ou un tarif coûte plus cher qu'un transfert. C'est la règle 2 de la consigne. Renvoyer le meilleur passage même hors sujet est exactement le défaut du BM25 sur « 95 balles ».
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-2avec uneContentpar texte et les préfixes de tâche - Je sais pourquoi l'index doit être construit avant le premier client sur Cloud Run