"""Regression tests for paragraph-semantic TableRowSplit oversized-table handling.""" import json import logging import pytest from lightrag.chunker.paragraph_semantic import ( _detect_table_format, _expand_block_with_table_splits, _merge_small_blocks, _new_block, _split_html_rows, _split_long_block, _split_rows_by_tokens, _split_table_text, chunking_by_paragraph_semantic, ) from lightrag.utils import Tokenizer, TokenizerInterface class _CharTokenizer(TokenizerInterface): """1:1 character-to-token mapping — keeps math obvious in assertions.""" def encode(self, content: str): return [ord(ch) for ch in content] def decode(self, tokens): return "".join(chr(t) for t in tokens) def _make_tokenizer() -> Tokenizer: return Tokenizer(model_name="char", tokenizer=_CharTokenizer()) @pytest.fixture def _propagate_lightrag_logger(monkeypatch): # The ``lightrag`` logger sets ``propagate=False`` (see lightrag.utils), so # caplog's root handler captures nothing by default. Flip it on for the # duration of a test that asserts on the degrade warning. monkeypatch.setattr(logging.getLogger("lightrag"), "propagate", True) @pytest.mark.offline def test_split_rows_by_tokens_few_rows_huge_total_no_empty_slice(): # Reproduces the bug where target_chunks > len(rows) made target_rows # < 1, so int((i+1)*target_rows) collapsed to start and the loop # appended empty slices (which would later serialise as []…). tokenizer = _make_tokenizer() # 3 rows that each individually exceed target_max — forces # math.ceil(total/target_ideal) and math.ceil(total/target_max) to # both be much greater than len(rows). rows = [ [{"col": "x" * 800}], [{"col": "y" * 800}], [{"col": "z" * 800}], ] chunks = _split_rows_by_tokens( rows, tokenizer, target_max=200, target_ideal=150, last_min=64, ) assert chunks, "expected at least one chunk" for chunk in chunks: assert chunk, "TableRowSplit must never emit an empty row slice" # Concatenation preserves all rows in order. flat: list = [] for chunk in chunks: flat.extend(chunk) assert flat == rows @pytest.mark.offline def test_split_rows_by_tokens_balanced_split_yields_one_row_per_chunk(): # When target_chunks gets capped at len(rows), each chunk holds one # row — verifies the cap kicks in and forward progress is preserved. tokenizer = _make_tokenizer() rows = [[{"col": "a" * 300}] for _ in range(4)] chunks = _split_rows_by_tokens( rows, tokenizer, target_max=200, target_ideal=150, last_min=10, # low enough that the tail-merge step doesn't fire ) assert all(chunk for chunk in chunks) # Each row appears exactly once across the chunks. flat: list = [] for chunk in chunks: flat.extend(chunk) assert flat == rows def _build_oversized_table_text(num_rows: int, row_payload_size: int) -> str: rows = [[f"r{idx}-" + "x" * row_payload_size] for idx in range(num_rows)] return f'
{json.dumps(rows)}
' def _write_blocks_jsonl(tmp_path, content: str) -> str: path = tmp_path / "doc.blocks.jsonl" row = { "type": "content", "heading": "Section", "parent_headings": [], "level": 2, "content": content, } path.write_text(json.dumps(row, ensure_ascii=False), encoding="utf-8") return str(path) @pytest.mark.offline def test_expand_block_assigns_first_and_last_roles_to_glued_blocks(): # An oversized table sandwiched between leading and trailing paragraphs # produces three slices: "first" (glued with leading paras), # "middle" (standalone), "last" (glued with trailing paras). Before # the fix, the first/last blocks defaulted to "none" and lost their # directional merge-protection. tokenizer = _make_tokenizer() table_text = _build_oversized_table_text(num_rows=6, row_payload_size=200) block = { "heading": "Section", "parent_headings": ["Doc"], "level": 2, "paragraphs": [ {"text": "lead paragraph", "is_table": False}, {"text": table_text, "is_table": True}, {"text": "trailing paragraph", "is_table": False}, ], } out = _expand_block_with_table_splits( block, tokenizer=tokenizer, table_max=400, table_ideal=300, table_min_last=128, ) roles = [b["table_chunk_role"] for b in out] assert roles[0] == "first", f"expected leading block role=first, got {roles}" assert roles[-1] == "last", f"expected trailing block role=last, got {roles}" assert all(r == "middle" for r in roles[1:-1]), ( f"expected middle slices between first/last, got {roles}" ) # Boundary glue still works: leading text sits inside the first block, # trailing text sits inside the last block. assert any(p["text"] == "lead paragraph" for p in out[0]["paragraphs"]), ( "leading paragraph must glue with the first table slice" ) assert any(p["text"] == "trailing paragraph" for p in out[-1]["paragraphs"]), ( "trailing paragraph must glue with the last table slice" ) assert all("表格片段" not in b["heading"] for b in out), ( "TableRowSplit should not expose legacy table-fragment heading suffixes" ) @pytest.mark.offline def test_expand_block_two_oversized_tables_separates_last_and_first_roles(): # Two oversized tables in the same heading block: the tail of the first # split must carry role="last" and not be silently merged into the # head of the second split (which must carry role="first"). tokenizer = _make_tokenizer() block = { "heading": "Section", "parent_headings": [], "level": 2, "paragraphs": [ { "text": _build_oversized_table_text(num_rows=4, row_payload_size=200), "is_table": True, }, {"text": "between tables", "is_table": False}, { "text": _build_oversized_table_text(num_rows=4, row_payload_size=200), "is_table": True, }, ], } out = _expand_block_with_table_splits( block, tokenizer=tokenizer, table_max=400, table_ideal=300, table_min_last=128, ) roles = [b["table_chunk_role"] for b in out] # We expect the role sequence to start with "first", end with "last", # and contain at least one "last" -> "first" transition (the boundary # between the two oversized tables) without any boundary block losing # its role. assert roles[0] == "first" assert roles[-1] == "last" assert "last" in roles # The transition: there must be a "last" immediately followed by a # "first" somewhere in the middle of the role sequence. transitions = list(zip(roles, roles[1:])) assert ("last", "first") in transitions, ( f"expected a last->first boundary between the two split tables, got {roles}" ) @pytest.mark.offline def test_expand_block_duplicates_short_text_between_oversized_tables(): tokenizer = _make_tokenizer() bridge = "between tables" block = { "heading": "Section", "parent_headings": [], "level": 2, "paragraphs": [ { "text": _build_oversized_table_text(num_rows=4, row_payload_size=200), "is_table": True, }, {"text": bridge, "is_table": False}, { "text": _build_oversized_table_text(num_rows=4, row_payload_size=200), "is_table": True, }, ], } out = _expand_block_with_table_splits( block, tokenizer=tokenizer, table_max=400, table_ideal=300, table_min_last=128, target_max=800, chunk_overlap_token_size=100, ) roles = [b["table_chunk_role"] for b in out] boundary_idx = next( i for i, (left, right) in enumerate(zip(roles, roles[1:])) if (left, right) == ("last", "first") ) assert bridge in out[boundary_idx]["content"] assert bridge in out[boundary_idx + 1]["content"] @pytest.mark.offline def test_expand_block_emits_middle_text_when_table_bridge_is_long(): tokenizer = _make_tokenizer() bridge = ("A" * 45) + ("B" * 50) + ("C" * 45) block = { "heading": "Section", "parent_headings": [], "level": 2, "paragraphs": [ { "text": _build_oversized_table_text(num_rows=6, row_payload_size=120), "is_table": True, }, {"text": bridge, "is_table": False}, { "text": _build_oversized_table_text(num_rows=6, row_payload_size=120), "is_table": True, }, ], } out = _expand_block_with_table_splits( block, tokenizer=tokenizer, table_max=260, table_ideal=180, table_min_last=32, target_max=400, chunk_overlap_token_size=45, ) # The standalone middle block carries R-style overlap with the text that # went left (prefix) and right (suffix): because each side's slice is # itself ≤ the overlap budget, the middle re-covers the whole bridge — the # bridge is never fragmented, only its head/tail are *also* duplicated into # the neighbouring table blocks. middle_idx = next( i for i, blk in enumerate(out) if blk["table_chunk_role"] == "none" and blk["content"] == bridge ) assert out[middle_idx - 1]["table_chunk_role"] == "last" assert "A" * 45 in out[middle_idx - 1]["content"] assert "B" * 50 not in out[middle_idx - 1]["content"] assert out[middle_idx + 1]["table_chunk_role"] == "first" assert out[middle_idx + 1]["content"].startswith("C" * 45) assert "B" * 50 not in out[middle_idx + 1]["content"] # The overlap never drags table markup into the middle text block. assert " 1 assert [chunk["heading"]["heading"] for chunk in chunks] == [ f"Section [part {idx}]" for idx in range(1, len(chunks) + 1) ] assert all("表格片段" not in chunk["heading"]["heading"] for chunk in chunks) # --------------------------------------------------------------------------- # Table-aware fallback tests (row-boundary first, character last). # --------------------------------------------------------------------------- @pytest.mark.offline def test_detect_table_format_explicit_attr(): assert _detect_table_format('id="t1" format="json"', "[]") == "json" assert _detect_table_format("format='html'", "") == "html" # Unknown formats fall through (force the caller to use char fallback). assert _detect_table_format('format="markdown"', "...") is None @pytest.mark.offline def test_detect_table_format_sniff_when_attrs_silent(): assert _detect_table_format("", '[{"a":1}]') == "json" assert _detect_table_format("", "x") == "html" # Body that doesn't look like JSON or HTML → unknown. assert _detect_table_format("", "plain text rows") is None @pytest.mark.offline def test_split_html_rows_extracts_tr_elements(): body = ( "h" "ab" ) rows = _split_html_rows(body) assert rows is not None assert len(rows) == 3 # Each row carries its parent wrapper so the chunk serialiser can # rebuild / instead of dropping them silently. assert [w for w, _ in rows] == ["thead", "tbody", "tbody"] assert all(tr.startswith("") for _, tr in rows) @pytest.mark.offline def test_split_html_rows_no_tr_returns_none(): assert _split_html_rows("just text, no rows") is None assert _split_html_rows("") is None @pytest.mark.offline def test_split_table_text_single_row_oversized_falls_to_character_split(): # A 1-row table whose single cell is huge cannot be reduced via row # boundary — the function must fall to character splitting and respect # target_max on every output piece. tokenizer = _make_tokenizer() rows = [[{"col": "x" * 2000}]] table_text = f'{json.dumps(rows)}
' pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=500, target_ideal=350, last_min=128, ) assert len(pieces) >= 2, "single-row oversized table must produce multiple pieces" # Every piece honors the cap (this is the contract violation the user # reported when the previous code emitted a single 2000-token table). assert all(_count_tokens(tokenizer, p) <= 500 for p in pieces) @pytest.mark.offline def test_split_table_text_multirow_one_huge_row_degrades_whole_table_to_recursive(): # A multi-row table where most rows fit but one row is itself huge. A single # row that exceeds the cap can no longer be expressed at row boundaries, so # the WHOLE table degrades to a recursive character split (rather than # emitting a mix of slices and orphaned character fragments). tokenizer = _make_tokenizer() small_row = [{"col": "ok"}] huge_row = [{"col": "z" * 2000}] rows = [small_row, huge_row, small_row] table_text = f'
{json.dumps(rows)}
' pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=500, target_ideal=350, last_min=64, ) assert len(pieces) >= 2, "oversized row must force a multi-piece recursive split" assert all(_count_tokens(tokenizer, p) <= 500 for p in pieces) # The whole table degraded: it was shredded as one recursive split, so no # piece survives as a complete re-wrapped ...
slice (the old # mixed output kept legal per-row table slices alongside text fragments). assert not any(p.startswith("") for p in pieces) @pytest.mark.offline def test_split_table_text_html_table_split_by_tr(): # HTML-format table: rows are ...; each output fragment must # remain a legal
{rows}
string. tokenizer = _make_tokenizer() body = "".join(f"{'r' * 200}" for _ in range(5)) table_text = f'{body}
' pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=500, target_ideal=350, last_min=64, ) assert len(pieces) >= 2 # All pieces should be legal ...
fragments (none of the # rows individually exceeds target_max, so no character fallback). assert all(p.startswith("") for p in pieces) assert all(_count_tokens(tokenizer, p) <= 500 for p in pieces) @pytest.mark.offline def test_split_table_text_html_preserves_thead_tbody_wrappers(): # When an HTML table mixes and , the row splitter # used to drop the wrappers entirely — the chunked output came back # as bare sequences. The fix re-emits each wrapper around its # rows in every chunk so the table structure survives splitting. tokenizer = _make_tokenizer() head_row = "" body_rows = "".join(f"" for i in range(4)) body = f"{head_row}{body_rows}" table_text = f'
" + ("h" * 80) + "
{'b' * 80}{i}
{body}
' pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=400, target_ideal=280, last_min=64, ) # Multiple chunks expected and every chunk must remain a legal # -wrapped fragment. assert len(pieces) >= 2 assert all(p.startswith("
") for p in pieces) # Every chunk that contains the header row must still wrap it in # ...; every chunk with body rows must wrap them in # .... Before the fix, both wrappers vanished. for piece in pieces: if "" in piece and "" in piece, piece if "" in piece and "" in piece, piece # Round-trip: concatenating just the row payloads from every chunk # recovers the original row sequence in order. extracted_rows: list[str] = [] import re for piece in pieces: extracted_rows.extend( re.findall(r"]*>.*?", piece, re.DOTALL | re.IGNORECASE) ) expected_rows = re.findall(r"]*>.*?", body, re.DOTALL | re.IGNORECASE) assert extracted_rows == expected_rows @pytest.mark.offline def test_split_table_text_html_injects_thead_into_nonfirst_slices(): """A long HTML table with an HTML header_body: the first slice keeps its own in-body (not double-injected); every middle/last slice has the raw spliced in exactly once, with rowspan/colspan preserved; every slice stays within the cap.""" tokenizer = _make_tokenizer() header = ( '' ) head_row = '' body_rows = "".join(f"" for i in range(6)) # Body carries its own (the source header) plus the data rows. body = f"{head_row}{body_rows}" table_text = f'
" in piece: assert "
" in piece: assert "
MetricGroup
MetricGroup
{'d' * 120}{i}
{body}
' pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=500, target_ideal=350, last_min=64, header_body=header, ) assert len(pieces) >= 3 # first / middle(s) / last assert all(p.startswith("") for p in pieces) assert all(_count_tokens(tokenizer, p) <= 500 for p in pieces) # First slice keeps its own header and is NOT injected with a second copy. assert pieces[0].count("") == 1 # Every non-first slice gets the header spliced in exactly once, verbatim # (rowspan/colspan preserved), immediately after the opening
. for piece in pieces[1:]: assert piece.count("") == 1, piece assert header in piece, piece assert 'rowspan="2"' in piece and 'colspan="2"' in piece, piece # The sits at the front of the body, ahead of the data rows. assert piece.index("") < piece.index(""), piece @pytest.mark.offline def test_split_table_text_raises_on_html_table_with_json_header(): """Cross-format guard: an HTML table fed a JSON-array header_body must raise (the sidecar header does not belong to this table).""" tokenizer = _make_tokenizer() body = "".join(f"" for _ in range(5)) table_text = f'
{'r' * 200}
{body}
' with pytest.raises(ValueError): _split_table_text( table_text, tokenizer=tokenizer, target_max=500, target_ideal=350, last_min=64, header_body='[["H1", "H2"]]', ) @pytest.mark.offline def test_split_table_text_unknown_format_falls_to_character(): # No format attr, body that doesn't look like JSON/HTML → unknown. tokenizer = _make_tokenizer() table_text = '' + ("plain row text " * 300) + "
" pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=500, target_ideal=350, last_min=64, ) assert len(pieces) >= 2 assert all(_count_tokens(tokenizer, p) <= 500 for p in pieces) @pytest.mark.offline def test_expand_block_single_row_table_no_longer_left_intact(): # TableRowSplit integration: previously a single-row oversized table was # appended back to cur_paras unchanged, leading the block to reach # AnchorSplit with the table whole and the character fallback shredding # the tag. After the fix, TableRowSplit itself produces multiple # pieces for such a table. tokenizer = _make_tokenizer() rows = [[{"col": "x" * 2000}]] # single huge row table_text = f'
{json.dumps(rows)}
' block = { "heading": "Section", "parent_headings": [], "level": 2, "paragraphs": [ {"text": "lead", "is_table": False}, {"text": table_text, "is_table": True}, {"text": "trail", "is_table": False}, ], } out = _expand_block_with_table_splits( block, tokenizer=tokenizer, table_max=400, table_ideal=300, table_min_last=128, ) # Multiple sub-blocks must be produced; the oversized table no longer # passes through whole. assert len(out) >= 2 # First/last role protection still fires when the table was reduced. roles = [b["table_chunk_role"] for b in out] assert "first" in roles or "last" in roles, ( f"expected first/last role assignment after table split, got {roles}" ) @pytest.mark.offline def test_split_long_block_table_dominant_no_anchor_keeps_some_table_markup(): # AnchorSplit integration: a block dominated by an oversized table with no # anchor candidates used to be character-split end-to-end, destroying # the tag. After the fix, at least some output sub-blocks # retain legal
...
markup for the rows that fit. tokenizer = _make_tokenizer() # Many small rows -> row-boundary split produces multiple legal # fragments, none of which individually exceed target_max. rows = [[{"col": f"r{i}-" + "v" * 200}] for i in range(8)] table_text = f'
{json.dumps(rows)}
' paragraphs = [ {"text": "Sufficiently long lead paragraph " * 30, "is_table": False}, {"text": table_text, "is_table": True}, ] sub_blocks = _split_long_block( paragraphs, heading="Heading", parent_headings=[], level=2, table_chunk_role="none", tokenizer=tokenizer, target_max=600, target_ideal=450, ) # Every sub-block respects the cap. assert all(b["tokens"] <= 600 for b in sub_blocks) # At least one sub-block keeps an unbroken fragment somewhere # in its content (proof that row-boundary preservation kicked in). contents = [b["content"] for b in sub_blocks] assert any(("
" in c) for c in contents), ( "expected at least one sub-block to retain a legal
fragment" ) @pytest.mark.offline def test_split_table_text_budgets_wrapper_overhead_for_target_max(): # ``_split_rows_by_tokens`` measures only the body (json.dumps(rows)); # the surrounding ``
`` wrapper costs tokens too. # Without wrapper-aware budgeting, a chunk whose body just fits # target_max would overflow once wrapped and trigger character # fallback — shredding the row structure for no good reason. tokenizer = _make_tokenizer() # A long attrs string forces a non-trivial wrapper overhead so the # body-only budget previously chosen (==target_max) overflows when # the wrapper is added back in. attrs_padding = "x" * 80 rows = [[{"col": "y" * 80}] for _ in range(4)] table_text = f'{json.dumps(rows)}
' pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=250, target_ideal=180, last_min=64, ) # Every output piece honors the cap. assert all(_count_tokens(tokenizer, p) <= 250 for p in pieces), [ _count_tokens(tokenizer, p) for p in pieces ] # Row structure preserved — none of the pieces fell back to # character fragments because of accidental wrapper overflow. assert all(p.startswith("") for p in pieces) def _count_tokens(tokenizer: Tokenizer, text: str) -> int: return len(tokenizer.encode(text)) # --------------------------------------------------------------------------- # HeaderRecovery — the source table's repeating header is budgeted out of the # per-slice cap and re-injected into the
body of every non-first slice # AT SPLIT TIME (not backfilled at output), so a slice + its header always # stays ≤ target_max, and split slices are frozen against LevelMerge so the # header is never duplicated by a re-merge. # --------------------------------------------------------------------------- _HEADER_BODY = '[["H1", "H2"]]' # After injection (or naturally on the first slice) a JSON slice's
body # begins with the header row. _INJECTED_PREFIX = '
[["H1", "H2"]' # Sentinel distinguishing "write tables.json without a table_header" from # "do not write tables.json at all". _NO_SIDECAR = object() def _write_tables_json(tmp_path, headers: dict) -> None: """Write a ``doc.tables.json`` beside ``doc.blocks.jsonl``. ``headers`` maps table id -> header string; a ``None`` value emits an entry WITHOUT the ``table_header`` field (a table that has no repeating header). """ tables: dict = {} for tid, header in headers.items(): entry = {"id": tid, "format": "json", "content": "[]", "caption": ""} if header is not None: entry["table_header"] = header tables[tid] = entry path = tmp_path / "doc.tables.json" path.write_text( json.dumps({"version": "1.0", "tables": tables}, ensure_ascii=False), encoding="utf-8", ) def _build_table_with_header(num_data_rows: int, payload_size: int) -> str: # First row is the real header; the rest are data rows. After row-splitting, # only the "first" slice keeps the header row; middle/last slices must have # it re-injected. rows = [["H1", "H2"]] + [ [f"r{idx}-" + "x" * payload_size, "y"] for idx in range(num_data_rows) ] return f'
{json.dumps(rows)}
' def _chunk_with_oversized_table( tmp_path, *, heading: str = "Section", sidecar=_HEADER_BODY, num_data_rows: int = 6, payload_size: int = 200, trailing: str = "trailing paragraph", chunk_token_size: int = 800, ): tokenizer = _make_tokenizer() body = "\n".join( [ "lead paragraph", _build_table_with_header( num_data_rows=num_data_rows, payload_size=payload_size ), trailing, ] ) if sidecar is _NO_SIDECAR: pass # leave the directory without a tables.json elif sidecar is None: _write_tables_json(tmp_path, {"tb-1": None}) # entry without table_header else: _write_tables_json(tmp_path, {"tb-1": sidecar}) path = tmp_path / "doc.blocks.jsonl" row = { "type": "content", "heading": heading, "parent_headings": [], "level": 2 if heading else 0, "content": body, } path.write_text(json.dumps(row, ensure_ascii=False), encoding="utf-8") chunks = chunking_by_paragraph_semantic( tokenizer, body, chunk_token_size=chunk_token_size, blocks_path=str(path), chunk_overlap_token_size=0, ) return chunks def _table_chunks(chunks): return [c for c in chunks if ' bool: content = chunk["content"] tbl = content[content.index('
[["H1", "H2"], ["a", "b"]]
' ) # HTML: the raw header is spliced verbatim ahead of the body, # preserving any rowspan/colspan markup it carries. html_slice = ( '
a
' ) html_header = 'H' assert _inject_header_into_table_slice(html_slice, html_header) == ( '' '' "
H
a
" ) # Unparseable header → no injection. assert _inject_header_into_table_slice(json_slice, "not json") is None # Non- fragment → no injection. assert _inject_header_into_table_slice("just text", '[["H1", "H2"]]') is None @pytest.mark.offline def test_inject_header_raises_on_cross_format(): """A header whose format disagrees with the slice's content format means the sidecar header does not belong to this table — inject must raise, not silently emit a malformed slice.""" from lightrag.chunker.paragraph_semantic import _inject_header_into_table_slice json_slice = '
[["a", "b"]]
' html_slice = ( '
a
' ) html_header = "H" # HTML header into a JSON slice → mismatch. with pytest.raises(ValueError): _inject_header_into_table_slice(json_slice, html_header) # JSON header into an HTML slice → mismatch. with pytest.raises(ValueError): _inject_header_into_table_slice(html_slice, '[["H1", "H2"]]') @pytest.mark.offline def test_inject_header_skips_html_slice_that_already_has_thead(): from lightrag.chunker.paragraph_semantic import _inject_header_into_table_slice # HTML: a slice that already carries a is left untouched rather # than gaining a second one. slice_with_thead = ( '' "" "
H2aH2b
d
" ) html_header = "H1aH1b" assert _inject_header_into_table_slice(slice_with_thead, html_header) is None @pytest.mark.offline def test_full_pipeline_injects_html_thead_into_split_html_table(tmp_path): """End-to-end through chunking_by_paragraph_semantic: an oversized HTML table whose tables.json carries a raw header has that header re-injected (verbatim, spans preserved) into every non-first table chunk.""" html_header = ( 'MetricGroup' ) head_row = 'MetricGroup' data_rows = "".join(f"{'d' * 160}{i}" for i in range(6)) table = ( '' f"{head_row}{data_rows}
" ) body = "\n".join(["lead paragraph", table, "trailing paragraph"]) # tables.json sidecar with an HTML-format header. (tmp_path / "doc.tables.json").write_text( json.dumps( { "version": "1.0", "tables": { "tb-h": { "id": "tb-h", "format": "html", "content": table, "caption": "", "table_header": html_header, } }, }, ensure_ascii=False, ), encoding="utf-8", ) blocks_path = tmp_path / "doc.blocks.jsonl" blocks_path.write_text( json.dumps( { "type": "content", "heading": "Section", "parent_headings": [], "level": 2, "content": body, }, ensure_ascii=False, ), encoding="utf-8", ) tokenizer = _make_tokenizer() chunks = chunking_by_paragraph_semantic( tokenizer, body, chunk_token_size=900, blocks_path=str(blocks_path), chunk_overlap_token_size=0, ) table_chunks = [c for c in chunks if '= 2 # the table was split # Every table chunk carries exactly one with spans intact; the # first keeps its own, later ones got the sidecar header spliced in. for chunk in table_chunks: assert chunk["content"].count("") == 1, chunk["content"] assert 'rowspan="2"' in chunk["content"] assert 'colspan="2"' in chunk["content"] # At least one non-first chunk literally contains the injected header. assert any(html_header in c["content"] for c in table_chunks[1:]) @pytest.mark.offline def test_split_table_text_multi_row_header_pinned_not_duplicated(): # A multi-row header is pinned out of the split body, so every slice gets # the full header exactly once — never a cut remnant duplicated into a # later slice (e.g. [H1, H2, H2, ...]). from lightrag.table_markup import TABLE_TAG_RE tokenizer = _make_tokenizer() h1 = ["H1", "h1"] h2 = ["H2", "h2"] header_rows = [h1, h2] header_body = json.dumps(header_rows) rows = [h1, h2] + [[f"D{i}", "x" * 30] for i in range(6)] table_text = f'
{json.dumps(rows)}
' pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=160, target_ideal=120, last_min=10, header_body=header_body, ) assert len(pieces) >= 2, "expected the data rows to be split across slices" for piece in pieces: body_rows = json.loads(TABLE_TAG_RE.match(piece).group("body")) # Full header present exactly once at the top of every slice. assert body_rows[:2] == header_rows, body_rows assert body_rows[2:].count(h1) == 0 and body_rows[2:].count(h2) == 0, body_rows assert _count_tokens(tokenizer, piece) <= 160 @pytest.mark.offline def test_split_table_text_preserves_data_row_equal_to_header(): # A genuine data row whose values equal the (single-row) header must be # preserved, not mistaken for a header remnant and dropped. Pinning the # header out of the body guarantees this: the body is split as pure data # and the header is prepended verbatim. from lightrag.table_markup import TABLE_TAG_RE tokenizer = _make_tokenizer() header_row = ["Label", "Value"] header_body = json.dumps([header_row]) # The 3rd data row is identical to the header (e.g. a repeated label row). data_rows = [ ["a", "x" * 40], ["b", "y" * 40], ["Label", "Value"], ["c", "z" * 40], ] rows = [header_row] + data_rows table_text = f'{json.dumps(rows)}
' pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=130, target_ideal=90, last_min=10, header_body=header_body, ) assert len(pieces) >= 2, "expected the data to be split across slices" # Reconstruct the data by stripping the one prepended header row from each # slice; every original data row (including the header-lookalike) survives. recovered: list = [] for piece in pieces: body_rows = json.loads(TABLE_TAG_RE.match(piece).group("body")) assert body_rows[0] == header_row, body_rows recovered.extend(body_rows[1:]) assert recovered == data_rows, recovered @pytest.mark.offline def test_split_table_text_resplits_reducible_slice_to_keep_header(): # A reducible multi-row non-first slice whose wrapped table fits target_max # but whose table+header would exceed it must be split further at row # boundaries so each sub-slice still carries the header — rather than being # accepted whole and then silently left header-less. Here the splitter # emits two 2-row chunks at 119 tokens (target_max=120, effective cap=106), # which must be re-split so every non-first slice keeps its header. from lightrag.table_markup import TABLE_TAG_RE tokenizer = _make_tokenizer() header_row = ["HH", "HH"] header_body = json.dumps([header_row]) rows = [header_row] + [[f"D{i}", "x" * 28] for i in range(4)] table_text = f'{json.dumps(rows)}
' pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=120, target_ideal=120, last_min=10, header_body=header_body, ) assert len(pieces) >= 3 # Every piece honors the hard cap. assert all(_count_tokens(tokenizer, p) <= 120 for p in pieces), [ _count_tokens(tokenizer, p) for p in pieces ] # No non-first slice is left header-less: each carries the recovered header # (every data row here is small enough that header + row fits target_max). for piece in pieces[1:]: body_rows = json.loads(TABLE_TAG_RE.match(piece).group("body")) assert body_rows[0] == header_row, body_rows @pytest.mark.offline def test_split_table_text_budgets_header_before_splitting(): # With a header supplied, every emitted slice must stay ≤ target_max even # though the header is prepended into the non-first slices — proving the # header tokens were budgeted out BEFORE the split, not backfilled after. tokenizer = _make_tokenizer() header_body = '[["' + "H" * 60 + '", "' + "K" * 60 + '"]]' # non-trivial header rows = [["H" * 60, "K" * 60]] + [[f"r{i}-" + "y" * 60, "z"] for i in range(8)] table_text = f'{json.dumps(rows)}
' pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=300, target_ideal=220, last_min=64, header_body=header_body, ) assert len(pieces) >= 3, "expected the oversized table to split into 3+ slices" # Every slice honors the cap including its (injected) header. assert all(_count_tokens(tokenizer, p) <= 300 for p in pieces), [ _count_tokens(tokenizer, p) for p in pieces ] # Non-first slices carry the recovered header row. assert all( p.startswith('[["' + "H" * 60) for p in pieces[1:] ) @pytest.mark.offline def test_split_table_text_degrades_when_header_would_exceed_cap( caplog, _propagate_lightrag_logger ): # Cap edge: a single-row slice whose wrapped
sits just under # target_max yet leaves no room for the header it would need. Rather than # silently keeping it header-less (the old size-guard behaviour), the whole # table degrades to a recursive split so the header is preserved as text, # the hard cap still holds, and a warning is logged. tokenizer = _make_tokenizer() target_max = 200 header_body = '[["HHHHHHHHHH", "KKKKKKKKKK"]]' # ~30 tokens of header # row0 is the real header (pinned out of the body); row1 is small; row2 is a # single big row whose wrapped
size sits just under target_max, so # it fits the bare cap yet leaves no room for the ~30-token header. rows = [["HHHHHHHHHH", "KKKKKKKKKK"], ["s", "y"], ["x" * 140, "y"]] table_text = f'
{json.dumps(rows)}
' with caplog.at_level(logging.WARNING, logger="lightrag"): pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=target_max, target_ideal=150, last_min=64, header_body=header_body, ) assert len(pieces) >= 2 # The cap holds for every piece, and the whole table degraded: no piece # survives as a complete re-wrapped ...
slice. assert all(_count_tokens(tokenizer, p) <= target_max for p in pieces), [ _count_tokens(tokenizer, p) for p in pieces ] assert not any(p.startswith("") for p in pieces) # The header content survives in the recursive fallback (not dropped). assert "HHHHHHHHHH" in "".join(pieces) assert any("degrading the whole table" in rec.message for rec in caplog.records), [ rec.message for rec in caplog.records ] @pytest.mark.offline def test_split_table_text_pinned_header_oversized_row_preserves_header( caplog, _propagate_lightrag_logger ): # Codex P2 regression: a pinned-header JSON table whose data portion holds a # single row larger than target_max. The old code character-split the # header-less ``wrapped`` body, so the later injection pass (which can't # repair a non-
fragment) dropped the header entirely. The whole # table must now degrade to a recursive split that keeps the header text. tokenizer = _make_tokenizer() target_max = 300 header_body = '[["HEADERCOL_A", "HEADERCOL_B"]]' # row0 repeats the header (pinned out of the body); row1 is a single huge # data row whose content alone blows past target_max. rows = [["HEADERCOL_A", "HEADERCOL_B"], ["x" * 2000, "y"]] table_text = f'
{json.dumps(rows)}
' with caplog.at_level(logging.WARNING, logger="lightrag"): pieces = _split_table_text( table_text, tokenizer=tokenizer, target_max=target_max, target_ideal=220, last_min=64, header_body=header_body, ) assert len(pieces) >= 2 assert all(_count_tokens(tokenizer, p) <= target_max for p in pieces), [ _count_tokens(tokenizer, p) for p in pieces ] # The header column names survive somewhere in the emitted fragments. assert "HEADERCOL_A" in "".join(pieces) assert any( "tb-7" in rec.message and "degrading the whole table" in rec.message for rec in caplog.records ), [rec.message for rec in caplog.records] @pytest.mark.offline def test_header_injected_into_every_table_slice(tmp_path): chunks = _chunk_with_oversized_table(tmp_path) table_chunks = _table_chunks(chunks) assert len(table_chunks) >= 2, "expected the oversized table to be split" # Every slice's now begins with the header row: the "first" slice # naturally (it kept the real header row), middle/last via re-injection. assert all(_slice_starts_with_header(c) for c in table_chunks), [ c["content"][:90] for c in table_chunks ] @pytest.mark.offline def test_every_chunk_respects_cap_after_header_injection(tmp_path): # The Codex P2 finding: a near-cap slice + a recovered header must not push # the chunk past the hard cap. With split-time budgeting this holds for a # table that has substantial trailing prose glued to its last slice too. chunk_token_size = 800 long_trailing = " ".join(f"word{i}" for i in range(120)) # ~ hundreds of tokens chunks = _chunk_with_oversized_table( tmp_path, num_data_rows=10, payload_size=120, trailing=long_trailing, chunk_token_size=chunk_token_size, ) tokenizer = _make_tokenizer() assert _table_chunks(chunks), "expected a split header-bearing table" for c in chunks: assert _count_tokens(tokenizer, c["content"]) <= chunk_token_size, c["content"][ :120 ] assert c["tokens"] <= chunk_token_size @pytest.mark.offline def test_split_table_slices_never_remerge_no_duplicate_header(tmp_path): # Split slices are frozen against LevelMerge. Two slices of one table are # never recombined into a single chunk (which, post-injection, would carry # the header twice). The header appears at most once per chunk. chunks = _chunk_with_oversized_table( tmp_path, num_data_rows=8, payload_size=120, trailing="", chunk_token_size=800 ) table_chunks = _table_chunks(chunks) assert len(table_chunks) >= 2, "table must remain split, not reassembled" for c in table_chunks: # The header signature must not be duplicated inside any single chunk. assert c["content"].count('"H1", "H2"') <= 1, c["content"] @pytest.mark.offline def test_levelmerge_freezes_first_and_last_slices(): # Deterministic freeze guard at the LevelMerge layer: a small "first" block # immediately followed by a small "last" block of the SAME 2-slice table — # both under target_ideal and same parent path — would have been reassembled # into one whole table before the freeze. Now neither merge direction fires, # so the two slices stay as two separate chunks (no duplicated header). tokenizer = _make_tokenizer() first = _new_block( heading="Section", parent_headings=["Doc"], level=2, paragraphs=[ { "text": '
[["H1","H2"],["a","b"]]
', "is_table": True, } ], table_chunk_role="first", tokenizer=tokenizer, ) last = _new_block( heading="Section", parent_headings=["Doc"], level=2, paragraphs=[ { "text": '[["H1","H2"],["c","d"]]
', "is_table": True, } ], table_chunk_role="last", tokenizer=tokenizer, ) merged = _merge_small_blocks( [first, last], tokenizer=tokenizer, target_max=10_000, # generous cap so size never blocks a merge target_ideal=10_000, # both blocks count as below-ideal small_tail_threshold=10_000, ) assert len(merged) == 2, "frozen split slices must not be reassembled" assert all(b["content"].count('"H1","H2"') == 1 for b in merged) @pytest.mark.offline def test_no_injection_when_source_table_has_no_header(tmp_path): # tables.json lists the table but WITHOUT a table_header field → nothing is # fabricated; only the first slice (which kept the real header) starts with it. chunks = _chunk_with_oversized_table(tmp_path, sidecar=None) table_chunks = _table_chunks(chunks) assert len(table_chunks) >= 2 starts = [_slice_starts_with_header(c) for c in table_chunks] assert starts[0] is True, "first slice keeps the table's own header row" assert any(s is False for s in starts[1:]), "later slices must not be injected" @pytest.mark.offline def test_no_injection_when_tables_json_missing(tmp_path): # No tables.json at all → silent degrade: no error, no injection. chunks = _chunk_with_oversized_table(tmp_path, sidecar=_NO_SIDECAR) table_chunks = _table_chunks(chunks) assert len(table_chunks) >= 2 starts = [_slice_starts_with_header(c) for c in table_chunks] assert starts[0] is True assert any(s is False for s in starts[1:]) @pytest.mark.offline def test_injection_works_when_source_heading_empty(tmp_path): # A preamble block (empty source heading) still gets the header injected. chunks = _chunk_with_oversized_table(tmp_path, heading="") table_chunks = _table_chunks(chunks) assert len(table_chunks) >= 2 assert all(_slice_starts_with_header(c) for c in table_chunks)