Repository navigation
Token-aware chunking with overlap and structure-safe splits #12
Description
Context
The deterministic chunker in packages/core/src/chunks.ts splits sections by paragraph and then hard-slices at MAX_CHUNK_CHARS = 4000 in splitLong() (line 4 / 126-146), cutting mid-sentence, mid-code-fence, and mid-table. It is the single chunking core consumed by BOTH pipelines: the CLI/MCP path and the production Worker queue consumer (apps/api/src/worker.ts calls renderChunksNdjson(buildChunks(...)) at ~2186 and ~2790). Poor chunk boundaries degrade RAG retrieval AND the verbatim grounding gate in facts.ts (validateQuote relies on normalizeText exported from chunks.ts).
Goal / user story
As an agent builder recalling extracted context, I want chunks that are token-budgeted, overlapping, and never split inside a sentence/code block/table, so retrieved snippets are self-contained and citable — improving both the grounded chat answers and fact extraction precision.
Acceptance criteria
- Chunk size is governed by an approximate token budget (~512-800 tokens) rather than a raw 4000-char cap (a cheap chars-per-token heuristic is acceptable; no heavy tokenizer dependency in the Worker bundle).
- Consecutive parts within a section carry ~10-15% overlap so a fact spanning a boundary survives in at least one chunk.
- Splits never occur inside a fenced code block (```), never break a markdown table (lines starting with
|), and prefer sentence/paragraph boundaries over mid-word cuts. -
chunkIdremains location-based and deterministic across reruns (sameroutePath+headingPath+ stable part index), soplanMemoryWritediffing andchunkGraphDigest()stay stable for the*/30freshness pipeline. -
normalizeTextstays the shared normalizer;facts.test.ts(validateQuote substring gate) andchunks.test.tsremain green, with new cases for code-fence/table/overlap.
Implementation notes
- Touch only
packages/core/src/chunks.ts; extendpackages/core/src/chunks.test.ts. Lifts both pipelines at once because both callbuildChunks. - Replace
splitLong(): tokenize-aware accumulation that emits parts at the token budget, then prepend the trailing ~10-15% of the previous part as overlap. Track code-fence open/close state and table-row runs while iterating lines so a split is deferred past those blocks. - Keep the
partCounterslocation-key scheme sochunkId = sha256Hex(${locationKey}\n#${partIndex}).slice(0,16)stays stable; overlap text must NOT change the partIndex sequence (overlap is additive content, not a new logical part). -
contentHashstayssha256Hex(normalizeText(part)); note overlap means a quote may appear in two chunks — that is fine,validateQuotechecks substring of the cited chunk. - Gotcha: the Worker bundle is size/CPU constrained — prefer a heuristic char estimate or a tiny pure-JS token estimator over
js-tiktoken/gpt-tokenizer.
Sui Overflow angle
Clean, stable chunk boundaries make chunkGraphDigest() a trustworthy "what was extracted" anchor — the exact value the on-chain contextmem.extract attribution receipt keys on. Better chunks also directly improve the grounded RAG chat that headlines the live demo.
Dependencies
None (foundational; the hybrid-retrieval issue builds on these chunks).
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Replace the char-cap
splitLong()inpackages/core/src/chunks.ts:126-146with a two-phase, structure-aware splitter: (1) segment each heading-section's markdown into atomic blocks (a fenced code block from```open→close is one block; a contiguous run of|table rows is one block; prose paragraphs split on blank lines→sentences), then (2) pack blocks greedily up to an approximate token budget (~512-800 tokens via a cheapchars/4heuristic, no tokenizer dep), prepending the trailing ~12% of the previous part as additive overlap. The key design decision: the new splitter keeps the exact samestring[]signature and emits exactly one entry per logical part, sobuildChunks'spartCounters/chunkIdlocation-key scheme (chunks.ts:25-29) is untouched — overlap is baked into the part text but never creates a newpartIndex, keepingchunkIddeterministic andplanMemoryWrite/chunkGraphDigeststable. This is a single-file core change; both pipelines (Workerworker.ts:2186/2674/2790, CLIcli/src/index.ts:218) lift automatically because both callbuildChunks.Prerequisites & dependencies
js-tiktoken/gpt-tokenizer(Worker bundle/CPU budget). Pure-JS heuristic only.Buffer.byteLength(used atchunks.ts:36) already works in the Worker undernodejs_compat(wrangler.jsonc); keep it.package.json:23→vitest run; core tests live inpackages/core/src/*.test.ts.Step-by-step
packages/core/src/chunks.ts(replacingMAX_CHUNK_CHARSat line 4):CHARS_PER_TOKEN = 4,TARGET_TOKENS = 600,MAX_TOKENS = 800,OVERLAP_RATIO = 0.12. Addfunction estimateTokens(text: string): number { return Math.ceil(text.length / CHARS_PER_TOKEN); }. Comment that these are heuristic and the band is 512-800.function segmentBlocks(text: string): string[]— walktext.split("\n")tracking state: a line matching/^\s*(```|~~~)/togglesinFence(accumulate the whole fence, opener→matching closer, as ONE block — never split inside); a line whose trimmed form starts with|starts/extends atablerun accumulated as ONE block; blank line flushes the current prose block; other lines accumulate into the current prose block. Returns ordered atomic block strings (newlines preserved). Defensive: an unterminated fence at EOF flushes as a single block.function packBlocks(blocks: string[]): string[]— greedy accumulate intobuffer; before appending blockb, ifbuffernon-empty andestimateTokens(buffer + "\n\n" + b) > MAX_TOKENS, flushbufferand start new. If a single block alone> MAX_TOKENS: if it is a prose block, sub-split it at paragraph (\n{2,}) then sentence boundaries (reuse the sentence regex pattern style fromtext.ts; fall back to whitespace/word boundary, hard-slice only as last resort); if it is a code/table block, keep it whole even when oversized (boundary-safety wins over budget — required by the acceptance criterion). Aim to flush nearTARGET_TOKENSbut tolerate up toMAX_TOKENSto avoid orphaning a block that just nudges past target.function tailOverlap(prev: string): string— return the trailing~OVERLAP_RATIOofprevmeasured in chars, snapped backward to a sentence/newline boundary so the overlap is a clean prefix (no mid-word/mid-fence). Because parts always end on a block boundary, the tail can never contain an unbalanced```.splitLong()withfunction splitSection(text: string): string[]returning final part strings:const body = packBlocks(segmentBlocks(text.trim())); for each part indexk>0, prependtailOverlap(body[k-1]) + "\n\n"tobody[k]; part 0 stays raw (no overlap, so short single-part sections return[trimmed]byte-identical to today — preserves existing tests). Keep raw newlines in returned text (code fences/tables must render;validateQuotenormalizes whitespace on both sides so grounding is unaffected).buildChunksatchunks.ts:20: changefor (const part of splitLong(section.text, MAX_CHUNK_CHARS))→for (const part of splitSection(section.text)). Leave lines 25-38 (locationKey,partCounters,chunkId = sha256Hex(\${locationKey}\n#${partIndex}`).slice(0,16),contentHash = sha256Hex(normalizeText(part)),byteLength) **unchanged** — overlap is insidepart` so it flows into text/contentHash/byteLength but the part count (hence partIndex sequence) is purely a function of section content.normalizeText(chunks.ts:122),splitMarkdownByHeadings,chunkGraphDigest,planMemoryWriteuntouched. No edits tofacts.ts,worker.ts,cli/src/index.ts,mcp— they consumebuildChunks/renderChunksNdjsonunchanged.packages/core/src/chunks.test.tswith the new cases (see Test plan).New / changed files
packages/core/src/chunks.ts— replaceMAX_CHUNK_CHARS+splitLong()withestimateTokens,segmentBlocks,packBlocks,tailOverlap,splitSection; one-line call swap inbuildChunks.packages/core/src/chunks.test.ts— add code-fence / table / overlap / token-budget / determinism cases.Test plan
chunks.test.ts4 cases (deterministic ids, id-stable-on-edit,planMemoryWriteadd/change/remove,chunkGraphDigest) andfacts.test.ts(validateQuote+ grounded-substring suite, which builds facts from the samebuildChunks(PAGES)). All PAGES there are short single-part sections →splitSectionreturns[trimmed]byte-identical to old output, so they pass unmodified.chunks.test.ts:```js … ```block; assert every chunk'stexthas an even count of fence markers (no chunk splits a fence) and the full code block appears intact within exactly one chunk.|---|separator + many rows); assert no chunk boundary falls between two|rows of the same table (the table run lands wholly in one chunk).kis a prefix substring of chunkk+1and overlap length is within ~10-15% of the prior part.estimateTokens(text) <= MAX_TOKENS(plus overlap) and that long input no longer produces 4000-char monoliths; assert an oversized indivisible code block is allowed to exceed budget (documents the deliberate boundary>budget rule).buildChunks(long)twice → identicalchunkIdarrays; andchunkGraphDigestequal across two runs of the long page.chunkIdstable,contentHashchanged (extends the existing edit test to confirm overlap didn't perturb ids).bun run check(typecheck +vitest run) from repo root; proves bothchunks.test.tsandfacts.test.tsgreen.memwal rememberdelta path (cli/src/index.ts:218-262) on a page containing a code block + table; confirmcontext/chunks.ndjsonchunks are budget-sized with overlap and that a re-run with unchanged content yields an identicalchunkGraphDigest(i.e.planMemoryWritereports all-unchanged), proving the*/30freshness diff stays stable.Risks & gotchas
Buffer.byteLength; no tokenizer import — that is the whole point of the heuristic. No risk to thenodejs_compatWorker path.partIndexes shift → those chunks read as removed+added inplanMemoryWrite. This already true of the current splitter; the new block-aware packing makes part counts more stable (boundaries snap to blocks), but call it out — boundary semantics changed, so the first re-extraction after this ships will show a one-time diff churn (acceptable; not a migration, no stored schema change).validateQuotestill passes against whichever chunk is cited (intended per issue notes); just don't dedupe chunks by content.MAX_TOKENSis emitted whole (over budget) by design — boundary-safety beats budget. Tests must assert this rather than fight it.ContextChunkshape (types.ts:726-736) is unchanged; NDJSON wire format unchanged.Acceptance-criteria mapping
chunkIdlocation-based & deterministic;planMemoryWrite/chunkGraphDigeststable → Steps 5-6 (partIndex/locationKey untouched, one logical-part-per-entry); Tests 5-6 + existing suite.normalizeTextstays shared;facts.test.ts+chunks.test.tsgreen w/ new fence/table/overlap cases → Step 7 (nofacts.tsedit) + Step 8; full Test plan.Effort
M — ~1.5-2.5 person-days. ~0.5d for
segmentBlocks/packBlocks/tailOverlap/splitSection, ~0.5-1d for the new tests + edge-case hardening (fence/table interplay, sentence sub-split), ~0.5d verifying regression green and the CLI round-trip digest stability.Implementation plan generated for execution (planning phase). Part of the roadmap (#4).