EAP – Document Chunking
Why We Need Chunking ?
# ── STEP 2: Chunk parsed files ─────────────────────────
chunked_prefix = f"{tenant_id}/{connector_id}/chunked"
logger.info("🧩 Chunking Parsed Files")
t0 = perf_counter()
chunk_result = await chunk_files(
input_bucket=bucket,
input_prefix=parsed_prefix,
output_bucket=bucket,
output_prefix=chunked_prefix,
)
timings["chunking"] = perf_counter() - t0
logger.info(
f"Chunking complete [{_fmt(timings['chunking'])}]: "
f"processed={chunk_result['processed']} | "
f"success={chunk_result['succeeded']} | "
f"failed={chunk_result['failed']}"
)
all_chunks = [
{"file_name": item.get("gcs_uri", ""), "body": chunk["body"]}
for item in chunk_result["results"]
if item["status"] == "success"
for chunk in item["chunks"]
]
logger.info(f"🧩 Total Chunks Collected: {len(all_chunks)}") from __future__ import annotations
import asyncio
import json
import logging
import re
from pathlib import Path
from typing import Any
from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
from rag_pipeline.chunking.helpers import (
_split_table,
_is_markdown_table,
_clean_file_name,
_context_prefix,
_merge_meta_keep_earliest,
_get_chunking_config
)
from rag_pipeline.chunking.docai_chunker import DocAIChunker
from rag_pipeline.config import get_settings
from rag_pipeline.parsing.helpers import _error_payload
from rag_pipeline.utils.gcs import GCSBucketConnector
class DocumentChunker:
"""Hierarchical markdown chunker with table-aware splitting and breadcrumb context."""
def __init__(self, max_chars: int | None = None, overlap: int | None = None):
"""Initialize chunker with optional config overrides.
Args:
max_chars: Max characters per chunk (loads from config if None)
overlap: Overlap characters between chunks (loads from config if None)
"""
self.logger = logging.getLogger(__name__)
config_max_chars, config_overlap, self.headers = _get_chunking_config()
self.max_chars = max_chars or config_max_chars
self.overlap = overlap or config_overlap
self.char_splitter = RecursiveCharacterTextSplitter(
chunk_size=self.max_chars,
chunk_overlap=self.overlap,
separators=["\n\n", "\n", ". ", " ", ""],
length_function=len,
)
def _header_split(self, text: str) -> list[Any]:
"""Split by headers progressively, expanding only oversized sections."""
# Start coarse (H1 only), then progressively split only oversized sections.
docs = MarkdownHeaderTextSplitter(headers_to_split_on=self.headers[:1], strip_headers=False).split_text(text)
for level in range(1, len(self.headers)):
expanded = []
for d in docs:
if len(d.page_content) <= self.max_chars:
expanded.append(d)
continue
sub = MarkdownHeaderTextSplitter(
headers_to_split_on=self.headers[:level + 1], strip_headers=False
).split_text(d.page_content)
for s in sub:
# Preserve parent header metadata on child sections.
s.metadata = {**d.metadata, **s.metadata}
expanded.extend(sub)
docs = expanded
return docs
def _hydrate_header_context(self, docs: list[Any]) -> list[Any]:
"""Fill missing parent headers so lower-level chunks carry full available hierarchy."""
last_seen: dict[str, str] = {}
hydrated = []
for d in docs:
meta = dict(d.metadata or {})
if meta.get("h1"):
last_seen["h1"] = meta["h1"]
last_seen.pop("h2", None)
last_seen.pop("h3", None)
last_seen.pop("h4", None)
elif last_seen.get("h1"):
meta["h1"] = last_seen["h1"]
if meta.get("h2"):
last_seen["h2"] = meta["h2"]
last_seen.pop("h3", None)
last_seen.pop("h4", None)
elif (meta.get("h3") or meta.get("h4")) and last_seen.get("h2"):
meta["h2"] = last_seen["h2"]
if meta.get("h3"):
last_seen["h3"] = meta["h3"]
last_seen.pop("h4", None)
elif meta.get("h4") and last_seen.get("h3"):
meta["h3"] = last_seen["h3"]
if meta.get("h4"):
last_seen["h4"] = meta["h4"]
d.metadata = meta
hydrated.append(d)
return hydrated
def _split_content(self, content: str) -> list[str]:
"""Split section content; do not inject context here."""
if len(content) <= self.max_chars:
return [content]
pieces: list[str] = []
# Table detection regex
TABLE_RE = re.compile(r"((?:^\|.*\|\s*$\n?){2,})", re.MULTILINE)
# Split around table-shaped blocks so we can treat tables differently.
segments = TABLE_RE.split(content)
for seg in segments:
if not seg or not seg.strip():
continue
if TABLE_RE.fullmatch(seg) and _is_markdown_table(seg):
if len(seg) > self.max_chars:
# Oversized tables are split row-wise with header repetition.
pieces.extend(_split_table(seg, self.max_chars))
else:
pieces.append(seg)
continue
# Non-table text uses recursive character splitting.
for chunk in self.char_splitter.split_text(seg):
if chunk and chunk.strip():
pieces.append(chunk)
coalesced: list[str] = []
for piece in pieces:
if not coalesced:
coalesced.append(piece)
continue
candidate = f"{coalesced[-1]}\n\n{piece}"
if len(candidate) <= self.max_chars:
# Opportunistically re-join neighboring fragments when safe.
coalesced[-1] = candidate
else:
coalesced.append(piece)
return coalesced
def chunk(self, markdown_text: str, file_name: str) -> list[dict[str, Any]]:
"""Chunk markdown text using header/table-aware splitting.
Returns list of dicts with source, hierarchical metadata, and chunk body.
Args:
markdown_text: Markdown content to chunk
file_name: Source file name (used for metadata)
Returns:
list: Dicts with keys: file_name, chunk_id, h1-h4 (if present), body
"""
text = str(markdown_text or "").replace("\r\n", "\n").replace("\r", "\n")
if not text.strip():
return []
source_name = _clean_file_name(file_name)
header_docs = self._hydrate_header_context(self._header_split(text))
final: list[tuple[dict, str]] = []
for d in header_docs:
meta = {
"h1": d.metadata.get("h1"),
"h2": d.metadata.get("h2"),
"h3": d.metadata.get("h3"),
"h4": d.metadata.get("h4"),
}
# Each header section may still split into multiple content chunks.
for chunk_text in self._split_content(d.page_content):
if chunk_text and chunk_text.strip():
final.append((meta, chunk_text))
# Greedy linear merge for larger average chunk size.
# Keep earliest metadata while preserving all content.
stabilized: list[tuple[dict, str]] = []
buffer_text = ""
buffer_meta: dict[str, Any] = {}
for meta, text in final:
if not text or not text.strip():
continue
if not buffer_text:
buffer_text = text
buffer_meta = dict(meta)
continue
candidate = f"{buffer_text}\n\n{text}"
if len(candidate) <= self.max_chars:
buffer_text = candidate
buffer_meta = _merge_meta_keep_earliest(buffer_meta, meta)
else:
stabilized.append((buffer_meta, buffer_text))
buffer_text = text
buffer_meta = dict(meta)
if buffer_text:
stabilized.append((buffer_meta, buffer_text))
# Build output docs; inject source/context once per chunk.
out = []
for meta, chunk_text in stabilized:
prefix = _context_prefix(meta, source_name)
body = chunk_text if (prefix and chunk_text.startswith(prefix)) else f"{prefix}{chunk_text}"
chunk_id = len(out) + 1
doc_out = {
"chunk_id": chunk_id,
"h1": meta.get("h1"),
"h2": meta.get("h2"),
"h3": meta.get("h3"),
"h4": meta.get("h4"),
"body": body,
}
out.append({k: v for k, v in doc_out.items() if v is not None})
self.logger.info(f"Chunked {file_name} into {len(out)} chunks")
return out
def _chunk_content_bytes(payload: bytes, object_name: str) -> list[dict]:
"""Chunk one parsed object payload into in-memory chunk records.
Routing is extension-based:
- ``.json``: treated as raw DocAI response(s), chunked via DocAIChunker
- ``.md``: treated as markdown, chunked via DocumentChunker
"""
suffix = Path(object_name).suffix.lower()
if suffix == ".json":
loaded = json.loads(payload.decode("utf-8"))
chunker = DocAIChunker()
file_name = Path(object_name).name
if isinstance(loaded, list):
out: list[dict] = []
for response in loaded:
if not isinstance(response, dict):
continue
out.extend(chunker.chunk(response, file_name=file_name))
for idx, chunk in enumerate(out, start=1):
chunk["chunk_id"] = idx
return out
if isinstance(loaded, dict):
return chunker.chunk(loaded, file_name=file_name)
raise ValueError("Unsupported DocAI JSON shape; expected dict or list[dict]")
if suffix == ".md":
markdown_text = payload.decode("utf-8")
return DocumentChunker().chunk(markdown_text, file_name=Path(object_name).name)
logging.getLogger(__name__).error(
"Unsupported parsed file format for chunking | object=%s | expected=.md or .json",
object_name,
)
raise ValueError(
f"Unsupported parsed file format '{suffix or '<none>'}' for '{object_name}'. Expected .md or .json"
)
async def chunk_files(
input_bucket: str,
input_prefix: str | None = None,
output_bucket: str | None = None, # 🔥 NEW
output_prefix: str | None = None,
) -> dict:
logger = logging.getLogger(__name__)
cfg = get_settings()
concurrency = int(cfg.get("chunking", {}).get("batch", {}).get("max_concurrency", 5))
semaphore = asyncio.Semaphore(max(1, int(concurrency)))
gcs = GCSBucketConnector()
# 🔥 SAME LOGIC AS PARSER
destination_bucket = output_bucket or input_bucket
object_names = await gcs.list_files(input_bucket, prefix=input_prefix)
object_names = [name for name in object_names if not name.endswith("/")]
async def _run_one(object_name: str) -> dict:
result = {
"gcs_uri": f"gs://{input_bucket}/{object_name}",
"output_gcs_uri": None,
"status": "failed",
"chunks_count": 0,
"chunks": [],
"error": None,
}
async with semaphore:
try:
logger.info(f"Chunking file | {object_name}")
# – ---------------------------
# Download parsed file
# – ---------------------------
payload = await gcs.download(input_bucket, object_name)
if not isinstance(payload, bytes):
raise TypeError(f"Downloaded object is not bytes for '{object_name}'")
# – ---------------------------
# Chunk content
# – ---------------------------
chunks = await asyncio.to_thread(
_chunk_content_bytes,
payload,
object_name
)
result["status"] = "success"
result["chunks_count"] = len(chunks)
result["chunks"] = chunks
# – ---------------------------
# 🔥 SAVE CHUNKS TO GCS
# – ---------------------------
if output_prefix:
file_name = Path(object_name).stem
output_object = f"{output_prefix}/{file_name}_chunks.json"
output_bytes = json.dumps(
chunks,
ensure_ascii=False,
indent=2
).encode("utf-8")
await gcs.save(
destination_bucket, # 🔥 FIXED
output_object,
data=output_bytes,
content_type="application/json"
)
result["output_gcs_uri"] = f"gs://{destination_bucket}/{output_object}"
return result
except Exception as exc:
err = _error_payload(exc)
result["error"] = err
logger.error(
f"Failed chunking file | {object_name} | "
f"{err['type']} | {err['message']}"
)
return result
# – ---------------------------
# RUN ALL FILES
# – ---------------------------
logger.info(
f"Starting chunking | input_bucket={input_bucket} | input_prefix={input_prefix} | "
f"output_bucket={destination_bucket} | total_files={len(object_names)}"
)
results = await asyncio.gather(*[_run_one(name) for name in object_names]) if object_names else []
succeeded = sum(1 for item in results if item.get("status") == "success")
failed = len(results) - succeeded
return {
"input_bucket": input_bucket,
"input_prefix": input_prefix,
"output_bucket": destination_bucket, # 🔥 NEW
"processed": len(results),
"succeeded": succeeded,
"failed": failed,
"results": results,
} from __future__ import annotations
import logging
from typing import Any
from rag_pipeline.chunking.helpers import (
_clean_file_name,
_context_prefix,
_get_chunking_config,
)
from rag_pipeline.chunking.helpers import _block_to_md, _detect_block_type
class DocAIChunker:
"""Structure-aware chunker for Document AI response JSON.
Splits the document using the block hierarchy produced by Document AI
(headings, tables, lists, paragraphs) and returns chunks in the same
dict format used by :class:`~rag_pipeline.chunking.chunker.DocumentChunker`.
Usage::
chunker = DocAIChunker()
# response is the dict returned by DocumentAIProcessor.process_bytes()
chunks = chunker.chunk(response, file_name="my_document.pdf")
"""
def __init__(self, max_chunk_size: int | None = None):
self.logger = logging.getLogger(__name__)
cfg_max, _, _ = _get_chunking_config()
self.max_chunk_size = max_chunk_size or cfg_max
# – ----------------------------------------------------------------
# Block traversal with heading tracking
# – ----------------------------------------------------------------
def _traverse_with_headers(
self, blocks: list, headers: dict | None = None
) -> list[dict]:
"""Recursively traverse blocks, tracking heading hierarchy.
Returns a list of section dicts::
{"headers": {"h1": ..., "h2": ..., ...}, "content": "<markdown>"}
"""
if headers is None:
headers = {}
sections: list[dict] = []
for block in blocks:
block_type = _detect_block_type(block)
new_headers = headers.copy()
if block_type.startswith("heading"):
level = block_type.split("-")[1]
text = block.get("textBlock", {}).get("text", "")
new_headers[f"h{level}"] = text
md = _block_to_md(block)
if md.strip():
sections.append({"headers": dict(new_headers), "content": md})
nested: list = []
if "textBlock" in block:
nested.extend(block["textBlock"].get("blocks", []))
if "listBlock" in block:
for entry in block["listBlock"].get("listEntries", []):
nested.extend(entry.get("blocks", []))
if nested:
sections.extend(self._traverse_with_headers(nested, new_headers))
return sections
# – ----------------------------------------------------------------
# Semantic accumulation into chunks
# – ----------------------------------------------------------------
def _accumulate(self, sections: list[dict]) -> list[tuple[dict, str]]:
"""Greedily merge adjacent sections into chunks within size limit."""
chunks: list[tuple[dict, str]] = []
buffer = ""
meta: dict[str, Any] = {}
for sec in sections:
text = sec["content"]
if len(buffer) + len(text) > self.max_chunk_size:
if buffer:
chunks.append((meta, buffer.strip()))
buffer = text
meta = sec["headers"]
else:
buffer += "\n" + text
meta = {**meta, **{k: v for k, v in sec["headers"].items() if k not in meta}}
if buffer:
chunks.append((meta, buffer.strip()))
return chunks
# – ----------------------------------------------------------------
# Public API
# – ----------------------------------------------------------------
def chunk(self, docai_response: dict, file_name: str) -> list[dict[str, Any]]:
"""Chunk a Document AI response dict into structured output records.
Args:
docai_response: Raw dict returned by
:meth:`~rag_pipeline.parsing.docai.DocumentAIProcessor.process_bytes`.
file_name: Source file name used for context metadata.
Returns:
List of dicts with keys ``chunk_id``, ``h1``–``h4`` (if present),
and ``body`` (text with injected source/breadcrumb prefix).
"""
document = docai_response.get("document", docai_response)
blocks = document.get("documentLayout", {}).get("blocks", [])
if not blocks:
return []
source_name = _clean_file_name(file_name)
sections = self._traverse_with_headers(blocks)
raw_chunks = self._accumulate(sections)
out: list[dict[str, Any]] = []
for meta, text in raw_chunks:
if not text.strip():
continue
prefix = _context_prefix(meta, source_name)
body = text if (prefix and text.startswith(prefix)) else f"{prefix}{text}"
chunk_id = len(out) + 1
doc_out: dict[str, Any] = {
"chunk_id": chunk_id,
"h1": meta.get("h1"),
"h2": meta.get("h2"),
"h3": meta.get("h3"),
"h4": meta.get("h4"),
"body": body,
}
out.append({k: v for k, v in doc_out.items() if v is not None})
self.logger.info(f"DocAI chunked '{file_name}' into {len(out)} chunks")
return out
