Files
hkuds--lightrag/lightrag/chunker/paragraph_semantic.py
T
2026-07-13 12:08:54 +08:00

2303 lines
99 KiB
Python

"""Paragraph Semantic Chunking for LightRAG.
Reads a LightRAG ``.blocks.jsonl`` sidecar — produced by any sidecar-emitting
parser (native / mineru / docling): heading-driven blocks, tables kept whole —
and produces a chunk list compatible with
:func:`lightrag.chunker.chunking_by_token_size`.
The full algorithm and rationale are documented in
``docs/ParagraphSemanticChunking-zh.md``. Parsers perform only heading-driven
structural splitting; all block sizing happens here. This module re-implements
the post-HeadingBlocks pipeline (TableRowSplit/AnchorSplit/LevelMerge) on top of
blocks.jsonl input, parameterised on ``chunk_token_size`` so chunk-size targets
follow the user's RAG configuration.
Pipeline:
- HeadingBlocks — heading-driven initial split: done at parse time and
persisted as one row per block in ``blocks.jsonl``.
- TableRowSplit — oversized-table re-split + first/middle/last gluing,
invoked when an embedded ``<table … format="json">`` (or ``"html"``)
exceeds ``table_max``. Splitting prefers structural row boundaries (JSON
list items, HTML ``<tr>`` rows) so each fragment stays a legal ``<table>``
tag; only when no row boundary is available, or one row alone exceeds the
cap, does it fall back to ``chunking_by_recursive_character`` on that
fragment. When two oversized tables are separated by text inside one
heading block, the bridge text may be duplicated into both table boundary
chunks so each table keeps nearby context. The source table's repeating
header (lifted at parse time into the ``.tables.json`` sidecar, keyed by the
slice's ``<table>`` id) is re-injected into the non-first slices *here*, with
its tokens budgeted out of the per-slice cap *before* splitting — so every
slice stays ``≤ target_max`` including its header. Because the header now
lives in the slice from the start, a split table's slices are frozen against
LevelMerge (re-merging two slices of one table would duplicate the header).
- AnchorSplit — anchor-driven long-block re-split: short non-table
paragraphs (≤ 100 chars) are promoted as split points and the block is
rebalanced toward ``target_ideal``. With no anchor, table-aware fallback
applies the same row-boundary-first strategy to any oversized table
paragraph and only character-splits the residual prose (using the
configured paragraph-semantic overlap).
- HeadingGlue — body-less heading glue: a section heading with no body of
its own is glued forward into its first strictly-deeper child block
(keeping the shallower parent heading), so it is never separated from that
child nor glued onto an unrelated same-level sibling. A heading with no
deeper child following is left untouched for LevelMerge.
- LevelMerge — bottom-up, level-aware small-block merging: undersized blocks
get absorbed by same-level neighbours (Phase A), then shallower levels
(Phase B), with a final tail-absorption pass for the trailing remainders.
"""
from __future__ import annotations
import json
import math
import os
import re
from collections.abc import Sequence
from pathlib import Path
from typing import Any, Callable
from lightrag.constants import (
DEFAULT_P_REFERENCES_HEADINGS,
DEFAULT_P_REFERENCES_TAIL_N,
)
from lightrag.table_markup import (
TABLE_TAG_RE as _TABLE_TAG_RE,
detect_table_format as _detect_table_format,
extract_table_id as _extract_table_id,
serialize_html_rows as _serialize_rows_with_wrappers,
split_html_rows as _split_html_rows,
)
from lightrag.utils import Tokenizer, logger
# ---------------------------------------------------------------------------
# Threshold ratios — derived from the audit-mode constants in
# lightrag/parser/docx/parse_document.py so the trade-off curves
# (table vs. block size, ideal vs. max, etc.) carry over verbatim. The
# absolute values scale with the user-configured ``chunk_token_size``.
# ---------------------------------------------------------------------------
# IDEAL/MAX = 6000/8000 = 0.75 in audit mode.
_IDEAL_RATIO = 0.75
# TABLE_MAX/MAX = 5000/8000 = 0.625 in audit mode.
_TABLE_MAX_RATIO = 0.625
# TABLE_IDEAL/MAX = 3000/8000 = 0.375 in audit mode.
_TABLE_IDEAL_RATIO = 0.375
# TABLE_MIN_LAST/TABLE_MAX = (TABLE_MAX-TABLE_IDEAL)*0.8/TABLE_MAX
# = (5000-3000)*0.8/5000 = 0.32 in audit mode.
_TABLE_MIN_LAST_RATIO = 0.32
# SMALL_TAIL_THRESHOLD/MAX = (MAX-IDEAL)/2/MAX = 1000/8000 = 0.125.
_SMALL_TAIL_RATIO = 0.125
# Anchor candidate length is a UI/readability constraint — keep absolute.
_MAX_ANCHOR_CANDIDATE_LENGTH = 100 # characters
# Table tag regex (``_TABLE_TAG_RE``) plus the ``_detect_table_format``,
# ``_split_html_rows`` and ``_serialize_rows_with_wrappers`` helpers are
# imported from :mod:`lightrag.table_markup` so the surrounding-context
# extractor can reuse the same primitives.
_LEGACY_TABLE_CHUNK_SUFFIX_RE = re.compile(r"\s*\[表格片段\d+\]\s*$")
_PART_SUFFIX_RE = re.compile(r"\s*\[part\s+\d+\]\s*$", re.IGNORECASE)
# Markdown heading-line pattern — 1-6 ``#`` followed by one or more spaces.
# Mirrors ``_MD_HEADING_RE`` in ``lightrag/parser/_markdown.py`` (the same
# pattern ``render_heading_line`` uses to render a heading content line) so a
# "heading-only" block — one whose content carries nothing but heading lines —
# can be detected without importing a private symbol across packages.
_HEADING_LINE_RE = re.compile(r"^#{1,6} +")
# ---------------------------------------------------------------------------
# Shared helpers.
# ---------------------------------------------------------------------------
def _count_tokens(tokenizer: Tokenizer, text: str) -> int:
if not text:
return 0
return len(tokenizer.encode(text))
def _bounded_overlap(target_max: int, chunk_overlap_token_size: int) -> int:
"""Return an overlap value safe for recursive-character splitting."""
overlap = max(int(chunk_overlap_token_size), 0)
if target_max <= 1:
return 0
return min(overlap, target_max - 1)
# Max heading length surfaced in a log line — a heading can be an entire long
# paragraph, so truncate before logging to keep the line readable.
_HEADING_LOG_MAX_CHARS = 50
def _heading_for_log(heading: str | None) -> str:
"""Truncate a heading to the first ``_HEADING_LOG_MAX_CHARS`` chars for logs."""
text = heading or ""
if len(text) <= _HEADING_LOG_MAX_CHARS:
return text
return text[:_HEADING_LOG_MAX_CHARS] + "…"
def _strip_generated_heading_suffixes(heading: str) -> str:
"""Remove generated split suffixes before assigning a fresh part number."""
cleaned = (heading or "").rstrip()
while True:
next_cleaned = _PART_SUFFIX_RE.sub("", cleaned).rstrip()
next_cleaned = _LEGACY_TABLE_CHUNK_SUFFIX_RE.sub("", next_cleaned).rstrip()
if next_cleaned == cleaned:
return cleaned
cleaned = next_cleaned
def _append_part_suffix(heading: str, part_number: int) -> str:
base = _strip_generated_heading_suffixes(heading)
suffix = f"[part {part_number}]"
return f"{base} {suffix}" if base else suffix
def _apply_part_suffixes(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Tag split fragments from one original block as ``[part n]``."""
if len(blocks) <= 1:
return blocks
for idx, block in enumerate(blocks, start=1):
block["heading"] = _append_part_suffix(block.get("heading", ""), idx)
return blocks
def _is_table_paragraph(text: str) -> bool:
stripped = text.strip()
return stripped.startswith("<table ") and stripped.endswith("</table>")
def _block_to_paragraphs(content: str) -> list[dict[str, Any]]:
"""Recover the per-paragraph view of a rewritten block.
The sidecar writer joins paragraphs with ``\\n`` and emits
tables/equations/drawings as single-line tags with no internal newlines,
so ``split("\\n")`` faithfully recovers paragraph boundaries.
"""
paragraphs: list[dict[str, Any]] = []
for line in content.split("\n"):
if not line.strip():
continue
paragraphs.append({"text": line, "is_table": _is_table_paragraph(line)})
return paragraphs
def _load_blocks_from_jsonl(blocks_path: str) -> list[dict[str, Any]]:
"""Read ``type == "content"`` rows from a blocks.jsonl file in order."""
rows: list[dict[str, Any]] = []
with Path(blocks_path).open("r", encoding="utf-8") as fh:
for raw in fh:
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except json.JSONDecodeError:
continue
if isinstance(obj, dict) and obj.get("type") == "content":
rows.append(obj)
return rows
def _references_tail_n() -> int:
"""Trailing-block window for reference detection (live from env).
Read at chunk time (NOT snapshotted) so changing
``CHUNK_P_REFERENCES_TAIL_N`` takes effect on re-runs of already-enqueued
documents. Falls back to :data:`DEFAULT_P_REFERENCES_TAIL_N`.
"""
raw = os.getenv("CHUNK_P_REFERENCES_TAIL_N")
if raw is None:
return DEFAULT_P_REFERENCES_TAIL_N
try:
return max(int(raw), 0)
except ValueError:
logger.warning(
"[paragraph_semantic] invalid CHUNK_P_REFERENCES_TAIL_N=%r; "
"using default %d",
raw,
DEFAULT_P_REFERENCES_TAIL_N,
)
return DEFAULT_P_REFERENCES_TAIL_N
def _references_headings() -> list[str]:
"""Reference heading prefixes (live from env), pipe-separated.
Read at chunk time (NOT snapshotted), mirroring :func:`_references_tail_n`.
Falls back to :data:`DEFAULT_P_REFERENCES_HEADINGS`.
"""
raw = os.getenv("CHUNK_P_REFERENCES_HEADINGS")
if raw is None:
return list(DEFAULT_P_REFERENCES_HEADINGS)
return [seg.strip() for seg in raw.split("|") if seg.strip()]
def _format_dropped_headings(
headings: Sequence[str], *, max_each: int = 60, max_items: int = 5
) -> str:
"""Render dropped reference headings as a compact, length-bounded string.
Each heading is truncated to ``max_each`` chars and at most ``max_items``
are listed (the rest collapse to ``(+N more)``) so the log line stays
scannable even with a large ``references_tail_n`` or an unusually long
heading field.
"""
shown = [
(h[:max_each] + "…") if len(h) > max_each else h for h in headings[:max_items]
]
rendered = ", ".join(repr(h) for h in shown)
extra = len(headings) - max_items
if extra > 0:
rendered += f" (+{extra} more)"
return rendered
def _is_reference_heading(heading: str, prefixes: Sequence[str]) -> bool:
"""Whether ``heading`` marks a reference section per ``prefixes``.
ASCII prefixes (``References``/``Bibliography``) match case-insensitively at
a word boundary, so ``Referenced work`` does NOT match. Non-ASCII prefixes
(the Chinese ``参考文献``) match as a plain prefix — CJK has no word boundary,
so ``参考文献列表`` still matches.
"""
low = (heading or "").strip().casefold()
if not low:
return False
for prefix in prefixes:
pref = (prefix or "").strip()
if not pref:
continue
pl = pref.casefold()
if not low.startswith(pl):
continue
if pref.isascii():
rest = low[len(pl) :]
if rest and rest[0].isalnum():
continue # e.g. "Referenced" — not a standalone word
return True
return False
def _tables_sidecar_path(blocks_path: str) -> str | None:
"""Derive the ``.tables.json`` sidecar path beside a ``.blocks.jsonl`` file.
Mirrors :func:`lightrag.utils_pipeline.sidecar_modality_path` naming
(``<base>.blocks.jsonl`` → ``<base>.tables.json``). Returns ``None`` when
the path does not follow the ``.blocks.jsonl`` convention.
"""
suffix = ".blocks.jsonl"
if not blocks_path.endswith(suffix):
return None
return blocks_path[: -len(suffix)] + ".tables.json"
def _load_table_headers(blocks_path: str) -> dict[str, str]:
"""Map ``table_id -> repeating-header body`` from the tables.json sidecar.
Only tables that carry a ``table_header`` field (the cross-page repeating
header the parser lifted from the source's header rows) are included; the
value is the verbatim string the writer stored — a JSON 2-D array for JSON
tables (e.g. ``'[["X", "Y"]]'``) or a raw ``<thead>…</thead>`` fragment for
HTML tables. A missing / unreadable / malformed sidecar degrades
silently to ``{}`` — a missing header must never block chunking (same
tolerance as :func:`_load_blocks_from_jsonl`).
"""
path = _tables_sidecar_path(blocks_path)
if not path:
return {}
try:
with Path(path).open("r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
return {}
tables = data.get("tables") if isinstance(data, dict) else None
if not isinstance(tables, dict):
return {}
headers: dict[str, str] = {}
for table_id, entry in tables.items():
if not isinstance(entry, dict):
continue
header = entry.get("table_header")
if isinstance(header, str) and header.strip():
headers[str(table_id)] = header
return headers
def _parse_header_rows(header_body: str) -> list[Any] | None:
"""Parse a stored ``table_header`` JSON 2-D array, or ``None`` if unusable."""
try:
header_rows = json.loads(header_body)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(header_rows, list) or not header_rows:
return None
return header_rows
def _classify_header_format(header_body: str | None) -> str | None:
"""Classify a stored ``table_header`` string as ``"json"`` / ``"html"`` / None.
The sidecar stores a JSON-format table's header as a JSON 2-D array string
(e.g. ``'[["H1","H2"]]'``) and an HTML-format table's header as a raw
``<thead>…</thead>`` fragment (preserving rowspan/colspan). Returns:
* ``"json"`` — parses as a non-empty JSON list (see :func:`_parse_header_rows`);
* ``"html"`` — carries ``<thead`` / ``<tr`` / ``<th`` markup;
* ``None`` — empty / whitespace / unusable for either path.
JSON is checked first (authoritative — a JSON 2-D array can never contain
``<thead``); the HTML check only sniffs for markup, trusting the writer to
emit a well-formed ``<thead>``.
"""
if not header_body or not header_body.strip():
return None
if _parse_header_rows(header_body) is not None:
return "json"
lowered = header_body.lower()
if "<thead" in lowered or "<tr" in lowered or "<th" in lowered:
return "html"
return None
def _header_reserve_tokens(header_rows: list[Any], tokenizer: Tokenizer) -> int:
"""Tokens to reserve out of a JSON slice's body budget for the header.
Conservative on purpose: we reserve the cost of the header rows serialized
on their own (``json.dumps(header_rows)``); the in-place form
``header_rows + data`` is actually a couple of tokens cheaper (one shared
set of brackets), so reserving the standalone cost can only over-budget,
never under-budget — the safe direction for the cap. (HTML headers reserve
the raw ``<thead>`` token cost directly in :func:`_split_table_text`.)
"""
return _count_tokens(tokenizer, json.dumps(header_rows, ensure_ascii=False))
def _inject_header_into_table_slice(text: str, header_body: str) -> str | None:
"""Re-inject the recovered repeating header into a table slice, in place.
Rebuilds ``<table {attrs}>{header}{original body}</table>`` keeping the
slice's own ``attrs`` (hence its ``id``):
* JSON slice + JSON header → header rows are prepended to the row array.
* HTML slice + HTML header → the raw ``<thead>…</thead>`` ``header_body``
is spliced verbatim ahead of the body (preserving rowspan/colspan).
Returns ``None`` (caller leaves the slice untouched) when the slice is not a
``<table>`` tag, the header is unusable, the slice format is indeterminate,
or an HTML slice already carries a ``<thead>``.
Raises ``ValueError`` on a clear cross-format mismatch (a JSON header into an
HTML slice, or an HTML header into a JSON slice) — a corrupted / foreign
sidecar — rather than silently emitting a malformed slice.
"""
match = _TABLE_TAG_RE.match((text or "").strip())
if not match:
return None
header_fmt = _classify_header_format(header_body)
if header_fmt is None:
return None
attrs = match.group("attrs")
body = match.group("body")
slice_fmt = _detect_table_format(attrs, body)
# CONSISTENCY DEFENSIVE CHECK: a clear json-vs-html disagreement means the
# sidecar header does not belong to this table (corrupted / foreign).
if slice_fmt in {"json", "html"} and slice_fmt != header_fmt:
raise ValueError(
f"table_header format {header_fmt!r} does not match table slice "
f"format {slice_fmt!r} for "
f"{_extract_table_id(attrs) or '<no-id>'}; refusing to inject a "
"cross-format header (corrupted sidecar?)"
)
if slice_fmt == "json":
# header_fmt is guaranteed "json" here (any mismatch raised above).
header_rows = _parse_header_rows(header_body)
if header_rows is None:
return None
try:
rows = json.loads(body)
except json.JSONDecodeError:
return None
if not isinstance(rows, list):
return None
# The JSON caller pins the repeating header out of the split body (see
# ``_split_table_text``), so a slice here only ever holds genuine data
# rows — prepend the full header verbatim, never heuristically stripping
# leading rows (which would corrupt a data row that happens to equal a
# header row).
merged = json.dumps(header_rows + rows, ensure_ascii=False)
return f"<table {attrs}>{merged}</table>"
if slice_fmt == "html":
# header_fmt is guaranteed "html" here. If the slice already carries a
# header (a <thead> the splitter kept when it cut inside a multi-row
# header), prepending another would duplicate it — leave it as-is.
if "<thead" in body.lower():
return None
return f"<table {attrs}>{header_body}{body}</table>"
# slice_fmt is None (indeterminate): cannot establish consistency, so do
# not inject and do not raise — leave the slice untouched.
return None
def _split_html_rows_by_tokens(
rows: list[tuple[str, str]],
tokenizer: Tokenizer,
*,
target_max: int,
target_ideal: int,
last_min: int,
) -> list[list[tuple[str, str]]]:
"""HTML-tuple analog of :func:`_split_rows_by_tokens`.
Same balanced-split + tail-merge algorithm; tokens are measured on
the row payloads (``tr_str``) only — wrapper overhead is amortised
later by the per-chunk serialiser plus the re-split-on-overflow
safety net in :func:`_split_table_text`.
"""
total = _count_tokens(tokenizer, "".join(tr for _, tr in rows))
if total <= target_max or len(rows) <= 1:
return [rows]
target_chunks = max(
math.ceil(total / target_ideal),
math.ceil(total / target_max),
)
target_chunks = min(target_chunks, len(rows))
target_rows = len(rows) / target_chunks
chunks: list[list[tuple[str, str]]] = []
start = 0
for i in range(target_chunks):
if i == target_chunks - 1:
end = len(rows)
else:
end = max(start + 1, min(int((i + 1) * target_rows), len(rows)))
remaining = len(rows) - end
if remaining > 0 and remaining < target_rows * 0.3:
end = len(rows)
chunks.append(rows[start:end])
start = end
if start >= len(rows):
break
if len(chunks) >= 2:
last_text = "".join(tr for _, tr in chunks[-1])
if _count_tokens(tokenizer, last_text) < last_min:
merged = chunks[-2] + chunks[-1]
merged_tokens = _count_tokens(tokenizer, "".join(tr for _, tr in merged))
if merged_tokens <= target_max:
chunks[-2] = merged
chunks.pop()
return chunks
def _dedup_preserving_order(values: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for v in values:
if v and v not in seen:
seen.add(v)
out.append(v)
return out
def _new_block(
*,
heading: str,
parent_headings: list[str],
level: int,
paragraphs: list[dict[str, Any]],
table_chunk_role: str,
tokenizer: Tokenizer,
blockids: list[str] | None = None,
) -> dict[str, Any]:
content = "\n".join(p["text"] for p in paragraphs)
return {
"heading": heading,
"parent_headings": list(parent_headings),
"level": level,
"paragraphs": list(paragraphs),
"content": content,
"tokens": _count_tokens(tokenizer, content),
"table_chunk_role": table_chunk_role,
# Ordered list of source blockids (deduped). Empty when the input
# blocks.jsonl row did not carry a blockid (raw/legacy input).
"blockids": _dedup_preserving_order(list(blockids or [])),
}
# ---------------------------------------------------------------------------
# TableRowSplit — oversized-table re-split with first/middle/last gluing.
# ---------------------------------------------------------------------------
def _split_rows_by_tokens(
rows: list[Any],
tokenizer: Tokenizer,
*,
target_max: int,
target_ideal: int,
last_min: int,
) -> list[list[Any]]:
"""Split ``rows`` into balanced row-bounded chunks (TableRowSplit core)."""
total = _count_tokens(tokenizer, json.dumps(rows, ensure_ascii=False))
if total <= target_max or len(rows) <= 1:
return [rows]
target_chunks = max(
math.ceil(total / target_ideal),
math.ceil(total / target_max),
)
# Cap at len(rows) so target_rows >= 1; otherwise int((i+1)*target_rows)
# can collapse to ``start`` and emit empty <table>[]</table> slices.
target_chunks = min(target_chunks, len(rows))
target_rows = len(rows) / target_chunks
chunks: list[list[Any]] = []
start = 0
for i in range(target_chunks):
if i == target_chunks - 1:
end = len(rows)
else:
# max(start + 1, ...) guarantees forward progress (>= 1 row per
# slice) even at fractional target_rows boundaries.
end = max(start + 1, min(int((i + 1) * target_rows), len(rows)))
remaining = len(rows) - end
if remaining > 0 and remaining < target_rows * 0.3:
end = len(rows)
chunks.append(rows[start:end])
start = end
if start >= len(rows):
break
# Merge a tiny last chunk back into the previous chunk when feasible.
if len(chunks) >= 2:
last_json = json.dumps(chunks[-1], ensure_ascii=False)
if _count_tokens(tokenizer, last_json) < last_min:
merged = chunks[-2] + chunks[-1]
merged_tokens = _count_tokens(
tokenizer, json.dumps(merged, ensure_ascii=False)
)
if merged_tokens <= target_max:
chunks[-2] = merged
chunks.pop()
return chunks
def _character_split_text(
text: str,
tokenizer: Tokenizer,
*,
target_max: int,
chunk_overlap_token_size: int = 0,
) -> list[str]:
"""Character-level fallback wrapped to return plain-text pieces.
Lazy import dodges the ``recursive_character`` ↔ ``paragraph_semantic``
circular dependency (same pattern as the sidecar-missing fallback in
:func:`chunking_by_paragraph_semantic`). Callers that split ordinary
prose pass the paragraph-semantic overlap; table character fallbacks
leave the default at zero so structured table row chunks do not gain
implicit row-level overlap.
"""
from lightrag.chunker.recursive_character import (
chunking_by_recursive_character,
)
pieces = chunking_by_recursive_character(
tokenizer,
text,
target_max,
chunk_overlap_token_size=_bounded_overlap(target_max, chunk_overlap_token_size),
)
return [p["content"] for p in pieces if p.get("content")]
def _split_table_text(
table_text: str,
*,
tokenizer: Tokenizer,
target_max: int,
target_ideal: int,
last_min: int,
header_body: str | None = None,
) -> list[str]:
"""Split a single oversized ``<table>...</table>`` text into ≤ target_max pieces.
Strategy (mirrors the user-supplied contract in
``docs/ParagraphSemanticChunking-zh.md`` — row boundary first,
character fallback last):
1. Match the outer ``<table {attrs}>{body}</table>``. If the regex
fails, character-split the original text and return.
2. Detect the body format via :func:`_detect_table_format` (with
body sniffing when ``attrs`` is silent).
3. Row-boundary split: JSON via :func:`_split_rows_by_tokens`,
HTML via :func:`_split_html_rows_by_tokens`. Re-wrap every
row-chunk as ``<table {attrs}>{rows}</table>``.
4. A multi-row chunk over the cap is recursively re-split at row
boundaries. When that collapses to a single row that still
can't be kept both ``≤ target_max`` and header-complete (the
row alone exceeds the cap, or it fits but leaves no room for
the header it would need), the WHOLE table degrades to a
recursive character split of the original text — its body
still carries the header, so the header survives as text —
and a warning is logged. A lone row that needs no header and
fits the cap is kept whole as legal markup.
5. Unknown / unparseable format → character-fallback the entire
original text.
When ``header_body`` (the stored ``table_header``) is supplied, the source
table carries a cross-page repeating header. Its representation follows the
table's format: a JSON 2-D array string for JSON tables, a raw
``<thead>…</thead>`` fragment for HTML tables (spans preserved). The header
is re-injected so every slice carries it, and its token cost is budgeted out
of the per-slice cap *before* splitting, so each emitted slice stays
``≤ target_max`` including the header (HeaderRecovery, §3.3.3):
* JSON → the header is pinned OUT of the split body and prepended back into
*every* slice (the first included).
* HTML → the first slice keeps its own in-body ``<thead>``; the raw
``<thead>`` is spliced verbatim only into the *non-first* slices.
A header whose format clearly disagrees with the table's own format means a
corrupted / foreign sidecar — :func:`_classify_header_format` flags it and
this function raises ``ValueError`` rather than splitting with it. A
header-less table (``header_body is None`` / unusable) leaves the split
untouched.
Output strings are either:
- a re-wrapped ``<table {attrs}>{rows}</table>`` (legal markup,
callers may keep ``is_table=True`` for these), or
- a character-fallback fragment (no ``<table>`` wrapper, callers
should mark ``is_table=False``).
"""
match = _TABLE_TAG_RE.match((table_text or "").strip())
if not match:
return _character_split_text(table_text, tokenizer, target_max=target_max)
attrs = match.group("attrs")
body = match.group("body")
fmt = _detect_table_format(attrs, body)
# Budget the <table {attrs}></table> wrapper out of the per-chunk
# caps before calling the row splitter — the splitter only measures
# the body (json.dumps(rows) / "".join(rows)), so without this the
# wrapped chunk can exceed target_max purely from the wrapper, which
# would force a needless character-fallback below.
wrapper_overhead = _count_tokens(tokenizer, f"<table {attrs}></table>")
# Classify the stored header once: a "json" header parses to row arrays
# (pinned out of the JSON body and prepended on injection); an "html" header
# is a raw <thead> fragment spliced verbatim into non-first HTML slices.
header_fmt = _classify_header_format(header_body) if header_body else None
# CONSISTENCY DEFENSIVE CHECK (early): if the table body's own format is
# concretely known and disagrees with the header's, the sidecar header does
# not belong to this table — raise before doing any splitting work rather
# than only discovering it in the per-slice injection loop.
if header_fmt is not None and fmt in {"json", "html"} and fmt != header_fmt:
raise ValueError(
f"table_header format {header_fmt!r} does not match table format "
f"{fmt!r} for {_extract_table_id(attrs) or '<no-id>'}; refusing to "
"split with a cross-format header (corrupted sidecar?)"
)
# JSON header rows (used by the json_pinned path below); None for HTML / no header.
header_rows = _parse_header_rows(header_body) if header_fmt == "json" else None
# HeaderRecovery budget: reserve room for the repeating header that will be
# prepended into every non-first slice, so a slice + its header never
# exceeds target_max. The first slice keeps its own header (already in the
# body), so reserving uniformly only makes it marginally smaller — safe.
if header_fmt == "json":
header_overhead = _header_reserve_tokens(header_rows, tokenizer)
elif header_fmt == "html":
# The raw <thead> string is spliced verbatim; reserve its own token cost.
header_overhead = _count_tokens(tokenizer, header_body)
else:
header_overhead = 0
body_budget = max(target_max - wrapper_overhead - header_overhead, 1)
body_max = body_budget
body_ideal = max(
min(target_ideal, target_max) - wrapper_overhead - header_overhead, 1
)
body_last_min = max(last_min - wrapper_overhead - header_overhead, 1)
row_chunks: list[list[Any]] | None = None
serialize: Callable[[list[Any]], str] | None = None
# HeaderRecovery (JSON): when the repeating header prefixes the body, pin it
# OUT of the rows being split so every emitted slice holds only genuine data
# rows. The full header is then re-injected into every slice afterwards —
# which means slices never carry header remnants, so injection needs no
# heuristic dedup (no duplicated header, no dropped data row that happens to
# equal a header row). Header-less / non-prefixing tables split as before.
json_pinned = False
if fmt == "json":
try:
rows = json.loads(body)
except json.JSONDecodeError:
rows = None
if isinstance(rows, list) and len(rows) > 1:
split_rows = rows
if header_rows is not None and rows[: len(header_rows)] == header_rows:
data_rows = rows[len(header_rows) :]
if data_rows:
split_rows = data_rows
json_pinned = True
row_chunks = _split_rows_by_tokens(
split_rows,
tokenizer,
target_max=body_max,
target_ideal=body_ideal,
last_min=body_last_min,
)
def serialize(chunk_rows: list[Any]) -> str:
return (
f"<table {attrs}>"
f"{json.dumps(chunk_rows, ensure_ascii=False)}"
f"</table>"
)
elif fmt == "html":
rows_html = _split_html_rows(body)
if rows_html and len(rows_html) > 1:
row_chunks = _split_html_rows_by_tokens(
rows_html,
tokenizer,
target_max=body_max,
target_ideal=body_ideal,
last_min=body_last_min,
)
def serialize(chunk_rows: list[tuple[str, str]]) -> str:
return (
f"<table {attrs}>"
f"{_serialize_rows_with_wrappers(chunk_rows)}"
f"</table>"
)
if row_chunks is None or serialize is None:
# No row boundary available (single-row table, parse failure,
# unknown format) → character-fallback the whole text.
return _character_split_text(table_text, tokenizer, target_max=target_max)
# Re-split any chunk whose wrapped form exceeds the cap before resorting to
# character-level shredding. For non-first slices the cap is the
# *header-inclusive* one (``target_max`` minus the header HeaderRecovery
# will prepend), so a reducible multi-row slice keeps getting cut at row
# boundaries until each piece can still host the header — rather than being
# accepted whole and then left header-less.
#
# When row-boundary splitting collapses to a single row that still can't
# satisfy its cap, the table can no longer be kept both ``≤ target_max`` and
# header-complete via row boundaries. Rather than shred just that slice's
# body — which for a pinned-header table drops the header entirely, since the
# header was held out of the body and the later injection pass cannot repair
# a non-<table> character fragment — the WHOLE table degrades to a recursive
# character split of the original ``table_text`` (its body still carries the
# header, so the header content survives as text). This fires for either a
# row whose content alone exceeds ``target_max`` or a row that fits
# ``target_max`` but leaves no room for the header it would need; both emit a
# warning. A lone row that needs no header and fits ``target_max`` is still
# kept whole as legal markup.
#
# Which slices receive an injected header (and so need the header-inclusive
# cap): for a pinned JSON table the header was held out of the body, so
# EVERY slice is injected; for HTML the first slice keeps its own in-body
# <thead> (full target_max) and only later slices are injected.
effective_max = max(target_max - header_overhead, 1)
inject_html_nonfirst = fmt == "html" and header_fmt == "html"
def _degrade_to_recursive() -> list[str]:
# A single row can no longer be kept both ≤ cap and header-complete via
# row boundaries → degrade the whole table to a recursive character
# split of the original text (header included in its body).
logger.warning(
"Table %s has a single row that cannot stay within the %d-token cap "
"alongside its header; degrading the whole table to a recursive "
"character split (header content preserved as text)",
_extract_table_id(attrs) or "<no-id>",
target_max,
)
return _character_split_text(table_text, tokenizer, target_max=target_max)
pieces: list[str] = []
pending: list[list[Any]] = list(row_chunks)
while pending:
chunk_rows = pending.pop(0)
wrapped = serialize(chunk_rows)
wrapped_tokens = _count_tokens(tokenizer, wrapped)
if json_pinned:
cap = effective_max
elif inject_html_nonfirst and pieces:
cap = effective_max
else:
cap = target_max
if wrapped_tokens <= cap:
pieces.append(wrapped)
continue
header_target = json_pinned or (inject_html_nonfirst and bool(pieces))
if len(chunk_rows) <= 1:
# A single row can't be split at row boundaries. Keep it whole only
# when it needs no injected header and still fits the hard cap;
# otherwise the whole table degrades to a recursive split so the
# header is never silently dropped.
if not header_target and wrapped_tokens <= target_max:
pieces.append(wrapped)
continue
return _degrade_to_recursive()
# Multi-row slice over the (header-inclusive) cap: force a finer cut so
# each sub-slice stays within budget. Cap the next-pass body budget at
# half the current wrapped size so target_chunks >= 2 inside the
# splitter. This guarantees forward progress (one row at minimum per
# sub-chunk, see the splitter's len(rows) cap).
halved = max(wrapped_tokens // 2, 1)
sub_max = max(min(body_max, halved), 1)
sub_ideal = max(sub_max // 2, 1)
sub_last_min = max(min(body_last_min, sub_max // 2), 1)
if fmt == "json":
sub_chunks = _split_rows_by_tokens(
chunk_rows,
tokenizer,
target_max=sub_max,
target_ideal=sub_ideal,
last_min=sub_last_min,
)
else:
sub_chunks = _split_html_rows_by_tokens(
chunk_rows,
tokenizer,
target_max=sub_max,
target_ideal=sub_ideal,
last_min=sub_last_min,
)
if len(sub_chunks) <= 1:
# The splitter could not reduce further (e.g. one row already
# dominates the body). Keep it whole only when it needs no injected
# header and fits the hard cap; otherwise degrade the whole table to
# a recursive split (avoids an infinite loop and never drops the
# header).
if not header_target and wrapped_tokens <= target_max:
pieces.append(wrapped)
continue
return _degrade_to_recursive()
# Process the finer cuts before any remaining peer chunks so the
# output keeps source order.
pending[0:0] = sub_chunks
# HeaderRecovery: re-inject the repeating header. For a pinned JSON table
# the header was held out of the split body, so EVERY slice (the first
# included) is a pure-data table that gets the header prepended; for HTML
# the first slice keeps its own in-body <thead> and only later slices are
# injected. The repair loop kept each injected slice within the
# header-inclusive cap, so injection fits ≤ target_max — any slice that
# could not host its header was already routed to the whole-table recursive
# degrade above (it never reaches here). The ``rebuilt is not None`` /
# size check below is a defensive belt-and-braces guard.
# Pinned JSON injects every slice (start=0; even a lone slice must regain
# the header that was pinned out); HTML injects only later slices (start=1).
start = 0 if json_pinned else (1 if inject_html_nonfirst else None)
if start is not None:
for i in range(start, len(pieces)):
rebuilt = _inject_header_into_table_slice(pieces[i], header_body)
if rebuilt is not None and _count_tokens(tokenizer, rebuilt) <= target_max:
pieces[i] = rebuilt
else:
# Defensive belt-and-braces: this slice should carry the
# recovered header but didn't get it — either it no longer
# parses as a <table> (``rebuilt is None``) or, despite the
# pre-split budget, the rebuilt slice would exceed target_max.
# Both are meant to be unreachable (the repair loop routed any
# header-incapable slice to the whole-table degrade), so surface
# it rather than silently emit a header-less slice.
logger.warning(
"Table %s slice %d kept header-less after HeaderRecovery "
"(%s); the recovered header could not be injected within the "
"%d-token cap",
_extract_table_id(attrs) or "<no-id>",
i,
"no longer parses as <table>" if rebuilt is None else "over-cap",
target_max,
)
return pieces
def _expand_block_with_table_splits(
block: dict[str, Any],
*,
tokenizer: Tokenizer,
table_max: int,
table_ideal: int,
table_min_last: int,
target_max: int | None = None,
chunk_overlap_token_size: int = 0,
table_headers: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
"""Apply TableRowSplit to one heading-driven block.
For every embedded table whose tokens exceed ``table_max``:
- the first row-slice glues with paragraphs already accumulated in
the current expansion (i.e. content *before* the table);
- middle slices are emitted as standalone blocks tagged
``table_chunk_role == "middle"`` so LevelMerge refuses to merge them;
- the last slice begins a fresh accumulation that will glue with
paragraphs *after* the table.
When a ``last`` table slice is followed by short bridge text and then
another oversized table's ``first`` slice, the bridge text is split
into table boundary context: a prefix may be duplicated into the
previous table block and a suffix into the next table block. If the
bridge is longer than both context budgets, the remaining middle text
is emitted as a standalone text block. Tables within the size limit
pass through untouched.
"""
if target_max is None:
target_max = table_max
target_max = max(int(target_max), 1)
context_overlap = _bounded_overlap(target_max, chunk_overlap_token_size)
sep_tokens = _count_tokens(tokenizer, "\n")
paragraphs = block["paragraphs"]
has_oversized_table = any(
p["is_table"] and _count_tokens(tokenizer, p["text"]) > table_max
for p in paragraphs
)
if not has_oversized_table:
return [block]
out: list[dict[str, Any]] = []
cur_paras: list[dict[str, Any]] = []
# Role to assign to ``cur_paras`` when it next flushes. Tracks the
# boundary semantics across split-table iterations so the merged
# block carries "first" / "last" instead of defaulting to "none" —
# otherwise LevelMerge's directional protections (a "first" block must
# not absorb backward, a "last" block must not absorb forward) silently
# disappear after the slice glues with surrounding paragraphs.
cur_role = "none"
def flush_cur() -> None:
nonlocal cur_role
if not cur_paras:
cur_role = "none"
return
out.append(
_new_block(
heading=block["heading"],
parent_headings=block["parent_headings"],
level=block["level"],
paragraphs=cur_paras,
table_chunk_role=cur_role,
tokenizer=tokenizer,
blockids=block.get("blockids"),
)
)
cur_paras.clear()
cur_role = "none"
def _append_bridge_block(
paragraphs: list[dict[str, Any]],
table_chunk_role: str,
) -> None:
if not paragraphs:
return
out.append(
_new_block(
heading=block["heading"],
parent_headings=block["parent_headings"],
level=block["level"],
paragraphs=paragraphs,
table_chunk_role=table_chunk_role,
tokenizer=tokenizer,
blockids=block.get("blockids"),
)
)
def _text_paragraph(text: str) -> dict[str, Any] | None:
if not text or not text.strip():
return None
return {"text": text, "is_table": False}
def _context_capacity(base_paras: list[dict[str, Any]]) -> int:
if context_overlap <= 0:
return 0
base_text = "\n".join(p["text"] for p in base_paras)
base_tokens = _count_tokens(tokenizer, base_text)
if base_tokens >= target_max:
return 0
# The context paragraph is joined to the table fragment with "\n".
# Cap each side at target_max // 2 as well so the duplicated bridge
# text can never dominate the whole block (§3.3.2).
return max(
min(
context_overlap,
target_max - base_tokens - sep_tokens,
target_max // 2,
),
0,
)
def _flush_last_bridge_before_next_first(
next_first_para: dict[str, Any],
) -> list[dict[str, Any]]:
"""Flush ``last + bridge`` before a following table ``first``.
Returns context paragraphs to prepend to the following first-table
block. Only non-table bridge paragraphs are duplicated/sliced; if
the bridge contains tables we keep the prior non-overlapping flush.
"""
nonlocal cur_role
if not cur_paras:
cur_role = "none"
return []
seed_paras = [cur_paras[0]]
bridge_paras = cur_paras[1:]
if (
context_overlap <= 0
or not bridge_paras
or any(p.get("is_table", False) for p in bridge_paras)
):
flush_cur()
return []
bridge_text = "\n".join(p["text"] for p in bridge_paras)
bridge_tokens = tokenizer.encode(bridge_text)
if not bridge_tokens:
flush_cur()
return []
prev_budget = _context_capacity(seed_paras)
next_budget = _context_capacity([next_first_para])
bridge_len = len(bridge_tokens)
if bridge_len <= prev_budget and bridge_len <= next_budget:
prefix_text = bridge_text
suffix_text = bridge_text
middle_text = ""
else:
prefix_len = min(prev_budget, bridge_len)
suffix_len = min(next_budget, bridge_len)
middle_start = prefix_len
middle_end = max(middle_start, bridge_len - suffix_len)
prefix_text = (
tokenizer.decode(bridge_tokens[:prefix_len]) if prefix_len else ""
)
suffix_text = (
tokenizer.decode(bridge_tokens[bridge_len - suffix_len :])
if suffix_len
else ""
)
# The standalone middle block keeps R-style overlap with the text
# that went left (into the previous table block) and right (into the
# next table block): extend it by ``context_overlap`` tokens on each
# side, clamped inside ``bridge_tokens``. Because the indices never
# leave the bridge — which by this branch contains no table
# paragraphs — the overlap is pure text and never duplicates any
# ``<table>`` content into the middle block.
mid_lo = max(0, middle_start - context_overlap)
mid_hi = min(bridge_len, middle_end + context_overlap)
middle_text = (
tokenizer.decode(bridge_tokens[mid_lo:mid_hi])
if mid_hi > mid_lo and middle_end > middle_start
else ""
)
prev_paras = list(seed_paras)
prefix_para = _text_paragraph(prefix_text)
if prefix_para is not None:
prev_paras.append(prefix_para)
_append_bridge_block(prev_paras, "last")
middle_para = _text_paragraph(middle_text)
if middle_para is not None:
_append_bridge_block([middle_para], "none")
cur_paras.clear()
cur_role = "none"
suffix_para = _text_paragraph(suffix_text)
return [suffix_para] if suffix_para is not None else []
for para in paragraphs:
text = para["text"]
if not (para["is_table"] and _count_tokens(tokenizer, text) > table_max):
cur_paras.append(para)
continue
# Trace this oversized table back to its tables.json entry via the
# slice's <table> id, so the splitter can budget + re-inject the source
# table's repeating header into the non-first slices (HeaderRecovery).
header_body: str | None = None
if table_headers:
tag_match = _TABLE_TAG_RE.match(text.strip())
if tag_match:
table_id = _extract_table_id(tag_match.group("attrs"))
if table_id:
header_body = table_headers.get(table_id)
# Row-boundary first, character fallback last. ``_split_table_text``
# returns one or more strings: row-wrapped ``<table>...</table>``
# fragments where row-splitting succeeded, plain text where it
# had to character-split (single-row tables, parse failures,
# rows whose own size exceeded ``table_max``).
pieces = _split_table_text(
text,
tokenizer=tokenizer,
target_max=table_max,
target_ideal=table_ideal,
last_min=table_min_last,
header_body=header_body,
)
if len(pieces) <= 1:
# No reduction was possible (e.g. very small unparseable table
# that already fits within ``table_max`` after a no-op character
# fallback). Keep the original paragraph to preserve content.
cur_paras.append(para)
continue
for chunk_idx, piece_text in enumerate(pieces):
stripped = piece_text.strip()
is_still_table = stripped.startswith("<table ") and stripped.endswith(
"</table>"
)
chunk_para = {"text": piece_text, "is_table": is_still_table}
is_first = chunk_idx == 0
is_last = chunk_idx == len(pieces) - 1
if is_first:
# First slice glues with everything currently accumulated
# (= the paragraphs that appeared before the table inside
# this heading block). If the buffer still carries the
# "last" tail of a previous oversized table, flush it first
# so its protective role survives instead of being
# overwritten by "first".
if cur_role == "last":
cur_paras.extend(_flush_last_bridge_before_next_first(chunk_para))
cur_paras.append(chunk_para)
cur_role = "first"
elif is_last:
# Flush the accumulated "first-glued" block, then begin a
# new accumulation seeded with this last slice — it will
# absorb the paragraphs that appear after the table.
flush_cur()
cur_paras.append(chunk_para)
cur_role = "last"
else:
# Middle slice: flush the first-glued block, then emit
# this middle slice as a standalone block that LevelMerge
# MUST keep intact (table_chunk_role == "middle").
flush_cur()
out.append(
_new_block(
heading=block["heading"],
parent_headings=block["parent_headings"],
level=block["level"],
paragraphs=[chunk_para],
table_chunk_role="middle",
tokenizer=tokenizer,
blockids=block.get("blockids"),
)
)
flush_cur()
return out
# ---------------------------------------------------------------------------
# AnchorSplit — anchor-driven long-block re-split.
# ---------------------------------------------------------------------------
def _split_long_block(
paragraphs: list[dict[str, Any]],
heading: str,
parent_headings: list[str],
level: int,
table_chunk_role: str,
*,
tokenizer: Tokenizer,
target_max: int,
target_ideal: int,
chunk_overlap_token_size: int = 100,
blockids: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Split an oversized block into balanced sub-blocks at short-paragraph anchors.
Mirrors :func:`lightrag.parser.docx.parse_document.split_long_block`,
parameterised on ``target_max`` / ``target_ideal``. Tables (``is_table``)
are excluded from the anchor candidate pool, so TableRowSplit's row-level
splits stay intact. When no anchor exists (including the single-
paragraph oversized case), the no-anchor branch below honors the cap
via row-boundary splitting (for tables) or character-level splitting
(for prose). The audit-mode parser would ``sys.exit(1)`` on no-anchor
failure, but the RAG pipeline must never drop a document silently.
Character-level splitting of ordinary prose uses
``chunk_overlap_token_size`` so long text under one JSONL content row
keeps semantic continuity across adjacent chunks.
"""
chunk_overlap_token_size = _bounded_overlap(target_max, chunk_overlap_token_size)
content = "\n".join(p["text"] for p in paragraphs)
total = _count_tokens(tokenizer, content)
if total <= target_max:
return [
_new_block(
heading=heading,
parent_headings=parent_headings,
level=level,
paragraphs=paragraphs,
table_chunk_role=table_chunk_role,
tokenizer=tokenizer,
blockids=blockids,
)
]
target_blocks = max(
math.ceil(total / target_ideal),
math.ceil(total / target_max),
)
target_size = total / target_blocks
# Build anchor candidates with cumulative token offsets. Index 0 is
# excluded: an anchor at the first paragraph yields an empty leading
# slice and a tail equal to the input, so it cannot divide the block —
# selecting it would re-enter this function with the same arguments
# and recurse until RecursionError.
candidates: list[dict[str, Any]] = []
cumulative = 0
for idx, para in enumerate(paragraphs):
text = para["text"]
if (
idx > 0
and not para.get("is_table", False)
and 0 < len(text) <= _MAX_ANCHOR_CANDIDATE_LENGTH
):
candidates.append({"index": idx, "text": text, "position": cumulative})
cumulative += _count_tokens(tokenizer, text)
if not candidates:
# All paragraphs in the block are longer than the anchor-length
# cap (typical for dense academic prose: every paragraph is a
# full body section). Anchor-driven splitting cannot proceed,
# but we must NOT emit a single oversized chunk: the
# embedding-time hard fallback uses ``embedding_token_limit``
# (often 8K), not ``chunk_token_size``, so the chunk would
# silently exceed the user-configured size. Prefer
# row-boundary splitting on any oversized table paragraph
# before falling back to character-level splitting on residual
# content — character splitting destroys ``<table>`` markup
# mid-tag and produces fragments LLMs can't interpret as
# tables.
logger.warning(
"[paragraph_semantic_chunking] block under heading %r exceeds "
"target_max=%d tokens (~%d tokens) but has no eligible anchor "
"paragraph (≤ %d chars); preferring table row-boundary split, "
"falling back to recursive-character splitting on residual "
"content.",
_heading_for_log(heading),
target_max,
total,
_MAX_ANCHOR_CANDIDATE_LENGTH,
)
# Step 1: expand each oversized table paragraph into row-bounded
# pieces; non-table or in-budget paragraphs pass through verbatim.
# ``last_min`` mirrors TableRowSplit's ratio (no separate constant — the
# tail-merge threshold is purely a row-balancing heuristic).
last_min = max(int(target_max * _TABLE_MIN_LAST_RATIO), 1)
pieces: list[str] = []
for para in paragraphs:
text = para["text"]
if (
para.get("is_table", False)
and _count_tokens(tokenizer, text) > target_max
):
pieces.extend(
_split_table_text(
text,
tokenizer=tokenizer,
target_max=target_max,
target_ideal=target_ideal,
last_min=last_min,
)
)
else:
pieces.append(text)
# Step 2: greedy-pack pieces into chunks ≤ target_max. A piece
# that is itself oversized (e.g. a single dense prose paragraph
# without short anchors) is character-split via
# :func:`chunking_by_recursive_character` after flushing the
# current buffer. The "\n" separator inserted by ``"\n".join(buf)``
# also costs tokens, so it must be debited from the budget —
# otherwise two pieces that sum to exactly target_max would
# overflow once joined.
sep_tokens = _count_tokens(tokenizer, "\n")
chunks_text: list[str] = []
buf: list[str] = []
buf_tokens = 0
for piece in pieces:
piece_tokens = _count_tokens(tokenizer, piece)
if piece_tokens > target_max:
if buf:
chunks_text.append("\n".join(buf))
buf, buf_tokens = [], 0
chunks_text.extend(
_character_split_text(
piece,
tokenizer,
target_max=target_max,
chunk_overlap_token_size=chunk_overlap_token_size,
)
)
continue
addition = piece_tokens + (sep_tokens if buf else 0)
if buf and buf_tokens + addition > target_max:
chunks_text.append("\n".join(buf))
buf, buf_tokens = [], 0
addition = piece_tokens
buf.append(piece)
buf_tokens += addition
if buf:
chunks_text.append("\n".join(buf))
if not chunks_text:
# Defensive: every piece was empty after stripping. Emit the
# original oversized block so the document is never silently
# dropped (matches the prior behaviour of the empty-R branch).
return [
_new_block(
heading=heading,
parent_headings=parent_headings,
level=level,
paragraphs=paragraphs,
table_chunk_role=table_chunk_role,
tokenizer=tokenizer,
blockids=blockids,
)
]
sub_blocks: list[dict[str, Any]] = []
for i, chunk_text in enumerate(chunks_text):
stripped = chunk_text.strip()
is_still_table = stripped.startswith("<table ") and stripped.endswith(
"</table>"
)
sub_blocks.append(
_new_block(
heading=heading,
parent_headings=parent_headings,
level=level,
paragraphs=[{"text": chunk_text, "is_table": is_still_table}],
# Only the first sub-block keeps the inbound
# table_chunk_role; the rest are text-only by
# construction (mirrors the anchor-split path below).
table_chunk_role=table_chunk_role if i == 0 else "none",
tokenizer=tokenizer,
blockids=blockids,
)
)
return sub_blocks
# Pick the anchors closest to evenly-spaced ideal positions.
pool = list(candidates)
selected: list[dict[str, Any]] = []
for i in range(1, target_blocks):
if not pool:
break
ideal_position = i * target_size
best = min(pool, key=lambda c: abs(c["position"] - ideal_position))
selected.append(best)
pool.remove(best)
selected.sort(key=lambda c: c["index"])
sub_blocks: list[dict[str, Any]] = []
prev_idx = 0
cur_heading = heading
cur_parents = list(parent_headings)
# Only the first sub-block keeps the inbound table_chunk_role; the
# post-anchor sub-blocks are text-only by construction.
cur_role = table_chunk_role
for anchor in selected:
split_idx = anchor["index"]
slice_paras = paragraphs[prev_idx:split_idx]
if slice_paras:
sub_blocks.append(
_new_block(
heading=cur_heading,
parent_headings=cur_parents,
level=level,
paragraphs=slice_paras,
table_chunk_role=cur_role,
tokenizer=tokenizer,
blockids=blockids,
)
)
# Anchor becomes the first paragraph (and heading) of the next sub-block.
cur_parents = (
list(parent_headings) + [heading]
if heading and cur_heading == heading
else list(cur_parents)
)
cur_heading = anchor["text"]
cur_role = "none"
prev_idx = split_idx
tail = paragraphs[prev_idx:]
if tail:
sub_blocks.append(
_new_block(
heading=cur_heading,
parent_headings=cur_parents,
level=level,
paragraphs=tail,
table_chunk_role=cur_role,
tokenizer=tokenizer,
blockids=blockids,
)
)
# Recursive guard: any sub-block still over target_max is re-split,
# including single-paragraph subs — the no-anchor branch above honors
# the cap via row-boundary or character-level splitting and is the
# only path that can shrink them.
out: list[dict[str, Any]] = []
for sub in sub_blocks:
if sub["tokens"] > target_max:
out.extend(
_split_long_block(
sub["paragraphs"],
sub["heading"],
sub["parent_headings"],
sub["level"],
sub["table_chunk_role"],
tokenizer=tokenizer,
target_max=target_max,
target_ideal=target_ideal,
chunk_overlap_token_size=chunk_overlap_token_size,
blockids=sub.get("blockids") or blockids,
)
)
else:
out.append(sub)
return out
# ---------------------------------------------------------------------------
# LevelMerge — bottom-up, level-aware small-block merging.
# ---------------------------------------------------------------------------
def _can_merge_forward(role: str) -> bool:
# A split-table slice (first/middle/last) is frozen against LevelMerge: its
# repeating header is already injected into the slice at TableRowSplit time,
# so re-merging two slices of one table would duplicate the header mid-body.
# Only ordinary blocks (role "none") may absorb a following block.
return role == "none"
def _can_merge_backward(role: str) -> bool:
# Symmetric to _can_merge_forward — only role "none" may be absorbed by a
# preceding block. Split-table slices never re-merge (see §3.6 / §3.3.3).
return role == "none"
def _same_parent_path(a: dict[str, Any], b: dict[str, Any]) -> bool:
"""True when two blocks share the identical parent-heading chain.
Same-level merging (Phase A, tail absorption) is gated on this so two
blocks at the same ``level`` are only fused when they are true siblings
under one parent — blocks that merely happen to share a ``level`` but sit
under different parents (e.g. ``2.4.1`` vs ``2.5.1``) are NOT merged,
which is the documented anti-cross-topic-pollution guarantee (§3.6.1 #4).
Blocks with no parents (preamble / non-hierarchical input) compare equal.
"""
return list(a.get("parent_headings") or []) == list(b.get("parent_headings") or [])
def _is_descendant(shallow: dict[str, Any], deep: dict[str, Any]) -> bool:
"""True when ``deep`` is nested under ``shallow`` in the heading tree.
Cross-level absorption (Phase B, shallower-absorbs-deeper) is gated on
this: the shallow block's full heading path (its ``parent_headings`` plus
its own ``heading``) must be a prefix of the deep block's
``parent_headings``. This prevents a shallow block from swallowing a deeper
block that belongs to an unrelated branch of the document tree. The
shallow heading is stripped of any generated ``[part n]`` suffix first
because ``parent_headings`` never carry that suffix. A shallow block with
no heading (preamble) yields an empty path that prefixes anything, so such
blocks are not blocked from absorbing — they have no hierarchy to violate.
"""
head = _strip_generated_heading_suffixes(shallow.get("heading") or "")
shallow_full = list(shallow.get("parent_headings") or []) + ([head] if head else [])
deep_parents = list(deep.get("parent_headings") or [])
return deep_parents[: len(shallow_full)] == shallow_full
def _merged_pair(
left: dict[str, Any],
right: dict[str, Any],
*,
keep: str,
tokenizer: Tokenizer,
) -> dict[str, Any]:
base = left if keep == "left" else right
paragraphs = list(left["paragraphs"]) + list(right["paragraphs"])
content = left["content"] + "\n\n" + right["content"]
merged_blockids = _dedup_preserving_order(
list(left.get("blockids") or []) + list(right.get("blockids") or [])
)
return {
"heading": base["heading"],
"parent_headings": list(base["parent_headings"]),
"level": base["level"],
"paragraphs": paragraphs,
"content": content,
"tokens": _count_tokens(tokenizer, content),
"table_chunk_role": "none",
"blockids": merged_blockids,
}
def _is_heading_only(block: dict[str, Any]) -> bool:
"""Return True when ``block`` carries a heading but no body content.
A blocks.jsonl content row always renders its heading as the first line
(``render_heading_line`` → ``"#" * level + " " + text``) and appends body
paragraphs after it, so a heading-only section's ``content`` consists
solely of heading lines. This still holds after two heading-only blocks
are glued together, letting :func:`_glue_heading_only_blocks` cascade
down a chain of bare ancestor headings.
The ``heading`` guard excludes preamble blocks (text before any heading)
and AnchorSplit anchor sub-blocks, whose body paragraphs are prose rather
than heading lines.
Note: a body line that genuinely begins with ``#`` + space is the same
accepted ambiguity documented in ``lightrag/parser/_markdown.py`` and is
treated as a heading line here too — rare prose, tolerated.
"""
if not block.get("heading"):
return False
saw_line = False
for line in (block.get("content") or "").split("\n"):
stripped = line.strip()
if not stripped:
continue
saw_line = True
if not _HEADING_LINE_RE.match(stripped):
return False
return saw_line
def _glue_heading_only_blocks(
blocks: list[dict[str, Any]],
*,
tokenizer: Tokenizer,
target_max: int,
target_ideal: int,
chunk_overlap_token_size: int = 0,
) -> list[dict[str, Any]]:
"""Glue each body-less heading block forward into its deeper child block.
A section heading with no body of its own (e.g. ``## 2.4`` immediately
followed by ``### 2.4.1``) must travel WITH its first child rather than be
left as a lone trailing heading or glued onto an unrelated same-level
sibling (e.g. ``## 2.3``). Running this before LevelMerge bonds the bare
heading to its child first. See ``docs/ParagraphSemanticChunking-zh.md``
§3.5 for the full rationale.
For a block that :func:`_is_heading_only` recognises whose NEXT block is
STRICTLY DEEPER and whose ``table_chunk_role`` is ``none`` or ``first``,
the pair merges with ``keep="left"`` — preserving the shallower parent's
identity (``heading`` / ``level`` / ``parent_headings``) while appending the
child's content. The merge is re-evaluated in place, so a chain of bare
ancestors (``# 2`` → ``## 2.4`` → ``### 2.4.1``) collapses into one block
keeping the shallowest identity. (Only ``none`` / ``first`` can follow a
heading-only row — ``middle`` / ``last`` occur only inside one row's table.
Gluing into a ``first`` slice KEEPS the ``first`` role so LevelMerge still
cannot absorb it backward, protecting the table boundary.)
Gluing is FORWARD only: a body-less heading whose next block is not deeper
(a shallower/sibling heading, or end of list) is left for LevelMerge, not
pulled backward into a deeper previous block (which would invert the
hierarchy and demote the heading's level).
Prepending the heading line(s) can tip the bonded block past ``target_max``,
and nothing downstream re-splits an oversized chunk, so :func:`_split_to_cap`
re-splits it here while keeping the heading attached to real body; every
emitted piece honours ``target_max``. Because ``keep="left"`` preserves the
parent's ``level``, the bonded group remains an ordinary small block (NOT
pinned independent) that LevelMerge may still merge backward by its usual
peer/tail rules — this pre-pass only guarantees the heading is never
separated FROM its child.
"""
if len(blocks) <= 1:
return blocks
out: list[dict[str, Any]] = []
def _split_full(paras: list[dict[str, Any]], block: dict[str, Any], **kw):
args = {
"target_max": target_max,
"target_ideal": target_ideal,
"chunk_overlap_token_size": chunk_overlap_token_size,
"blockids": block.get("blockids"),
}
args.update(kw)
return _split_long_block(
paras,
block["heading"],
block["parent_headings"],
block["level"],
block.get("table_chunk_role", "none"),
tokenizer=tokenizer,
**args,
)
def _split_to_cap(block: dict[str, Any]) -> list[dict[str, Any]]:
# The bonded block exceeds target_max. Peel the leading run of
# heading-line paragraphs (the prepended parent heading plus the child's
# own heading line, or a chain) off the body, split only the BODY, then
# glue the heading prefix back onto the FIRST body piece. The prefix is
# never handed to the splitter, so the bare heading can never be sliced
# off as a content-less first chunk — a heading-only orphan that LevelMerge
# would re-absorb backward into the previous sibling. (Fusing the prefix
# into the body instead does NOT work: when the fused paragraph itself
# exceeds the cap, char-splitting re-separates the headings at their
# newline and the orphan returns.)
paras = block["paragraphs"]
n = 0
for para in paras:
if para.get("is_table", False) or not _HEADING_LINE_RE.match(
para["text"].strip()
):
break
n += 1
prefix, body = paras[:n], paras[n:]
prefix_tokens = _count_tokens(tokenizer, "\n".join(p["text"] for p in prefix))
sep_tokens = _count_tokens(tokenizer, "\n")
if not prefix or not body or prefix_tokens + sep_tokens >= target_max:
# No prefix to protect; no body to anchor on; OR the prefix alone
# fills/exceeds the cap (a very long title, or a tiny
# chunk_token_size) so it cannot be kept whole. The hard cap wins
# over heading-intactness: split the whole block directly —
# _split_long_block char-splits any oversized heading line so every
# emitted piece still honours target_max.
return _split_full(paras, block)
# Split the body at the FULL target_max so later body pieces (which do
# NOT carry the prefix) keep the full budget — never shrunk to the
# leftover first-chunk budget. Reserve room for the prefix only on the
# first piece, re-splitting that one piece if it cannot also hold it.
pieces = _split_full(body, block)
first, rest = pieces[0], list(pieces[1:])
if prefix_tokens + sep_tokens + first["tokens"] > target_max:
reduced_max = max(target_max - prefix_tokens - sep_tokens, 1)
refit = _split_full(
first["paragraphs"],
block,
target_max=reduced_max,
target_ideal=min(target_ideal, reduced_max),
blockids=first.get("blockids") or block.get("blockids"),
)
first, rest = refit[0], list(refit[1:]) + rest
rebuilt = _new_block(
heading=block["heading"],
parent_headings=block["parent_headings"],
level=block["level"],
paragraphs=prefix + first["paragraphs"],
table_chunk_role=block.get("table_chunk_role", "none"),
tokenizer=tokenizer,
blockids=first.get("blockids") or block.get("blockids"),
)
return [rebuilt, *rest]
def _emit(block: dict[str, Any], *, glued: bool) -> None:
# A forward-glued block can be tipped over target_max by its prepended
# heading line(s); re-split via AnchorSplit so the hard cap still holds.
if glued and block["tokens"] > target_max:
out.extend(_split_to_cap(block))
else:
out.append(block)
cur = blocks[0]
cur_glued = False
for nxt in blocks[1:]:
nxt_role = nxt.get("table_chunk_role", "none")
if (
_is_heading_only(cur)
and nxt.get("level", 1) > cur.get("level", 1)
and nxt_role in ("none", "first")
):
cur = _merged_pair(cur, nxt, keep="left", tokenizer=tokenizer)
# Preserve a "first" table-slice role so the bonded block still
# cannot be absorbed backward into the previous sibling by LevelMerge
# (the prepended heading is exactly the preceding context a "first"
# slice is meant to carry). "none" stays "none" — unchanged.
cur["table_chunk_role"] = nxt_role
cur_glued = True
else:
_emit(cur, glued=cur_glued)
cur, cur_glued = nxt, False
_emit(cur, glued=cur_glued)
return out
def _merge_small_blocks(
blocks: list[dict[str, Any]],
*,
tokenizer: Tokenizer,
target_max: int,
target_ideal: int,
small_tail_threshold: int,
) -> list[dict[str, Any]]:
"""Bottom-up, level-aware small-block merging.
Re-implementation of
:func:`lightrag.parser.docx.parse_document.merge_small_blocks`,
parameterised on the chunk-size targets and operating on internal
block dicts (no ``uuid`` / ``table_header`` propagation needed: the
chunking output schema does not carry them).
"""
if len(blocks) <= 1:
return blocks
result = list(blocks)
levels = sorted({b.get("level", 1) for b in result}, reverse=True)
for current_level in levels:
# Phase A — same-level merging.
changed = True
while changed:
changed = False
new_result: list[dict[str, Any]] = []
i = 0
while i < len(result):
cur = result[i]
cur_tokens = cur["tokens"]
cur_level = cur.get("level", 1)
cur_role = cur.get("table_chunk_role", "none")
below_ideal = 0 < cur_tokens < target_ideal
is_cur_lv = cur_level == current_level
if below_ideal and is_cur_lv:
merged = False
if _can_merge_forward(cur_role) and i + 1 < len(result):
nxt = result[i + 1]
if (
nxt.get("level", 1) == current_level
and _can_merge_backward(nxt.get("table_chunk_role", "none"))
and _same_parent_path(cur, nxt)
):
combined = _merged_pair(
cur, nxt, keep="left", tokenizer=tokenizer
)
if combined["tokens"] <= target_max:
new_result.append(combined)
i += 2
changed = True
merged = True
if not merged and _can_merge_backward(cur_role) and new_result:
prev = new_result[-1]
if (
prev.get("level", 1) == current_level
and _can_merge_forward(prev.get("table_chunk_role", "none"))
and prev["tokens"] < target_ideal
and _same_parent_path(prev, cur)
):
combined = _merged_pair(
prev, cur, keep="left", tokenizer=tokenizer
)
if combined["tokens"] <= target_max:
new_result[-1] = combined
i += 1
changed = True
merged = True
if not merged:
new_result.append(cur)
i += 1
else:
# Tail absorption: an at-or-above-IDEAL block can absorb
# a short run of subsequent same-level blocks if their
# combined size stays under SMALL_TAIL_THRESHOLD and
# fits within target_max — eliminates the document's
# trailing sliver of zero-content remainders.
if is_cur_lv and cur_tokens >= target_ideal and cur_role == "none":
tail_total = 0
end_idx = i + 1
for j in range(i + 1, len(result)):
nxt = result[j]
if nxt.get("level", 1) != current_level:
break
# A split-table slice (first/middle/last) is frozen —
# never pull one into a tail-absorption run, which
# would re-merge it and duplicate its header.
if nxt.get("table_chunk_role", "none") != "none":
break
# Same-level only is not enough — a sibling under a
# different parent would be cross-topic. Stop the run
# at the first block whose parent path diverges.
if not _same_parent_path(cur, nxt):
break
tail_total += nxt["tokens"]
end_idx = j + 1
if (
tail_total > 0
and tail_total < small_tail_threshold
and cur_tokens + tail_total <= target_max
):
absorbed_paragraphs = list(cur["paragraphs"])
absorbed_content = cur["content"]
for j in range(i + 1, end_idx):
nxt = result[j]
absorbed_paragraphs.extend(nxt["paragraphs"])
absorbed_content += "\n\n" + nxt["content"]
# The cheap predicate above sums per-block
# tokens, but absorption joins blocks with
# ``"\n\n"`` — those separator tokens are
# real and can push the merged block over
# target_max. Re-measure the joined content
# before committing to absorb.
absorbed_tokens = _count_tokens(tokenizer, absorbed_content)
if absorbed_tokens <= target_max:
new_result.append(
{
"heading": cur["heading"],
"parent_headings": list(cur["parent_headings"]),
"level": cur["level"],
"paragraphs": absorbed_paragraphs,
"content": absorbed_content,
"tokens": absorbed_tokens,
"table_chunk_role": "none",
}
)
i = end_idx
changed = True
continue
new_result.append(cur)
i += 1
result = new_result
# Phase B — cross-level absorption (shallower absorbs deeper).
changed = True
while changed:
changed = False
new_result = []
i = 0
while i < len(result):
cur = result[i]
cur_tokens = cur["tokens"]
cur_level = cur.get("level", 1)
cur_role = cur.get("table_chunk_role", "none")
below_ideal = 0 < cur_tokens < target_ideal
is_cur_lv = cur_level == current_level
if below_ideal and is_cur_lv:
merged = False
if _can_merge_forward(cur_role) and i + 1 < len(result):
nxt = result[i + 1]
if (
nxt.get("level", 1) > current_level
and _can_merge_backward(nxt.get("table_chunk_role", "none"))
and _is_descendant(cur, nxt)
):
combined = _merged_pair(
cur, nxt, keep="left", tokenizer=tokenizer
)
if combined["tokens"] <= target_max:
new_result.append(combined)
i += 2
changed = True
merged = True
if not merged and _can_merge_backward(cur_role) and new_result:
prev = new_result[-1]
if (
prev.get("level", 1) < current_level
and _can_merge_forward(prev.get("table_chunk_role", "none"))
and prev["tokens"] < target_ideal
and _is_descendant(prev, cur)
):
combined = _merged_pair(
prev, cur, keep="left", tokenizer=tokenizer
)
if combined["tokens"] <= target_max:
new_result[-1] = combined
i += 1
changed = True
merged = True
if not merged:
new_result.append(cur)
i += 1
else:
new_result.append(cur)
i += 1
result = new_result
return result
# ---------------------------------------------------------------------------
# Public entrypoint.
# ---------------------------------------------------------------------------
def chunking_by_paragraph_semantic(
tokenizer: Tokenizer,
content: str,
chunk_token_size: int = 2000,
*,
blocks_path: str | None = None,
chunk_overlap_token_size: int = 100,
drop_references: bool = False,
references_tail_n: int | None = None,
references_headings: Sequence[str] | None = None,
doc_id: str | None = None,
) -> list[dict[str, Any]]:
"""Paragraph Semantic Chunking — the ``chunking="P"`` strategy.
Reads structured blocks from a ``.blocks.jsonl`` sidecar (HeadingBlocks
output, emitted by any sidecar-producing parser — native / mineru /
docling) and applies TableRowSplit (table re-split + glue), AnchorSplit
(anchor-driven long-block re-split) and LevelMerge (bottom-up, level-aware
merging). Output rows match the schema produced by
:func:`lightrag.chunker.chunking_by_token_size`
(``tokens``/``content``/``chunk_order_index``), enriched with a nested
``heading`` block (``{level, heading, parent_headings}``) and an optional
``sidecar`` tracing source blockids, so KG extraction can leverage the
document hierarchy.
Signature follows the LightRAG chunker contract — the standard
prefix ``(tokenizer, content, chunk_token_size)`` is shared with
every other chunker, while strategy-specific knobs are keyword-only:
- ``blocks_path`` (this strategy's required input — the
``.blocks.jsonl`` sidecar produced at parse time)
Knobs that ``chunking_by_token_size`` exposes for delimiter-based
splitting (``split_by_character``, ``split_by_character_only``) are
deliberately absent here because paragraph-semantic chunks are
heading-aligned. ``chunk_overlap_token_size`` is supported for two
paragraph-semantic cases where overlap preserves meaning inside one
JSONL content row: recursive-character fallback for long prose, and
bridge text duplicated around adjacent oversized table boundary chunks.
When one original ``blocks.jsonl`` content row is split into multiple
fragments, every fragment heading receives a row-local ``[part n]``
suffix; unsplit rows keep their original heading.
Args:
tokenizer: LightRAG tokenizer (used for all token counting; matches
the unit used by ``chunk_token_size``).
content: Merged plain-text content of the document. Used as the
fallback corpus when ``blocks_path`` is missing or unreadable
so the pipeline never silently drops a document.
chunk_token_size: Hard upper bound for each chunk in tokens. The
ideal target is set at 75 % of this value (mirroring the
audit-mode 6000/8000 ratio); see threshold ratio constants
above for the full mapping.
blocks_path: Path to the document's ``.blocks.jsonl`` sidecar
(typically ``parsed_data["blocks_path"]``). When ``None``,
unreadable, or empty, this function falls back to
:func:`chunking_by_recursive_character` on ``content``
(per ``docs/FileProcessingConfiguration-zh.md`` §3).
That fallback hard-requires ``langchain-text-splitters``;
an :class:`ImportError` is surfaced rather than silently
degrading further.
chunk_overlap_token_size: Token overlap used only when P must
fall back to recursive-character splitting of ordinary text,
and as the per-side budget for duplicating text between two
adjacent oversized table chunks. Structural table row splits
remain row-bounded and non-overlapping.
drop_references: When ``True``, drop the trailing reference section
(e.g. "References" / "参考文献") before splitting/merging. A block
is removed only when it BOTH sits within the last
``references_tail_n`` content blocks AND its heading matches a
reference prefix. This is the only reference knob that is
snapshotted into ``chunk_options`` (it may come from a per-file
hint) and recorded in ``doc_status.metadata['chunk_opts']``.
references_tail_n: Trailing-block window for the detection above.
``None`` (the normal pipeline value) means "read
``CHUNK_P_REFERENCES_TAIL_N`` env live at run time, default
``DEFAULT_P_REFERENCES_TAIL_N``"; a kwarg is only passed by tests.
references_headings: Reference heading prefixes. ``None`` means "read
``CHUNK_P_REFERENCES_HEADINGS`` env live at run time, default
:data:`DEFAULT_P_REFERENCES_HEADINGS`"; a kwarg is only passed by
tests. Neither knob is snapshotted, so editing the env changes the
behaviour of re-runs without re-enqueueing.
doc_id: Document id, used only to attribute the drop_references log
line to a document; ``None`` renders as ``"unknown"``.
Returns:
Ordered list of chunk dicts, each shaped::
{
"tokens": int,
"content": str,
"chunk_order_index": int,
"heading": {"level": int, "heading": str,
"parent_headings": list[str]},
"sidecar": {"type": "block", "id": str, "refs": [...]}, # optional
}
``heading`` is a nested block (``level`` / ``parent_headings`` live
inside it, not at the top level; the ``[part n]`` suffix lands on
``heading["heading"]``). ``sidecar`` is present only when the source
row carried a ``blockid``.
Notes:
blocks.jsonl field analysis vs. algorithm requirements:
- ``content`` (``\\n``-joined, tags single-line) → split back into
per-paragraph text via ``split("\\n")``; lossless because
table/equation/drawing tags are single-line replacements.
- ``heading`` / ``parent_headings`` / ``level`` → consumed by
AnchorSplit/LevelMerge for hierarchy-aware merging. A row split into
multiple fragments gets a ``[part n]`` suffix on ``heading`` after
TableRowSplit/AnchorSplit and before LevelMerge; ``parent_headings``
are untouched.
- ``<table … format="json">{rows_json}</table>`` → JSON body
row-level re-split in TableRowSplit when over the per-table cap;
short text between two split tables may be repeated into both
boundary chunks, with any longer middle remainder left standalone.
- ``<equation>`` / ``<drawing>`` → atomic non-table paragraphs,
neither splittable nor anchorable.
- Per-paragraph paraIds are NOT preserved (only block-level
``positions[].range``); fine, the output schema does not need them.
- ``table_slice`` is always ``"none"`` in blocks.jsonl (tables kept
whole at parse time), so the ``table_chunk_role`` LevelMerge
consumes is recomputed on the fly inside TableRowSplit.
"""
target_max = max(int(chunk_token_size), 1)
target_ideal = max(int(target_max * _IDEAL_RATIO), 1)
table_max = max(int(target_max * _TABLE_MAX_RATIO), 1)
table_ideal = max(int(target_max * _TABLE_IDEAL_RATIO), 1)
table_min_last = max(int(table_max * _TABLE_MIN_LAST_RATIO), 1)
small_tail_threshold = max(int(target_max * _SMALL_TAIL_RATIO), 1)
overlap = _bounded_overlap(target_max, chunk_overlap_token_size)
rows: list[dict[str, Any]] = []
fallback_reason: str | None = None
if not blocks_path:
fallback_reason = "blocks_path is empty"
else:
try:
rows = _load_blocks_from_jsonl(blocks_path)
except OSError as exc:
fallback_reason = f"cannot read blocks.jsonl at {blocks_path}: {exc}"
else:
if not rows:
fallback_reason = (
f"blocks.jsonl at {blocks_path} contains no content rows"
)
if fallback_reason is not None:
# Defer to recursive-character chunking when the sidecar is
# absent — ensures non-structured documents and edge-case parses
# still produce chunks instead of silently dropping content. The
# document contract (FileProcessingConfiguration-zh.md §3) is
# explicit that P falls back to R; that contract requires
# langchain-text-splitters to be installed, so an ImportError
# here is intentional rather than a silent degrade to F. Lazy
# import dodges the recursive_character ↔ paragraph_semantic
# circular dependency.
logger.warning(
"[paragraph_semantic_chunking] %s; falling back to "
"recursive-character chunking with chunk_token_size=%d.",
fallback_reason,
target_max,
)
from lightrag.chunker.recursive_character import (
chunking_by_recursive_character,
)
return chunking_by_recursive_character(
tokenizer,
content,
target_max,
chunk_overlap_token_size=overlap,
)
# Drop the trailing reference section before any split/merge runs (operates
# on the raw blocks.jsonl content rows — one heading == one block). A block
# is dropped only when it BOTH sits within the last ``tail_n`` rows AND its
# heading matches a reference prefix; the tail window is a safety guard so a
# mid-document "References" subsection is never removed. Detection knobs are
# read live from env (NOT snapshotted) unless injected via kwargs (tests).
if drop_references and rows:
prefixes = (
list(references_headings)
if references_headings is not None
else _references_headings()
)
tail_n = (
max(int(references_tail_n), 0)
if references_tail_n is not None
else _references_tail_n()
)
if tail_n:
start = max(0, len(rows) - tail_n)
kept: list[dict[str, Any]] = []
dropped_headings: list[str] = []
for idx, row in enumerate(rows):
if idx >= start and _is_reference_heading(
row.get("heading", "") or "", prefixes
):
dropped_headings.append((row.get("heading") or "").strip())
else:
kept.append(row)
# Protect against an empty document: base the guard on rows that
# still carry content (the loop below skips blank-content rows), so
# a pathological sidecar whose only kept rows are empty does not
# silently produce zero chunks.
if dropped_headings and any(
(row.get("content") or "").strip() for row in kept
):
logger.info(
"[paragraph_semantic] removed %d reference block(s) %s "
"from doc_id: %s",
len(dropped_headings),
_format_dropped_headings(dropped_headings),
doc_id or "unknown",
)
rows = kept
elif dropped_headings:
logger.warning(
"[paragraph_semantic] dropping reference block(s) %s would "
"leave no content; keeping them from doc_id: %s",
_format_dropped_headings(dropped_headings),
doc_id or "unknown",
)
# Build initial blocks (HeadingBlocks output, already persisted).
initial: list[dict[str, Any]] = []
for row in rows:
text = row.get("content", "") or ""
if not text.strip():
continue
paragraphs = _block_to_paragraphs(text)
if not paragraphs:
continue
row_blockid = str(row.get("blockid") or "").strip()
initial.append(
_new_block(
heading=row.get("heading", "") or "",
parent_headings=list(row.get("parent_headings") or []),
level=int(row.get("level", 1) or 1),
paragraphs=paragraphs,
table_chunk_role="none",
tokenizer=tokenizer,
blockids=[row_blockid] if row_blockid else None,
)
)
# Repeating-header lookup for table slices that lose their header during
# row-splitting. Loaded once here (never on the missing-sidecar fallback
# path, which returned above); an absent / unreadable tables.json degrades
# to an empty map, so HeaderRecovery silently no-ops. Fed into TableRowSplit
# so the header's tokens are budgeted *before* splitting (the slice + header
# never exceeds target_max), rather than backfilled after the cap is set.
table_headers = _load_table_headers(blocks_path) if blocks_path else {}
# TableRowSplit/AnchorSplit are run per original blocks.jsonl content row so split
# fragments can be labelled with [part n] using a row-local counter
# before LevelMerge merges small neighbours.
after_c: list[dict[str, Any]] = []
for blk in initial:
block_after_b = _expand_block_with_table_splits(
blk,
tokenizer=tokenizer,
table_max=table_max,
table_ideal=table_ideal,
table_min_last=table_min_last,
target_max=target_max,
chunk_overlap_token_size=overlap,
table_headers=table_headers,
)
block_after_c: list[dict[str, Any]] = []
for split_blk in block_after_b:
block_after_c.extend(
_split_long_block(
split_blk["paragraphs"],
split_blk["heading"],
split_blk["parent_headings"],
split_blk["level"],
split_blk.get("table_chunk_role", "none"),
tokenizer=tokenizer,
target_max=target_max,
target_ideal=target_ideal,
chunk_overlap_token_size=overlap,
blockids=split_blk.get("blockids") or blk.get("blockids"),
)
)
after_c.extend(_apply_part_suffixes(block_after_c))
# HeadingGlue — glue each body-less heading block FORWARD into its
# strictly-deeper child (role "none" or the "first" slice of a split table),
# so the bare heading never reaches _merge_small_blocks detached from its
# child content nor glued onto an unrelated same-level sibling. Gluing into a
# "first" slice keeps the "first" role so LevelMerge still can't pull it back.
# A body-less heading whose next block is not deeper is left for LevelMerge
# (not pulled into a deeper previous block — that would invert the
# hierarchy). A forward-glued block tipped past target_max is re-split via
# AnchorSplit so the hard cap holds. Runs across original rows after [part n]
# tagging is finalised (heading-only rows are never split, so no part suffix).
after_c = _glue_heading_only_blocks(
after_c,
tokenizer=tokenizer,
target_max=target_max,
target_ideal=target_ideal,
chunk_overlap_token_size=overlap,
)
# LevelMerge — bottom-up, level-aware small-block merging.
final = _merge_small_blocks(
after_c,
tokenizer=tokenizer,
target_max=target_max,
target_ideal=target_ideal,
small_tail_threshold=small_tail_threshold,
)
# Convert internal block dicts to the new chunk schema: nested heading
# dict + sidecar block carrying source blockid refs so the multimodal
# pipeline (and document-delete cache cleanup) can trace each chunk
# back to its blocks.jsonl row(s).
chunks: list[dict[str, Any]] = []
for idx, blk in enumerate(final):
body = blk["content"].strip()
if not body:
continue
chunk_dict: dict[str, Any] = {
"tokens": blk["tokens"],
"content": body,
"chunk_order_index": idx,
"heading": {
"level": int(blk.get("level") or 0),
"heading": str(blk.get("heading") or ""),
"parent_headings": list(blk.get("parent_headings") or []),
},
}
blockids = blk.get("blockids") or []
if blockids:
chunk_dict["sidecar"] = {
"type": "block",
"id": blockids[0],
"refs": [{"type": "block", "id": bid} for bid in blockids],
}
chunks.append(chunk_dict)
return chunks