22
33import re
44from dataclasses import dataclass
5+ from typing import Protocol
56
7+ from llm_knowledge_ingestion .chunking .tokenizer import Tokenizer , get_tokenizer
68from llm_knowledge_ingestion .contracts .models import Chunk
79from 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
3051def _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 ())
0 commit comments