Keep the format boring. One JSON Lines file, one judgment per line. The fields that earn their place: a stable query id, the exact query text, the source, an intent tag, the expected answer string, the passage id, the grade, and the corpus version the passage came from.
{"query_id":"q0042","query":"can I change my billing address after the order ships","source":"ticket","intent":"billing","answer":"Yes, until the carrier scans the parcel. Address changes after the first scan require a carrier intercept.","judgments":[{"passage_id":"kb-billing-03#p2","grade":2,"doc_version":"2026-03-11"},{"passage_id":"kb-shipping-11#p1","grade":1,"doc_version":"2026-03-11"}],"unanswerable":false}
Three design choices to make deliberately.
Store passage ids, not passage text. The chunk text lives in your index. If you store text in the test set you will eventually compare against a stale copy and get confusing scores. If you want the text for readability, store it in a separate build artifact keyed by passage id.
Store the document version. When a policy page changes, the version tells you which judgments need review, and you can compute coverage and freshness by joining the test set to the index.
Store the query as typed, and optionally a normalized copy. The typed version tests realistic spelling. The normalized version is useful when you debug embedding behavior. Do not replace the typed version: it is the thing your users actually did.
Then write the scorer once and keep it fixed:
def score(run, judgments, k=5):
# run: {query_id: [passage_id, ...]} in rank order
# judgments: {query_id: {passage_id: grade}}
hits, ndcg = 0, 0.0
for qid, ranked in run.items():
grades = judgments[qid]
top = [grades.get(pid, 0) for pid in ranked[:k]]
hits += 1 if 2 in top else 0
dcg = sum((2**g - 1) / math.log2(i + 2) for i, g in enumerate(top))
ideal = sorted(grades.values(), reverse=True)[:k]
idcg = sum((2**g - 1) / math.log2(i + 2) for i, g in enumerate(ideal)) or 1.0
ndcg += dcg / idcg
return {"answer_recall_at_k": hits / len(run), "ndcg_at_k": ndcg / len(run)}
Reporting answer recall at k (does a grade 2 passage appear in the top k) alongside nDCG@k gives you both a blunt "can it be answered" number and a ranking-quality number. When you swap chunking schemes, run both against the identical file and compare per intent bucket, not just overall. Chunk size effects are not uniform: smaller chunks of 64 to 128 tokens were optimal for datasets with concise fact-based answers, while chunks of 512 to 1,024 tokens improved retrieval where broader context was needed, and different embedding models showed distinct sensitivities to chunk size (arXiv chunk size study). Averages hide exactly this, which is why the intent tag belongs in the file.