255 lines
8.6 KiB
Python
255 lines
8.6 KiB
Python
"""Unit tests for ``chunking_by_recursive_character`` (process_options=R)."""
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("langchain_text_splitters")
|
|
from langchain_text_splitters import RecursiveCharacterTextSplitter # noqa: E402
|
|
|
|
from lightrag.chunker import chunking_by_recursive_character # noqa: E402
|
|
from lightrag.chunker.recursive_character import _split_text_with_spans # noqa: E402
|
|
from lightrag.utils import Tokenizer, TokenizerInterface # noqa: E402
|
|
|
|
|
|
class _CharTokenizer(TokenizerInterface):
|
|
"""1 char ≈ 1 token; lets assertions reason in terms of input length."""
|
|
|
|
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 _tok() -> Tokenizer:
|
|
return Tokenizer("char-tokenizer", _CharTokenizer())
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_empty_input_returns_empty_list():
|
|
chunks = chunking_by_recursive_character(_tok(), "")
|
|
assert chunks == []
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_short_input_single_chunk():
|
|
body = "Para A.\n\nPara B."
|
|
chunks = chunking_by_recursive_character(_tok(), body, chunk_token_size=1000)
|
|
|
|
assert len(chunks) == 1
|
|
assert chunks[0]["content"] == body
|
|
assert chunks[0]["_source_span"] == {"start": 0, "end": len(body)}
|
|
assert chunks[0]["chunk_order_index"] == 0
|
|
assert chunks[0]["tokens"] == len(body)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_paragraph_separator_used_first():
|
|
"""``\\n\\n`` is the first separator in the default cascade — three
|
|
paragraphs that each fit under the cap should split exactly there."""
|
|
body = "Alpha section.\n\nBeta section.\n\nGamma section."
|
|
chunks = chunking_by_recursive_character(
|
|
_tok(),
|
|
body,
|
|
chunk_token_size=20,
|
|
chunk_overlap_token_size=0,
|
|
)
|
|
|
|
assert [c["chunk_order_index"] for c in chunks] == list(range(len(chunks)))
|
|
assert all(c["content"].strip() for c in chunks)
|
|
# Reconstructed (joined with the splitter's separator semantics) must
|
|
# at least contain each original paragraph as a substring.
|
|
joined = "\n\n".join(c["content"] for c in chunks)
|
|
for para in ("Alpha section.", "Beta section.", "Gamma section."):
|
|
assert para in joined
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_token_field_matches_tokenizer_encode_length():
|
|
chunks = chunking_by_recursive_character(
|
|
_tok(),
|
|
"X" * 50 + "\n\n" + "Y" * 50,
|
|
chunk_token_size=40,
|
|
chunk_overlap_token_size=5,
|
|
)
|
|
tok = _tok()
|
|
for c in chunks:
|
|
assert c["tokens"] == len(tok.encode(c["content"]))
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_custom_separators_are_honored():
|
|
body = "alpha|beta|gamma|delta"
|
|
chunks = chunking_by_recursive_character(
|
|
_tok(),
|
|
body,
|
|
chunk_token_size=10,
|
|
chunk_overlap_token_size=0,
|
|
separators=["|", ""],
|
|
)
|
|
contents = [c["content"] for c in chunks]
|
|
# With "|" as the primary separator and a 10-token cap, each 5-char
|
|
# token name must land in its own chunk.
|
|
assert any("alpha" in c for c in contents)
|
|
assert any("delta" in c for c in contents)
|
|
# Every chunk fits the cap.
|
|
for c in chunks:
|
|
assert c["tokens"] <= 10
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_recursive_chunks_carry_exact_source_spans_with_overlap():
|
|
body = "Alpha section.\n\nBeta section.\n\nGamma section."
|
|
chunks = chunking_by_recursive_character(
|
|
_tok(),
|
|
body,
|
|
chunk_token_size=22,
|
|
chunk_overlap_token_size=6,
|
|
)
|
|
|
|
assert len(chunks) >= 2
|
|
for chunk in chunks:
|
|
span = chunk["_source_span"]
|
|
assert body[span["start"] : span["end"]] == chunk["content"]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_long_unique_text_keeps_every_source_span():
|
|
"""Every chunk of a long, non-repeating document must carry a span.
|
|
|
|
Regression for the LangChain ``add_start_index`` unit mismatch: with a
|
|
token-based length function its search cursor overshot each chunk's true
|
|
start, dropping ``_source_span`` (and failing span-first backfill) on
|
|
roughly half of a unique document's chunks.
|
|
"""
|
|
body = " ".join(f"word{i}" for i in range(800))
|
|
chunks = chunking_by_recursive_character(
|
|
_tok(),
|
|
body,
|
|
chunk_token_size=50,
|
|
chunk_overlap_token_size=10,
|
|
)
|
|
|
|
assert len(chunks) > 5
|
|
for chunk in chunks:
|
|
span = chunk["_source_span"]
|
|
assert body[span["start"] : span["end"]] == chunk["content"]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_repeated_text_spans_tile_whole_document():
|
|
"""Spans over heavily repeated text must tile the document, not cluster.
|
|
|
|
Regression for the repeated-content ambiguity: a naive forward ``find``
|
|
matches every identical overlapping chunk to the nearest repetition, so all
|
|
spans collapse into the document head and the tail gets no provenance. The
|
|
offset-aware splitter mirror must instead advance spans across the whole text.
|
|
"""
|
|
unit = "ABCDE fghij. "
|
|
body = unit * 60
|
|
chunks = chunking_by_recursive_character(
|
|
_tok(),
|
|
body,
|
|
chunk_token_size=40,
|
|
chunk_overlap_token_size=10,
|
|
)
|
|
|
|
spans = [c["_source_span"] for c in chunks]
|
|
assert len(spans) == len(chunks) # no chunk lost its span
|
|
|
|
# Each span is exact and starts are monotonically non-decreasing.
|
|
for chunk, span in zip(chunks, spans):
|
|
assert body[span["start"] : span["end"]] == chunk["content"]
|
|
starts = [s["start"] for s in spans]
|
|
assert starts == sorted(starts)
|
|
|
|
# Spans reach the document tail rather than clustering at the head. Under
|
|
# the old nearest-repetition behaviour the furthest end stalled near the
|
|
# first few hundred chars; tiling reaches within one ``unit`` of the end.
|
|
assert max(s["end"] for s in spans) >= len(body) - len(unit)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_span_mirror_matches_langchain_split_text():
|
|
"""Drift guard for the offset-aware mirror of LangChain's splitter."""
|
|
body = (
|
|
"Alpha repeats.\n\n"
|
|
"ff hh aa hh ee\n"
|
|
"ff hh aa hh ee\n\n"
|
|
"Tail repeats. Tail repeats. Tail repeats."
|
|
)
|
|
tok = _tok()
|
|
|
|
def length_function(text: str) -> int:
|
|
return len(tok.encode(text))
|
|
|
|
splitter = RecursiveCharacterTextSplitter(
|
|
chunk_size=36,
|
|
chunk_overlap=14,
|
|
length_function=length_function,
|
|
strip_whitespace=True,
|
|
)
|
|
|
|
mirrored = _split_text_with_spans(
|
|
body,
|
|
base_offset=0,
|
|
separators=splitter._separators,
|
|
chunk_size=splitter._chunk_size,
|
|
chunk_overlap=splitter._chunk_overlap,
|
|
length_function=length_function,
|
|
keep_separator=splitter._keep_separator,
|
|
is_separator_regex=splitter._is_separator_regex,
|
|
strip_whitespace=splitter._strip_whitespace,
|
|
)
|
|
|
|
assert [piece for piece, _, _ in mirrored] == splitter.split_text(body)
|
|
for piece, start, end in mirrored:
|
|
assert body[start:end] == piece
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_chunk_repeating_an_earlier_block_maps_to_its_own_block():
|
|
"""A chunk whose text also appears in an earlier, different block must map
|
|
to *its own* occurrence, not the earlier copy.
|
|
|
|
Regression for cross-block duplicate provenance: merged text ``"ff aa\\n\\naa"``
|
|
splits into ``["ff aa", "aa"]``. The second chunk ``"aa"`` is block 2, so its
|
|
span must point at the *second* ``"aa"`` (offset 7), not the ``"aa"`` inside
|
|
``"ff aa"`` (offset 3). The earlier locator anchored on a predicted offset
|
|
and snapped to the nearest copy, silently emitting the wrong — but
|
|
legal-looking — span that strict backfill would trust.
|
|
"""
|
|
merged = "ff aa\n\naa"
|
|
chunks = chunking_by_recursive_character(
|
|
_tok(),
|
|
merged,
|
|
chunk_token_size=5,
|
|
chunk_overlap_token_size=1,
|
|
)
|
|
|
|
assert [c["content"] for c in chunks] == ["ff aa", "aa"]
|
|
assert chunks[0]["_source_span"] == {"start": 0, "end": 5}
|
|
# The second "aa" lives in block 2 at offset 7, after the "\n\n" separator —
|
|
# not the "aa" embedded in "ff aa" at offset 3.
|
|
assert chunks[1]["_source_span"] == {"start": 7, "end": 9}
|
|
assert merged[7:9] == "aa"
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_repeated_block_window_does_not_shift_to_previous_duplicate():
|
|
"""A repeated multi-block chunk must not slide back into the prior duplicate."""
|
|
block = "ff hh aa hh ee"
|
|
merged = "\n\n".join([block] * 4)
|
|
chunks = chunking_by_recursive_character(
|
|
_tok(),
|
|
merged,
|
|
chunk_token_size=36,
|
|
chunk_overlap_token_size=14,
|
|
)
|
|
|
|
assert [c["content"] for c in chunks] == [
|
|
f"{block}\n\n{block}",
|
|
f"{block}\n\n{block}",
|
|
]
|
|
assert chunks[0]["_source_span"] == {"start": 0, "end": 30}
|
|
assert chunks[1]["_source_span"] == {"start": 32, "end": 62}
|