6. Codification RAG

Rôle de l’étape

La branche RAG (Retrieval-Augmented Generation) code les libellés par recherche sémantique : on encode la nomenclature COICOP dans une base vectorielle, on récupère pour chaque produit les codes les plus proches, et on demande à un LLM de choisir parmi cette courte liste de candidats.

Elle se compose de deux traitements, désormais répartis dans deux workflows distincts :

Traitement Workflow Code Rôle
index-notices argo/index-notices-pipeline.yaml rag-notices/scripts/0_create_vector_db.py encode la nomenclature prunée dans Qdrant
classify-rag-notices argo/codif-pipeline.yaml rag-notices/scripts/2_run_rag.py recherche vectorielle + génération LLM
  • Code : rag-notices/ · config : rag-notices/config/config.yaml

index-notices — un workflow autonome

argo/index-notices-pipeline.yaml enchaîne deux étapes seulement :

prune-codes (--only nomenclature) ──→ index-notices

Il est autonome au sens strict : la nomenclature dérive d’un CSV statique, aucune donnée issue d’un run de classification n’y entre. D’où le drapeau --only nomenclature, qui arrête prune-codes après l’étape 1 — les étapes suivantes liraient des sorties de classify-regex qui n’existent pas ici.

Paramètre Défaut Rôle
git-branch main branche clonée par les deux étapes
run_id / run_date calculés par Argo identité du run d’indexation, reprise dans le nom de la collection
collection-name (vide) échappatoire : forcer un nom exact au lieu de le composer

Chaque notice COICOP prunée (lue depuis prune-codes/nomenclature_pruned.parquet, sans re-traitement) est mise en forme en texte (code, libellé FR, notes générales, contenu central, exclusions, lignage des parents) puis encodée en vecteur :

  • Modèle d’embedding : qwen3-embedding-8b (dimension 4096), via l’API OpenAI-compatible de llm.lab (LLMLAB_URL / LLMLAB_API_KEY).
  • Base vectorielle : Qdrant, distance cosinus. La collection porte un nom unique composé depuis qdrant.collection_base (coicop_notices) et l’identité du run d’indexation : coicop_notices__{run_date}__{run_id}, p. ex. coicop_notices__2026-09-02__index-notices-a7k2p.
NotePourquoi des noms uniques

L’ancienne collection s’appelait coicop_lineage, un nom fixe partagé par tous les runs : réindexer détruisait la base que lisait un run concurrent, et rien ne permettait de savoir quelle version de la nomenclature était embarquée. La clé de config a été renommée collection_base (et non collection_name) exprès : le consommateur reçoit le nom complet en argument, et une clé homonyme dans la config l’inciterait à retomber en silence sur l’ancienne collection partagée.

À côté de la collection, l’étape écrit un manifeste JSON sous s3://projet-budget-famille/data/vector_db_manifests/{collection_name}.json : modèle d’embedding, dimension, stratégie de découpage (with_hierarchy), CSV source, nombre de points effectivement comptés dans Qdrant, SHA du commit. Le nom ne peut pas porter tout cela, et deux collections de même dimension bâties avec des réglages différents sont sinon indistinguables.

Le job se termine en imprimant la ligne à recopier :

classify-rag-notices-collection: coicop_notices__2026-09-02__index-notices-a7k2p

classify-rag-notices — recherche + génération

Cette étape appartient au pipeline de codification. Le nom de la collection à interroger lui est passé par le paramètre classify-rag-notices-collection, obligatoire et sans valeur par défaut : un nom vide fait échouer l’étape en quelques secondes (garde dans le conteneur, avant même le git clone, et required=True en argparse).

Validation de l’index avant tout travail coûteux. Le script relit le manifeste et vérifie que la collection existe, n’est pas vide, n’est pas en statut RED, et qu’elle a bien la dimension, le modèle d’embedding et la stratégie que ce run attend. Deux détails de conception : la validation a lieu avant mlflow.set_experiment, pour ne pas laisser un run FAILED polluant l’expérience ; et chaque message d’erreur nomme le paramètre fautif et le workflow à relancer. Le SHA git et le run_id de l’index sont ensuite loggués comme tags MLflow — sans quoi deux runs aux métriques différentes mais bâtis sur des index différents seraient indistinguables.

Pour chaque libellé à coder (rag-notices/scripts/2_run_rag.py) :

  1. Embedding du libellé l_pr_product (même modèle qu’à l’indexation).

  2. Recherche dans Qdrant : les retrieval.size = 10 codes COICOP les plus proches.

  3. Construction du prompt par prepare_prompts() : le libellé, un bloc enseigne, un bloc prix, et la liste des codes candidats récupérés sont injectés dans un gabarit. Détail important : le gabarit de prompt n’est pas dans le dépôt — il est géré dans Langfuse (prompt-multi-level, version 12, cf. config.yaml).

    prompt_template.compile(
        product=searched_product["l_pr_product"],
        enseigne_bloc=enseigne_bloc,      # "acheté dans cette enseigne : Action…"
        price_bloc=price_bloc,            # "a coûté : 4.0 euros."
        proposed_codes="\n\n## ".join(qdrant_results_texts[i]),
        list_proposed_codes=qdrant_results_codes[i],
    )
  4. Génération par le LLM (gemma4-26b-moe, temperature 0.1, max_tokens 2048), via la même API llm.lab (LLMLAB_URL / LLMLAB_API_KEY), avec 32 appels parallèles (llm.concurrency dans config.yaml).

  5. Parsing de la réponse JSON (extract_json_from_response) → code_predict, codable, confidence, puis re-pruning du code prédit (troncature niveau 4 + mapping mapping_lvl4.parquet) : même si le LLM répond au niveau 5 ou sur un code replié, la prédiction retombe dans l’espace de codes pruné.

  6. Traçage : paramètres et compteurs dans MLflow (expérience test), traces dans Langfuse. Aucune accuracy n’est calculée ici — c’est l’étape evaluate qui mesure. Ce module évaluait autrefois sans condition, et loguait donc une accuracy ≈ 0 sur chaque run de production.

Exemple sur le fil rouge

Pour Max garden flowers balle surprise (Action, 3,99 €) :

  • la recherche Qdrant renvoie 10 candidats COICOP (jouets, articles de jardin, articles de fête…) ;
  • le prompt y ajoute l’enseigne (Action, magasin discount) et le prix (3,99 €) ;
  • le LLM choisit le code le plus plausible et fournit une confidence. Comme le libellé est ambigu, la confiance peut être moyenne — ce que l’arbitrage final prendra en compte.

Sorties

Variable config Chemin S3
predictions.s3_path …/{run}/classify-rag-notices/predictions.parquet (rag_code, confidence, codable)
predictions.s3_path_retrieved_codes …/{run}/classify-rag-notices/retrieved_codes.parquet (top-10 candidats par produit)
Astuce

retrieved_codes.parquet permet d’auditer le retrieval indépendamment de la génération : si le bon code n’est pas dans les 10 candidats, le problème vient de l’embedding ou de la nomenclature indexée — pas du LLM. C’est le premier fichier à inspecter quand rag_code semble aberrant.

➡️ Étape suivante : 7. RAG sur annotations