Chapter 49 · Retrieval

Building a retrieval test set from real questions

The thirty-four written in advance measure anticipated language. Here is how to harvest real questions, label them without it becoming a job, and keep the set representative as it grows.

11 min read

The previous chapter ended on a gap: 0.99 on the deliberate test set, 0.94 on real questions, and widening. This chapter closes it.

The thirty-four cases from chapter 7 were the right way to start — they existed before the system and they shaped the slice. They are not a sample of what people ask, because they were written by people imagining what people would ask.

What real questions look like

Atlas's first month, 412 questions from 19 of 24 reps. The differences from the written set are systematic.

PropertyWritten setReal questions
Median length11 words6 words
Contain a pronoun0%23%
Follow-up to a previous turn0%31%
Contain internal jargon18%64%
Contain a typo0%9%
Are a yes/no confirmation6%27%

The last row is the one with teeth. "Is Henderson still Net 30?" is the shape chapter 46 showed steering retrieval toward the assumption in the question, and it is more than a quarter of real traffic while being almost absent from the written set.

Harvesting, without labelling everything

412 questions is too many to label by hand and far more than you need. Sample deliberately rather than taking the first fifty.

-- Four strata, each catching a different failure. ~40 cases total. -- 1. Retrieved nothing. The clearest signal. select 'no_results' as stratum, id, question, asked_at from question_log where result_count = 0 and asked_at > now() - interval '30 days' order by random() limit 10 union all -- 2. Marked unhelpful by the person who asked. select 'unhelpful', id, question, asked_at from question_log where feedback = 'unhelpful' and asked_at > now() - interval '30 days' order by random() limit 10 union all -- 3. Asked repeatedly by different people. High value, probably working. select 'frequent', id, question, asked_at from ( select id, question, asked_at, count(*) over (partition by normalise(question)) as asks from question_log where asked_at > now() - interval '30 days' ) q where asks >= 4 order by random() limit 10 union all -- 4. A plain random sample. ⚠️ Without this the set is all failures, and a -- set of only failures cannot detect a regression on what works. select 'random', id, question, asked_at from question_log where asked_at > now() - interval '30 days' order by random() limit 10;

The fourth stratum is the one people skip. A test set harvested only from failures measures your worst cases and goes blind to regressions on the 90% that work.

Labelling, which is the actual cost

A case needs the supporting chunks identified, and that is a human judgement. The trick is to make the human confirm rather than search.

// scripts/label-case.ts — propose, then confirm. Labelling a case takes // about ninety seconds this way and about ten minutes without it. export async function proposeLabels(question: string) { // Deliberately deep and unfiltered: as an administrator, 30 candidates, // so the answer is almost certainly in the list even when production // retrieval failed to surface it. const candidates = await retrieveAndRerank(question, { scope: ADMIN, limit: 30 }) return candidates.map((c, i) => ({ index: i + 1, document: c.title, heading: c.headingPath, excerpt: c.content.slice(0, 200), productionRank: c.productionRank ?? null, // where the real pipeline put it })) }
label: "whats the rma window on damaged stuff"            [stratum: no_results]

  1. Returns and Credits Policy > Returns > Damaged goods        (prod: —)
     Damaged goods may be returned within 30 days of delivery…
  2. Returns and Credits Policy > Returns > Late returns         (prod: 1)
     Items returned after 30 days are assessed individually…
  3. RMA Procedure > Raising an RMA                              (prod: —)
     An RMA number must be obtained before any return is shipped…

  supporting? 1,3    distractors? 2    tags? exact_token,jargon

Two things that make this work:

Show where production ranked each candidate. A chunk the administrator-scoped deep search found at rank 1 and production never returned is the finding, visible without any analysis.

Capture distractors while labelling. Candidate 2 is the near-miss, and recording it costs one keystroke now and is the thing that makes the numbers honest later.

# The case, written verbatim. ⚠️ Keep the typo and the jargon. Correcting # "whats" to "what is" removes exactly the property being tested. - question_id: real-0041 question: "whats the rma window on damaged stuff" asker: inside_sales stratum: no_results tags: [jargon, typo, single_source] supporting: - { document: "Returns and Credits Policy", heading: ["Returns", "Damaged goods"] } - { document: "RMA Procedure", heading: ["Raising an RMA"] } distractors: - { document: "Returns and Credits Policy", heading: ["Returns", "Late returns"] }

Follow-ups need their history

31% of real questions are follow-ups, and a follow-up without its context is a different question.

- question_id: real-0088 question: "what about electronics" asker: inside_sales history: - question: "What's the returns window for damaged goods?" tags: [follow_up, short] # The honest expectation: the corpus does not distinguish electronics, so # correct behaviour is to retrieve the general policy, and generation # abstains on the category. Chapter 10's abstention case. supporting: - { document: "Returns and Credits Policy", heading: ["Returns", "Damaged goods"] } expect_abstention_on_category: true
// Which means the harness must replay history, not just the question. const prepared = await prepareQuery(testCase.question, testCase.history ?? []) const results = await retrieveAndRerank(prepared.search, { scope, ...config })

Without that, every follow-up case tests a question nobody asked and the context stitching that was built for them is never exercised.

Keeping the set representative as it grows

A set that grows only by adding failures drifts toward being a set of failures.

// Target a composition, and report drift from it rather than discovering it. const TARGET_COMPOSITION = { no_results: 0.15, unhelpful: 0.15, frequent: 0.20, random: 0.35, written_original: 0.15, // the first 34, kept as a stable baseline } as const export function compositionDrift(cases: RetrievalCase[]) { const actual = countBy(cases, (c) => c.stratum) return Object.entries(TARGET_COMPOSITION).map(([stratum, target]) => ({ stratum, target, actual: (actual[stratum] ?? 0) / cases.length, drift: (actual[stratum] ?? 0) / cases.length - target, })) }

Keeping the original 34 as a labelled stratum is worth it. They are the only cases whose history is continuous from before the system existed, so they are the baseline that makes a long-run comparison meaningful.

// And retire cases deliberately, rather than accumulating forever. export function retirementCandidates(cases: RetrievalCase[], history: EvalHistory) { return cases.filter((c) => { const runs = history.forCase(c.question_id) // Passed every time for six months: it is no longer measuring anything. return runs.length > 50 && runs.every((r) => r.recall === 1 && r.mrr === 1) }) }

⚠️ Retire by moving to a separate stable.yaml that runs weekly rather than per-commit, not by deleting. A case that has passed 50 times is still the one that catches a catastrophic regression; it just does not need to run on every pull request.

How big

Set sizeDetectsLabelling cost
30A 15-point change1 hour
100A 7-point change3 hours
250A 4-point change7 hours
1,000A 2-point changeA project

The useful number for most internal systems is 100 to 250. Below 100 the noise band is wider than most real improvements, which is chapter 31's overfitting problem. Above 250 the labelling cost grows faster than the discrimination.

Atlas settled at 120: the original 34 plus 86 harvested over two months.

What the real cases revealed

Scored against the production pipeline at the time:

Subsetnrecall@6Finding
Written original340.99The set it was tuned on
Harvested: frequent240.96Common questions work
Harvested: random320.91The honest number
Harvested: unhelpful160.69—
Harvested: no results140.43—

The bottom two rows were invisible before this exercise, and they decompose usefully:

no_results (14), diagnosed:
  not_ingested      6   scanned contracts — the known gap
  not_retrieved     5   all jargon: "rma", "bol", "cogs"
  ranked_out        3

Five of fourteen were a missing glossary, which is a data file and twenty minutes of work. That finding came from harvesting, not from tuning, and no amount of parameter search would have produced it.

The failure mode: labelling with the system's own answer

The quickest way to label is to run the question, look at what came back, and mark the plausible chunk as supporting.

This produces a test set that defines correct as whatever the system currently returns. Scores are high immediately, every change looks like a regression, and the set actively resists improvement.

// Three guards against it. // 1. Label from a DEEP administrator search, not from production output. // 30 candidates, unfiltered, so the labeller sees what production missed. const candidates = await retrieveAndRerank(question, { scope: ADMIN, limit: 30 }) // 2. Hide the production ranking until after the choice is made. // Shown first, it anchors the labeller on rank 1. const display = candidates.map((c) => omit(c, ['productionRank', 'score'])) // 3. For any case where nothing in 30 candidates is right, label it // `supporting: unknown` and escalate — the answer may not be in the // corpus at all, which is chapter 18's no-source finding, not a // retrieval failure.

The third is the one that found the six scanned contracts in the table above. A labeller who must pick something from the list would have picked the nearest contract chunk, and six cases would have been permanently mislabelled as retrieval failures against a document that does not exist.

A labeller who can only choose from what was retrieved cannot record that the right answer was never there.

Key points

  • The written set measures anticipated language. Real questions are shorter, full of pronouns and jargon, and a quarter are confirmations.
  • Harvest in four strata: no results, unhelpful, frequent, and random. The random stratum is what keeps the set from being all failures.
  • Label by confirming a deep administrator-scoped search, not by searching. Ninety seconds a case instead of ten minutes.
  • Capture distractors while labelling. One keystroke now, honest numbers later.
  • Keep the question verbatim, typos and jargon included. Correcting them removes the property under test.
  • Follow-ups need their history recorded, and the harness must replay it.
  • Target a composition and report drift. Keep the original cases as a stable baseline stratum.
  • Retire always-passing cases to a weekly set rather than deleting them.
  • 100 to 250 cases is the useful range. Below 100, noise exceeds most real improvements.
  • Never label from production output. A labeller who can only choose from what was retrieved cannot record that the answer was never there.

Next, and the last chapter of this section: tuning against the test set, and knowing when to stop.