Skip to content

fix: preserve markdown table rows when chunking - #1074

Open
SomSamantray wants to merge 2 commits into
tobi:mainfrom
SomSamantray:fix/955-markdown-table-chunking
Open

SomSamantray wants to merge 2 commits into
tobi:mainfrom
SomSamantray:fix/955-markdown-table-chunking

Conversation

@SomSamantray

Copy link
Copy Markdown

Large Markdown tables now produce chunks with complete rows and the column header in every continuation. Tables that fit the chunk budget stay together, and fenced code examples containing pipe characters remain code.

Fixes #955.

Tests

  • Node 22 Vitest: 57 files passed; 1,540 passed, 81 skipped.
  • Bun: 56 files; 1,536 passed, 89 skipped.

AI disclosure: Developed with OpenAI Codex assistance.

@S3yon S3yon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran it locally: the store tests pass on Node (330 pass) and tsc is clean. On Bun one store test fails ("legacy fingerprint adoption ... 16 MiB SQLite budget"), and it fails the same way on 86d2365, so it isn't from this PR.

One problem in the path that indexes documents. The embed path goes through chunkDocumentByTokensWithLlm, which re-splits any chunk over the token limit and rebuilds each piece with text.slice(subChunk.pos, subChunk.pos + subChunk.text.length) (src/store.ts:4028). That assumes chunk.text is the source span starting at pos. With the header prefixed it isn't, so the slice drops the header and runs header-length characters past the cut.

Tables hit that path a lot because they're token-dense. A 300-row numeric table (| p0 | 0 | 13 | 0 | ms | and so on) through chunkDocumentByTokens with the defaults gave 15 chunks: 10 of the 14 continuation chunks had no header, and one ended in a cut row (| p67). Through chunkDocument alone the same table comes out right, which is what the new tests cover.

A possible fix: push subChunk.text as-is instead of re-slicing, and add a table test on the token path. The "Token-based Chunking" block is skipped in CI, so it may need a small fake tokenizer.

Smaller one: a table that fits the 1.5x allowance, but starts more than a window before the target and ends past charPos + maxChars * 1.5, is cut once and then emitted whole in the next chunk. chunkDocument("x".repeat(150) + "\n\n" + <12-row table> + ..., 400, 60, 100) gives a chunk with rows 0 to 5, then one with all 12. Not wrong, just duplicated.

Markdown table chunks on the token-based embedding path lost their header
and cut rows, and chunk positions drifted from the document text.

- Re-split pieces are taken from their own text. Re-slicing the parent by
  length dropped a continued table's header and ran past the piece's end.
- Each chunk reports its header prefix length, and the token path maps
  positions through it, so pos is the source offset of the chunk's raw
  text on both paths.
- A chunk end that lands inside a table which fits the budget stops just
  before the table, so the overlap rewind no longer emits its leading rows
  twice. The rewind also skips a table the chunk already holds whole, and
  a chunk that would hold only overlap starts at the table instead.
- A chunk end inside an oversized table's header extends to its first row,
  so no chunk holds a header alone.
- Each recursion step must strictly shrink every piece, since a continued
  table re-adds its header to each piece. Otherwise the recursion can
  stop making progress.
- A piece whose source span lies inside the previous piece's is dropped,
  so a row that nearly fills the budget is not emitted twice.
- The closing fence of a code block ends just past its marker when the
  fence is indented.

Refs tobi#955
@SomSamantray

Copy link
Copy Markdown
Author

Thanks for the review, @S3yon. Both points are fixed in 05f6568.

  1. Token-path slice: re-split pieces are now taken from their own text instead of re-slicing the parent by length, so continued table chunks keep their header and no row is cut. Chunk positions on that path also map through the header prefix now (each chunk reports prefixLength), so pos is the source offset of the chunk's raw text on both paths.
  2. Duplication: the cut before a fitting table and the overlap rewind were emitting its leading rows twice. A chunk now ends before such a table, and the rewind skips a table the chunk already holds whole. While testing I also found a row near the budget emitted twice on the token path; that duplicate is dropped now. A chunk that would end inside an oversized table's header extends to its first row, so no chunk holds only a header.

Also in this commit: each recursion step must strictly shrink its pieces (a continued table re-adds its header to each piece), so the token recursion always terminates, and an indented closing code fence now ends just past its marker.

Tests: a token-path table test with a small fake tokenizer that runs in CI (header on every chunk, no cut rows, positions match the source), duplication regression tests on both paths, a termination test, a header-only test, and the fence-end test. Node 22 vitest suite and the Bun store tests pass, and tsc is clean.

@S3yon S3yon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-ran it at 05f6568. The store tests pass on Node (350) and tsc is clean. On Bun the only failure is the same 16 MiB budget test that fails on 86d2365.

The token path is fixed. The 300-row numeric table through chunkDocumentByTokens with the real tokenizer and the defaults now gives 10 chunks: every continued chunk starts with the header, no row is cut, all 300 rows are there, and pos lines up with the source text after the header. Same with prose around the table, and at a 120-token budget where the pieces get re-split more than once.

The duplication repro now puts each of the 12 rows in exactly one chunk.

Looks good to me. Thanks for the extra tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug report: chunker splits markdown tables mid-rows

2 participants