Why are BLEU and ROUGE poor metrics for LLM outputs, and what replaced them?
766 views
71 of 83
1
The problem
Question presented to candidate:
"Someone on the team proposes tracking ROUGE and BLEU against reference answers as the quality metric for our LLM assistant. Explain what those metrics actually measure, why they are a poor fit for LLM outputs, and what you would use instead."
What a strong answer should cover:
What they compute: BLEU is clipped n-gram precision against one or more references with a brevity penalty (Papineni et al., 2002, built for machine translation); ROUGE is n-gram or longest-common-subsequence recall or F1 (Lin, 2004, built for summarisation)
The two-sided failure: a correct paraphrase shares few n-grams and scores low, while a wrong answer that changes one word (months for days, an inserted "not", a wrong number) keeps most n-grams and scores high; they are blind to factuality, negation, instruction-following and safety
Reference dependence: open-ended tasks have many valid outputs, so the score depends on which reference someone wrote; more references help paraphrases but do not fix wrong answers
What replaced them, chosen per task: task-specific code checks first (exact values, schema, tests pass, citations resolve), learned or embedding metrics (BERTScore, BLEURT, COMET) where references exist, rubric-based LLM-as-judge (G-Eval style) for open-ended quality, and human or outcome metrics as the ground truth
Where overlap metrics still have a place: cheap regression smoke tests, constrained tasks, and comparability with older literature; and the evidence, such as the WMT22 metrics task finding that neural metrics correlate better with expert human ratings than BLEU
Code / implementation expected: Optional โ implementing BLEU and ROUGE-L on a few answers and showing the paraphrase and wrong-word cases is a strong way to make the point.
metrics
bleu
rouge
semantic-similarity
evals
2
Try it yourself
Sketch your approach before you read the solution. That is what the interview tests.
Stuck? Get a hint.
A short nudge toward the approach. It will not give the answer away.
3
Solution
Target Audience: Engineers who have seen BLEU or ROUGE in a dashboard or a paper and need to explain why they mislead for LLM features, and candidates expected to know the modern metric stack.
Difficulty: Hard
Verified with: Node 22 and Python 3.12 (BLEU, ROUGE-1 and ROUGE-L implemented from scratch, scored on hand-written answers next to a key-fact check, then against different references). Published results are cited by paper, checked October 2026.
How to read this doc: Every idea is explained in plain language first, then tagged with ๐ Interview term: so you also learn the words an interviewer expects. All program output shown was produced by actually running the examples; the Verified section quotes it in full.
1. Why This Even Matters โ A Story First
A teacher marks history essays by counting how many words each essay shares with her own model answer.
The solution is hidden so you can try first
14 min read ยท 13 sections
4
Read the code
BLEU and ROUGE from scratch: paraphrase versus one wrong word
// BLEU and ROUGE from scratch, on answers to one question, next to a key-fact check// and a human verdict. Reference: "Refunds take 5-7 business days."consttok = (s) => s.toLowerCase().match(/[a-z0-9]+(?:-[a-z0-9]+)*/g) || [];
functionngrams(t, n) {
const m = newMap();
for (let i = 0; i + n <= t.length; i++) { const k = t.slice(i, i + n).join(" "); m.set(k, (m.get(k) || 0) + 1); }
return m;
}
functionbleu(cand, refs) {
const c = tok(cand), rs = refs.map(tok);
let logSum = 0;
for (let n = 1; n <= 4; n++) {
const cg = ngrams(c, n);
let match = 0, total = 0;
for (const [g, cnt] of cg) {
total += cnt;
match += Math.min(cnt, Math.max(...rs.map((r) =>ngrams(r, n).get(g) || 0)));
}
const p = n === 1 ? match / total : (match + 1) / (total + 1); // add-one smoothing for n >= 2if (p === 0) return0;
logSum += Math.log(p) / 4;
}
const r = rs.map((x) => x.length).sort((a, b) =>Math.abs(a - c.length) - Math.abs(b - c.length) || a - b)[0];
const bp = c.length > r ? 1 : Math.exp(1 - r / c.length);
return bp * Math.exp(logSum);
}
functionf1(match, cLen, rLen) { if (!match) return0; const p = match / cLen, r = match / rLen; return (2 * p * r) / (p + r); }
functionrouge1(cand, ref) {
const c = ngrams(tok(cand), 1), r = ngrams(tok(ref), 1);
let m = 0;
for (const [g, cnt] of c) m += Math.min(cnt, r.get(g) || 0);
returnf1(m, tok(cand).length, tok(ref).length);
}
functionrougeL(cand, ref) {
const a = tok(cand), b = tok(ref);
const dp = Array.from({ length: a.length + 1 }, () =>newArray(b.length + 1).fill(0));
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++)
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
returnf1(dp[a.length][b.length], a.length, b.length);
}
// task-specific code check: normalise a few equivalent phrasings, then require the fact and no negationfunctionfactCheck(s) {
const t = " " + tok(s).join(" ") + " ";
const n = t.replace(/ five to seven /g, " 5-7 ").replace(/ working /g, " business ");
return n.includes(" 5-7 business days ") && !/ (not|never|no) /.test(n);
}
constREF = "Refunds take 5-7 business days.";
const answers = [
["copy of reference", "Refunds take 5-7 business days.", "correct"],
["paraphrase", "Expect the money back within five to seven working days.", "correct"],
["friendly, longer", "Thanks for asking! Refunds usually take 5-7 business days to reach your account.", "correct"],
["wrong unit", "Refunds take 5-7 business months.", "wrong"],
["negated", "Refunds do not take 5-7 business days.", "wrong"],
["wrong number", "Refunds take 15-20 business days.", "wrong"],
];
console.log(`reference: "${REF}"`);
console.log("answer BLEU ROUGE-1 ROUGE-L fact check human");
const rows = answers.map(([name, text, human]) => {
const r = { name, human, bleu: bleu(text, [REF]), r1: rouge1(text, REF), rl: rougeL(text, REF), fact: factCheck(text) };
console.log(`${name.padEnd(18)}${r.bleu.toFixed(3)}${r.r1.toFixed(3).padStart(7)}${r.rl.toFixed(3).padStart(7)}${(r.fact ? "pass" : "fail").padStart(10)}${human}`);
return r;
});
console.log("");
// best achievable accuracy if you pick the single best pass threshold for each metricfor (const key of ["bleu", "r1", "rl"]) {
let best = 0, bestT = 0;
for (const t of rows.map((r) => r[key])) {
const acc = rows.filter((r) => (r[key] >= t) === (r.human === "correct")).length;
if (acc > best) { best = acc; bestT = t; }
}
const label = { bleu: "BLEU", r1: "ROUGE-1", rl: "ROUGE-L" }[key];
console.log(`${label.padEnd(8)} best threshold ${bestT.toFixed(3)} -> ${best}/${rows.length} verdicts right`);
}
console.log(`fact check -> ${rows.filter((r) => r.fact === (r.human === "correct")).length}/${rows.length} verdicts right`);
Reference dependence: the same answers scored against different references