Challenge 01 - Vérification d'un PoC RAG
Le PoC RAG du Challenge 01, expliqué pour partir de zéro et finir capable d'en parler à un expert
Un grossiste en fruits et légumes possède un assortiment : des produits, rangés en catégories, fournis par des fournisseurs, avec des noms traduits en trois langues (français, allemand, anglais). Ses équipes aimeraient poser des questions en langage courant ("montre-moi tous les produits de la catégorie baies") au lieu de passer par l'informatique.
Pour tester l'idée, l'entreprise a fait construire un PoC (proof of concept, un prototype de démonstration : assez pour juger si l'idée tient, pas assez pour être utilisé en vrai). L'épreuve l'appelle "PoC RAG" (retrieval-augmented generation : une IA censée aller chercher les vraies données avant de répondre, au lieu de répondre de mémoire). Précision qui te donnera un point de crédibilité : au sens strict, ce que fait ce PoC s'appelle du text-to-SQL avec du function-calling - le LLM traduit ta phrase en requête (ou en choix de requête) vers la base, la base exécute, et le résultat repart tel quel. Le LLM ne voit jamais les données elles-mêmes, il écrit seulement la requête. La section 4 le montre en détail.
Ton rôle de candidat AIBS dans l'épreuve : vérifier ce PoC. C'est-à-dire l'essayer, mesurer ce qui marche, comprendre pourquoi ça rate quand ça rate, et savoir l'expliquer - exactement ce que ce cours te prépare à faire.
Le dire à un expert
Traduction des termes : une API REST est une porte d'entrée standardisée par laquelle des programmes se parlent via le web ; un endpoint est un guichet précis de cette porte (une URL qui répond à une question donnée) ; un LLM (large language model) est un modèle d'IA qui comprend et produit du texte ; industrialiser, c'est passer du prototype au vrai système de production.
Une base de données relationnelle, c'est un ensemble de tableaux (appelés tables) qui se renvoient les uns aux autres. Chaque table a des colonnes (les champs : id, prix, nom...) et des lignes (les enregistrements : une ligne = un produit, un fournisseur...).
Ces numéros ont des noms précis. La clé primaire (primary key, PK) est le numéro d'identité unique d'une ligne dans sa table : la colonne id. La clé étrangère (foreign key, FK) est une colonne qui contient l'id d'une ligne d'une AUTRE table, pour la citer : supplier_id dans la table des produits pointe vers l'id de la table des fournisseurs.
Voici le plan exact de la base (relevé directement dedans, rien d'inventé). Le badge PK marque la clé primaire, FK une clé étrangère avec la table qu'elle cite. Retiens bien : il y a SIX tables, et suppliers n'a PAS de table de traduction.
Surprise du schéma : la table categories ne contient QUE des numéros, aucun nom. Et la table products ne contient ni nom ni description, juste un prix et deux renvois. Où sont les noms ? Dans les tables de traduction. Note aussi le nom de la colonne : dans la maquette publiée ici, la clé de langue s'appelle language_id ; la base de l'examen utilisait lang (même idée, colonne légèrement différente - utile à savoir pour lire la preuve de Q6).
Le mieux pour comprendre est de suivre une vraie donnée à travers les tables. Prenons le produit n° 1. Voici les lignes réelles de la base (les couleurs montrent qui cite qui).
| id | price | supplier_id | category_id |
|---|---|---|---|
| 1 | 4.9 | 1 | 1 |
Cette ligne dit seulement : "le produit n° 1 coûte 4.90, vient du fournisseur n° 1 et appartient à la catégorie n° 1". Pour savoir comment il s'appelle, direction la table de traduction des produits :
| id | product_id | language_id | name | description |
|---|---|---|---|---|
| 1 | 1 | 1 | Fraises | Fraises fraîches de saison, calibre 25-35mm |
| 2 | 1 | 2 | Erdbeeren | Fraises fraîches de saison, calibre 25-35mm |
| 3 | 1 | 3 | Strawberries | Fresh seasonal strawberries, 25-35mm grade |
Oui, la description de la ligne allemande est restée en français : c'est un défaut réel des données du PoC, pas une coquille du cours - exactement le genre de détail qu'un vérificateur de PoC consigne dans son rapport.
Trois lignes pour un seul produit : une par langue. Le language_id cite la table des langues :
| id | code | name |
|---|---|---|
| 1 | fr | Français |
| 2 | de | Deutsch |
| 3 | en | English |
Même mécanique pour la catégorie n° 1, qui n'a un nom que dans sa table de traduction :
| id | category_id | language_id | name |
|---|---|---|---|
| 1 | 1 | 1 | Baies |
| 2 | 1 | 2 | Beeren |
| 3 | 1 | 3 | Berries |
Garde cette table en tête : la catégorie n° 1 s'appelle Baies en français et Berries en anglais. C'est précisément ce mot "Berries" qui va piéger le LLM en Q4.
Et le fournisseur n° 1, lui, vit dans une table toute simple, sans aucune traduction :
| id | name | country |
|---|---|---|
| 1 | Marché Primeur SA | France |
Bilan de la visite : "Fraises, 4.90, catégorie Baies, fournisseur Marché Primeur SA" n'existe nulle part en une seule ligne. Cette phrase est reconstituée en suivant les renvois à travers 5 tables. Cette opération de reconstitution s'appelle une jointure (join) - retiens ce mot, il revient partout.
Ce découpage qui paraît compliqué a une logique : chaque information n'est écrite qu'une seule fois. Si "Marché Primeur SA" change de nom, on corrige une ligne, pas 29. Si on ajoute l'italien, on ajoute des lignes de traduction sans toucher aux produits. C'est ce qu'on appelle la normalisation : organiser les tables pour éliminer les doublons et les risques d'incohérence.
Le suffixe _i18n est une convention du métier : abréviation d'internationalization (i + 18 lettres + n). Une table x_i18n contient les textes traduits de la table x. La contrainte UNIQUE verrouille le bon sens, et elle protège les deux tables de traduction : une catégorie (ou un produit) ne peut pas avoir deux noms différents dans la même langue.
products et categories ont leur table de traduction, suppliers n'en a PAS (les noms de sociétés ne se traduisent pas). Un humain qui lit le schéma le voit. À l'épreuve, le LLM du guichet v2 a généralisé le motif _i18n par imitation et a inventé suppliers_i18n - c'est la question Q6. En t'entraînant, tu la verras parfois réussir, parfois inventer autre chose : c'est son caractère probabiliste.Le dire à un expert
Quand tu lances GET /categories, voici le trajet complet, en cinq étapes :
/categories?lang=fr (tout ce qui suit le ? est une option glissée dans l'adresse : ici l'option lang avec la valeur fr).Point capital, valable pour les six tables : le LLM n'apparaît nulle part dans ce voyage. Les guichets de données sont du pur code, sans aucune IA. C'est ce qui les rend fiables.
Ce qui suit est le code réel du guichet des catégories, découpé en quatre blocs. Tu n'as pas besoin de savoir le réécrire : l'objectif est de pouvoir le LIRE.
@router.get("/categories", response_model=list[CategoryOut], summary="Lister toutes les catégories") def list_categories(lang: str = Query(default="fr", ...), db: Session = Depends(get_db)):
@router.get("/categories") dit à FastAPI : "quand quelqu'un appelle GET /categories, exécute la fonction ci-dessous". Le paramètre lang a "fr" comme valeur par défaut : si tu ne précises rien, la réponse sera en français. db est la connexion à la base, fournie automatiquement. language = db.query(Language).filter(Language.code == lang).first()
lang_id = language.id if language else 1
languages la ligne dont le code vaut "fr" pour récupérer son id (1). Détail révélateur : si la langue demandée n'existe pas, le code retombe en silence sur 1 (le français) au lieu de signaler l'erreur - le genre de choix discutable qu'un vérificateur de PoC note dans son rapport. rows = (
db.query(Category.id, CategoryI18n.name, func.count(Product.id).label("product_count"))
.join(CategoryI18n, (CategoryI18n.category_id == Category.id) & (CategoryI18n.language_id == lang_id))
.outerjoin(Product, Product.category_id == Category.id)
.group_by(Category.id, CategoryI18n.name)
.order_by(Category.id)
.all()
)
.join(CategoryI18n, ...) = "va chercher le nom dans la table de traduction, dans la langue demandée" (la jointure de la section 2.3) ; .outerjoin(Product, ...) = "rattache les produits de chaque catégorie, et garde aussi les catégories sans produit" (c'est le sens de outer) ; .group_by(...) = "regroupe par catégorie pour que le comptage se fasse catégorie par catégorie".return [CategoryOut(id=r.id, name=r.name, product_count=r.product_count) for r in rows]
Derrière le Python, SQLAlchemy produit du SQL. Voici la requête équivalente, à un détail près : le code de 3.2 ajoute un tri par id (ORDER BY c.id) que cette variante omet. C'est exactement le SQL que le guichet IA v1 - que tu découvriras en section 4 - renvoie dans son champ sql_used. Sa traduction française terme à terme suit :
SELECT c.id, ci.name, COUNT(p.id) FROM categories c JOIN categories_i18n ci ON ci.category_id = c.id AND ci.language_id = 1 LEFT JOIN products p ON p.category_id = c.id GROUP BY c.id, ci.name
SELECT = "donne-moi ces colonnes" ; FROM categories = "en partant de la table des catégories" ; JOIN ... ON ... = "relie chaque catégorie à sa ligne de traduction, à condition que la langue soit la n° 1" ; LEFT JOIN products = "relie aussi les produits, en gardant les catégories qui n'en ont pas" ; GROUP BY = "regroupe les lignes par catégorie" (pour que COUNT compte par groupe). Les lettres c, ci, p sont des surnoms (alias) donnés aux tables pour raccourcir.
Le dire à un expert
Les guichets /llm/v1 et /llm/v2 acceptent tous les deux une phrase libre ("prompt") et utilisent le même modèle d'IA local (Ollama, gratuit, aucune clé API). Toute la différence tient à la mission qu'on lui confie - et c'est le coeur des questions Q4 à Q6. Dans les deux cas, le LLM ne voit jamais les données : il produit seulement une requête, ou un choix de requête.
C'est du function-calling fait à la main : le modèle choisit une fonction dans un catalogue fermé et remplit les blancs. Zone de risque : le CHOIX et le REMPLISSAGE. Le LLM peut choisir le mauvais plat, remplir le mauvais paramètre, ou - pire - renvoyer une réponse fluide et fausse. C'est la mécanique des questions Q4 et Q5.
Zone de risque : TOUT. Table inventée (suppliers_i18n...), faute de syntaxe, logique fausse mais résultat plausible. C'est la mécanique de la question Q6.
Prompt de la donnée : Show me all the products in the berries category. Voici ce que le guichet v1 a vraiment répondu le jour J (preuve complète sur la page Preuves du jour J) :
"answer": "We have two products in the berries category: Mandarins and
Oranges. ... Mandarins (Sku ZF-003) ... Oranges (Sku ZF-001) ...",
"endpoint": "GET /categories/{category_id}/products"
Pourquoi ce dérapage ? À cause d'une erreur de résolution d'entité multilingue. "Berries" est un mot anglais. Le LLM doit décider à quelle catégorie réelle il correspond (rappel de la section 2.3 : la catégorie n° 1 s'appelle "Baies" en français, "Berries" en anglais). Faute de langue précisée et faute de contrainte stricte, il fait une correspondance approximative et tombe à côté - tout en gardant un ton parfaitement sûr de lui. Le ton assuré et l'endpoint annoncé masquent l'erreur.
Réponse Q4 : le défaut n'est pas une panne technique, c'est que le LLM a renvoyé les mauvais produits (des agrumes au lieu de baies) en ayant l'air sûr de lui. Une erreur de résolution d'entité, présentée comme une vérité.
Réponse Q5 (ce qui manque au prompt) : il manque un identifiant de catégorie sans ambiguïté et la langue. Correction : fournir la catégorie exacte (par id, ou par nom localisé exact + lang), ou - mieux - contraindre le système à résoudre l'entité contre le vrai catalogue AVANT de répondre. Principe à retenir : ne jamais laisser la couche générative inventer la correspondance d'entité ; résoudre les entités de façon déterministe d'abord.
En t'entraînant en live sur la maquette, le même prompt sera instable : parfois une réponse fausse comme ci-dessus, parfois un nom de catégorie inventé tiré d'un produit (ex. "fraises") qui donne un 422 Category 'fraises' not found in language 'fr', parfois du JSON invalide (un commentaire # glissé dans le JSON) qui donne un 500 LLM returned invalid JSON, parfois une réussite. Cette instabilité EST la leçon : un composant non déterministe sur le chemin critique d'une requête de données.
Prompt de la donnée : Show me the details of bananas. Le guichet v2 a généré du SQL qui joint une table hallucinée. Erreur d'exécution dure renvoyée le jour J (preuve complète sur la page Preuves du jour J) :
Table 'examdb.suppliers_i18n' doesn't exist (erreur 1146)
La table inventée est suppliers_i18n. Le mécanisme : généralisation du motif _i18n par imitation. Le modèle a vu categories → categories_i18n et products → products_i18n, donc il a supposé suppliers → suppliers_i18n. Sauf que les fournisseurs n'ont pas de table de traduction (rappel : l'encart rouge de la section 2.2). La base refuse, code 1146 "table inconnue". C'est une hallucination de schéma : inventer une table qui colle au motif mais n'existe pas.
Détail à connaître pour lire la preuve : la base de l'examen utilisait une colonne lang dans les tables i18n, la maquette publiée ici utilise language_id. Même idée, colonne légèrement différente.
Réponse Q6 : la table inventée est suppliers_i18n, par fausse analogie avec le motif _i18n. Les 6 vraies tables sont celles de la section 2.2.
En live, "Show me the details of bananas." est lui aussi instable : parfois du SQL valide mais 0 résultat (filtre banane absent ou faux), parfois une erreur de syntaxe SQL, parfois une autre table hallucinée (ex. product_i18n au singulier). La méthode : repérer dans le generated_sql toute table qui n'appartient pas aux 6 vraies, et la traiter comme une alerte rouge.
Rien de magique : le serveur envoie au LLM un texte d'instructions. Le menu de v1 (les 4 requêtes prédéfinies parmi lesquelles le modèle choisit) :
GET_CATEGORIES # lister les catégories avec compte de produits (param : lang) GET_PRODUCTS # lister les produits, filtre possible par nom de catégorie (lang, category_name) GET_PRODUCT_DETAIL # détail d'un produit par son nom (lang, product_name requis) GET_MOST_EXPENSIVE # trouver le produit le plus cher (lang)
Consigne textuelle de v1 (traduite de l'anglais) : "Tu es un sélectionneur de requêtes de base de données pour un grossiste en fruits et légumes. Choisis la requête prédéfinie la plus adaptée au prompt de l'utilisateur et extrais les paramètres. Réponds UNIQUEMENT en JSON." Voilà le function-calling artisanal : un catalogue fermé, un choix, des paramètres.
Pour v2, le serveur transmet le plan de la base tel quel (c'est tout ce que l'IA sait du schéma) :
Database schema: - languages(id PK, code, name) - categories(id PK) - categories_i18n(id PK, category_id FK, language_id FK, name) - products(id PK, price, supplier_id FK, category_id FK) - products_i18n(id PK, product_id FK, language_id FK, name, description) - suppliers(id PK, name, country)
avec la consigne : "Tu es un expert SQL. Génère uniquement une requête SELECT, sans explication ni mise en forme." Relis ce plan : suppliers n'y a pas de table i18n. L'information était sous les yeux de l'IA et, à l'épreuve, elle a quand même inventé suppliers_i18n par imitation du motif. Voilà pourquoi on parle d'hallucination : produire avec assurance quelque chose qui n'existe pas.
| Guichets données (GET) | v1 - function-calling artisanal | v2 - génération SQL dynamique | |
|---|---|---|---|
| Qui écrit le SQL | Le développeur | Le développeur (le LLM choisit et remplit) | Le LLM, librement |
| Comportement | Déterministe : même question, même réponse | Probabiliste sur le choix, déterministe sur l'exécution | Probabiliste de bout en bout |
| Erreurs possibles | Quasi aucune | Mauvais choix, mauvais paramètre, réponse fausse silencieuse | Tables hallucinées, syntaxe fausse, logique fausse plausible |
| Dans l'épreuve | Q1, Q2, Q3 | Q4, Q5 | Q6 |
Le dire à un expert
Le label "PoC RAG" masque que le mécanisme réel est du text-to-SQL avec function-calling : le LLM ne voit jamais les données, il écrit seulement une requête contre un schéma. Cette couche est non déterministe et échoue de trois façons, classées ici par gravité décroissante.
A l'air juste, est faux. Aucune erreur levée. C'est le cas Q4 : des agrumes présentés comme des baies, sur un ton parfaitement assuré. Le pire, car rien ne le signale - seule une vérification contre la base le révèle.
Table ou colonne hallucinée, la base refuse. C'est le cas Q6 : suppliers_i18n n'existe pas, erreur 1146. Désagréable, mais honnête : l'échec est immédiatement visible.
Le même prompt donne des résultats différents d'une exécution à l'autre : tantôt juste, tantôt 422, tantôt JSON invalide, tantôt SQL faux. Un composant non déterministe sur le chemin critique.
La règle d'or du vérificateur de PoC : pour toute question factuelle, lire la base directement (un GET) et compter soi-même. La couche LLM se traite comme une suggestion à vérifier, jamais comme une vérité.
generated_sql (hors des 6 vraies tables) OU un SQL invalide.Pour chaque question de l'épreuve, la formulation que tu peux assumer devant un jury ou un spécialiste - chaque terme a été expliqué plus haut.
| Q1-Q3 | "Je réponds par les endpoints REST déterministes : la base calcule déjà les agrégats (product_count via COUNT et GROUP BY), et le dénombrement final ou le tri, je le fais moi-même sur le JSON reçu. Je ne délègue jamais un calcul à un LLM : il prédit une valeur plausible, il ne calcule pas." |
|---|---|
| Q4 | "L'échec n'est pas une erreur technique : v1 a renvoyé une réponse fluide et fausse, présentant des agrumes (Mandarines, Oranges) comme des baies. C'est une erreur de résolution d'entité multilingue - 'berries' mal rattaché à la catégorie réelle - et c'est le mode d'échec le plus dangereux : une réponse crédible mais fausse, qu'aucune erreur ne signale." |
| Q5 | "Il manque au prompt un identifiant de catégorie sans ambiguïté et la langue. Correction : résoudre l'entité de façon déterministe contre le vrai catalogue avant de répondre, plutôt que de laisser la couche générative inventer la correspondance." |
| Q6 | "Le modèle a généralisé le motif _i18n par fausse analogie et référencé une table suppliers_i18n absente du schéma : hallucination de schéma typique du text-to-SQL sans validation. Les fournisseurs n'ont pas de table de traduction." |