feat: HierarchicalTextSplitter — header-aware chunking with verbless sentence filtering#548
feat: HierarchicalTextSplitter — header-aware chunking with verbless sentence filtering#548jexp wants to merge 4 commits into
Conversation
Remove hierarchical_splitter.py and its tests from this branch (now in PR neo4j#548). Update example script to reference the companion PR.
…_line/spacy_verbless header strategies Implements HierarchicalTextSplitter(TextSplitter) based on the chunking strategy from Min et al. (arXiv:2507.03226). Splits documents at section headers first, then recursively at a configurable character limit with overlap, and optionally drops verb-free sentences as a cheap pre-filter. Four header detection strategies: - markdown: lines starting with # (for MarkdownLoader / LiteParseLoader markdown output) - capitalization: short ALL_CAPS or Title Case lines without terminal punctuation - blank_line: short lines surrounded by blank lines - spacy_verbless: SpaCy-detected verbless sentences Includes 20 unit tests (spacy mocked) and 2 integration tests (skipped when en_core_web_sm is not installed).
…ration tests" This reverts commit dfa93b4.
Remove hierarchical_splitter.py and its tests from this branch (now in PR neo4j#548). Update example script to reference the companion PR.
7f5f96e to
450cab9
Compare
matteomedioli
left a comment
There was a problem hiding this comment.
Great work on this, @jexp! thanks for putting it together! I spent some time testing it locally and left some minor comments/nits along the way. None of them are blocking, but I'd value your take on a couple of them when you get a chance. Thanks again for the contribution!
| chunk_overlap: int = 200, | ||
| header_strategy: str = "markdown", | ||
| model: str = "en_core_web_sm", | ||
| drop_verbless_sentences: bool = True, |
There was a problem hiding this comment.
With header_strategy="spacy_verbless", the header this strategy just detected gets deleted by the default verb filter (drop_verbless_sentences=True):
splitter = HierarchicalTextSplitter(header_strategy="spacy_verbless") # drop_verbless_sentences=True by default
await splitter.run("Introduction. This section covers the basics. Conclusion. This wraps up.")Step 1: _split_at_spacy_verbless finds "Introduction." and "Conclusion." (short, no verb, followed by long sentence) → uses them as section boundaries.
Step 2: sections become chunks.
Step 3: verb filter runs on the final chunks, sees "Introduction." has no verb → deletes it. Same for "Conclusion."
Result: 2 chunks, no "Introduction", no "Conclusion".
Maybe we should skip verb filter on text just used as a section boundary, or warn when both options are combined together.
| def _split_with_overlap(text: str, max_size: int, overlap: int) -> list[str]: | ||
| """Split *text* into character-level chunks of at most *max_size* with | ||
| *overlap* characters carried over from the previous chunk. | ||
|
|
||
| Splits are attempted at whitespace boundaries to avoid cutting words. | ||
| """ | ||
| if not text: | ||
| return [] | ||
| chunks: list[str] = [] | ||
| start = 0 | ||
| length = len(text) | ||
| step = max(1, max_size - overlap) | ||
|
|
||
| while start < length: | ||
| end = min(start + max_size, length) | ||
| chunk = text[start:end] | ||
|
|
||
| # Prefer to cut at a whitespace boundary when not at end of text. | ||
| if end < length: | ||
| # Walk backwards to find a space. | ||
| cut = end | ||
| while cut > start and not text[cut - 1].isspace(): | ||
| cut -= 1 | ||
| if cut > start: | ||
| end = cut | ||
| chunk = text[start:end] | ||
|
|
||
| chunks.append(chunk) | ||
|
|
||
| # Advance by step, ensuring we always make progress. | ||
| next_start = start + step | ||
| if next_start <= start: | ||
| next_start = start + 1 | ||
| start = next_start | ||
|
|
||
| return chunks |
There was a problem hiding this comment.
Nit: chunk_overlap width can drift a bit.
Whitespace-snapping (so words don't get chopped) shifts where each chunk ends, but the next chunk's start is still fixed (start + (max_size - overlap)), so the shared region between two chunks isn't always exactly chunk_overlap chars — just close to it. Nothing is lost, content still overlaps either way.
text = "The cat sat on the warm mat today."
splitter = HierarchicalTextSplitter(max_chunk_size=15, chunk_overlap=5, header_strategy="markdown")# chunk 0: 'The cat sat on '
# chunk 1: 't on the warm '
# chunk 2: 'arm mat today.'
# gap 0->1: tail='t on ' == head='t on ' -> matches
# gap 1->2: tail='warm ' != head='arm m' -> off by one, drifted
Nit, not blocking at all, maybe might be worth a quick docstring note so it's clear the overlap is "approximate" rather than exact.
| has_verb = any(tok.pos_ in ("VERB", "AUX") for tok in sent) | ||
| if has_verb: | ||
| kept.append(sent.text) | ||
| return " ".join(kept) |
There was a problem hiding this comment.
Nit: text reformatted even when nothing is dropped
text = "# Section\nThe team shipped the release.\nThey tested it thoroughly.\n"
HierarchicalTextSplitter().run(text) # every sentence has a verbStep 1: verb filter runs anyway (default True, unconditional).
Step 2: splits into sentences, keeps all (all have verbs).
Step 3: rejoins with " ".join(...).
Result: output != input -> the newline between sentences becomes a single space, even though nothing was filtered.
Possible solution: return the original text unchanged when len(kept) == len(list(doc.sents))
| return [s for s in sections if s.strip()] | ||
|
|
||
|
|
||
| def _split_at_capitalization(text: str) -> list[str]: |
There was a problem hiding this comment.
Nit, very edge case:
text = "This code selects records.\nSELECT SINGLE F1 FROM VBBS INTO WA\nWHERE ID = X\nDone."
HierarchicalTextSplitter(header_strategy="capitalization").run(text)Both code lines get misread as headers here:
SELECT SINGLE F1 FROM VBBS INTO WA
WHERE ID = X
Splits the snippet line by line instead of keeping it together. low risk, just flagging.
| # Header-detection helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| _MARKDOWN_HEADER_RE = re.compile(r"^#{1,6}\s+\S", re.MULTILINE) |
There was a problem hiding this comment.
defined, never referenced
| "Install it with: pip install 'neo4j-graphrag[nlp]'" | ||
| ) from exc | ||
| try: | ||
| return spacy.load(model) |
There was a problem hiding this comment.
Nit: loads NER/lemmatizer/etc even though only sentence boundaries + POS tags are used. We can consider spacy.load(model, disable=["ner", "lemmatizer", "attribute_ruler"]).
| for raw in raw_chunks: | ||
| filtered = self._filter_verbless(raw) | ||
| if filtered.strip(): | ||
| chunks.append(TextChunk(text=filtered, index=len(chunks))) |
There was a problem hiding this comment.
Nit: calls _filter_verbless once per chunk in a Python loop, which calls nlp(text) (line 269) each time: spaCy processes one chunk at a time instead of as a batch.
Possible other approach:
# spacy batch instead of one nlp() call per chunk:
docs = list(nlp.pipe(raw_chunks))
for raw, doc in zip(raw_chunks, docs):
kept = [s.text for s in doc.sents if any(t.pos_ in ("VERB", "AUX") for t in s)]
filtered = " ".join(kept) if kept else raw
if filtered.strip():
chunks.append(TextChunk(text=filtered, index=len(chunks)))|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any |
There was a problem hiding this comment.
unused, remove for linter
Summary
Adds
HierarchicalTextSplitter, a newTextSplitterbased on the chunking strategy from Min et al., arXiv:2507.03226 — Towards Practical GraphRAG: Efficient KG Construction and Hybrid Retrieval at Scale.Splits documents at section headers first, then recursively at a configurable character limit with overlap, and optionally drops verb-free sentences as a cheap pre-filter before chunking.
Related: the companion PR #547 (
SpacyEntityRelationExtractor) uses this splitter in its example pipeline.Header detection strategies
"markdown"(default)#LiteParseLoader(output_format="markdown")"capitalization""blank_line""spacy_verbless"Constructor
Usage
Pairs naturally with
LiteParseLoader(output_format="markdown")from PR #534.Test plan
uv run pytest tests/unit/experimental/components/text_splitters/test_hierarchical_splitter.py -v(20 unit tests, spacy mocked — no model download)python -m spacy download en_core_web_sm && uv run pytest tests/unit/experimental/components/text_splitters/test_hierarchical_splitter_integration.py -v(2 integration tests)uv run python -c "from neo4j_graphrag.experimental.components.text_splitters import HierarchicalTextSplitter; print('ok')"