thamu _

Evaluer la GenAI avec mlflow.genai.evaluate() : au-dela de l'accuracy

par Thamu Mnyulwa 11 min de lecture

La premiere erreur dans l’evaluation des systemes GenAI consiste a traiter la sortie comme une etiquette de classification.

Cela fonctionne pour des taches de machine learning deterministes. Cela ne fonctionne pas pour un systeme Retrieval-Augmented Generation (RAG) ou pour un agent qui appelle des outils, recupere du contexte, puis ecrit une reponse en langage naturel. Il peut exister plusieurs reponses valides. Le modele peut paraphraser correctement. Il peut aussi produire une phrase fluide tout en inventant des faits.

C’est pour cela que BLEU, ROUGE, exact match et la similarite de chaines sont de mauvais indicateurs principaux pour les systemes GenAI en production. Ils mesurent le recouvrement lexical. Ils ne disent pas si la reponse est fondee sur le contexte recupere, si le retriever a apporte assez d’evidence, si la reponse repond vraiment a l’utilisateur, ou si un changement de prompt a introduit une regression.

Dans MLflow 3.x, l’API pratique pour ce travail est mlflow.genai.evaluate(). Si tu as deja utilise mlflow.evaluate() pour l’evaluation ML classique, le modele mental est proche, mais le chemin GenAI s’appuie sur des traces, des scorers, des juges LLM, des expectations et des evaluation runs.

Cet article decrit le harnais d’evaluation que je construirais pour une pipeline RAG : dataset, scorers explicites, metriques personnalisees conscientes des traces, et workflow de comparaison de versions de prompts.

Qu’est-Ce Qu’on Evalue Vraiment?

Un systeme RAG a au moins trois surfaces de qualite :

  • Qualite du retrieval : le retriever a-t-il recupere des documents pertinents pour la question?
  • Suffisance du contexte : ces documents contiennent-ils assez d’informations pour repondre?
  • Qualite de generation : le modele utilise-t-il correctement le contexte et repond-il sans halluciner?

Ce sont des modes de panne differents. Si on les compresse dans un seul score “accuracy”, on perd la capacite de reparer le systeme.

Exemples :

  • Si la reponse est fausse parce que le retriever a recupere des chunks non pertinents, changer le prompt ne suffit pas.
  • Si les chunks contiennent la reponse mais que le modele les ignore, ameliorer les embeddings ne suffit pas.
  • Si la reponse est fondee mais trop longue, le probleme vient de la policy de reponse ou du prompt, pas du retrieval.

Le harnais d’evaluation doit conserver ces frontieres.

La Forme MLflow 3.x

Une evaluation GenAI avec MLflow a trois composants :

  1. Un dataset
  2. Une predict_fn, sauf si l’on evalue des sorties ou des traces deja produites
  3. Une liste explicite de scorers

Un petit dataset peut etre une liste de dictionnaires :

eval_data = [
    {
        "inputs": {
            "question": "What does MLflow Tracing capture for an LLM call?"
        },
        "expectations": {
            "expected_facts": [
                "The prompt and response are captured",
                "Model parameters can be captured",
                "Token usage and latency can be attributed to the model call"
            ]
        },
        "tags": {
            "topic": "observability",
            "difficulty": "baseline"
        },
    },
    {
        "inputs": {
            "question": "Why is exact match weak for RAG evaluation?"
        },
        "expectations": {
            "expected_facts": [
                "Many valid answers can be phrased differently",
                "Lexical overlap does not prove groundedness",
                "A fluent answer can still hallucinate"
            ]
        },
        "tags": {
            "topic": "evaluation",
            "difficulty": "baseline"
        },
    },
]

En production, je transformerais cela en MLflow Evaluation Dataset pour versionner, relire, annoter et reutiliser les cas de test. Pour l’iteration locale, une liste suffit.

La predict_fn doit appeler le meme chemin applicatif que celui que tu comptes deployer. Si tu evalues un wrapper simplifie mais que tu deployes une autre chain, ta metrique ne mesure que le wrapper.

import mlflow

@mlflow.trace(span_type="AGENT")
def answer_question(question: str) -> dict:
    documents = retrieve_docs(question)
    response = generate_answer(question=question, documents=documents)
    return {
        "response": response,
        "retrieved_doc_count": len(documents),
    }

Si ton dataset contient inputs: {"question": "..."}, MLflow peut appeler cette fonction directement parce que le nom du parametre correspond a la cle du dataset.

Les Scorers: Partir Des Modes De Panne

MLflow rend la selection des scorers explicite. C’est une bonne chose. Les suites automatiques sont pratiques, mais elles produisent souvent des dashboards vagues. Une evaluation de production doit dire exactement ce qu’elle mesure.

Pour une application RAG, je commencerais par ces dimensions :

  • RetrievalGroundedness : la reponse est-elle supportee par le contexte recupere?
  • RetrievalSufficiency : le retriever a-t-il apporte assez d’evidence pour les faits attendus?
  • RelevanceToQuery : la reponse repond-elle a la question?
  • Guidelines : la reponse respecte-t-elle les regles de policy, de style ou de format?
  • Scorers code-based : le systeme respecte-t-il les contraintes metier deterministes?

Voici le squelette :

import mlflow
from mlflow.genai.scorers import (
    Guidelines,
    RelevanceToQuery,
    RetrievalGroundedness,
    RetrievalSufficiency,
)

judge_model = "openai:/gpt-4o-mini"

scorers = [
    RelevanceToQuery(model=judge_model),
    RetrievalGroundedness(model=judge_model),
    RetrievalSufficiency(model=judge_model),
    Guidelines(
        name="answer_style",
        guidelines=[
            "The response must be concise and technical.",
            "The response must not claim that a metric proves correctness by itself.",
            "The response must mention uncertainty when the retrieved context is insufficient.",
        ],
        model=judge_model,
    ),
]

results = mlflow.genai.evaluate(
    data=eval_data,
    predict_fn=answer_question,
    scorers=scorers,
)

print(results.metrics)

Le modele utilise comme juge est une decision de cout et de qualite. En debut de developpement, un modele plus fort peut aider l’analyse d’erreurs. A l’echelle, un juge moins couteux peut suffire s’il est calibre avec du feedback humain.

Evaluation RAG Consciente Des Traces

Les juges RAG deviennent vraiment utiles quand l’application est tracee correctement. L’etape de retrieval doit etre un span RETRIEVER, et les documents recuperes doivent etre captures dans une structure que MLflow peut inspecter.

from mlflow.entities import Document

@mlflow.trace(name="knowledge_base_search", span_type="RETRIEVER")
def retrieve_docs(question: str) -> list[Document]:
    rows = vector_store.search(question, k=4)
    return [
        Document(
            id=row["chunk_id"],
            page_content=row["text"],
            metadata={
                "doc_uri": row["source_uri"],
                "score": row["score"],
            },
        )
        for row in rows
    ]

Ce design compte. Si le retriever retourne seulement une chaine concatenee, le juge ne peut pas separer proprement la qualite du retrieval et la qualite de generation. Si le span retriever manque, les juges RAG trace-aware ne peuvent pas examiner le chemin d’evidence.

En production, je traiterais cela comme un contrat d’interface : chaque retriever doit retourner des objets document avec des metadonnees stables comme doc_uri, chunk_id et un score de pertinence provenant de la recherche vectorielle.

Ajouter Des Scorers Code-Based

Les juges LLM sont utiles pour la qualite semantique. Ils ne sont pas le bon outil pour chaque verification.

Si une exigence est deterministe, ecris du code. Exemples :

  • La reponse doit contenir au moins une citation.
  • La reponse ne doit pas depasser 180 mots.
  • Le retriever doit retourner au moins deux documents.
  • La reponse doit etre du JSON valide.
  • Une question numerique doit appeler un outil de calcul.

Les scorers code-based de MLflow utilisent le decorateur @scorer et peuvent retourner une valeur simple ou un objet Feedback.

from mlflow.entities import Feedback
from mlflow.genai.scorers import scorer

@scorer
def has_citation(outputs: dict) -> Feedback:
    response = outputs.get("response", "")
    passed = "[source:" in response
    return Feedback(
        value=passed,
        rationale=(
            "The response includes a source marker."
            if passed
            else "The response does not include a source marker."
        ),
    )

@scorer
def compact_answer(outputs: dict) -> Feedback:
    response = outputs.get("response", "")
    word_count = len(response.split())
    passed = word_count <= 180
    return Feedback(
        value=passed,
        rationale=f"The response has {word_count} words.",
        metadata={"word_count": word_count, "limit": 180},
    )

Puis on les ajoute a la meme evaluation :

results = mlflow.genai.evaluate(
    data=eval_data,
    predict_fn=answer_question,
    scorers=[
        RelevanceToQuery(model=judge_model),
        RetrievalGroundedness(model=judge_model),
        RetrievalSufficiency(model=judge_model),
        has_citation,
        compact_answer,
    ],
)

L’habitude importante est de separer jugement et invariants. Utilise les juges LLM pour les qualites semantiques. Utilise le code pour ce qui doit etre objectivement vrai.

Comparer Des Versions De Prompt

Le prompt engineering devient risque quand on lit cinq exemples et qu’on decide que la nouvelle formulation “semble meilleure”.

Un meilleur workflow :

  1. Enregistrer les versions de prompts.
  2. Executer le meme dataset contre chaque version.
  3. Comparer groundedness, relevance, sufficiency et les metriques de policy.
  4. Promouvoir le prompt uniquement s’il ameliore la metrique cible sans regression sur les contraintes critiques.

MLflow Prompt Registry fournit des templates de prompts versionnes. Exemple simplifie :

import mlflow

PROMPT_V1 = [
    {
        "role": "system",
        "content": (
            "Answer the user's question using the provided context."
        ),
    },
    {
        "role": "user",
        "content": "Question: {{question}}\n\nContext:\n{{context}}",
    },
]

PROMPT_V2 = [
    {
        "role": "system",
        "content": (
            "You are a precise technical assistant. Answer only from the "
            "provided context. If the context is insufficient, say what is "
            "missing instead of guessing. Include source markers."
        ),
    },
    {
        "role": "user",
        "content": "Question: {{question}}\n\nContext:\n{{context}}",
    },
]

mlflow.genai.register_prompt(
    name="rag_answer_prompt",
    template=PROMPT_V1,
    commit_message="Baseline RAG prompt",
)

mlflow.genai.register_prompt(
    name="rag_answer_prompt",
    template=PROMPT_V2,
    commit_message="Require source-grounded answers and uncertainty handling",
)

Ensuite, on wrappe l’application pour que seule la version de prompt change.

def make_predict_fn(prompt_uri: str):
    prompt_template = mlflow.genai.load_prompt(prompt_uri)

    @mlflow.trace(span_type="AGENT")
    def predict(question: str) -> dict:
        docs = retrieve_docs(question)
        context = "\n\n".join(
            f"[source:{doc.id}] {doc.page_content}" for doc in docs
        )
        messages = prompt_template.format(question=question, context=context)
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=messages,
            temperature=0,
        )
        return {"response": response.choices[0].message.content}

    return predict

Puis on lance la comparaison :

with mlflow.start_run(run_name="rag_prompt_v1_eval"):
    results_v1 = mlflow.genai.evaluate(
        data=eval_data,
        predict_fn=make_predict_fn("prompts:/rag_answer_prompt/1"),
        scorers=scorers + [has_citation, compact_answer],
    )

with mlflow.start_run(run_name="rag_prompt_v2_eval"):
    results_v2 = mlflow.genai.evaluate(
        data=eval_data,
        predict_fn=make_predict_fn("prompts:/rag_answer_prompt/2"),
        scorers=scorers + [has_citation, compact_answer],
    )

print("V1:", results_v1.metrics)
print("V2:", results_v2.metrics)

C’est ici que le prompt engineering devient de l’engineering. La question n’est plus : “Est-ce que j’aime cette reponse?” La question devient : “Cette version ameliore-t-elle la groundedness et la presence de citations sans degrader la relevance?”

Evaluer Des Traces Existantes

Il y a deux modes courants :

  • Executer l’application pendant l’evaluation avec predict_fn.
  • Evaluer des sorties ou des traces deja collectees.

Le deuxieme mode compte en production, car relancer des appels LLM coute cher. On peut collecter des traces de trafic realiste, en annoter certaines avec des faits attendus ou du feedback humain, puis rescorrer ces traces quand les juges ou les policies evoluent.

La telemetrie de production devient alors un asset d’evaluation. La trace n’est pas seulement un artefact de debug. Elle devient l’unite que l’on inspecte, annote, score et compare dans le temps.

Ce Que Je Mettrais Dans La CI

Toutes les evaluations ne doivent pas etre dans la CI. Une suite complete LLM-as-a-judge peut etre lente et couteuse.

Je separerais les couches :

  • Checks deterministes rapides a chaque pull request : schema, citations, longueur maximale, chemin d’outil requis.
  • Petit smoke test avec juges sur les changements risquants de prompt ou de retriever.
  • Evaluation nightly plus large sur un dataset representatif.
  • Sampling de traces de production pour monitoring et revue humaine periodique.

Le gate CI doit rester simple :

required = {
    "has_citation/mean": 0.95,
    "compact_answer/mean": 0.90,
    "relevance_to_query/mean": 0.85,
    "retrieval_groundedness/mean": 0.90,
}

for metric, threshold in required.items():
    value = results.metrics.get(metric)
    if value is not None and value < threshold:
        raise SystemExit(f"{metric}={value:.3f} below threshold {threshold}")

Les noms exacts des metriques dependent de la configuration des scorers, mais le principe est stable : echouer le build uniquement sur des metriques liees clairement au risque de production.

Les Limites Des Juges LLM

LLM-as-a-judge est utile, mais ce n’est pas magique.

Les juges peuvent preferer des reponses plus longues. Ils peuvent survaloriser un style fluide. Ils peuvent manquer des erreurs de domaine subtiles. Ils peuvent aussi s’eloigner de ce que les vrais utilisateurs ou experts considerent comme de la qualite.

Je n’utiliserais donc pas une suite de juges sans :

  • Un dataset d’evaluation stable.
  • Une revue humaine sur un echantillon de traces.
  • Des prompts de juges ou definitions de scorers versionnes.
  • Des slices de regression par sujet, langue, segment client et type d’erreur.
  • Une calibration reguliere contre de nouveaux labels humains.

La metrique n’est pas l’objectif. L’objectif est de reduire les pannes en production.

Conclusion

Le passage de l’evaluation ML classique a l’evaluation GenAI n’est pas seulement un changement d’API. C’est un changement dans la definition meme de la qualite.

Pour RAG et les agents, la qualite vit dans le chemin d’execution : ce qui a ete recupere, ce qui a ete ignore, ce que le modele a affirme, ce que le prompt exigeait et quelles contraintes ont ete violees.

mlflow.genai.evaluate() donne un harnais pratique pour rendre cela mesurable. Les juges integres couvrent les questions semantiques courantes. Guidelines transforme la policy en checks repetables. Les scorers code-based verifient les invariants deterministes. Prompt Registry permet de comparer les versions de prompts sans perdre la lineage.

C’est la discipline dont la GenAI en production a besoin : pas des impressions, pas des demos isolees, mais des evaluation runs repetables qui montrent si le systeme progresse vraiment.

Sources

Share