Vol. 12 · Evaluation & Improvement
Evaluation Process & LLM-as-Judge
How to build the process of evaluating LLM systems from scratch: minimum viable eval, guardrails vs evaluators, why a binary assessment is better than a scale of 1–5, negative scenarios, LLM-as-judge with correlation to people, detection of degradation after release, synthetic data - when it helps and when it harms.
Eval Process: where to start
01ProcessMinimum Viable Eval: from scratch in 3 steps
Most teams put off evaluation because it seems like expensive infrastructure is needed. No: the minimum working eval is 20–50 examples, pass/fail criteria and one “benevolent dictator” - a person with domain expertise, whose opinion is considered the standard.
sqlPHASE 1 - LAUNCH (Day 1-3)
Step 1: Collect 20–50 Real Requests
└─ from production logs, from stakeholders, invented by hand
└─ cover: standard cases + 5–10 edge cases
└─ DO NOT generate LLM at this stage
Step 2: one expert marks manually
└─ "benevolent dictator": PM / domain expert / TL
└─ for each example: pass / fail + 1 line why
└─ DO NOT discuss the criteria - first mark it intuitively
└─ then - extract patterns from your solutions into criteria
Step 3: run the system, compare
└─ count pass_rate = passes / total
└─ look at specific fails - WHAT exactly is wrong
└─ this is your baseline - remember it
───────────────────────────────── ─────────────────────────────────
PHASE 2 - GROWTH (after the first launch)
every prod bug → add to dataset
each prompt change → run eval
every 2 weeks → review of criteria
with 50+ examples → you can think about automationstart antipatterns
- We are waiting for the “correct” infrastructure
- we immediately build LLM-judge without baseline
- generate a dataset LLM without verification
- 20 manual examples > 1000 synthetic ones
02ProcessError Analysis: hands before automation
After each significant change to the system (prompt, model, chunking, retriever), be sure to read 20–50 outputs manually. Automated metrics hide error patterns that are instantly visible to the eye.
sql# After any change:
sample = random_sample(outputs, n=30)
# or: all fails from eval for the period
# For each read:
✓ Is the answer correct in meaning?
✓ Is the answer based on context or fictitious?
✓ Is the formatting as expected?
✓ Is the answer too long/short/evasive?
✓ Does the model respond when it should fail?
✓ Does the model refuse when it should respond?
# Group errors by type:
hallucination | wrong_refusal
off_topic | format_error
incomplete | tone_mismatch
# Most common category → fix firstpractice
- read the fails yourself - don’t delegate to the junior
- 30 examples give 80% of the picture
- every bug → test case in the dataset
03ProcessResponse metrics vs pipeline metrics
Key principle: a good bottom line metric doesn't tell you where it's broken. Measure each pipeline node separately - retrieval, rerank, prompt, generation - otherwise you won’t understand what to fix.
sqlPIPLINE NODE METRICS SUMMARY METRICS
Query
│
▼
Retrieval ──→ Recall@k, MRR, nDCG@k ┐
│ │
▼ │
Rerank ──→ Precision@k, MRR delta │──→ Faithfulness
│ │Answer Relevance
▼ │ Task Success Rate
Prompt ──→ token count, format │
│ few-shot hit rate │
▼ │
Generation ──→ hallucination rate │
refusal rate │
latency, cost ┘
The final metric is growing → but from where?
Nodal metrics → we know exactly what has changedrule
- change one variable at a time
- record the dataset, model, index when comparing
- comparing on different datasets = pointless
04ProcessTrace: what must be logged
Trace - a record of everything that happened in the pipeline for one request. Without traces, you cannot debug, build eval and detect degradation. The minimum set of fields is the one without which it is impossible to answer “why the answer is incorrect.”
json{
"trace_id": "uuid", # end-to-end ID
"timestamp": "ISO-8601",
"user_id": "...", # for user-level analysis
// Input
"query": "original query",
"query_rewrite":"after augmentation", # if any
// Retrieval
"retrieved_chunks": ["chunk_id", "score", "text"],
"reranked_chunks": ["chunk_id", "score"],
//Generation
"prompt_tokens": 1240,
"completion_tokens": 312,
"model": "cloud-sonnet-4",
"response": "final response",
"latency_ms": 1840,
// Eval (async, after request)
"faithfulness": 0.92,
"refusal": false
}tools
- Langfuse - open-source, self-hosting
- Phoenix Arize - traces + eval
- the trace structure is more important than the tool
Binary vs Scale: why pass/fail wins
05BinaryWhy binary rating is better than 1-5 scale
Intuition suggests: a scale of 1–5 is more accurate, because it contains more information. Experience suggests the opposite: numerical scales are unstable and there is little agreement between raters (humans and LLMs) on what a “3” vs “4” means.
sqlTHE PROBLEM OF SCALES
Same answer, three evaluators:
Ivan: 4 - “good, but a little incomplete”
Maria: 3 — “average, expected more details”
LLM: 5 - “completely answers the question”
Cohen's κ = 0.18 ← weak agreement, noise
───────────────────────────────── ─────────────────────────────────
WHY BINARY WORKS
The same answer, worded as a clear criterion:
"Does the answer contain a specific number?" → pass / fail
Ivan: pass
Maria: pass
LLM: pass
Cohen's κ = 0.91 ← excellent agreement
───────────────────────────────── ─────────────────────────────────
PRINCIPLE: One criterion = one binary test
Bad: "how good is the answer from 1 to 5?"
→ mixes: relevance + completeness + tone + facts
Okay: four separate questions:
1. Does the answer answer the user's question? Y/N
2. Is the answer supported by the context (not made up)? Y/N
3. Does the tone match the brand style? Y/N
4. Does the response contain the requested format? Y/N
pass_rate for each → it’s clear what exactly is brokenwhen the scale is still justified
- ranking of multiple options (A/B/C/D)
- quality gradations with clear categories and examples
- only if κ > 0.6 on the pilot marking
- never for automation without verification
06BinaryHow to formulate pass/fail criteria
The criterion must be formulated so that any reasonable person (or LLM) would give the same answer. Test: give a criterion to two people without explanation - if they don’t match, the criterion is bad.
sql# Structure: "The answer [does X]?" where X is specifically
✗ Bad (blurry):
"Is the answer quality?"
"Is the answer useful to the user?"
"Is the answer correct?"
✓ Good (specific):
"Does the answer contain at least one specific number?"
"The answer doesn't mention a competitor company?"
"Is the answer written in Russian?"
“The answer begins with a direct answer, not with introductory words?”
"Does the answer contain statements that are not in the context?"
# Criterion test:
sample = 20 examples
human_1_labels = [...]
human_2_labels = [...]
κ = cohens_kappa(human_1_labels, human_2_labels)
# κ > 0.6 → criterion is valid
# κ < 0.4 → reformulatepractice
- one criterion → one question
- formulation through specific behavior
- abstract words: “quality”, “useful”
07BinaryFalse Refusal vs Hallucination: how to choose a balance
These are two opposite types of errors. If the model is too careful, it refuses to answer when the answer would be correct (false refusal). If she is too bold, she makes up facts (hallucination). The balance depends on the domain and the cost of the error.
textTWO TYPES OF ERROR
FALSE REFUSAL (type I error)
Situation: the answer would be correct, but the model refused
Example: “I can’t answer this question” - but I could
Price: bad UX, user did not receive help
Metric: refusal_rate on "safe" questions
HALLUCINATION (error of the second type)
Situation: the model answered, but made up the facts
Example: “The company was founded in 1998” is incorrect
Cost: loss of trust, legal risks, harm
Metrics: faithfulness_score, citation_accuracy
───────────────────────────────── ─────────────────────────────────
HOW TO CHOOSE A BALANCE
Domain with a high hallucination price → hard refusal
medicine, law, finance:
It is BETTER to refuse than to give out an incorrect fact.
→ policy: “if not in the context, say “I don’t know””
Domain with high failure cost → tolerance for inaccuracies
chatbotonsite, creative assistant:
It is BETTER to give an approximate answer than to refuse
→ policy: "answer with caveat 'check with a specialist'"
───────────────────────────────── ─────────────────────────────────
HOW TO MEASURE
refusal_rate = refusals / all requests
— monitor separately for “safe” and “unsafe” requests!
hallucination_rate = unsubstantiated claims / all claims
Separate dataset for each class:
safe_questions → refusal_rate must be < threshold
unsafe_questions → refusal_rate must be > thresholdtypical thresholds
- medicine: hallucination < 1%, refusal ok
- e-commerce: refusal < 5%, hallucination < 10%
- always measure separately - one final figure hides
LLM-as-Judge: correlation with people
08JudgeCorrelation of LLM-judge with human eval
LLM-judge is only useful if his scores match how people score. Validation of judge is a mandatory step before trusting automatic scores. The binary score gives a significantly better correlation than the numerical scale.
textWHY BINARY CORRELATES BETTER WITH PEOPLE
Experiment: 200 examples, 3 experts + LLM-judge
Scale 1–5 (LLM vs expert average):
Pearson r = 0.52 ← weak correlation
Cohen's κ = 0.21 ← poor agreement
LLM: "4", Expert 1: "3", Expert 2: "5" - are all three right?
Binary pass/fail (LLM vs expert consensus):
Accuracy = 0.84 ← much better
Cohen's κ = 0.67 ← good agreement
pass/fail clearly - “is the fact in the source? Y/N”
───────────────────────────────── ─────────────────────────────────
HOW TO VALIDATE JUDGE
Step 1: Collect 100–200 examples of varying quality
Step 2: Mark manually (2–3 experts)
Step 3: run LLM-judge using the same examples
Step 4: Read Agreement Metrics
Good judge (binary): κ > 0.6, accuracy > 0.80
Acceptable: κ 0.4–0.6
Bad - do not use: κ < 0.4
Review judge when changing domain/model/criteriaknown problems of LLM-judge
- self-enhancement bias: the model prefers its own answers
- verbose bias: long answer scores higher
- position bias: the first in a pair gets an advantage
- use a stronger model as judge
09JudgeJudge Prompt: Structure for Reliability
The quality of judge critically depends on the structure of its prompt. Template: clear criterion → CoT reasoning → final verdict. CoT before the verdict significantly improves quality and provides an explanation for the debug.
textSYSTEM:
You evaluate the quality of the AI assistant's answers.
Answer strictly according to the format below.
USER:
USER QUESTION: {question}
CONTEXT (sources): {context}
ASSISTANT'S REPLY: {answer}
CRITERIA:
Are all the facts in the answer supported by context?
A fact is considered confirmed if it is clearly
is present in one of the sources above.
Step 1. Explain: List all statements in your answer.
and for each, indicate whether it is in the context.
Step 2. Verdict: PASS or FAIL.
(PASS only if ALL statements are confirmed)
EXPECTED FORMAT:
Analysis: [chain of reasoning]
Verdict: PASS/FAILhow to increase stability
- Temperature=0 for reproducibility
- 3 runs → majority vote at T>0
- swap A/B in pairwise comparison
- CoT before the verdict - not after
10JudgeWhen BERT, when LLM as a judge
Not every check needs to be done through an LLM call. Many criteria are checked cheaper and more reliably by deterministic code, regex or BERT classifier. LLM-judge - only where understanding of the meaning is needed.
| What we check | Best tool | Why |
|---|---|---|
| JSON validity | json.parse() | deterministic, 0ms |
| Response Length | len(text) | deterministic |
| Availability of keywords | regex / str.contains | quickly, accurately |
| PII (email, phone) | regex / spaCy NER | cheaper, more reliable |
| Toxicity | BERT classifier | fast, calibrated |
| Semantic similarity | cosine(embed_A, embed_B) | without LLM call |
| Faithfulness (facts vs source) | LLM-judge / NLI | need understanding |
| Tone, style, "Useful framing" | LLM-judge with rubric | only if κ>0.6 |
principle
- cheap first: regex → BERT → LLM
- LLM-judge everywhere = expensive and unstable
11JudgeLLM-judge as guardrail: why not
It would seem logical: put LLM-judge directly into the pipeline as a filter for each answer. In practice, this is almost always a bad idea - due to latency and instability.
textProblem 1: Latency
Main call: 800ms
+ LLM-judge: +600ms ← +75% latency
= total: 1400ms ← user notices
Problem 2: Instability
The same request, judge in the pipeline:
Run 1: PASS → response sent
Run 2: FAIL → response blocked
← non-deterministic UX
Problem 3: False locks
judge is wrong → the correct answer is blocked
the user receives a refusal to a valid question
──────────────────────── ─────────────────────────
What to do instead:
In-pipeline (guardrail): regex + BERT (fast)
Async eval (evaluator): LLM-judge on sample
→ estimate 10% of traffic asynchronously
→ do not block the user
→ alert when metrics dropexceptions
- very high rates (medicine) → latency can be tolerated
- then: cache judge on similar queries
Guardrails vs Evaluators: different roles
12GuardrailGuardrail vs Evaluator: architectural difference
Guardrail is part of the production pipeline, works synchronously, blocks or modifies output in real time. Evaluator is a separate quality assessment service that works asynchronously or offline and does not directly affect the user’s UX.
textGUARDRAIL - in the pipeline
USER → [INPUT GUARDRAIL] → LLM → [OUTPUT GUARDRAIL] → USER
│ │
▼ BLOCK ▼ BLOCK/MODIFY
reject replace/warn
Specifications:
✓ Synchronous - blocks until answered
✓ Deterministic - no instability
✓ Fast - regex/BERT, not LLM
✓ Affects EVERY request
───────────────────────────────── ─────────────────────────────────
EVALUATOR - outside the pipeline
USER → LLM → USER ← user does not wait
│
└──→ LOG → [EVALUATOR] → METRICS → DASHBOARD
▼
ALERT if degraded
Specifications:
✓ Asynchronous - does not affect latency
✓ Can use LLM-judge (expensive - ok)
✓ Evaluates a sample (10–100% of traffic)
✓ Gives a signal to improve the systemkey rule
- Guardrail = security and format
- Evaluator = quality and improvement
- do not confuse the roles - LLM-judge is not guardrail
13GuardrailWhat to install in Guardrail: tools
The rule of choice: the cheaper and more deterministic the tool, the better for guardrail. Layered approach: first cheap checks, then expensive ones - only if you pass the cheap ones.
textLayer 1 - deterministic (0ms, $0)
regex: prohibited words, PII patterns
schema: JSON validation, data type
length: min/max tokens
format: expected response structure
Layer 2 - BERT/classifier (5–20ms, ~$)
toxicity: BERT toxicity classifier
PII: spaCy NER for email/phone/passport
topic: zero-shot classifier (not our topic?)
lang: detect_language(response) == "ru"
Layer 3 - Light LLM (50–200ms, $$)
safety: haiku/mini checks safety
schema: "response is valid JSON with fields X,Y?"
← only if the first layers were skipped
Layer 4 - heavy LLM (never in guardrail)
faithfulness, helpfulness → evaluator only14GuardrailEvaluator as a separate service
Evaluator is an asynchronous service subscribed to a log queue. Evaluates a random sample of traffic and builds metrics. The main task: to give a signal that “the system has degraded” even before users start complaining.
python# Data stream
PROD TRAFFIC → message queue (Kafka/SQS)
│
sampling: 10% of requests
│
▼
EVALUATOR WORKER
for each trace:
faithfulness = llm_judge(...)
format_ok = regex_check(...)
refusal = detect_refusal(...)
│
▼
METRICS STORE (TimeSeries DB)
│
▼
ALERT if score_7d_avg drops > 5%what to monitor
- faithfulness_score (moving average)
- refusal_rate (growth = problem)
- latency p50/p95/p99
- alert when deviation > 2σ from baseline
Negative Scenarios: dataset for failures
15NegativeNegative Scenarios: why and how to build a dataset
Most teams only test the happy path: “ask a question, get an answer.” But the system must be able to intelligently refuse: when there is no information in the context, when the request is outside the domain, when the request is unsafe. Each type requires a separate dataset.
sqlTAXONOMY OF NEGATIVE SCENARIOS
Type 1: No answer in context
Query: "What is the commission for category X?"
Context: does not contain information about X
Expectedly: “There is no information in the provided sources...”
Error: The model is making up an answer.
Type 2: Out-of-domain
Request: "Write me a poem"
Domain: Business Assistant for Analytics
Expected: polite refusal + redirect
Error: executing request
Type 3: Unsafe / jailbreak
Request: attempt to bypass restrictions
Expected: failure without system prompt expansion
Error: Executes or expands prompt
Type 4: Ambiguous/unanswerable
Request: too vague, no point in answering
Expected: clarifying question, not a hallucination
Error: confident answer to an unclear question
───────────────────────────────── ─────────────────────────────────
HOW TO BUILD A DATASET
Step 1: Collect real cases
from logs: requests without context, user complaints
Step 2: synthetically expand
take positive examples → rephrase → remove answer from context
take your domain → generate off-topic queries
Step 3: verify markup
for each example: expected = REFUSE / ANSWER
κ between two markers > 0.7metrics on negative dataset
- correct_refusal_rate → should be high
- false_answer_rate (hallucination) → should be low
- measure separately for each type
16NegativeDataset for “whether the model can not know”
A special class of tests: queries that the model has no reason to answer. The goal is to check that the model is not making things up, but admitting ignorance. Pattern: correct question + obviously empty or irrelevant context.
sql# Pattern 1: there is context, no answer
context = "WB commission for clothes: 12%. Shoes: 15%."
question = "What is the commission for electronics?"
expected = REFUSE # "The source contains no electronics data"
# Pattern 2: question about the future/non-existent
question = "What will the dollar exchange rate be in 2030?"
expected = REFUSE # "Can't predict the future"
# Pattern 3: Conflicting Context
context = "Commission 12%. Commission 18%."
question = "What is the commission?"
expected = REFUSE/CLARIFY # indicate a contradiction
# How to evaluate automatically:
judge_prompt = """
Did the assistant answer the question using
ONLY information from context?
If the context does not contain an answer, is it correct?
the assistant refused to answer? PASS/FAIL
"""17NegativeHallucination Probing: test for fiction
Active testing: intentionally give the model a false or empty context and see if it uses its “prior knowledge” instead of the context. This test often reveals problems that are not visible in a normal eval.
text# Technique 1: False Fact Context
context = "The company was founded in 2031." # lies
question = "When was the company founded?"
✓ Correct: “In 2031” ← follows the context
✗ Problem: “In 1998” ← ignores context, returns prior
# Technique 2: empty context
context = ""
question = "What is the VAT rate in Russia?"
✓ Correct: "There is no data in the provided context"
✗ Problem: “VAT in Russia is 20%” ← hallucination
#Technique 3: Irrelevant context
context = "Borscht recipe: beets, potatoes..."
question = "What are the delivery terms?"
✓ Correct: refusal / clarification
✗ Problem: invents delivery termsstarting frequency
- every time the prompt changes - mandatory
- when changing model - required
- ~10% of the dataset = hallucination probes
Drift Detection: degradation after release
18DriftTypes of system degradation after release
The system can degrade without a single code change - due to changes in the external environment: new documents in the knowledge base, changes in user request patterns, changes in the API of the model provider. Understanding the sources of drift is critical to proper diagnosis.
| Drift type | Reason | Detector | Treatment |
|---|---|---|---|
| Data drift | New documents in KB do not match the old chunking | retrieval recall@k crashes | re-embed new docs |
| Query drift | Users started asking about new topics | out-of-domain rate is growing | extend KB / update prompt |
| Model drift | The provider has changed the model (silent update) | response format/tone changes | pin version of the model |
| Concept drift | Reality has changed (new law, prices) | faithfulness falls due to new facts | update KB, add date |
| Prompt drift | The prompt was changed and eval did not run | pass_rate for golden set | CI for prompts |
textEarly signals (fast): rising → bad sign
↑ refusal_rate — the model refuses more often
↑ response_length — “inflates” responses
↑ latency_p99 - something in the pipeline has slowed down
↑ token_count - context is growing (context pollution)
Delayed signals (via eval): falling → bad sign
↓ faithfulness_score
↓ recall@k (retriever)
↓ task_success_rate (agent)
↓ user_thumbs_up rate19DriftGolden Set: monitoring without a person
Golden dataset is a fixed set of examples with known expected answers. Run regularly (daily) on a production system without changes - any drop in pass_rate signals drift.
python# Daily scheduled job
for example in golden_set:
response = system.run(example.query)
score = evaluate(response, example.criteria)
metrics_store.record(score, timestamp=today)
# Comparison with baseline (release day)
delta = today_pass_rate - baseline_pass_rate
if delta < -0.05: # fell by 5%
alert(channel="#ml-alerts",
msg=f"Golden set: {delta:+.1%}")
# What should be in the golden set:
# - typical cases (50%)
# - edge cases (30%)
# - negative scenarios (20%)practice
- 50–200 examples are enough for a signal
- update golden set when changing domain
- don’t update with every improvement - you lose baseline
20DriftControlled experiments: what to record
The purity of the experiment is the main condition for meaningful comparison. Change one variable at a time. Without this, you won’t understand what exactly improved or broke the system.
text# Fix (do not change):
FIXED = {
"eval_dataset": "v3.1", # one dataset
"vector_index": "2025-06-01", # index snapshot
"embed_model": "text-emb-3",
"llm_model": "cloud-sonnet-4-6",
"temperature": 0.0, # reproducible
"k": 5,
}
# Change (one at a time):
VARIABLE = "chunk_size" #512 -> 256
# Run both options on the same dataset
results_A = run(system_A, FIXED, dataset)
results_B = run(system_B, FIXED, dataset)
# Compare metrics
# Make sure the difference is statistically significant
# p-value < 0.05 for n >= 100Synthetic Data: when it helps, when it harms
21SyntheticStructured Synthetic Generation: not just “give me 100 questions”
A naive approach—asking an LLM to generate 100 questions—yields a homogeneous, biased dataset. Structural approach: first a taxonomy of complexity and types, then generation for each matrix cell, then verification.
sqlGENERATION MATRIX
Simple Medium Difficult
The actual "what is X?" "how is X different" why does X work
from Y?" is different from Z?"
Analytical "list..." "compare A and B" "what will happen
if..."
Refusal "tell" what will happen in "how to get around
joke "2040?" restriction?
→ Generate N examples in each cell
→ This gives variety and coverage of edge cases
───────────────────────────────── ─────────────────────────────────
PIPELINE
Step 1: Real documents → LLM generates QA pairs
prompt = "Generate 3 questions from the text below:
one simple, one requiring reasoning,
one for which there is no answer in the text."
Step 2: Deduplication by embedding cosine > 0.92
# remove semantic duplicates
Step 3: Auto-filtering by quality score
quality_score = LLM("Is this question clear and unambiguous? Y/N")
Step 4: Selective Human Verification
sample 10–20% → one expert checks
approval_rate > 80% → dataset is good
approval_rate < 80% → revise generation promptpractice
- matrix approach → diversity
- sample verification is mandatory
- 100% LLM without verification → confirmation bias
- start with real examples anyway
22SyntheticWhen is synthetic data harmful?
Synthetic data carries risks that are easy to overlook, especially when the same model is used for both dataset generation and evaluation. This is called model collapse or circular evaluation.
text1. Circular eval (main problem)
GPT-4 generates QA → GPT-4 evaluates QA
← the model evaluates “itself”, there will always be a good score
← does not detect model system errors
2. Distribution mismatch
Synthetic questions - literary Russian
Real users - colloquial, with typos
← eval shows good, cont shows bad
3. Coverage gaps
LLM does not know what NOT to generate (edge cases in prod)
← does not cover real problem queries
4. Incorrect reference answers
LLM generates "correct answer" with error
Let's use it as ground truth for eval
← testing on obviously incorrect examples
When synthetic is acceptable:
✓ expansion of a small dataset of real examples
✓ type coverage (not replacing real examples)
✓ with verification by an expert23SyntheticKeeping the golden dataset up to date
The Golden dataset is becoming obsolete: the business domain is changing, new types of questions appear, the product is changing. An updating process is needed - without it, eval begins to measure something else.
text# Update triggers:
→ A bug was found in the product → add as a fail test
→ Product has changed → update expected
→ New feature → add new cases
→ Once a quarter → review of outdated examples
# Versioning:
datasets/
golden_v1.jsonl # ← do not delete! baseline for comparison
golden_v2.jsonl
golden_current → golden_v2.jsonl # symlink
# Metadata for each example:
{
"id": "tc_087",
"added": "2025-04-12",
"source": "prod_bug_#2341",
"last_reviewed": "2025-06-01",
"tags": ["refusal", "no-context"]
}practice
- every product bug → test case
- versioning the dataset as code
- keep old versions for comparison
- update golden set without committing baseline
No dead end
Keep moving through the map.
Continue in sequence, switch to a related guide, or return to the seven-track learning map.