Repository navigation
_chunk_markdown returns the whole file as a single chunk when the file has no # heading, ignoring MAX_CHUNK_CHARS #369
Description
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 casesVerified 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