feat(eval): chunking experiments, corrected nDCG, and a 1000/100 default - #168
Merged
Merged
Conversation
#78 asked whether a different chunking strategy beats the shipped 500/50. The answer, measured against the frozen v1.4 baseline, is yes — and finding it exposed a metric bug. The experiment (`scripts/eval-chunking.mjs`, `docs/eval/chunking-v1.5.md`) runs the real harness once per variant with retrieval held fixed: variant R@1 R@5 MRR nDCG@10 chunks baseline v1.4 500/50 0.7667 0.9333 0.8492 0.8899 36 small 250/25 0.7333 0.9833 0.8372 0.8787 70 large 1000/100 0.8333 1.0000 0.9278 0.9437 19 <- adopted large + span pages 0.8333 1.0000 0.9278 0.9437 19 heading-aware 500/50 0.7667 0.9000 0.8317 0.8756 52 `large` improves Recall@1, MRR and nDCG and *halves* the index, so it is adopted: `DEFAULT_CHUNK_OPTIONS` is now 1000/100, and `baseline-v1.5.json` is the frozen successor. `baseline-v1.4.*` is kept untouched as history. The report states the limitation plainly: the corpus is small, so Recall@5 saturates and Recall@1/MRR carry the result; re-check on a larger corpus before treating it as settled. The metric bug: `ndcgAtK()` counted a gain for every rank covering a ground-truth block, but normalized by an ideal with one gain per *block*. Several overlapping chunks recovering the same block therefore pushed nDCG above 1 (small chunks scored 1.2164). A rank now only gains when it covers a block no earlier rank covered, and matches beyond the declared ground truth are ignored, so nDCG is bounded by 1 by construction. The v1.4 numbers are unchanged — the bug only appeared under the smaller chunks this spike introduced. - `ChunkOptions.respectHeadings` (default false) added for the heading-aware variant; it forces a new chunk at each heading. - The harness takes `chunkOptions` and reports `chunkCount` (deterministic, so it is in the committed JSON) plus `indexingMs` (timing, excluded from it). - `--eval-baseline=` selects the report label, so `npm run eval` writes v1.5. - CI's determinism check now diffs `baseline-v1.5.json`. Verified: npm run typecheck; npm test (382 pass, incl. new nDCG bounds, small chunks); npm run check:design; npm run build; `npm run eval` twice is byte-identical; `electron . --smoke-test` PASS (26 checks).
Same reason as the baseline: scripts/eval-chunking.mjs owns these files, so the formatter must not be a second writer that leaves a dirty tree.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Runs the chunking experiments #78 asked for, adopts the winner, and fixes a metric bug the experiments exposed.
Why?
#78 is a timeboxed spike: compare chunking strategies against the frozen v1.4 baseline and adopt one only if the numbers support it. It is also the prerequisite for #77 — retrieval must be measured on a frozen chunking baseline, or the retrieval experiment is measuring a moving target.
Related issue
Fixes #78
Related to #154, #77
The result
Every variant runs the real harness over the same corpus and 30 questions, with dense retrieval held fixed (
docs/eval/chunking-v1.5.md):Adopted:
large (1000/100). It clears the frozen rule (Recall@5 improves, nDCG@10 does not regress), improves Recall@1 and MRR too, and halves the index.DEFAULT_CHUNK_OPTIONSis nowchunkSize=1000, chunkOverlap=100;docs/eval/baseline-v1.5.jsonis the frozen successor andbaseline-v1.4.*is kept untouched as history.Limitation, stated in the report: the corpus is 13 short documents (19–36 chunks), so Recall@5 saturates near 1.0 and is the least discriminating metric here — Recall@1 and MRR carry the result. This should be re-checked on a larger, multi-format corpus before it is treated as settled.
The metric bug it exposed
ndcgAtK()counted a gain for every rank that covered a ground-truth block, then normalized by an ideal with one gain per block. Several overlapping chunks recovering the same block therefore scored several gains against an ideal of one, and nDCG rose above 1 — the small-chunk variant measured 1.2164, which is not a valid ranking metric. The baseline never showed it because 500-char chunks rarely re-cover the same block.A rank now gains only when it covers a block no earlier rank covered, and matches beyond the declared ground truth are ignored, so nDCG ≤ 1 by construction. The v1.4 baseline numbers are unchanged (0.8899) — the bug only manifested under the smaller chunks this spike introduced.
What changed
scripts/eval-chunking.mjs+npm run eval:chunking: runs each variant and writesdocs/eval/chunking-v1.5.{md,json}.ChunkOptions.respectHeadings(default false): forces a new chunk at each heading (the heading-aware variants).chunkOptions, reportschunkCount(deterministic → committed JSON) andindexingMs(timing → excluded, as before).--eval-baseline=selects the report label, sonpm run evalwritesbaseline-v1.5.*.baseline-v1.5.json.ndcgAtKfix + tests, and the generated chunking files are added to.prettierignore(same reason as the baseline).How was this tested?
npm run typecheck— passes.npm test— 382 pass (new: nDCG counts a block once, nDCG never exceeds 1).npm run check:design— no violations.npm run build— passes.npm run evaltwice is byte-identical (the CI determinism gate).electron . --smoke-test— PASS, 26 checks, with the new default.npm run eval:chunking.Checklist
npm run typecheckpasses.npm run buildpasses.Desktop / build changes