Skip to content

Commit 597aa8f

Browse files
committed
Add sentence- and heading-aware chunking with a pluggable tokenizer (#26)
Replace the fixed-window-only chunker with selectable strategies behind a ChunkingStrategy protocol: - fixed_tokens (default, unchanged behavior), sentence_aware (packs whole sentences, never splits mid-sentence, with sentence-level overlap), and heading_aware (splits on parser-emitted heading boundaries then sentence-packs each section, tagging chunks with their heading). - Pluggable tokenizer: real BPE counts via the optional ingestion-tokenizer extra (tiktoken), whitespace word-count fallback otherwise. - Orchestrator passes parser sections and a per-run tokenizer into chunking. - Chunk IDs remain deterministic across strategies. Docs updated. Ingestion coverage 87% -> 89%.
1 parent 324eb67 commit 597aa8f

7 files changed

Lines changed: 378 additions & 35 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ pip install -e ".[dev]"
6464
| `observability-cli` | `jsonschema`, `numpy`, `pandas` | `--validate`, `validate-config`, `--detect-anomalies`, `--export-csv` |
6565
| `parquet` | `pyarrow` | `--export-parquet` (combine with `observability-cli`) |
6666
| `ingestion-pdf` | `pypdf` | Ingesting `.pdf` source documents |
67+
| `ingestion-tokenizer` | `tiktoken` | Real token counts for chunking (whitespace fallback otherwise) |
6768

6869
Example: `pip install -e ".[dev,observability-cli,parquet]"`.
6970

docs/mvp-ingestion.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ ingestion:
3232
input_path: ./examples # directory scanned recursively
3333
max_documents: 100 # cap on processed files
3434
chunking:
35-
strategy: fixed_tokens
35+
strategy: fixed_tokens # fixed_tokens | sentence_aware | heading_aware
3636
target_tokens: 400
3737
overlap_tokens: 40
3838
output:
@@ -45,11 +45,32 @@ output:
4545
4646
Relative paths are resolved against the config file's directory.
4747
48+
## Chunking strategies
49+
50+
Selectable via `chunking.strategy`:
51+
52+
- `fixed_tokens` (default) — sliding window over whitespace tokens. Fast and
53+
format-agnostic; may split mid-sentence.
54+
- `sentence_aware` — packs whole sentences up to `target_tokens`, carrying
55+
`overlap_tokens` worth of trailing sentences into the next chunk. Never splits
56+
mid-sentence. Best for prose.
57+
- `heading_aware` — splits on heading boundaries emitted by the Markdown/HTML
58+
parsers, then sentence-packs within each section; each chunk records its
59+
originating heading in `section`. Falls back to `sentence_aware` when the
60+
document has no headings.
61+
62+
Token counts use a real tokenizer when the optional `ingestion-tokenizer` extra
63+
(`tiktoken`) is installed, and fall back to a whitespace word count otherwise.
64+
Chunk IDs are deterministic under every strategy (keyed by document, index, and
65+
offsets), so artifacts stay diffable across runs.
66+
4867
## Supported inputs
4968

5069
- `.txt`
51-
- `.md`
52-
- `.json`
70+
- `.md` / `.markdown` (frontmatter stripped, headings preserved)
71+
- `.html` / `.htm` (reduced to clean text)
72+
- `.json` (deterministic re-serialization, or configurable text-field selection)
73+
- `.pdf` (requires the optional `ingestion-pdf` extra)
5374

5475
Files are discovered recursively under `ingestion.input_path` and processed in stable sorted order.
5576

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ parquet = [
3535
ingestion-pdf = [
3636
"pypdf>=4.0.0",
3737
]
38+
ingestion-tokenizer = [
39+
"tiktoken>=0.7.0",
40+
]
3841
dev = [
3942
"mypy>=1.11.0",
4043
"pytest>=8.3.0",

src/llm_knowledge_ingestion/chunking/strategies.py

Lines changed: 216 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@
22

33
import re
44
from dataclasses import dataclass
5+
from typing import Protocol
56

7+
from llm_knowledge_ingestion.chunking.tokenizer import Tokenizer, get_tokenizer
68
from llm_knowledge_ingestion.contracts.models import Chunk
79
from llm_knowledge_ingestion.dedup.hashing import sha256_text
10+
from llm_knowledge_ingestion.parsers.base import Section
11+
12+
SUPPORTED_STRATEGIES = {"fixed_tokens", "sentence_aware", "heading_aware"}
13+
14+
TOKEN_RE = re.compile(r"\S+")
15+
# Split after sentence-ending punctuation followed by whitespace.
16+
SENTENCE_BOUNDARY_RE = re.compile(r"(?<=[.!?])\s+")
817

918

1019
@dataclass(frozen=True, slots=True)
@@ -14,8 +23,10 @@ class ChunkingConfig:
1423
overlap_tokens: int = 40
1524

1625
def __post_init__(self) -> None:
17-
if self.strategy != "fixed_tokens":
18-
raise ValueError("Only fixed_tokens strategy is supported in MVP")
26+
if self.strategy not in SUPPORTED_STRATEGIES:
27+
raise ValueError(
28+
f"strategy must be one of {sorted(SUPPORTED_STRATEGIES)}, got {self.strategy!r}"
29+
)
1930
if self.target_tokens <= 0:
2031
raise ValueError("target_tokens must be > 0")
2132
if self.overlap_tokens < 0:
@@ -24,44 +35,218 @@ def __post_init__(self) -> None:
2435
raise ValueError("overlap_tokens must be < target_tokens")
2536

2637

27-
TOKEN_RE = re.compile(r"\S+")
38+
class ChunkingStrategy(Protocol):
39+
def split(
40+
self,
41+
text: str,
42+
document_id: str,
43+
config: ChunkingConfig,
44+
sections: list[Section] | None,
45+
tokenizer: Tokenizer,
46+
) -> list[Chunk]:
47+
"""Split text into deterministic chunks."""
48+
...
2849

2950

3051
def _chunk_id(document_id: str, chunk_index: int, text: str, start: int, end: int) -> str:
3152
digest = sha256_text(f"{document_id}|{chunk_index}|{start}|{end}|{text}")
3253
return f"chk_{digest[:28]}"
3354

3455

35-
def chunk_document(content: str, document_id: str, config: ChunkingConfig) -> list[Chunk]:
36-
"""Split content into deterministic token-window chunks."""
37-
tokens = list(TOKEN_RE.finditer(content))
38-
if not tokens:
39-
return []
56+
def _make_chunk(
57+
document_id: str,
58+
chunk_index: int,
59+
content: str,
60+
start: int,
61+
end: int,
62+
token_count: int,
63+
section: str | None,
64+
) -> Chunk:
65+
text = content[start:end]
66+
return Chunk(
67+
chunk_id=_chunk_id(document_id, chunk_index, text, start, end),
68+
document_id=document_id,
69+
chunk_index=chunk_index,
70+
text=text,
71+
token_count_estimate=token_count,
72+
start_offset=start,
73+
end_offset=end,
74+
section=section,
75+
metadata={},
76+
)
4077

41-
step = config.target_tokens - config.overlap_tokens
42-
chunks: list[Chunk] = []
43-
chunk_index = 0
4478

45-
for token_start in range(0, len(tokens), step):
46-
token_end = min(len(tokens), token_start + config.target_tokens)
47-
start_offset = tokens[token_start].start()
48-
end_offset = tokens[token_end - 1].end()
49-
text = content[start_offset:end_offset]
50-
chunks.append(
51-
Chunk(
52-
chunk_id=_chunk_id(document_id, chunk_index, text, start_offset, end_offset),
53-
document_id=document_id,
54-
chunk_index=chunk_index,
55-
text=text,
56-
token_count_estimate=token_end - token_start,
57-
start_offset=start_offset,
58-
end_offset=end_offset,
59-
section=None,
60-
metadata={},
79+
class FixedTokenStrategy:
80+
"""Whitespace-token sliding window — the backwards-compatible default."""
81+
82+
def split(
83+
self,
84+
text: str,
85+
document_id: str,
86+
config: ChunkingConfig,
87+
sections: list[Section] | None,
88+
tokenizer: Tokenizer,
89+
) -> list[Chunk]:
90+
tokens = list(TOKEN_RE.finditer(text))
91+
if not tokens:
92+
return []
93+
step = config.target_tokens - config.overlap_tokens
94+
chunks: list[Chunk] = []
95+
chunk_index = 0
96+
for token_start in range(0, len(tokens), step):
97+
token_end = min(len(tokens), token_start + config.target_tokens)
98+
start = tokens[token_start].start()
99+
end = tokens[token_end - 1].end()
100+
chunks.append(
101+
_make_chunk(
102+
document_id,
103+
chunk_index,
104+
text,
105+
start,
106+
end,
107+
tokenizer.count(text[start:end]),
108+
None,
109+
)
61110
)
62-
)
63-
chunk_index += 1
64-
if token_end == len(tokens):
65-
break
111+
chunk_index += 1
112+
if token_end == len(tokens):
113+
break
114+
return chunks
115+
116+
117+
def _sentence_spans(text: str, start: int, end: int) -> list[tuple[int, int]]:
118+
"""Return (start, end) char spans of sentences within text[start:end]."""
119+
segment = text[start:end]
120+
spans: list[tuple[int, int]] = []
121+
cursor = 0
122+
for piece in SENTENCE_BOUNDARY_RE.split(segment):
123+
if not piece:
124+
continue
125+
idx = segment.find(piece, cursor)
126+
if idx < 0:
127+
continue
128+
spans.append((start + idx, start + idx + len(piece)))
129+
cursor = idx + len(piece)
130+
return spans
66131

132+
133+
def _pack_sentences(
134+
text: str,
135+
document_id: str,
136+
config: ChunkingConfig,
137+
tokenizer: Tokenizer,
138+
region_start: int,
139+
region_end: int,
140+
section: str | None,
141+
start_index: int,
142+
) -> list[Chunk]:
143+
"""Greedily pack sentences into chunks under the token budget."""
144+
spans = _sentence_spans(text, region_start, region_end)
145+
if not spans:
146+
return []
147+
chunks: list[Chunk] = []
148+
chunk_index = start_index
149+
window: list[tuple[int, int]] = []
150+
window_tokens = 0
151+
for span in spans:
152+
span_tokens = tokenizer.count(text[span[0] : span[1]])
153+
# Flush when the window is non-empty and adding the next sentence overflows.
154+
if window and window_tokens + span_tokens > config.target_tokens:
155+
start, end = window[0][0], window[-1][1]
156+
chunks.append(
157+
_make_chunk(document_id, chunk_index, text, start, end, window_tokens, section)
158+
)
159+
chunk_index += 1
160+
# Carry trailing sentences as overlap.
161+
overlap: list[tuple[int, int]] = []
162+
overlap_tokens = 0
163+
for prev in reversed(window):
164+
prev_tokens = tokenizer.count(text[prev[0] : prev[1]])
165+
if overlap_tokens + prev_tokens > config.overlap_tokens:
166+
break
167+
overlap.insert(0, prev)
168+
overlap_tokens += prev_tokens
169+
window = overlap
170+
window_tokens = overlap_tokens
171+
window.append(span)
172+
window_tokens += span_tokens
173+
if window:
174+
start, end = window[0][0], window[-1][1]
175+
chunks.append(
176+
_make_chunk(document_id, chunk_index, text, start, end, window_tokens, section)
177+
)
67178
return chunks
179+
180+
181+
class SentenceAwareStrategy:
182+
"""Pack whole sentences into chunks, preferring sentence boundaries."""
183+
184+
def split(
185+
self,
186+
text: str,
187+
document_id: str,
188+
config: ChunkingConfig,
189+
sections: list[Section] | None,
190+
tokenizer: Tokenizer,
191+
) -> list[Chunk]:
192+
return _pack_sentences(text, document_id, config, tokenizer, 0, len(text), None, 0)
193+
194+
195+
class HeadingAwareStrategy:
196+
"""Split on parser-emitted heading boundaries, then sentence-pack each region."""
197+
198+
def split(
199+
self,
200+
text: str,
201+
document_id: str,
202+
config: ChunkingConfig,
203+
sections: list[Section] | None,
204+
tokenizer: Tokenizer,
205+
) -> list[Chunk]:
206+
if not sections:
207+
# No structure available: degrade to sentence packing.
208+
return SentenceAwareStrategy().split(text, document_id, config, sections, tokenizer)
209+
210+
# Build (region_start, region_end, heading) tuples covering the whole text.
211+
ordered = sorted(sections, key=lambda s: s.start_offset)
212+
boundaries: list[tuple[int, int, str | None]] = []
213+
if ordered[0].start_offset > 0:
214+
boundaries.append((0, ordered[0].start_offset, None))
215+
for index, section in enumerate(ordered):
216+
region_end = ordered[index + 1].start_offset if index + 1 < len(ordered) else len(text)
217+
boundaries.append((section.start_offset, region_end, section.heading))
218+
219+
chunks: list[Chunk] = []
220+
for region_start, region_end, heading in boundaries:
221+
chunks.extend(
222+
_pack_sentences(
223+
text,
224+
document_id,
225+
config,
226+
tokenizer,
227+
region_start,
228+
region_end,
229+
heading,
230+
len(chunks),
231+
)
232+
)
233+
return chunks
234+
235+
236+
_STRATEGIES: dict[str, ChunkingStrategy] = {
237+
"fixed_tokens": FixedTokenStrategy(),
238+
"sentence_aware": SentenceAwareStrategy(),
239+
"heading_aware": HeadingAwareStrategy(),
240+
}
241+
242+
243+
def chunk_document(
244+
content: str,
245+
document_id: str,
246+
config: ChunkingConfig,
247+
sections: list[Section] | None = None,
248+
tokenizer: Tokenizer | None = None,
249+
) -> list[Chunk]:
250+
"""Split content into deterministic chunks using the configured strategy."""
251+
strategy = _STRATEGIES[config.strategy]
252+
return strategy.split(content, document_id, config, sections, tokenizer or get_tokenizer())
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from typing import Protocol
5+
6+
TOKEN_RE = re.compile(r"\S+")
7+
8+
9+
class Tokenizer(Protocol):
10+
name: str
11+
12+
def count(self, text: str) -> int:
13+
"""Return the number of tokens in ``text``."""
14+
...
15+
16+
17+
class WhitespaceTokenizer:
18+
"""Dependency-free fallback: counts whitespace-separated words."""
19+
20+
name = "whitespace"
21+
22+
def count(self, text: str) -> int:
23+
return len(TOKEN_RE.findall(text))
24+
25+
26+
class TiktokenTokenizer:
27+
"""Real BPE token counts via the optional ``tiktoken`` dependency."""
28+
29+
def __init__(self, encoding_name: str = "cl100k_base") -> None:
30+
import tiktoken
31+
32+
self._encoding = tiktoken.get_encoding(encoding_name)
33+
self.name = f"tiktoken:{encoding_name}"
34+
35+
def count(self, text: str) -> int:
36+
return len(self._encoding.encode(text))
37+
38+
39+
def get_tokenizer() -> Tokenizer:
40+
"""Return a real tokenizer when ``tiktoken`` is installed, else the fallback."""
41+
try:
42+
return TiktokenTokenizer()
43+
except ImportError:
44+
return WhitespaceTokenizer()

0 commit comments

Comments
 (0)