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

716 lines
27 KiB
Python

"""F-chunking parity between raw and lightrag formats.
After the F-chunking unification, ``apipeline_process_enqueue_documents``
strips the ``{{LRdoc}}`` marker from lightrag-format content and feeds the
result into the same ``chunking_func`` used by raw documents. These tests
guard the contract end-to-end:
* T1: identical input text produces identical chunking inputs whether it
arrives as raw or as an already-parsed lightrag full_docs row pulled
back through the parse queue on resume (ReuseParser) — the only
production path for lightrag rows now that the ``docs_format="lightrag"``
enqueue entrypoint is removed (the in-run parse → chunk path is T6).
* T2: after a pending_parse document is parsed by the native engine,
``full_docs.content`` carries the *full* merged text with the
``{{LRdoc}}`` marker (written by ``_persist_parsed_full_docs``), while
``doc_status`` reports the bare body length / summary (no marker
leakage).
* T3: ``extraction_meta["parse_format"]`` (surfaced via
``doc_status.metadata``) is ``"lightrag"`` for natively-parsed docs —
previously a structured-parse fallback always tagged ``raw`` and
silently mislabelled the persisted record.
* T4: a raw document whose body coincidentally *looks* like structured
JSONL is still tokenised as plain text — guards against re-introducing
dropped structured-format detection in the raw path.
* T5: ``process_options`` selecting R/V/P logs the deferred-strategy
warning and falls back to fixed-token chunking.
* T6: a ``pending_parse`` document that resolves to lightrag at parse
time ends up with a real ``content_summary`` after PROCESSED — the
ANALYZING transition refreshes the summary from the parsed body so
pending-parse rows no longer carry the empty enqueue-time placeholder
through to the user-facing list APIs.
* T7: a raw document whose body *literally* starts with ``{{LRdoc}}``
is chunked verbatim — guards against accidental re-introduction of an
unconditional ``strip_lightrag_doc_prefix`` at the chunking boundary
(which would silently drop the user's first 9 characters).
"""
import asyncio
import json
import logging
from pathlib import Path
import numpy as np
import pytest
from lightrag import LightRAG, ROLES, RoleLLMConfig
from lightrag.constants import (
FULL_DOCS_FORMAT_LIGHTRAG,
FULL_DOCS_FORMAT_PENDING_PARSE,
LIGHTRAG_DOC_CONTENT_PREFIX,
)
from lightrag.utils import (
EmbeddingFunc,
Tokenizer,
compute_mdhash_id,
get_content_summary,
)
from lightrag.utils_pipeline import make_lightrag_doc_content
# ---------------------------------------------------------------------------
# Shared fixtures (mirrors the harness used by test_pipeline_release_closure)
# ---------------------------------------------------------------------------
class _SimpleTokenizerImpl:
"""Char-level tokenizer so 1 char ≈ 1 token; keeps assertions readable."""
def encode(self, content: str) -> list[int]:
return [ord(ch) for ch in content]
def decode(self, tokens: list[int]) -> str:
return "".join(chr(t) for t in tokens)
async def _mock_embedding(texts: list[str]) -> np.ndarray:
return np.random.rand(len(texts), 32)
async def _mock_llm(prompt, **kwargs):
return '{"name":"x","summary":"s","detail_description":"d"}'
_ROLE_FIELD_SUFFIXES = (
("_llm_model_func", "func"),
("_llm_model_kwargs", "kwargs"),
("_llm_model_max_async", "max_async"),
("_llm_timeout", "timeout"),
)
def _new_rag(tmp_path: Path, **kwargs) -> LightRAG:
role_configs: dict[str, RoleLLMConfig] = {}
for spec in ROLES:
bucket = {}
for suffix, target in _ROLE_FIELD_SUFFIXES:
key = f"{spec.name}{suffix}"
if key in kwargs:
bucket[target] = kwargs.pop(key)
if bucket:
role_configs[spec.name] = RoleLLMConfig(**bucket)
if role_configs:
kwargs["role_llm_configs"] = role_configs
return LightRAG(
working_dir=str(tmp_path),
workspace=f"chunking-parity-{tmp_path.name}",
llm_model_func=_mock_llm,
embedding_func=EmbeddingFunc(
embedding_dim=32,
max_token_size=4096,
func=_mock_embedding,
),
tokenizer=Tokenizer("mock-tokenizer", _SimpleTokenizerImpl()),
**kwargs,
)
def _attach_chunking_spy(rag: LightRAG) -> dict:
"""Replace ``rag.chunking_func`` with a recording wrapper.
Returns a dict whose ``input`` key receives the second positional arg
(the content string) at every chunking call. The original chunker
runs normally so the pipeline reaches PROCESSED.
"""
captured: dict = {"input": None, "calls": 0}
real = rag.chunking_func
def _spy(tokenizer, content, *args, **kwargs):
captured["input"] = content
captured["calls"] += 1
return real(tokenizer, content, *args, **kwargs)
rag.chunking_func = _spy
return captured
# ---------------------------------------------------------------------------
# T1 — parity: raw vs lightrag produce identical chunking input
# ---------------------------------------------------------------------------
async def _seed_lightrag_row(rag: LightRAG, doc_id: str, *, body: str, file_path: str):
"""Seed an already-parsed lightrag full_docs row + a PENDING doc_status.
Mirrors how lightrag-format rows exist in production: written by the
parsers (``_persist_parsed_full_docs``) and pulled back through the
parse queue (→ ReuseParser) only on resume/retry.
"""
from datetime import datetime, timezone
await rag.full_docs.upsert(
{
doc_id: {
"content": make_lightrag_doc_content(body),
"file_path": file_path,
"parse_format": FULL_DOCS_FORMAT_LIGHTRAG,
}
}
)
now = datetime.now(timezone.utc).isoformat()
await rag.doc_status.upsert(
{
doc_id: {
"status": "pending",
"content_summary": get_content_summary(body),
"content_length": len(body),
"file_path": file_path,
"created_at": now,
"updated_at": now,
"track_id": "track-lr",
}
}
)
@pytest.mark.offline
def test_chunking_input_parity_raw_vs_lightrag(tmp_path):
"""Same body text must reach ``chunking_func`` with byte-identical input
whether it arrives as raw or as a resumed lightrag full_docs row."""
paragraphs = [
"Alpha paragraph with enough words to make it look real.",
"Beta paragraph extends the body so chunking has substance.",
"Gamma paragraph closes the document with a few more sentences.",
]
expected_merged = "\n\n".join(paragraphs)
async def _run():
# ---- RAW path ----
rag_raw = _new_rag(tmp_path / "raw")
await rag_raw.initialize_storages()
spy_raw = _attach_chunking_spy(rag_raw)
try:
await rag_raw.apipeline_enqueue_documents(
expected_merged,
file_paths="parity_raw.txt",
track_id="track-raw",
)
await rag_raw.apipeline_process_enqueue_documents()
finally:
await rag_raw.finalize_storages()
# ---- LIGHTRAG path (resume: seeded parsed row → ReuseParser) ----
rag_lr = _new_rag(tmp_path / "lr")
await rag_lr.initialize_storages()
spy_lr = _attach_chunking_spy(rag_lr)
try:
await _seed_lightrag_row(
rag_lr,
"doc-parity-lr",
body=expected_merged,
file_path="parity.lightrag",
)
await rag_lr.apipeline_process_enqueue_documents()
finally:
await rag_lr.finalize_storages()
assert spy_raw["calls"] >= 1, "raw doc never reached chunking_func"
assert spy_lr["calls"] >= 1, "lightrag doc never reached chunking_func"
assert spy_lr["input"] == spy_raw["input"] == expected_merged, (
"chunking_func received different inputs for raw vs lightrag; "
f"raw={spy_raw['input']!r}\nlr={spy_lr['input']!r}"
)
assert not spy_lr["input"].startswith(LIGHTRAG_DOC_CONTENT_PREFIX), (
"{{LRdoc}} marker leaked into chunking_func input"
)
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T2 — full_docs.content carries full text; doc_status reports bare body
# ---------------------------------------------------------------------------
def _stub_docx_blocks(monkeypatch, body_paragraphs: list[str]) -> None:
"""Stub the docx extractor so native parse yields deterministic blocks;
the adapter still writes the canonical .blocks.jsonl + sidecars and
persists the lightrag-format full_docs row."""
def _stub_extract(file_path, drawing_context=None, **kwargs):
return [
{
"uuid": f"para-{i}",
"uuid_end": f"para-{i}",
"heading": "",
"content": para,
"type": "text",
"parent_headings": [],
"level": 0,
"table_chunk_role": "none",
}
for i, para in enumerate(body_paragraphs)
]
monkeypatch.setattr(
"lightrag.parser.docx.parse_document.extract_docx_blocks",
_stub_extract,
)
@pytest.mark.offline
def test_full_docs_content_carries_full_merged_text(tmp_path, monkeypatch):
"""After the native engine parses a pending_parse upload,
``_persist_parsed_full_docs`` stores the *full* merged text with the
``{{LRdoc}}`` marker while doc_status reports bare-body semantics."""
body = "x" * 5000 # single paragraph, 5000 chars
async def _run():
input_dir = tmp_path / "input"
input_dir.mkdir()
monkeypatch.setenv("INPUT_DIR", str(input_dir))
(input_dir / "big.docx").write_bytes(b"fake docx bytes")
_stub_docx_blocks(monkeypatch, [body])
rag = _new_rag(tmp_path / "work")
await rag.initialize_storages()
try:
await rag.apipeline_enqueue_documents(
"",
file_paths="big.docx",
docs_format=FULL_DOCS_FORMAT_PENDING_PARSE,
parse_engine="native",
track_id="track-big",
)
await rag.apipeline_process_enqueue_documents()
doc_id = compute_mdhash_id("big.docx", prefix="doc-")
full_doc = await rag.full_docs.get_by_id(doc_id)
assert full_doc is not None
# full_docs preserves the marker AND the full merged text.
assert full_doc["content"] == LIGHTRAG_DOC_CONTENT_PREFIX + body
assert full_doc.get("parse_format") == FULL_DOCS_FORMAT_LIGHTRAG
assert full_doc.get("sidecar_location"), (
"native parse must record the sidecar_location on the "
"lightrag full_docs row"
)
# doc_status reports body-length semantics (no marker leakage).
status_doc = await rag.doc_status.get_by_id(doc_id)
assert status_doc is not None
length = (
status_doc.get("content_length")
if isinstance(status_doc, dict)
else getattr(status_doc, "content_length", None)
)
summary = (
status_doc.get("content_summary")
if isinstance(status_doc, dict)
else getattr(status_doc, "content_summary", "")
)
assert length == 5000, f"content_length should match body, got {length}"
assert not summary.startswith(LIGHTRAG_DOC_CONTENT_PREFIX)
finally:
await rag.finalize_storages()
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T3 — extraction_meta.parse_format reflects persisted format (regression guard)
# ---------------------------------------------------------------------------
@pytest.mark.offline
def test_extraction_meta_records_lightrag_parse_format(tmp_path, monkeypatch):
"""Before the unification, a structured-parse fallback tagged
``extraction_meta.parse_format = raw`` for lightrag docs, silently
mislabelling them in ``doc_status.metadata``. Assert the tag now
reflects the persisted format end-to-end (pending_parse upload →
native parse → lightrag full_docs row)."""
paragraphs = ["Body paragraph for parse_format tagging test."]
async def _run():
input_dir = tmp_path / "input"
input_dir.mkdir()
monkeypatch.setenv("INPUT_DIR", str(input_dir))
(input_dir / "tag.docx").write_bytes(b"fake docx bytes")
_stub_docx_blocks(monkeypatch, paragraphs)
rag = _new_rag(tmp_path / "work")
await rag.initialize_storages()
try:
await rag.apipeline_enqueue_documents(
"",
file_paths="tag.docx",
docs_format=FULL_DOCS_FORMAT_PENDING_PARSE,
parse_engine="native",
track_id="track-tag",
)
await rag.apipeline_process_enqueue_documents()
doc_id = compute_mdhash_id("tag.docx", prefix="doc-")
status_doc = await rag.doc_status.get_by_id(doc_id)
assert status_doc is not None
metadata = (
status_doc.get("metadata")
if isinstance(status_doc, dict)
else getattr(status_doc, "metadata", None)
)
assert isinstance(metadata, dict), (
f"doc_status.metadata should be a dict, got {type(metadata)!r}"
)
assert metadata.get("parse_format") == FULL_DOCS_FORMAT_LIGHTRAG, (
f"doc_status.metadata.parse_format="
f"{metadata.get('parse_format')!r}; "
f"expected {FULL_DOCS_FORMAT_LIGHTRAG!r} so the multimodal "
f"sidecar merge path opens"
)
finally:
await rag.finalize_storages()
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T4 — JSONL-shaped raw text is still treated as plain text
# ---------------------------------------------------------------------------
@pytest.mark.offline
def test_jsonl_shaped_raw_text_chunks_as_plain_text(tmp_path):
"""A raw document whose body coincidentally resembles structured JSONL
must be tokenised plainly — guarding against accidental
re-introduction of removed structured-format detection."""
# No trailing newline — sanitize_text_for_encoding strips trailing
# whitespace on raw enqueue, and that pre-chunking cleanup is unrelated
# to structured-format detection.
pseudo_jsonl = (
json.dumps({"type": "meta", "format_version": "1.0"})
+ "\n"
+ json.dumps(
{
"type": "text",
"chunk_id": "c0",
"chunk_order_index": 0,
"content": "fake structured line",
}
)
)
async def _run():
rag = _new_rag(tmp_path)
await rag.initialize_storages()
spy = _attach_chunking_spy(rag)
try:
await rag.apipeline_enqueue_documents(
pseudo_jsonl,
file_paths="pseudo.txt",
track_id="track-pseudo",
)
await rag.apipeline_process_enqueue_documents()
finally:
await rag.finalize_storages()
# The full pseudo-jsonl text reaches chunking_func; nothing parses
# it as JSONL and hijacks the chunks list.
assert spy["input"] == pseudo_jsonl
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T5 — R/V/P process_options trigger the deferred-strategy warning
# ---------------------------------------------------------------------------
class _ListHandler(logging.Handler):
"""Capture log records into an in-memory list.
The ``lightrag`` logger has ``propagate = False`` so pytest's caplog
fixture cannot intercept its records via the root logger; this handler
attaches directly to the logger we care about.
"""
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
@pytest.mark.offline
def test_explicit_R_dispatches_to_recursive_character(tmp_path, monkeypatch):
"""``process_options=R`` must invoke
:func:`chunking_by_recursive_character` (the new file-chunker
contract) rather than the legacy ``chunking_func``.
Verifies the explicit-selector dispatch contract:
1. ``chunking_by_recursive_character`` runs at least once.
2. The legacy ``chunking_func`` is bypassed entirely.
3. The deprecated "R/V not yet implemented" warning no longer
appears (now that R has a real implementation).
"""
pytest.importorskip("langchain_text_splitters")
import lightrag.chunker as chunker_pkg
from lightrag.chunker import chunking_by_recursive_character as real_r
captured = {"calls": 0}
def _r_spy(*args, **kwargs):
captured["calls"] += 1
return real_r(*args, **kwargs)
# The dispatcher does ``from lightrag.chunker import …`` inside the
# function body, which re-resolves the name from the package each
# call — patching the package attribute is enough to intercept it.
monkeypatch.setattr(chunker_pkg, "chunking_by_recursive_character", _r_spy)
async def _run():
rag = _new_rag(tmp_path)
await rag.initialize_storages()
legacy_spy = _attach_chunking_spy(rag)
lightrag_logger = logging.getLogger("lightrag")
list_handler = _ListHandler()
list_handler.setLevel(logging.WARNING)
lightrag_logger.addHandler(list_handler)
try:
await rag.apipeline_enqueue_documents(
"Body paragraph one.\n\nBody paragraph two for R dispatch test.",
file_paths="rs.[native-R].txt",
track_id="track-rs",
process_options="R",
)
await rag.apipeline_process_enqueue_documents()
finally:
lightrag_logger.removeHandler(list_handler)
await rag.finalize_storages()
assert captured["calls"] >= 1, "R must route to chunking_by_recursive_character"
assert legacy_spy["calls"] == 0, (
"explicit process_options selector must bypass legacy "
"chunking_func; got "
f"{legacy_spy['calls']} calls"
)
warning_messages = [
rec.getMessage()
for rec in list_handler.records
if rec.levelno == logging.WARNING
]
assert not any(
"R/V strategies are not yet implemented" in msg for msg in warning_messages
), (
"deprecated 'not yet implemented' warning must be gone now "
f"that R is wired up; saw: {warning_messages!r}"
)
asyncio.run(_run())
@pytest.mark.offline
def test_explicit_V_dispatches_to_semantic_vector(tmp_path, monkeypatch):
"""``process_options=V`` must invoke
:func:`chunking_by_semantic_vector` and bypass the legacy
``chunking_func``. The test installs a stub embedding (the spy
short-circuits before the real LangChain SemanticChunker runs) so
the assertion is purely about dispatch routing, not chunk quality.
"""
pytest.importorskip("langchain_experimental")
import lightrag.chunker as chunker_pkg
captured = {"calls": 0}
async def _v_spy(*args, **kwargs):
# Short-circuit: skip langchain SemanticChunker entirely and
# return one synthetic chunk. We're only verifying that the
# dispatcher routed here with the right keyword args.
captured["calls"] += 1
captured["embedding_func"] = kwargs.get("embedding_func")
captured["chunk_token_size"] = args[2] if len(args) > 2 else None
return [
{"tokens": 5, "content": "stub", "chunk_order_index": 0},
]
monkeypatch.setattr(chunker_pkg, "chunking_by_semantic_vector", _v_spy)
async def _run():
rag = _new_rag(tmp_path)
await rag.initialize_storages()
legacy_spy = _attach_chunking_spy(rag)
try:
await rag.apipeline_enqueue_documents(
"Body for V dispatch test. Sentence one. Sentence two.",
file_paths="vs.[native-V].txt",
track_id="track-vs",
process_options="V",
)
await rag.apipeline_process_enqueue_documents()
finally:
await rag.finalize_storages()
assert captured["calls"] >= 1, "V must route to chunking_by_semantic_vector"
assert captured.get("embedding_func") is rag.embedding_func, (
"dispatcher must hand the LightRAG embedding_func to the V chunker"
)
assert legacy_spy["calls"] == 0, (
"explicit process_options selector must bypass legacy chunking_func"
)
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T6 — pending_parse → lightrag summary is populated after PROCESSED
# ---------------------------------------------------------------------------
@pytest.mark.offline
def test_pending_parse_lightrag_summary_populated_after_processed(
tmp_path, monkeypatch
):
"""A document enqueued as ``pending_parse`` has empty content at
enqueue time, so ``content_summary`` starts empty. After
``parse_native`` produces ``.blocks.jsonl`` and the state machine
moves through ANALYZING → PROCESSING → PROCESSED, the summary must
reflect the parsed body — not the enqueue-time placeholder."""
body_paragraphs = [
"Pending-parse summary regression body paragraph one.",
"Body paragraph two carries enough text for a meaningful preview.",
"Body paragraph three closes the document.",
]
async def _run():
input_dir = tmp_path / "input"
input_dir.mkdir()
monkeypatch.setenv("INPUT_DIR", str(input_dir))
source_path = input_dir / "summary.docx"
source_path.write_bytes(b"fake docx bytes")
_stub_docx_blocks(monkeypatch, body_paragraphs)
rag = _new_rag(tmp_path / "work")
await rag.initialize_storages()
try:
await rag.apipeline_enqueue_documents(
"",
file_paths="summary.docx",
docs_format=FULL_DOCS_FORMAT_PENDING_PARSE,
parse_engine="native",
track_id="track-summary",
)
doc_id = compute_mdhash_id("summary.docx", prefix="doc-")
pending = await rag.doc_status.get_by_id(doc_id)
assert pending is not None
pending_summary = (
pending.get("content_summary")
if isinstance(pending, dict)
else getattr(pending, "content_summary", "")
)
# At enqueue time pending_parse content is "" so summary is empty.
assert pending_summary == "", (
f"pending_parse should start with empty summary, got "
f"{pending_summary!r}"
)
await rag.apipeline_process_enqueue_documents()
final = await rag.doc_status.get_by_id(doc_id)
assert final is not None
final_summary = (
final.get("content_summary")
if isinstance(final, dict)
else getattr(final, "content_summary", "")
)
final_length = (
final.get("content_length")
if isinstance(final, dict)
else getattr(final, "content_length", 0)
)
assert final_summary, (
"content_summary still empty after PROCESSED; ANALYZING "
"refresh did not propagate"
)
assert not final_summary.startswith(LIGHTRAG_DOC_CONTENT_PREFIX), (
f"{{LRdoc}} marker leaked into doc_status summary: {final_summary!r}"
)
# The parser stub produces these paragraphs verbatim; the
# blocks.jsonl writer joins them with a blank line, so the
# summary must be a prefix of that merged text.
merged_text = "\n\n".join(body_paragraphs)
assert final_summary == get_content_summary(merged_text), (
f"summary should match get_content_summary(merged_text); "
f"got {final_summary!r} vs "
f"{get_content_summary(merged_text)!r}"
)
assert final_length == len(merged_text), (
f"content_length should equal len(merged_text)={len(merged_text)}, "
f"got {final_length}"
)
finally:
await rag.finalize_storages()
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T7 — raw text starting with {{LRdoc}} must not be stripped at chunking
# ---------------------------------------------------------------------------
@pytest.mark.offline
def test_raw_text_starting_with_marker_chunked_verbatim(tmp_path):
"""A raw document whose body literally begins with ``{{LRdoc}}`` is a
legitimate user input — the chunking branch must not strip those 9
characters. ``strip_lightrag_doc_prefix`` is a lightrag-only contract
enforced by ``parse_native``; raw paths return ``content_data["content"]``
verbatim, so chunking must hand the body to ``chunking_func`` unchanged."""
body_with_marker = LIGHTRAG_DOC_CONTENT_PREFIX + (
"literal-marker-prefix raw document body that should survive "
"the chunking boundary intact."
)
async def _run():
rag = _new_rag(tmp_path)
await rag.initialize_storages()
spy = _attach_chunking_spy(rag)
try:
await rag.apipeline_enqueue_documents(
body_with_marker,
file_paths="marker_raw.txt",
track_id="track-marker",
)
await rag.apipeline_process_enqueue_documents()
finally:
await rag.finalize_storages()
assert spy["calls"] >= 1, "raw doc never reached chunking_func"
# The full body — including the literal {{LRdoc}} prefix — must
# reach chunking_func; nothing in the chunking branch should
# treat the marker as a stripping signal for raw content.
assert spy["input"] == body_with_marker, (
"chunking_func received corrupted input: "
f"got {spy['input']!r}, expected {body_with_marker!r}"
)
assert spy["input"].startswith(LIGHTRAG_DOC_CONTENT_PREFIX), (
"literal marker prefix lost at chunking boundary"
)
asyncio.run(_run())