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

311 lines
10 KiB
Python

"""Tests for the paragraph-semantic ``drop_references`` option (chunking=P).
A reference block is dropped only when it BOTH sits within the last
``references_tail_n`` content blocks AND its heading matches a reference prefix.
The switch is the only knob that flows through ``chunk_options``; the tail
window and heading prefixes are read live from env at chunk time (verified here
by mutating env between calls, proving they are NOT snapshotted).
Assertions check the *content* of the emitted chunks (each block carries a
distinctive marker) rather than heading names, because LevelMerge may merge
small blocks and rewrite the surviving heading.
"""
import json
import logging
import pytest
from lightrag.chunker.paragraph_semantic import (
_format_dropped_headings,
_is_reference_heading,
chunking_by_paragraph_semantic,
)
from lightrag.constants import DEFAULT_P_REFERENCES_HEADINGS
from lightrag.utils import Tokenizer, TokenizerInterface, logger as _lightrag_logger
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())
def _row(heading: str, content: str, *, level: int = 1) -> dict:
return {
"type": "content",
"blockid": heading or "blk",
"format": "plain_text",
"content": content,
"heading": heading,
"parent_headings": [],
"level": level,
"session_type": "body",
"table_slice": "none",
"positions": [],
}
def _write_blocks_jsonl(tmp_path, rows: list[dict]) -> str:
path = tmp_path / "doc.blocks.jsonl"
path.write_text(
"\n".join(json.dumps(row, ensure_ascii=False) for row in rows),
encoding="utf-8",
)
return str(path)
def _all_content(chunks: list[dict]) -> str:
return "\n".join(c["content"] for c in chunks)
# --------------------------------------------------------------------------- #
# _is_reference_heading (word boundary vs. plain CJK prefix)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"heading,expected",
[
("References", True),
("references", True), # case-insensitive
("REFERENCES", True),
("Bibliography", True),
("References [1-50]", True), # word boundary: next char is non-alnum
("参考文献", True),
("参考文献列表", True), # CJK: plain prefix, no word boundary
("Referenced work", False), # ASCII word boundary excludes this
("Related Work", False),
("", False),
],
)
def test_is_reference_heading(heading, expected):
assert _is_reference_heading(heading, DEFAULT_P_REFERENCES_HEADINGS) is expected
# --------------------------------------------------------------------------- #
# _format_dropped_headings (length-bounded log rendering)
# --------------------------------------------------------------------------- #
def test_format_dropped_headings_short_list():
assert _format_dropped_headings(["References"]) == "'References'"
assert (
_format_dropped_headings(["References", "Bibliography"])
== "'References', 'Bibliography'"
)
def test_format_dropped_headings_truncates_long_heading():
out = _format_dropped_headings(["X" * 200], max_each=60)
# 60 kept chars + an ellipsis, all within one repr'd string.
assert "X" * 60 + "…" in out
assert "X" * 61 not in out
def test_format_dropped_headings_caps_item_count():
out = _format_dropped_headings([f"H{i}" for i in range(12)], max_items=5)
assert out.count("'H") == 5 # only 5 listed
assert "(+7 more)" in out
# --------------------------------------------------------------------------- #
# Filtering behaviour (assert on content markers, robust to LevelMerge)
# --------------------------------------------------------------------------- #
@pytest.mark.offline
def test_drops_trailing_reference_block(tmp_path):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("Method", "METHOD_MARKER method body"),
_row("References", "REF_MARKER [1] Foo. [2] Bar."),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
)
assert "REF_MARKER" not in body
assert "INTRO_MARKER" in body and "METHOD_MARKER" in body
@pytest.mark.offline
def test_default_keeps_reference_block(tmp_path):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("References", "REF_MARKER [1] Foo."),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
# drop_references defaults to False — references survive.
body = _all_content(
chunking_by_paragraph_semantic(tokenizer, "", 2000, blocks_path=blocks_path)
)
assert "REF_MARKER" in body
@pytest.mark.offline
def test_mid_document_reference_section_outside_window_kept(tmp_path):
tokenizer = _make_tokenizer()
# A "References" heading mid-document, with two non-reference blocks after
# it, so it falls outside the last-2 window and must NOT be dropped.
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("References", "REF_MARKER discusses references"),
_row("Method", "METHOD_MARKER method body"),
_row("Results", "RESULTS_MARKER results body"),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer,
"",
2000,
blocks_path=blocks_path,
drop_references=True,
references_tail_n=2,
)
)
assert "REF_MARKER" in body
@pytest.mark.offline
def test_word_boundary_keeps_referenced(tmp_path):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("参考文献列表", "CJKREF_MARKER [1] Foo."),
_row("Referenced datasets", "DATASET_MARKER dataset details"),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
)
# CJK prefix block dropped; "Referenced ..." kept (ASCII word boundary).
assert "CJKREF_MARKER" not in body
assert "DATASET_MARKER" in body
@pytest.mark.offline
def test_all_blocks_match_keeps_to_avoid_empty(tmp_path):
tokenizer = _make_tokenizer()
rows = [
_row("References", "REF1_MARKER [1] Foo."),
_row("Bibliography", "REF2_MARKER [2] Bar."),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
# Both blocks match within the last-2 window; dropping all would leave no
# content, so the chunker keeps them and warns instead.
chunks = chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
body = _all_content(chunks)
assert chunks # not an empty document
assert "REF1_MARKER" in body and "REF2_MARKER" in body
# --------------------------------------------------------------------------- #
# Detection knobs are read LIVE from env (not snapshotted)
# --------------------------------------------------------------------------- #
@pytest.mark.offline
def test_tail_n_read_live_from_env(tmp_path, monkeypatch):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("References", "REF_MARKER [1] Foo."),
_row("Appendix", "APPENDIX_MARKER appendix body"),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
# tail_n=1: only the last block ("Appendix") is in the window → References
# is outside it and survives.
monkeypatch.setenv("CHUNK_P_REFERENCES_TAIL_N", "1")
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
)
assert "REF_MARKER" in body
# Widen the window to 2 → References now falls inside and is dropped. Same
# call, only the env changed: proves the knob is read live, not snapshotted.
monkeypatch.setenv("CHUNK_P_REFERENCES_TAIL_N", "2")
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
)
assert "REF_MARKER" not in body
@pytest.mark.offline
def test_drop_references_emits_info_log(tmp_path, caplog):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("References", "REF_MARKER [1] Foo."),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
# The lightrag logger sets propagate=False, so caplog can't see it by
# default — enable propagation for the duration of the call.
original_propagate = _lightrag_logger.propagate
_lightrag_logger.propagate = True
try:
with caplog.at_level(logging.INFO, logger=_lightrag_logger.name):
chunking_by_paragraph_semantic(
tokenizer,
"",
2000,
blocks_path=blocks_path,
drop_references=True,
doc_id="doc-xyz",
)
finally:
_lightrag_logger.propagate = original_propagate
info_msgs = [r.getMessage() for r in caplog.records if r.levelno == logging.INFO]
# Log names the dropped heading and attributes it to the doc_id.
assert any("References" in m and "doc_id: doc-xyz" in m for m in info_msgs), (
info_msgs
)
@pytest.mark.offline
def test_headings_read_live_from_env(tmp_path, monkeypatch):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("Literature", "LIT_MARKER [1] Foo. [2] Bar."),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
# Custom prefix list makes "Literature" a reference heading.
monkeypatch.setenv("CHUNK_P_REFERENCES_HEADINGS", "Literature|参考文献")
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
)
assert "LIT_MARKER" not in body