Skip to content

Evaluating Prompt Quality

You can't optimize what you can't measure. Evaluating prompt quality means defining what "good" looks like, building a test set that represents real usage, and scoring outputs against a rubric. This lesson covers the three pillars of evaluation: metrics, test sets, and LLM-as-judge -- when to use each, and how to avoid the traps that make your evaluation numbers lie.

Gnome mascot

What you'll learn

  • Define success metrics before you write the prompt: accuracy for classification, relevance for retrieval, format compliance for structured output, and task-specific metrics (ROUGE, BLEU, exact match) for generation
  • A test set is a fixed collection of inputs and expected outputs. It must be representative of real usage, large enough to detect differences (30+ examples minimum), and never used during prompt development
  • Scoring rubrics turn subjective quality into a number. A 3-point rubric (correct, partially correct, incorrect) is more reliable than a 10-point scale because human judgment is noisy at fine granularity
  • LLM-as-judge uses a strong model (GPT-4, Claude) to score outputs automatically. It's cheaper than human evaluation but requires careful calibration -- the judge model has its own biases

The problem

You have two prompts that extract dates from legal documents. Prompt A feels more precise. Prompt B feels more robust. Which one is better? Without a metric and a test set, you're guessing. You might ship Prompt A because it "sounds better," only to discover it fails on the date format your users actually submit.

Evaluation answers the question definitively. You define a metric, run both prompts against the same test set, and compare the numbers. Prompt A scores 82% and Prompt B scores 91%. Ship B. No argument, no gut feeling, no "let me ask the team what they think."

Options & when to use each

Metric typeWhat it measuresWhen to useExample
Exact matchOutput == expected (after normalization)Classification, entity extraction, any task with a single correct answer"positive" == "positive"
Contains matchExpected string appears in outputExtraction tasks where the answer is embedded in longer text"March 15, 2024" in "The contract was signed on March 15, 2024"
Format complianceOutput follows a specified schema or structureStructured output, JSON, code generationjson.loads(output) succeeds without error
LLM-as-judgeA strong model scores the output against a rubricOpen-ended generation: summaries, translations, creative writing"Rate the summary on a scale of 1-5 for completeness"
Human evaluationA person scores the outputWhen LLM-as-judge isn't reliable: tone, safety, subjective quality"Does this response sound empathetic?"

Build it

Step 1: Build a test set

A test set is the foundation of evaluation. Build it once, freeze it, and never use it for prompt development:

python
import json
from pathlib import Path

class TestSet:
    def __init__(self, name: str, metric: str):
        self.name = name
        self.metric = metric
        self.cases = []

    def add(self, input_text: str, expected: str, metadata: dict = None):
        self.cases.append({
            "id": f"{self.name}-{len(self.cases):04d}",
            "input": input_text,
            "expected": expected,
            "metadata": metadata or {}
        })

    def save(self, path: str):
        with open(path, "w") as f:
            json.dump({
                "name": self.name,
                "metric": self.metric,
                "cases": self.cases
            }, f, indent=2)

    @classmethod
    def load(cls, path: str) -> "TestSet":
        with open(path) as f:
            data = json.load(f)
        ts = cls(data["name"], data["metric"])
        ts.cases = data["cases"]
        return ts

    def __len__(self):
        return len(self.cases)


# Build a sentiment classification test set
test_set = TestSet(name="sentiment-v1", metric="exact_match")

# Positive examples
test_set.add("I absolutely love this product, it changed my life!", "positive")
test_set.add("Great service, would recommend to anyone.", "positive")
test_set.add("Five stars, no complaints whatsoever.", "positive")

# Negative examples
test_set.add("Complete waste of money, broke after one use.", "negative")
test_set.add("Terrible customer service, never buying again.", "negative")
test_set.add("Arrived damaged and they refused to refund.", "negative")

# Neutral / edge cases
test_set.add("It arrived on time.", "neutral")
test_set.add("The product is blue.", "neutral")
test_set.add("I haven't used it yet.", "neutral")

# Tricky cases -- mixed sentiment
test_set.add("The product works great but the packaging was terrible.", "mixed")
test_set.add("Customer service was helpful but the product is mediocre.", "mixed")

test_set.save("test_sets/sentiment-v1.json")
print(f"Test set: {len(test_set)} cases")

Step 2: Define scoring functions

Each metric type needs its own scoring function. Build them once, reuse them everywhere:

python
import re
import json as json_lib

def score_exact_match(output: str, expected: str) -> float:
    """Normalize and compare. 1.0 for exact match, 0.0 otherwise."""
    def normalize(s: str) -> str:
        return s.strip().lower().rstrip(".")
    return 1.0 if normalize(output) == normalize(expected) else 0.0

def score_contains(output: str, expected: str) -> float:
    """Check if the expected string appears anywhere in the output."""
    return 1.0 if expected.lower() in output.lower() else 0.0

def score_format_compliance(output: str, schema_type: str = "json") -> float:
    """Check if the output is valid JSON (or other format)."""
    if schema_type == "json":
        try:
            # Find JSON in the output (it might be wrapped in markdown)
            json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', output, re.DOTALL)
            if json_match:
                json_lib.loads(json_match.group(1))
                return 1.0
            json_lib.loads(output)
            return 1.0
        except (json_lib.JSONDecodeError, ValueError):
            return 0.0
    return 0.0

def score_llm_judge(output: str, expected: str, rubric: str, model: str = "gpt-4o") -> float:
    """Use a strong model to score the output against a rubric."""
    from openai import OpenAI
    client = OpenAI()

    judge_prompt = f"""You are evaluating the quality of an AI-generated response.

Rubric:
{rubric}

Reference (expected) output:
{expected}

Generated output to evaluate:
{output}

Score the generated output on a scale of 0-100 based on the rubric. Return ONLY the number."""

    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": judge_prompt}],
        temperature=0
    )
    score_text = response.choices[0].message.content.strip()
    try:
        return float(score_text) / 100.0
    except ValueError:
        return 0.0

Step 3: Run evaluation against a test set

This step executes the chosen prompt against the test set and calculates accuracy metrics. The function tracks correct answers, identifies failures for debugging, and computes the 95% confidence interval.

python
def evaluate(
    prompt_fn,  # Function that takes input text and returns output
    test_set: TestSet,
    metric_fn,  # Function that takes (output, expected) and returns float
    model: str = "gpt-4o-mini"
) -> dict:
    """Run a prompt against a test set and return detailed results."""
    results = []
    scores = []

    for case in test_set.cases:
        output = prompt_fn(case["input"])
        score = metric_fn(output, case["expected"])
        results.append({
            "id": case["id"],
            "input": case["input"],
            "expected": case["expected"],
            "output": output,
            "score": score
        })
        scores.append(score)

    accuracy = sum(scores) / len(scores) if scores else 0.0

    # Find failure cases
    failures = [r for r in results if r["score"] < 1.0]

    # Compute confidence interval (simple binomial proportion)
    n = len(scores)
    se = (accuracy * (1 - accuracy) / n) ** 0.5 if n > 0 else 0
    ci_lower = max(0, accuracy - 1.96 * se)
    ci_upper = min(1, accuracy + 1.96 * se)

    return {
        "test_set": test_set.name,
        "n_cases": n,
        "accuracy": accuracy,
        "ci_95": (ci_lower, ci_upper),
        "failures": failures,
        "results": results
    }

Step 4: Build a scoring rubric

A rubric turns "this looks good" into a number. The key is making it specific enough that two evaluators would give the same score:

python
RUBRIC_SUMMARIZATION = """
Score the summary on the following criteria, each worth 25 points (total 100):

1. COMPLETENESS (0-25): Does the summary capture all key points from the source?
   - 25: All key points present, nothing important missing
   - 15: Most key points present, 1-2 minor omissions
   - 5: Several key points missing
   - 0: Summary misses the main point entirely

2. CONCISENESS (0-25): Is the summary free of unnecessary detail?
   - 25: Every sentence adds value, no repetition
   - 15: Mostly concise, 1-2 redundant sentences
   - 5: Contains significant filler or repetition
   - 0: Longer than the original or mostly filler

3. ACCURACY (0-25): Does the summary contain any factual errors?
   - 25: No factual errors
   - 15: One minor error that doesn't change the meaning
   - 5: One significant error or multiple minor errors
   - 0: Fabricated information not in the source

4. COHERENCE (0-25): Does the summary read as a well-structured paragraph?
   - 25: Flows naturally, logical order
   - 15: Mostly coherent, one awkward transition
   - 5: Jumpy, hard to follow
   - 0: Incoherent, random sentences
"""

# Use the rubric with LLM-as-judge
score = score_llm_judge(
    output="The meeting covered Q3 budget planning. The team agreed to increase marketing spend by 15%.",
    expected="Key points: Q3 budget review, marketing spend increase of 15%, team consensus reached.",
    rubric=RUBRIC_SUMMARIZATION
)

What goes wrong

MistakeHow you notice itThe fix
Test set is too smallAccuracy jumps from 70% to 90% between runs with no prompt changesMinimum 30 examples. For production, 100+. A test set of 5 examples has a confidence interval of +/- 40% -- useless
Test set leaks into developmentAccuracy is 95% on the test set but users complainNever look at the test set while developing prompts. Split into train/dev/test. Only run against test when you're done iterating
LLM-as-judge has positional biasThe same output scores higher when it appears first in the comparisonRandomize the order of outputs when using LLM-as-judge for pairwise comparison. Or use a pointwise rubric (score each output independently)
Exact match is too strictA correct answer gets scored 0 because of capitalization or trailing whitespaceAlways normalize before comparison: lowercase, strip whitespace, remove trailing punctuation
Rubric is too vagueLLM-as-judge gives every output 85-95, no discriminationMake rubrics behavioral: "The summary includes the budget number" not "The summary is complete." Test the rubric on known-good and known-bad outputs before using it

Confirm it worked

Execute this script to verify the evaluation utilities function as designed. These tests ensure exact match scoring handles case insensitivity and trailing punctuation while checking that the test set loads properly.

python
# Test 1: Exact match scoring
assert score_exact_match("positive", "positive") == 1.0
assert score_exact_match("positive", "Positive") == 1.0  # Case insensitive
assert score_exact_match("positive.", "positive") == 1.0  # Trailing punctuation
assert score_exact_match("positive", "negative") == 0.0

# Test 2: Test set loads and saves
ts = TestSet("test", "exact_match")
ts.add("hello", "world")
ts.save("/tmp/test_set.json")
loaded = TestSet.load("/tmp/test_set.json")
assert len(loaded) == 1
assert loaded.cases[0]["expected"] == "world"

# Test 3: Evaluation produces confidence intervals
def dummy_prompt(text):
    return "positive"  # Always predicts positive

ts = TestSet("binary", "exact_match")
ts.add("I love it", "positive")
ts.add("I hate it", "negative")
ts.add("It's okay", "neutral")

result = evaluate(dummy_prompt, ts, score_exact_match)
assert result["accuracy"] == 1/3  # Only 1 of 3 correct
assert result["ci_95"][0] <= result["accuracy"] <= result["ci_95"][1]
assert len(result["failures"]) == 2  # Two incorrect predictions

# Test 4: Format compliance scoring
assert score_format_compliance('{"key": "value"}') == 1.0
assert score_format_compliance("not json") == 0.0
assert score_format_compliance('```json\n{"key": "value"}\n```') == 1.0

Next: A/B Testing Prompts -- once you can measure quality, you run controlled experiments to compare prompts.