Source code for fastrag.ingestion.steps.chunking.heading
"""Markdown sections kept whole, with their heading ancestry for retrieval."""
from __future__ import annotations
import re
from collections.abc import Iterator, Mapping
from typing import Any
from fastrag.models import Document
from fastrag.ingestion.steps.chunking.base import Chunker, SplitPiece
_ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$")
_SETEXT = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
[docs]
class HeadingChunker(Chunker):
"""Split Markdown at headings without splitting section bodies.
Each chunk includes its ancestor headings in text and a ``headings``
metadata list ordered from outermost to innermost. ATX and Setext headings
are recognized; headings inside fenced or indented code are ignored.
Consecutive ancestor headings travel with their first subsection instead
of becoming isolated title chunks. Text before the first heading is kept
with an empty heading path. Sections have no size cap or overlap, so code,
lists and tables stay intact even when a section is long.
Inherits ``Chunker``'s ``cache`` and ``policy`` constructor arguments.
"""
accepted_content_formats = frozenset(["text", "markdown"])
[docs]
def config(self) -> Mapping[str, Any]:
"""Return the section splitting configuration for cache identity."""
return {"headings": "atx_and_setext", "include_ancestors": True}
[docs]
def split(self, document: Document) -> Iterator[SplitPiece]:
"""Yield whole sections with their heading paths and original Markdown."""
self.validate_input(document)
# (level, title, original heading Markdown)
headings: list[tuple[int, str, str]] = []
body: list[str] = []
fence: str | None = None
lines = document.content.splitlines(keepends=True)
index = 0
def section() -> SplitPiece:
prefix = "\n\n".join(heading[2] for heading in headings)
text = "\n\n".join(part for part in (prefix, "".join(body).strip()) if part)
return text, {"headings": [heading[1] for heading in headings]}
while index < len(lines):
line = lines[index].rstrip("\r\n")
marker = _FENCE.match(line)
if fence is not None:
body.append(lines[index])
if (
marker
and marker[1][0] == fence[0]
and len(marker[1]) >= len(fence)
and not marker[2].strip()
):
fence = None
index += 1
continue
if marker and not (marker[1][0] == "`" and "`" in marker[2]):
fence = marker[1]
body.append(lines[index])
index += 1
continue
heading = _ATX.match(line)
underline = (
_SETEXT.match(lines[index + 1].rstrip("\r\n"))
if index + 1 < len(lines)
and line.strip()
and not line.startswith((" ", "\t"))
else None
)
if heading:
level = len(heading[1])
title = re.sub(r"[ \t]+#+[ \t]*$", "", heading[2] or "").strip()
original = line
consumed = 1
elif underline:
level = 1 if underline[1][0] == "=" else 2
title = line.strip()
original = line + "\n" + lines[index + 1].rstrip("\r\n")
consumed = 2
else:
body.append(lines[index])
index += 1
continue
if "".join(body).strip() or (headings and level <= headings[-1][0]):
yield section()
body = []
headings = [entry for entry in headings if entry[0] < level]
headings.append((level, title, original))
index += consumed
if headings or "".join(body).strip():
yield section()