Skip to content

_chunk_markdown returns the whole file as a single chunk when the file has no # heading, ignoring MAX_CHUNK_CHARS #369

Open

Description

@MartinEbner

Describe the bug

_chunk_markdown in src/rag.py begins:

HEADING_RE = re.compile(r"^(#{1,4})\s+(.+)$", re.MULTILINE)
...
def _chunk_markdown(text, filename):
    """Heading-aware markdown chunking with breadcrumb tracking."""
    matches = list(HEADING_RE.finditer(text))
    if not matches:
        return [{"text": text.strip(), "breadcrumb": filename}]

HEADING_RE matches ATX headings only (# , ## , ### , #### ). A markdown file that uses
no # markers — plain text given a .md extension, a converted PDF, an exported book, anything whose
sections are headed 1. Introduction — produces no matches, and the function returns the entire
file as one chunk
.

That early return is the only path in the function not bounded by MAX_CHUNK_CHARS: everything that
reaches the paragraph splitter below it is capped, and this return never reaches it. The function has
exactly two returns, and only this one can produce a chunk of arbitrary size.

To Reproduce

from rag import _chunk_markdown          # `pyproject.toml` already puts `src` on the path

size = lambda cs: (len(cs), max(len(c["text"]) for c in cs))

text = ("word " * 3000 + "\n\n") * 8            # ~120 KB of ordinary paragraphs, no "#"
print(size(_chunk_markdown(text, "plain.md")))          # (1, 120013)   <- one chunk, 20x the cap

text2 = "# Chapter\n\n" + text                  # the SAME text, one heading line added
print(size(_chunk_markdown(text2, "with.md")))          # (8, 14999)

One # character changes the outcome completely.

On real input: we index a corpus of 32 book-length markdown files. 22 carry no ATX heading, and all
22 yield exactly one chunk
— for the largest, one chunk of 4,417,137 characters from a 4.4 MB
file. The other 10 do carry headings (between 1 and 13) and chunk normally. The number of heading-less
files and the number of whole-file chunks match exactly.

Expected behavior

MAX_CHUNK_CHARS bounds every chunk the function returns, whether or not the document happens to use
ATX headings.

Actual behavior

A heading-less file becomes a single chunk of unbounded size, which is then passed to the embedding
provider as one input. OpenAI caps a single input at 8192 tokens, so a cloud provider rejects it. A
local OpenAI-compatible server may instead truncate it without saying so: embedding the 4.4 MB
file against a local Ollama endpoint returns a vector identical (cosine 1.000000) to the vector of its
first 30,000 characters — the model's context window. Either way the file is not usefully indexed.

This is not quite silent, but nothing flags it: init_knowledge logs
f"{filename}: indexed {len(chunks)} chunks", so a multi-megabyte book appears in the log as
indexed 1 chunks. An operator has to notice that the number is 1.

Related: #366, which reports that one request per file exceeds the 300,000-token per-request cap. The
two compound — a whole-file chunk is both a single oversized input and, on its own, an oversized
request — but they are separate defects with separate fixes.

Possible fix

Let a heading-less document fall through to the paragraph splitter that already exists a few lines
below, instead of returning it as a finished chunk — i.e. treat it as one section rather than one
chunk:

matches = list(HEADING_RE.finditer(text))
if not matches:
    merged = [{"text": text.strip(), "breadcrumb": filename}]
else:
    ...                       # existing heading walk, unchanged
# the existing "Split large sections on paragraph boundaries" loop then runs in both cases

Verified through the function's own code by prepending a single heading line to the same 4.4 MB
input, which routes it down the splitter: 979 chunks, largest 6,001 characters instead of one
chunk of 4.4 MB.

That alone is not sufficient, though, and the reproduction above shows why: the splitter is
paragraph-granular, so it can only cut where \n\n occurs. A single paragraph longer than
MAX_CHUNK_CHARS still comes out oversized — the 14,999-character chunk in the reproduction is one
such paragraph — and a document with no blank lines at all is returned whole by the fall-through
just as it is today. Text reflowed to single newlines, or generated without blank lines, hits exactly
that.

So the bound is only guaranteed by adding a last-resort character-level cut for any piece still
over MAX_CHUNK_CHARS after the paragraph pass. The fall-through fixes the common case; the hard cut
is what makes MAX_CHUNK_CHARS a bound rather than a hint.

One more option, not a substitute: recognising setext headings (Title underlined with ==== or
----) would widen what counts as structure. It would help documents that have headings the regex
does not see, but a document with no headings of any kind still has to be chunked.

Whatever the split strategy, a warning when a chunk still exceeds MAX_CHUNK_CHARS would turn this
class of problem from "the log says indexed 1 chunks" into something an operator can act on.

Environment

Current main. src/rag.py only; independent of OS, channel, LLM provider and embedding provider —
the chunker runs before any of them. It needs only a .md file in the knowledge directory that uses
no ATX headings and is larger than MAX_CHUNK_CHARS.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    backlogThe issue has been included in the backlogdiscussion

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions