Skip to content

Commit c37ff2a

Browse files
committed
fix(chunk): carry a token-bounded tail so overlap applies when paragraphs exceed the budget
1 parent d31db86 commit c37ff2a

2 files changed

Lines changed: 138 additions & 4 deletions

File tree

app/ingest/chunk.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,41 @@ def _split_to_budget(text: str, budget_tokens: int, model: str | None = None) ->
128128
return [text[i : i + approx_chars] for i in range(0, len(text), approx_chars)]
129129

130130

131+
def _tail_within_budget(text: str, budget_tokens: int, model: str | None = None) -> str:
132+
"""The last `budget_tokens` worth of `text`, cut at a clean boundary.
133+
134+
Starts from a word estimate (~0.8 words per token) and trims until it fits,
135+
rather than tokenising once per candidate length — a paragraph is long
136+
enough that a naive scan costs hundreds of tokenizer calls per boundary.
137+
138+
Prefers to begin at a sentence boundary so the carried context reads as
139+
prose rather than starting mid-clause, but only when that keeps most of the
140+
budget: a fragment is still better overlap than none at all.
141+
"""
142+
if budget_tokens <= 0:
143+
return ""
144+
145+
words = text.split()
146+
if not words:
147+
return ""
148+
149+
take = min(len(words), max(1, int(budget_tokens * 0.8)))
150+
tail = " ".join(words[-take:])
151+
while take > 1 and count_tokens(tail, model) > budget_tokens:
152+
take = max(1, int(take * 0.85))
153+
tail = " ".join(words[-take:])
154+
155+
if count_tokens(tail, model) > budget_tokens:
156+
return ""
157+
158+
boundary = re.search(r"(?<=[.!?])\s+", tail)
159+
if boundary:
160+
candidate = tail[boundary.end() :]
161+
if count_tokens(candidate, model) >= budget_tokens * 0.5:
162+
return candidate
163+
return tail
164+
165+
131166
def _emit(buffer: list[_Segment], index: int, model: str | None) -> BuiltChunk:
132167
content = "\n\n".join(s.text for s in buffer).strip()
133168
pages = [s.page for s in buffer]
@@ -174,10 +209,30 @@ def chunk_recursive(
174209
carried = 0
175210
for prev in reversed(buffer):
176211
prev_tokens = count_tokens(prev.text, model)
177-
if carried + prev_tokens > overlap_tokens:
178-
break
179-
carry.insert(0, prev)
180-
carried += prev_tokens
212+
if carried + prev_tokens <= overlap_tokens:
213+
carry.insert(0, prev)
214+
carried += prev_tokens
215+
continue
216+
217+
# This segment is too big to carry whole, so carry a slice of
218+
# its tail instead of nothing.
219+
#
220+
# Carrying only whole segments looks reasonable and silently
221+
# does nothing on real prose: segments are paragraphs, and a
222+
# paragraph is routinely larger than the entire overlap budget
223+
# (90 tokens at 600/15%). The loop then breaks on its first
224+
# iteration and the "15% overlap" config produces no overlap at
225+
# all — measured at 4% of boundaries before this fix.
226+
remaining = overlap_tokens - carried
227+
tail = _tail_within_budget(prev.text, remaining, model)
228+
if tail:
229+
carry.insert(
230+
0,
231+
_Segment(text=tail, page=prev.page, heading_path=prev.heading_path),
232+
)
233+
carried += count_tokens(tail, model)
234+
break
235+
181236
buffer = carry
182237
buffer_tokens = carried
183238

tests/test_chunking.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import pytest
1313

1414
from app.config import ChunkStrategy, RetrievalConfig
15+
from app.generation.providers import count_tokens
1516
from app.db.models import ExtractionMethod
1617
from app.ingest.chunk import build_chunks, chunk_fixed, chunk_recursive
1718
from app.ingest.parse import Heading, ParsedPage
@@ -204,3 +205,81 @@ def test_ligatures_are_folded_to_ascii():
204205
assert clean_extracted_text("effectiveness") == "effectiveness"
205206
assert clean_extracted_text("difficult") == "difficult"
206207
assert clean_extracted_text("flow") == "flow"
208+
209+
210+
# --- overlap actually happening ---------------------------------------------
211+
212+
213+
def prose(paragraphs: int, words_per: int = 140) -> str:
214+
"""Paragraphs sized like real academic prose — comfortably larger than the
215+
overlap budget, which is the case that used to produce no overlap."""
216+
return "\n\n".join(
217+
" ".join(f"p{p}w{i}" for i in range(words_per)) + "." for p in range(paragraphs)
218+
)
219+
220+
221+
def carried_words(previous: str, current: str) -> int:
222+
"""Longest suffix of `previous` that is a literal prefix of `current`."""
223+
pw, cw = previous.split(), current.split()
224+
for n in range(min(len(pw), len(cw), 400), 0, -1):
225+
if pw[-n:] == cw[:n]:
226+
return n
227+
return 0
228+
229+
230+
def test_overlap_happens_even_when_paragraphs_exceed_the_overlap_budget(recursive_config):
231+
"""The regression this guards.
232+
233+
Carrying only *whole* segments silently produced no overlap on real prose:
234+
a paragraph routinely exceeds the entire overlap budget (90 tokens at
235+
600/15%), so the carry loop broke on its first iteration. Config B was
236+
labelled "recursive + 15% overlap" while behaving identically to no
237+
overlap, which would have made the A-to-B comparison measure nothing.
238+
"""
239+
# A production-sized budget, so the 15% overlap is ~90 tokens rather than
240+
# the 18 the small test fixture would give.
241+
config = recursive_config.model_copy(update={"chunk_size_tokens": 600})
242+
overlap_budget = int(600 * config.chunk_overlap_ratio)
243+
244+
chunks = chunk_recursive([make_page(1, prose(12))], config)
245+
assert len(chunks) > 2
246+
247+
carried = [carried_words(a.content, b.content) for a, b in pairwise(chunks)]
248+
with_overlap = [c for c in carried if c > 0]
249+
250+
assert len(with_overlap) >= 0.8 * len(carried), (
251+
f"only {len(with_overlap)}/{len(carried)} boundaries carried text — "
252+
"the overlap setting is not taking effect"
253+
)
254+
255+
# Carried spans should use a real share of the budget, not a token or two,
256+
# and must never exceed it.
257+
for a, b in pairwise(chunks):
258+
n = carried_words(a.content, b.content)
259+
if not n:
260+
continue
261+
tokens = count_tokens(" ".join(b.content.split()[:n]))
262+
assert tokens <= overlap_budget * 1.2, f"carried {tokens} tokens > budget {overlap_budget}"
263+
assert tokens >= overlap_budget * 0.25, (
264+
f"carried only {tokens} tokens of a {overlap_budget}-token budget"
265+
)
266+
267+
268+
def test_zero_overlap_config_carries_nothing(recursive_config):
269+
no_overlap = recursive_config.model_copy(update={"chunk_overlap_ratio": 0.0})
270+
chunks = chunk_recursive([make_page(1, prose(12))], no_overlap)
271+
272+
carried = [carried_words(a.content, b.content) for a, b in pairwise(chunks)]
273+
assert not any(carried), f"overlap=0 still carried text: {carried}"
274+
275+
276+
def test_carried_overlap_respects_the_token_budget(recursive_config):
277+
from app.ingest.chunk import _tail_within_budget
278+
from app.generation.providers import count_tokens
279+
280+
text = " ".join(f"word{i}" for i in range(500)) + "."
281+
for budget in (10, 45, 90):
282+
tail = _tail_within_budget(text, budget)
283+
assert tail, f"no tail produced for budget {budget}"
284+
assert count_tokens(tail) <= budget, f"tail exceeded budget {budget}"
285+
assert text.endswith(tail), "the tail must come from the end of the text"

0 commit comments

Comments
 (0)