Chapitre 817 minDébutant
RAG, embeddings et appels d'outils
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- expliquer les deux phases d'un RAG ;
- calculer une similarité cosinus entre deux vecteurs ;
- choisir entre RAG, contexte long et ajustement d'un modèle ;
- décrire un appel d'outil et écrire son schéma JSON.
3 exercices corrigés1 schéma
Le RAG (Retrieval-Augmented Generation, génération augmentée par récupération) consiste à chercher dans tes propres documents les passages qui répondent à une question, puis à les donner au modèle avec la question pour qu'il réponde à partir d'eux. Les appels d'outils (function calling ou tool use) permettent au modèle de demander à ton programme d'exécuter une fonction, consulter un ticket par exemple, puis d'utiliser le résultat. Les deux techniques relient un LLM à des données qu'il n'a jamais vues pendant son entraînement, ce qui réduit les hallucinations sur ces données.
Pourquoi le RAG
Le modèle ne connaît ni les procédures de Sorbier, ni son plan d'adressage, ni l'historique de ses tickets. Trois solutions existent pour qu'il s'en serve :
| Solution | Principe | Avantages | Limites |
|---|---|---|---|
| Tout coller dans le contexte | Envoyer tout le classeur des procédures avec chaque question | Simple, aucun outil | Coûteux à chaque requête, limité par la fenêtre, information noyée au milieu |
| RAG | Retrouver les 2 à 5 passages utiles et n'envoyer qu'eux | Données à jour dès qu'un document change, sources citables, droits d'accès respectés | Il faut découper, indexer, et bien chercher |
| Ajustement du modèle (fine-tuning) | Réentraîner le modèle sur tes documents | Utile pour imposer un style ou un format | Cher, à refaire à chaque mise à jour, ne cite pas ses sources, peut halluciner quand même |
Pour faire répondre un assistant à partir de documents internes qui évoluent, le RAG est presque toujours le bon choix.
Les deux phases d'un RAG
L'indexation, faite une fois puis à chaque mise à jour des documents :
- Découper chaque document en passages (chunks) de quelques centaines de tokens, si possible en suivant sa structure : une procédure, une section, un paragraphe.
- Calculer le plongement de chaque passage avec un modèle d'embedding : un vecteur de quelques centaines à quelques milliers de nombres qui résume son sens.
- Stocker les vecteurs avec le texte et des métadonnées (source, date, droits d'accès) dans une base vectorielle : une extension de base de données comme pgvector pour PostgreSQL, ou un moteur dédié comme Qdrant ou Chroma.
L'interrogation, à chaque question :
- Calculer le plongement de la question avec le même modèle d'embedding.
- Chercher les passages dont les vecteurs sont les plus proches.
- Construire le prompt : les passages trouvés, la question, et la consigne de ne répondre qu'à partir d'eux en citant leur source.
- Le LLM génère la réponse.
Les embeddings et la similarité cosinus
Un modèle d'embedding transforme un texte en vecteur, de façon que deux textes de sens proche donnent des vecteurs qui pointent dans des directions proches, même s'ils n'ont aucun mot en commun : « le VPN ne marche plus » et « impossible de se connecter au réseau de l'entreprise depuis l'hôtel ».
Pour mesurer cette proximité, on calcule la similarité cosinus, le cosinus de l'angle entre les deux vecteurs :
cos(a, b) = (a · b) / (‖a‖ × ‖b‖)
a · b = a₁b₁ + a₂b₂ + … + aₙbₙ (produit scalaire)
‖a‖ = √(a₁² + a₂² + … + aₙ²) (norme, la longueur du vecteur)
Le résultat est compris entre −1 et 1. Proche de 1, les deux textes parlent de la même chose ; proche de 0, ils n'ont rien à voir. La recherche garde les passages dont le cosinus avec la question est le plus élevé. Les vrais vecteurs ont des centaines de dimensions, mais le calcul est le même qu'en dimension 3, comme dans l'exercice de fin de chapitre.
Un mini-RAG en Python avec Ollama
Ce programme fait tout le chemin avec trois procédures de Sorbier, une base vectorielle réduite à une liste Python, et deux modèles Ollama : embeddinggemma pour les plongements, llama3.2:3b pour la réponse. Télécharge-les d'abord avec ollama pull embeddinggemma et ollama pull llama3.2:3b.
import json
import math
import urllib.request
OLLAMA = "http://localhost:11434"
def appeler(chemin, donnees):
requete = urllib.request.Request(
OLLAMA + chemin,
data=json.dumps(donnees).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(requete, timeout=300) as reponse:
return json.load(reponse)
def vecteurs(textes):
return appeler("/api/embed", {"model": "embeddinggemma", "input": textes})["embeddings"]
def cosinus(a, b):
produit = sum(x * y for x, y in zip(a, b))
return produit / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
# 1. Indexation : une seule fois, puis à chaque mise à jour des procédures
procedures = [
"MDP-01 : pour réinitialiser un mot de passe, le technicien vérifie l'identité du salarié "
"en le rappelant sur son poste fixe, puis impose un changement à la prochaine connexion.",
"IMP-03 : si une imprimante signale un bourrage alors qu'aucune feuille n'est coincée, "
"l'éteindre, ouvrir le capot arrière, vérifier qu'aucun morceau de papier ne reste près "
"du rouleau de sortie, puis la rallumer.",
"VPN-02 : un nomade qui n'arrive plus à se connecter au VPN WireGuard vérifie d'abord la "
"date et l'heure de son poste, puis réimporte le profil fourni par le service informatique.",
]
index = list(zip(procedures, vecteurs(procedures)))
# 2. Interrogation : à chaque question
question = "Un commercial en déplacement n'arrive plus à joindre le réseau de l'entreprise."
vecteur_question = vecteurs([question])[0]
classement = sorted(index, key=lambda paire: cosinus(vecteur_question, paire[1]), reverse=True)
extraits = "\n".join(texte for texte, _ in classement[:2])
reponse = appeler("/api/chat", {
"model": "llama3.2:3b",
"stream": False,
"messages": [
{"role": "system", "content": "Réponds uniquement à partir des procédures fournies et cite leur code. "
"Si elles ne permettent pas de répondre, dis-le."},
{"role": "user", "content": f"<procedures>\n{extraits}\n</procedures>\n\nQuestion : {question}"},
],
})
print(reponse["message"]["content"])
L'endpoint /api/embed accepte une liste de textes et renvoie une liste de vecteurs, un par texte. La question ne contient ni « VPN » ni « WireGuard », et pourtant la procédure VPN-02 doit arriver en tête : c'est tout l'intérêt de la recherche par le sens. Affiche les scores avec print(cosinus(vecteur_question, v)) pour le vérifier.
Les pièges du RAG
- Un mauvais découpage. Une procédure coupée au milieu donne des passages incompréhensibles ; des passages trop longs noient l'information.
- Des documents périmés. Le RAG répond avec ce qu'il trouve : une ancienne procédure non supprimée devient une réponse fausse, citée avec assurance. L'indexation suit le cycle de vie des documents.
- Les droits d'accès. Si la base contient les procédures RH et les comptes rendus de direction, l'assistant peut les citer à n'importe quel salarié. Filtre les passages selon les droits de l'utilisateur avant de les envoyer au modèle, jamais en lui demandant de ne pas les citer.
- L'injection par les documents. Un document indexé peut contenir des instructions cachées (chapitre 9).
- La recherche qui ne trouve pas. Si aucun passage n'est pertinent, le modèle doit le dire. La consigne « si les procédures ne permettent pas de répondre, dis-le » est indispensable, et un seuil de similarité minimal aide.
Les appels d'outils
Avec les appels d'outils, le modèle ne se contente plus de lire : il demande une action à ton programme. Le principe :
- Dans la requête, tu décris les outils disponibles : un nom, une description, et le schéma JSON des paramètres.
- Si le modèle a besoin d'un outil, il ne répond pas par du texte : il renvoie un appel d'outil, avec le nom de l'outil et des arguments en JSON, et
stop_reasonvauttool_use. - Ton code exécute la fonction, avec ses propres contrôles, et renvoie le résultat au modèle.
- Le modèle rédige la réponse finale à partir du résultat, ou demande un autre outil.
Le modèle n'exécute jamais rien lui-même : il écrit une demande, et c'est ton programme qui décide de l'exécuter. Voici outil_ticket.py, un assistant qui peut consulter un ticket de la base parc, ici remplacée par un dictionnaire :
import json
import anthropic
client = anthropic.Anthropic()
# Les données viennent ici d'un dictionnaire ; dans l'application, d'une requête préparée sur la base parc.
TICKETS = {
6: {"titre": "Ventilateur bruyant", "priorite": "basse", "statut": "en_cours"},
}
outils = [{
"name": "consulter_ticket",
"description": "Renvoie le titre, la priorité et le statut d'un ticket d'assistance à partir de son numéro.",
"input_schema": {
"type": "object",
"properties": {
"id_ticket": {"type": "integer", "description": "Numéro du ticket, par exemple 6"},
},
"required": ["id_ticket"],
},
}]
def executer(nom, arguments):
if nom != "consulter_ticket" or not isinstance(arguments.get("id_ticket"), int):
return json.dumps({"erreur": "appel d'outil invalide"})
ticket = TICKETS.get(arguments["id_ticket"])
return json.dumps(ticket if ticket else {"erreur": "ticket introuvable"}, ensure_ascii=False)
messages = [{"role": "user", "content": "Où en est mon ticket 6 ?"}]
while True:
reponse = client.messages.create(
model="claude-opus-5-5",
max_tokens=16000,
tools=outils,
messages=messages,
)
if reponse.stop_reason != "tool_use":
break
messages.append({"role": "assistant", "content": reponse.content})
resultats = []
for bloc in reponse.content:
if bloc.type == "tool_use":
resultats.append({
"type": "tool_result",
"tool_use_id": bloc.id,
"content": executer(bloc.name, bloc.input),
})
messages.append({"role": "user", "content": resultats})
print("".join(bloc.text for bloc in reponse.content if bloc.type == "text"))
Le déroulé d'un tour :
- Premier appel : le modèle répond par un bloc
tool_useavecname = "consulter_ticket"etinput = {"id_ticket": 6}. - Le programme renvoie toute la réponse du modèle, puis un message
usercontenant un bloctool_result, avec le même identifiant (tool_use_id) et le résultat en JSON. - Second appel : le modèle lit le résultat et répond par du texte, « Ton ticket 6, Ventilateur bruyant, est en cours de traitement ».
stop_reasonvautend_turn, la boucle s'arrête.
C'est exactement la boucle d'un agent du chapitre 6, avec un seul outil. Ollama propose la même mécanique avec le champ tools de /api/chat, pour les modèles qui la prennent en charge. Un serveur MCP, lui, sert à partager des outils entre plusieurs assistants sans les redéfinir dans chaque application (MCP).
Sécuriser les outils
Un outil donne au modèle un pouvoir sur ton système. Les arguments qu'il fournit sont des données non fiables, au même titre qu'une saisie dans un formulaire : ils peuvent être faux, ou dictés par une injection de prompt.
- Moindre privilège. L'outil
consulter_ticketlit ; il ne modifie rien. La connexion à la base utilise un compte en lecture seule sur les tables utiles. - Validation dans le code. Vérifie le type et les valeurs de chaque argument, comme
executer()le fait pourid_ticket, puis utilise une requête préparée. - Contrôle des droits dans le code. L'utilisateur connecté a-t-il le droit de voir ce ticket ? C'est ton application qui le vérifie, avec l'identité de l'utilisateur, pas le modèle.
- Confirmation humaine pour toute action qui modifie ou supprime : clôturer un ticket, réinitialiser un mot de passe, créer un compte.
- Journalisation de chaque appel d'outil : qui, quoi, avec quels arguments, quel résultat.
Exercices
Exercice 1 sur 3 Calculer une similarité cosinus Réussi À revoir
Pour simplifier, on représente les textes par des vecteurs de dimension 3. La question de l'utilisateur donne q = (0,9 ; 0,1 ; 0,3). Trois passages ont pour vecteurs d1 = (0,8 ; 0,2 ; 0,1), d2 = (0,1 ; 0,9 ; 0,2) et d3 = (0,3 ; 0,1 ; 0,9).
Calcule cos(q, d1), cos(q, d2) et cos(q, d3), arrondis à 0,01. Quel passage le RAG envoie-t-il en premier ? Lesquels garde-t-il s'il retient les deux meilleurs ?
Voir la réponseMasquer la réponse de l'exercice 1
‖q‖ = √(0,81 + 0,01 + 0,09) = √0,91 ≈ 0,9539.
- d1 : q · d1 = 0,72 + 0,02 + 0,03 = 0,77 ; ‖d1‖ = √0,69 ≈ 0,8307 ; cos ≈ 0,77 / (0,9539 × 0,8307) ≈ 0,97.
- d2 : q · d2 = 0,09 + 0,09 + 0,06 = 0,24 ; ‖d2‖ = √0,86 ≈ 0,9274 ; cos ≈ 0,24 / (0,9539 × 0,9274) ≈ 0,27.
- d3 : q · d3 = 0,27 + 0,01 + 0,27 = 0,55 ; ‖d3‖ = √0,91 ≈ 0,9539 ; cos ≈ 0,55 / 0,91 ≈ 0,60.
Le passage d1 arrive en premier. Avec les deux meilleurs, le RAG envoie d1 et d3. On pourrait aussi fixer un seuil, par exemple 0,5, en dessous duquel un passage n'est jamais envoyé : d2 serait alors écarté même si on en retenait trois.
Tu avais trouvé ?
Exercice 2 sur 3 RAG, contexte ou ajustement ? Réussi À revoir
Pour chaque besoin, choisis la solution la plus adaptée et justifie.
- Un assistant qui répond aux questions des salariés à partir des 300 procédures du wiki, mises à jour chaque semaine.
- Faire relire une seule note de service de deux pages avant de l'envoyer.
- Faire écrire tous les comptes rendus d'intervention dans le format très particulier exigé par un client, sur des milliers d'exemples.
Voir la réponseMasquer la réponse de l'exercice 2
- RAG : beaucoup de documents qui changent souvent, et le besoin de citer la procédure utilisée. L'index doit aussi respecter les droits d'accès au wiki.
- Coller le texte dans le contexte : un document court, une seule fois. Un RAG serait disproportionné.
- Ajustement du modèle, ou plus simplement un prompt few-shot avec quelques exemples du format (chapitre 4). L'ajustement se justifie quand on a des milliers d'exemples et que le few-shot ne suffit plus. Il s'agit d'un style, pas de connaissances qui changent.
Tu avais trouvé ?
Exercice 3 sur 3 Le schéma d'un outil Réussi À revoir
Écris la définition d'un outil creer_ticket pour l'API d'Anthropic. Il prend un titre (texte de 100 caractères au plus), une priorité (basse, normale, haute ou critique) et le numéro de l'employé demandeur. Les trois paramètres sont obligatoires. Puis cite deux contrôles que le code doit faire avant d'exécuter l'appel.
Voir la réponseMasquer la réponse de l'exercice 3
{
"name": "creer_ticket",
"description": "Crée un ticket d'assistance au nom d'un employé.",
"input_schema": {
"type": "object",
"properties": {
"titre": {"type": "string", "maxLength": 100, "description": "Résumé du problème"},
"priorite": {"type": "string", "enum": ["basse", "normale", "haute", "critique"]},
"id_demandeur": {"type": "integer", "description": "Numéro de l'employé demandeur"},
},
"required": ["titre", "priorite", "id_demandeur"],
},
}
Contrôles dans le code, par exemple :
- l'
id_demandeurest bien celui de l'utilisateur connecté (ou l'utilisateur a le droit de créer un ticket pour un autre), vérifié avec la session, pas avec ce que dit le modèle ; - le titre et la priorité respectent les contraintes (longueur, valeur dans la liste), puis l'insertion se fait avec une requête préparée ;
- éventuellement, une confirmation de l'utilisateur avant la création, et une limite du nombre de tickets créés par minute.
Le schéma aide le modèle à produire des arguments valides, mais ne remplace jamais ces contrôles : la base parc a d'ailleurs sa propre contrainte CHECK sur la priorité.
Tu avais trouvé ?