7. RAG sur annotations
Codification par RAG sur exemples déjà codifiés (few-shot)
Rôle de l’étape
Le module rag-annotations/ code chaque produit par RAG sur exemples annotés : on récupère dans une base vectorielle les produits déjà codifiés les plus similaires, puis on demande à un LLM de choisir un code parmi ceux de ces voisins.
La différence avec la branche RAG notices est fondamentale :
| RAG notices | RAG annotations | |
|---|---|---|
| Ce qui est indexé | les notices de la nomenclature COICOP (définitions, inclusions, exclusions) | des produits réels déjà codés (annotations manuelles + suggester) |
| Candidats proposés au LLM | les codes dont la définition ressemble au libellé | les codes portés par des libellés ressemblant au libellé |
| Force | couvre toute la nomenclature, même les codes jamais rencontrés | colle aux usages réels de codification et au vocabulaire des enquêtés |
| Faiblesse | la définition officielle ressemble rarement à « bagu. tradition U » | ne peut proposer qu’un code déjà présent dans la KB |
- Code :
rag-annotations/· config :rag-annotations/config/config.yaml - Deux étapes Argo, dans deux workflows distincts :
index-annotations(argo/index-annotations-pipeline.yaml) construit la base vectorielle ;classify-rag-annotations(argo/codif-pipeline.yaml) l’interroge.
Une représentation textuelle unique
Point de conception central : le produit est décrit par une seule fonction (build_location_text), utilisée à l’identique à l’indexation, à la requête et dans les exemples du prompt. Un même produit est donc représenté exactement de la même façon des deux côtés de la recherche vectorielle :
café
café - magasin ou lieu d'achat : Super U
café - magasin ou lieu d'achat : Super U (type : supermarché)
L’enseigne et le type de magasin sont ajoutés quand ils sont disponibles : le lieu d’achat est un signal fort pour la COICOP (le même mot « menu » ne se code pas pareil au supermarché et au restaurant).
Étape 1 — construire la base vectorielle (workflow autonome)
argo/index-annotations-pipeline.yaml est un workflow à part entière, hors du pipeline de codification :
build-datasets ──→ prune-codes (--only kb) ──→ index-annotations
Ce qu’il indexe, ce sont les produits déjà annotés : annotations_full + suggester au sens de build-datasets, rien d’autre.
input_file ici
input_file désigne les produits à classer. Constituer une base d’exemples n’a rien à voir avec cela. Pour la même raison, classify-regex n’est pas dans la chaîne : la KB n’a aucune raison d’être amputée des produits que les regex savent coder — ce sont justement de bons exemples.
| Paramètre | Défaut | Rôle |
|---|---|---|
git-branch |
main |
branche clonée par les trois étapes |
run_id / run_date |
calculés par Argo | identité du run d’indexation, reprise dans le nom de la collection |
kb-sample-size |
(vide) | plafonne le nombre de points indexés, donc le temps d’embedding |
collection-name |
(vide) | échappatoire : forcer un nom exact au lieu de le composer |
prune-codes --only kb produit la nomenclature et le mapping (nécessaires au pruning des codes), puis la KB et le suggester, mais pas de jeu à coder — celui-ci n’a pas de sens dans un run d’indexation. Les deux sources lui sont passées explicitement (--annotations-train, --suggester-path, --suggester-product-col) et pointent sur build-datasets, pas sur classify-regex ni sur le CSV brut du suggester.
scripts/0_build_annotation_vector_db.py indexe ensuite deux jeux, déjà tronqués et élagués par prune-codes (le module ne re-prune rien) :
| Source indexée | Fichier |
|---|---|
| KB d’annotations | …/{run}/prune-codes/annotations_train_pruned.parquet |
| Suggester | …/{run}/prune-codes/suggester_pruned.parquet |
- Embeddings :
qwen3-embedding-8b(model_len4096,batch_size64),upload_batch_size56. - Collection Qdrant au nom unique, composé depuis
qdrant.collection_base(coicop_annotations) et l’identité du run :coicop_annotations__{run_date}__{run_id}[__sampleN], p. ex.coicop_annotations__2026-09-02__index-annotations-b3x9q.
Le job se termine en imprimant la ligne à recopier dans argo/params.yaml :
classify-rag-annotations-collection: coicop_annotations__2026-09-02__index-annotations-b3x9q
Le suggester arrive au niveau 5 et non pruné à la source : c’est l’étape prune-codes qui lui applique exactement le même prune_annotation_lvl4 (troncature niveau 4 + mapping canonique) qu’aux annotations, pour que tous les candidats vivent dans le même espace de codes.
kb-scope
Elle valait full (tous les produits annotés) ou train (la moitié réservée par l’ancien split), et la portée était inscrite dans le nom de la collection (__full__ / __train__). Ce split n’existait que faute de jeu de test indépendant : il fallait bien réserver des lignes pour mesurer. Les nouveaux produits annotés en fournissent un naturellement, donc la KB est désormais toute la base annotée, et le segment de nom n’a plus rien à distinguer. Supprimés, ainsi que le second profil de sources exclues, qui était déjà identique au premier.
Le manifeste
À côté de chaque collection, l’indexation écrit un manifeste JSON sous s3://projet-budget-famille/data/vector_db_manifests/{collection_name}.json : modèle d’embedding, dimension, sample_size, nombre de points recomptés auprès de Qdrant (jamais len(points) : seule mesure qui atteste que les points sont bien arrivés), source des annotations, SHA du commit.
classify-rag-annotations le relit au démarrage, avant tout travail coûteux et avant d’ouvrir un run MLflow, et refuse de continuer si la collection est absente, vide, ou bâtie avec une autre dimension ou un autre modèle d’embedding. Même dimension ne veut pas dire même espace vectoriel : sans cette garde, les résultats seraient silencieusement faux.
Sources exclues de la KB
Un seul profil, la clé annotations.exclude_sources de config/config.yaml, qui vaut aujourd’hui [bdf_2017, copain] :
copainn’est de toute façon plus importé par le build-datasets depuis juin 2026 ; la mention est conservée par défense (sans effet si la source n’arrive jamais).bdf_2017est écarté de la base vectorielle : millésime ancien, dont les libellés et usages de codification ne reflètent plus la campagne courante.- Si
suggesterfigurait dans la liste, le bloc suggester ne serait pas indexé — même avecsuggester.enabled: true.
Index d’essai
Pour construire vite une petite base et tester la chaîne, kb-sample-size plafonne le nombre de points indexés (échantillonnage sans remise, graine eval.seed, appliqué après la fusion annotations + suggester pour que le total indexé vaille exactement la valeur demandée) :
argo submit argo/index-annotations-pipeline.yaml -p kb-sample-size=1000La collection obtenue porte alors un suffixe __sample1000 : sans lui, un index-jouet et un index complet auraient des noms de même forme, et rien ne signalerait qu’on vient de recopier le premier dans les paramètres de production. L’ancien mécanisme — une collection parallèle suffixée _test, dont la valeur de sample-annotations devait être passée à l’identique aux deux scripts pour qu’ils visent la même — a disparu avec les noms uniques.
Étape 2 — codifier
scripts/1_run_rag.py (étape classify-rag-annotations du pipeline de codification) reçoit le nom de la collection par le paramètre classify-rag-annotations-collection, obligatoire et sans défaut : un nom vide fait échouer l’étape en quelques secondes. Pour chaque produit à coder (…/{run}/prune-codes/annotations_test_pruned.parquet) :
- Embedding de la représentation
build_location_textdu produit ; - Recherche des
retrieval.size = 10produits annotés les plus proches ; - Prompt few-shot : les voisins et leurs codes constituent la liste fermée des candidats autorisés. Prompt hébergé dans Langfuse (
prompt-annotation-rag, version2), avec repli sur le fichier localprompts/annotation_rag.mdsillm.use_langfuse: false; - Génération (
gemma4-26b-moe,temperature 0.1,max_tokens 2048,concurrency 32) ; - Parsing du JSON
{codable, code_predict, confidence}.
Les consignes du prompt sont volontairement strictes : le code doit être repris à l’identique dans la liste des candidats, sans le tronquer ni le compléter, et si aucun candidat ne convient le modèle doit répondre codable: false plutôt qu’un code « le moins pire ».
Cette garantie de pruning est incitative, pas structurelle : contrairement au RAG notices, le module ne re-prune pas sa sortie. C’est reconcile-llm qui normalise ragann_code (troncature niveau 4 + élagage) avant le consensus et l’arbitrage.
Évaluation
1_run_rag.py ne calcule aucune métrique. Il produit des prédictions, et rien d’autre.
Il y avait ici une évaluation pilotée par --skip-eval, qui restreignait de surcroît le jeu à coder à certaines sources — un filtre sur le périmètre codé, décidé par une option d’évaluation. Tout cela est parti dans l’étape evaluate, qui mesure une fois, à la fin, sur l’ensemble du run : accuracy et recall de retrieval par niveau, distorsion de distribution, AUROC, fiabilité du drapeau codable, et la ventilation par provenance du produit.
Ce que le module loggue encore dans MLflow (expérience rag-annotation, réglable par mlflow.experiment_name) : ses paramètres, ses compteurs, et les tags de provenance de l’index (index.git_sha, index.run_id) avec le nombre de points de la collection interrogée — sans quoi deux runs bâtis sur des index différents seraient indistinguables.
Sortie et intégration
| Fichier | Colonnes |
|---|---|
…/{run}/classify-rag-annotations/predictions.parquet |
code_predict, codable, confidence, parsed, list_retrieved_codes, method="rag-annotations" |
Le dossier de sortie porte le nom de l’étape Argo, classify-rag-annotations.
reconcile-llm renomme ces colonnes en ragann_code, ragann_confidence, ragann_codable, et les traite comme la quatrième prédiction candidate — y compris dans le court-circuit consensus.
list_retrieved_codes permet d’auditer le retrieval : si le bon code n’y figure pas, le LLM ne pouvait pas le choisir (la liste des candidats est fermée) — le problème vient alors de la KB ou de l’embedding, pas de la génération.
➡️ Étape suivante : 8. Decide — arbitrage LLM-as-judge