How do you build a golden dataset for evaluating an LLM feature?
1.1k views
20 of 83
1
The problem
Question presented to candidate:
"We are about to launch an LLM support assistant and want an eval set we can trust for every future prompt change and model upgrade. Walk me through how you would build that golden dataset: where the cases come from, how many, how you label them, and how you keep it useful over time."
What a strong answer should cover:
Sources: real production or beta traces first (sampled, deduplicated), then targeted additions โ known failures, edge cases, adversarial and should-refuse inputs, and synthetic cases only to fill gaps, reviewed by a human
Coverage over volume: stratify by intent or slice with a floor for rare but high-stakes categories, because proportional sampling of traffic gives them zero cases; dedupe and cluster near-duplicates so 40 copies of one question do not count as 40 cases
What a case holds: input (and context such as retrieved docs or conversation state), the expected answer or, more often, the properties a correct answer must have, slice tags and provenance
Labels from people who know the domain, with a written rubric, two annotators on a sample, agreement measured with Cohen's kappa (raw agreement flatters), and disagreements adjudicated into rubric clarifications
Hygiene: a dev split you iterate on and a held-out test split split by cluster so near-twins do not leak, versioning of the dataset, PII scrubbing, and a feedback loop that turns each production failure into a new case
Clarifying questions expected:
Do we have production or beta traffic yet, and can we legally use it for evaluation (PII, consent, retention)?
Which failures are most costly for the business (wrong refund promise, leaked data, rude tone), so we know which slices need depth?
Who are the domain experts who can say what a correct answer is?
Code / implementation expected: Optional โ a script that dedupes traces, stratifies by intent and splits by cluster, or a kappa calculation for label quality.
golden-dataset
evals
labelling
quality
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 know why evals matter and now have to assemble the dataset behind them, and candidates asked to design one in an interview.
Difficulty: Medium
Verified with: Node 22 and Python 3.12 (a dataset builder over 200 simulated traces, and an inter-annotator agreement calculation). The traces and labels are synthetic and stated as such; no model is called.
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 driving examiner who tests every learner on the same quiet street, in daylight, with no other cars, will pass people who cannot merge onto a motorway. The test is not wrong,
The solution is hidden so you can try first
15 min read ยท 13 sections
4
Read the code
From 200 traces to a 20-case golden set: dedupe, cluster, stratify, split
// Building a golden dataset from production traces (simulated in memory):// normalise + dedupe, cluster near-duplicates, stratify by intent with a floor// for rare intents, keep user-flagged failures, split dev/test by cluster.const phrasings = {
order_status: ["where is my order N", "where is my order N please", "wheres my order N", "has order N shipped",
"has my order N shipped yet", "track package N", "when will order N arrive", "order N still not here", "is order N delayed"],
refund: ["how do i get a refund", "how do i get a refund for order N", "refund for a damaged item",
"refund for damaged item please", "how long do refunds take", "i was charged twice", "money back for order N"],
cancel: ["cancel order N", "please cancel order N", "can i cancel my subscription", "stop my subscription"],
account: ["reset my password", "change my email address", "delete my account"],
legal: ["i will report you to consumer protection", "this is a chargeback threat"],
out_of_scope: ["write my history essay", "what stock should i buy"],
};
const volume = { order_status: 120, refund: 50, cancel: 20, account: 6, legal: 2, out_of_scope: 2 };
const intents = Object.keys(volume);
let seed = 2024;
constrand = () => { seed = (seed * 48271) % 2147483647; return seed / 2147483647; };
const traces = [];
for (const intent of intents) {
const pool = phrasings[intent];
for (let i = 0; i < volume[intent]; i++) {
const p = i < pool.length ? pool[i] : pool[Math.floor(rand() * pool.length)];
let text = p.replace("N", String(1000 + Math.floor(rand() * 9000)));
if (rand() < 0.3) text = text.toUpperCase();
if (rand() < 0.2) text += "??";
traces.push({ intent, text, flagged: rand() < 0.04 });
}
}
console.log(`raw traces: ${traces.length}`);
constnorm = (s) => s.toLowerCase().replace(/[0-9]+/g, "N").replace(/[^a-zN ]/g, "").replace(/\s+/g, " ").trim();
const byKey = newMap();
for (const t of traces) {
const k = norm(t.text);
if (!byKey.has(k)) byKey.set(k, { key: k, intent: t.intent, count: 0, flagged: false });
const u = byKey.get(k);
u.count++;
u.flagged = u.flagged || t.flagged;
}
const unique = [...byKey.values()];
console.log(`after normalise + exact dedupe: ${unique.length} distinct requests`);
constjaccard = (a, b) => {
const A = newSet(a.split(" ")), B = newSet(b.split(" "));
let i = 0;
for (const w of A) if (B.has(w)) i++;
return i / (A.size + B.size - i);
};
constNEAR = 0.6;
const clusters = [];
for (const u of unique) {
const c = clusters.find((cl) => cl.intent === u.intent && cl.members.some((m) =>jaccard(m.key, u.key) >= NEAR));
if (c) { c.members.push(u); u.cluster = c.id; } else { u.cluster = clusters.length; clusters.push({ id: clusters.length, intent: u.intent, members: [u] }); }
}
console.log(`near-duplicate clusters (jaccard >= ${NEAR}): ${clusters.length}`);
console.log("");
constTARGET = 20, FLOOR = 2;
constrank = (a, b) => (b.flagged - a.flagged) || (b.count - a.count) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
console.log("intent traffic share clusters proportional stratified");
const golden = [];
for (const intent of intents) {
const share = volume[intent] / traces.length;
const cl = clusters.filter((c) => c.intent === intent);
const prop = Math.min(cl.length, Math.round(share * TARGET));
const strat = Math.min(cl.length, Math.max(FLOOR, prop));
const reps = cl.map((c) => c.members.slice().sort(rank)[0]).sort(rank);
golden.push(...reps.slice(0, strat));
console.log(`${intent.padEnd(14)}${String(volume[intent]).padStart(7)}${(100 * share).toFixed(1).padStart(5)}% ${String(cl.length).padStart(8)}${String(prop).padStart(12)}${String(strat).padStart(10)}`);
}
console.log(`golden set: ${golden.length} cases, ${golden.filter((g) => g.flagged).length} contain a user-flagged failure`);
console.log("");
constleaks = (test, dev) => test.filter((x) => dev.some((y) => x.intent === y.intent && jaccard(x.key, y.key) >= NEAR)).length;
const nDev = unique.filter((u, i) => i % 2 === 0), nTest = unique.filter((u, i) => i % 2 === 1);
console.log(`naive split of distinct requests: ${leaks(nTest, nDev)} of ${nTest.length} test cases have a near-twin in dev`);
const gDev = [], gTest = [];
for (const intent of intents) golden.filter((g) => g.intent === intent).forEach((g, i) => (i % 2 === 0 ? gDev : gTest).push(g));
console.log(`one case per cluster, split within each intent: dev ${gDev.length}, test ${gTest.length}, leaks ${leaks(gTest, gDev)}`);
console.log("");
console.log("rare-intent cases kept by the floor:");
for (const g of golden.filter((x) => volume[x.intent] < 10)) console.log(` [${g.intent}] "${g.key}" seen ${g.count}x${g.flagged ? ", flagged" : ""}`);
Label quality: raw agreement versus Cohen kappa, before and after a rubric fix