GenAI-Evaluation mit mlflow.genai.evaluate(): Jenseits von Accuracy
Der erste Fehler bei der Evaluation von GenAI-Systemen ist, die Antwort wie ein Klassifikationslabel zu behandeln.
Das funktioniert bei deterministischen Machine-Learning-Aufgaben. Es funktioniert nicht bei einem Retrieval-Augmented-Generation-System (RAG) oder einem Agenten, der Tools aufruft, Kontext sucht und dann eine Antwort in natuerlicher Sprache schreibt. Es kann viele gueltige Antworten geben. Das Modell kann korrekt paraphrasieren. Es kann aber auch fluessig klingen und trotzdem Fakten erfinden.
Deshalb sind BLEU, ROUGE, Exact Match und String-Similarity schlechte Hauptmetriken fuer produktive GenAI-Systeme. Sie messen lexikalische Ueberschneidung. Sie sagen nicht, ob die Antwort im Kontext verankert ist, ob der Retriever genug Evidenz geholt hat, ob die Antwort die Frage wirklich beantwortet oder ob eine Prompt-Aenderung heimlich eine Regression eingefuehrt hat.
In MLflow 3.x ist die relevante API fuer diese Arbeit mlflow.genai.evaluate(). Wenn du mlflow.evaluate() aus klassischer ML-Evaluation kennst, ist das Grundprinzip aehnlich, aber der GenAI-Weg arbeitet mit Traces, Scorern, LLM-Judges, Expectations und Evaluation Runs.
Dieser Artikel zeigt den Evaluations-Harness, den ich fuer eine RAG-Pipeline bauen wuerde: Dataset, explizite Scorer, trace-bewusste Custom Metrics und ein Workflow zum Vergleich von Prompt-Versionen.
Was Evaluieren Wir Eigentlich?
Ein RAG-System hat mindestens drei Qualitaetsflaechen:
- Retrieval-Qualitaet: Hat der Retriever Dokumente geholt, die zur Frage passen?
- Kontext-Suffizienz: Enthalten diese Dokumente genug Informationen, um die Frage zu beantworten?
- Generierungsqualitaet: Nutzt das Modell den Kontext korrekt und antwortet ohne Halluzinationen?
Das sind unterschiedliche Fehlerklassen. Wenn man sie in eine einzige “Accuracy”-Zahl presst, verliert man die Faehigkeit, das System gezielt zu reparieren.
Beispiele:
- Wenn die Antwort falsch ist, weil der Retriever irrelevante Chunks geholt hat, hilft kein besserer Prompt.
- Wenn die Chunks die Antwort enthalten, das Modell sie aber ignoriert, helfen bessere Embeddings nicht.
- Wenn die Antwort korrekt geerdet, aber zu lang ist, liegt das Problem bei Response Policy oder Prompt Design, nicht beim Retrieval.
Der Evaluations-Harness muss diese Grenzen sichtbar halten.
Die MLflow-3.x-Struktur
Eine MLflow-GenAI-Evaluation hat drei zentrale Teile:
- Ein Dataset
- Eine
predict_fn, ausser du bewertest bereits erzeugte Outputs oder Traces - Eine explizite Liste von Scorern
Ein kleines Dataset kann eine Liste von Dictionaries sein:
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"
},
},
]
Fuer Produktion wuerde ich daraus ein MLflow Evaluation Dataset machen, damit Testfaelle versioniert, reviewed, annotiert und wiederverwendet werden koennen. Fuer schnelle lokale Iteration reicht eine Liste.
Die predict_fn sollte denselben Anwendungspfad aufrufen, den du ausliefern willst. Wenn du einen Spielzeug-Wrapper evaluierst, aber eine andere Chain deployest, misst die Metrik nur das Spielzeug.
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),
}
Wenn dein Dataset inputs: {"question": "..."} enthaelt, kann MLflow diese Funktion direkt aufrufen, weil der Parametername zum Dataset-Key passt.
Die Scorer: Starte Mit Den Fehlerklassen
MLflow macht die Scorer-Auswahl explizit. Das ist gut. Automatische Suites sind bequem, fuehren aber oft zu vagen Dashboards. Eine produktive Evaluation sollte genau sagen, was sie misst.
Fuer eine RAG-Anwendung wuerde ich mit diesen Dimensionen starten:
RetrievalGroundedness: Ist die Antwort durch den abgerufenen Kontext gestuetzt?RetrievalSufficiency: Hat der Retriever genug Evidenz fuer die erwarteten Fakten geliefert?RelevanceToQuery: Beantwortet die Antwort die Frage des Users?Guidelines: Folgt die Antwort Policy, Stil oder Formatregeln?- Eigene code-basierte Scorer: Erfuellt das System deterministische Business Constraints?
Das Grundgeruest:
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)
Das Judge-Modell ist eine Kosten- und Qualitaetsentscheidung. In frueher Entwicklung ist ein staerkeres Modell sinnvoll, wenn du bessere Fehleranalyse brauchst. Bei Skalierung kann ein kleinerer Judge reichen, wenn er mit menschlichem Feedback kalibriert wird.
Trace-Bewusste RAG-Evaluation
Die RAG-Judges sind besonders nuetzlich, wenn die Anwendung korrekt getraced ist. Der Retrieval-Schritt sollte ein RETRIEVER-Span sein, und die abgerufenen Dokumente sollten in einer Struktur liegen, die MLflow inspizieren kann.
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
]
Dieses Design ist wichtig. Wenn der Retriever nur einen zusammengefuegten String zurueckgibt, kann der Judge Retrieval-Qualitaet und Generierungsqualitaet nicht sauber trennen. Wenn das Retriever-Span fehlt, koennen trace-bewusste RAG-Judges den Evidenzpfad nicht auswerten.
Fuer produktive Systeme wuerde ich das als Interface-Vertrag behandeln: Jeder Retriever gibt Dokumentobjekte mit stabilen Metadaten wie doc_uri, chunk_id und einem Relevanzscore aus der Vektorsuche zurueck.
Code-Based Scorers Hinzufuegen
LLM-Judges sind gut fuer semantische Qualitaet. Sie sind nicht fuer jede Pruefung das richtige Werkzeug.
Wenn eine Anforderung deterministisch ist, schreibe Code. Beispiele:
- Die Antwort muss mindestens eine Citation enthalten.
- Die Antwort darf 180 Woerter nicht ueberschreiten.
- Der Retriever muss mindestens zwei Dokumente liefern.
- Die Antwort muss valides JSON sein.
- Bei numerischen Fragen muss ein Calculator-Tool aufgerufen werden.
MLflow-Code-Scorer nutzen den @scorer-Decorator und koennen einfache Werte oder ein Feedback-Objekt zurueckgeben.
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},
)
Dann kommen sie in dieselbe 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,
],
)
Die wichtige Gewohnheit: Urteil und Invarianten trennen. Nutze LLM-Judges fuer semantische Qualitaeten. Nutze Code fuer Dinge, die objektiv wahr sein muessen.
Prompt-Versionen Vergleichen
Prompt Engineering wird riskant, wenn man fuenf Beispiele liest und entscheidet, dass die neue Formulierung “besser klingt”.
Ein besserer Workflow ist:
- Prompt-Versionen registrieren.
- Dasselbe Dataset gegen jede Version laufen lassen.
- Groundedness, Relevance, Sufficiency und Policy-Metriken vergleichen.
- Den Prompt nur promoten, wenn die Zielmetrik steigt und kritische Constraints nicht schlechter werden.
MLflow Prompt Registry gibt dir versionierte Prompt Templates. Ein vereinfachtes Beispiel:
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",
)
Dann sollte die Anwendung so gewrappt werden, dass nur die Prompt-Version variiert.
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
Der Vergleich:
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)
Hier wird Prompt Engineering zu Engineering. Die Frage lautet nicht mehr: “Gefallt mir diese Antwort?” Die Frage lautet: “Hat diese Version Groundedness und Citation Compliance verbessert, ohne Relevance zu verschlechtern?”
Bestehende Traces Evaluieren
Es gibt zwei typische Modi:
- Die App waehrend der Evaluation mit
predict_fnausfuehren. - Bereits vorhandene Outputs oder Traces bewerten.
Der zweite Modus ist in Produktion wichtig, weil wiederholte LLM-Aufrufe teuer sind. Du kannst Traces aus realistischem Traffic sammeln, einige davon mit erwarteten Fakten oder menschlichem Feedback annotieren und diese Traces spaeter neu scoren, wenn du Judges oder Policies verbesserst.
Damit wird Produktionstelemetrie zu einem Evaluations-Asset. Der Trace ist nicht nur ein Debugging-Artefakt. Er wird zur Einheit, die man inspizieren, labeln, bewerten und ueber Zeit vergleichen kann.
Was Ich In CI Packen Wuerde
Nicht jede Evaluation gehoert in CI. Eine komplette LLM-as-a-Judge-Suite kann teuer und langsam sein.
Ich wuerde Evaluation schichten:
- Schnelle deterministische Checks bei jedem Pull Request: Schema, Citations, maximale Antwortlaenge, erforderlicher Tool-Pfad.
- Kleiner Judge-basierter Smoke Test bei riskanten Prompt- oder Retriever-Aenderungen.
- Groessere Nightly Evaluation auf einem repraesentativen Dataset.
- Production Trace Sampling fuer Monitoring und regelmaessigen Human Review.
Das CI-Gate sollte langweilig sein:
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}")
Die konkreten Metriknamen haengen von der Scorer-Konfiguration ab, aber das Prinzip ist stabil: Den Build nur auf Metriken scheitern lassen, die klar mit Produktionsrisiko verbunden sind.
Fehlerklassen Bei LLM-Judges
LLM-as-a-Judge ist nuetzlich, aber nicht magisch.
Judges koennen laengere Antworten bevorzugen. Sie koennen fluessigen Stil ueberbewerten. Sie koennen subtile Domain-Fehler uebersehen. Und sie koennen von dem abweichen, was echte User oder Fachexperten als Qualitaet betrachten.
Deshalb wuerde ich keine Judge-Suite betreiben ohne:
- Ein stabiles Evaluation Dataset.
- Menschlichen Review auf einem Trace-Sample.
- Versionierte Judge Prompts oder Scorer-Definitionen.
- Regression Slices nach Thema, Sprache, Kundensegment und Fehlertyp.
- Regelmaessige Kalibrierung gegen frische Human Labels.
Die Metrik ist nicht das Ziel. Das Ziel sind weniger Produktionsfehler.
Fazit
Der Wechsel von klassischer ML-Evaluation zu GenAI-Evaluation ist nicht nur ein API-Wechsel. Es ist ein Wechsel darin, was Qualitaet bedeutet.
Bei RAG und Agenten liegt Qualitaet im Ausfuehrungspfad: Was wurde abgerufen? Was wurde ignoriert? Was hat das Modell behauptet? Was verlangte der Prompt? Welche Constraints wurden verletzt?
mlflow.genai.evaluate() liefert einen praktischen Harness, um das messbar zu machen. Built-in Judges decken die ueblichen semantischen Fragen ab. Guidelines machen Policy zu wiederholbaren Checks. Code-based Scorers pruefen deterministische Invarianten. Prompt Registry erlaubt Prompt-Vergleiche ohne Lineage-Verlust.
Das ist die Disziplin, die produktive GenAI braucht: keine Vibes, keine isolierten Demos, sondern wiederholbare Evaluation Runs, die zeigen, ob das System wirklich besser wird.
Quellen
- MLflow: Evaluating LLMs and Agents
- MLflow: Evaluation examples
- MLflow: Built-in LLM Judges
- MLflow: RetrievalGroundedness judge
- MLflow: RetrievalSufficiency judge
- MLflow: Answer and Context Relevance judges
- MLflow: Guidelines judge
- MLflow: Custom code-based scorers
- MLflow: Evaluating prompts
- MLflow: Prompt Registry