Skip to content

feat(eval): chunking experiments, corrected nDCG, and a 1000/100 default - #168

Merged
mrsibe merged 2 commits into
mainfrom
spike/78-chunking-experiments
Sep 28, 2026
Merged

mrsibe merged 2 commits into
mainfrom
spike/78-chunking-experiments

Conversation

@mrsibe

@mrsibe mrsibe commented Sep 28, 2026

Copy link
Copy Markdown
Owner

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):

Variant Recall@1 Recall@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 (+94%)
large (1000/100) 0.8333 1.0000 0.9278 0.9437 19 (−47%)
large + page spanning (1000/100) 0.8333 1.0000 0.9278 0.9437 19
heading-aware (500/50) 0.7667 0.9000 0.8317 0.8756 52 (+44%)
heading-aware large (1000/100) 0.7667 0.9000 0.8317 0.8756 52 (+44%)

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_OPTIONS is now chunkSize=1000, chunkOverlap=100; docs/eval/baseline-v1.5.json is the frozen successor and baseline-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 writes docs/eval/chunking-v1.5.{md,json}.
  • ChunkOptions.respectHeadings (default false): forces a new chunk at each heading (the heading-aware variants).
  • The harness takes chunkOptions, reports chunkCount (deterministic → committed JSON) and indexingMs (timing → excluded, as before).
  • --eval-baseline= selects the report label, so npm run eval writes baseline-v1.5.*.
  • CI's determinism check now diffs baseline-v1.5.json.
  • ndcgAtK fix + 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 eval twice is byte-identical (the CI determinism gate).
  • electron . --smoke-test — PASS, 26 checks, with the new default.
  • The chunking table is harness output, reproducible with npm run eval:chunking.

Checklist

  • I have reviewed my own changes.
  • npm run typecheck passes.
  • npm run build passes.
  • I have tested the affected user workflow.
  • I have not included unrelated changes.
  • I have updated documentation when necessary.

Desktop / build changes

  • Not applicable (a default chunk size changed, but no schema or packaging change).

#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).
@mrsibe mrsibe added enhancement New feature or request area:retrieval Retrieval quality and evaluation research/spike Timeboxed investigation; adopt only if the measurements support it labels Sep 28, 2026
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.
@mrsibe
mrsibe merged commit 111c8dd into main Sep 28, 2026
3 checks passed
@mrsibe
mrsibe deleted the spike/78-chunking-experiments branch September 28, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:retrieval Retrieval quality and evaluation enhancement New feature or request research/spike Timeboxed investigation; adopt only if the measurements support it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Spike] Chunking experiments: size / overlap / heading-aware / parent-child

1 participant