Repository navigation
fix(xpacks/llm): stop TokenCountSplitter dropping characters at punctuation cuts - #279
linhongyu510 wants to merge 2 commits into
Conversation
…uation cuts
TokenCountSplitter cut a chunk at the last punctuation mark, then advanced the
token cursor by len(encode(kept_text)). Token boundaries need not align with the
character cut, so the token straddling the cut was skipped and the characters
between the punctuation and the next token boundary were lost. Example:
TokenCountSplitter(min_tokens=1, max_tokens=3).chunk("a.b.c.d.e.f.g.h.i.j.k.l.")
dropped "f", "i", "l" -- rejoining the chunks yielded "a.b.c.d.e..g.h..j.k.." .
Advance instead by exactly the whole source tokens whose decoded text stays
within the cut (the longest token prefix that is still a prefix of the kept
text), and re-emit the straddling token in the next chunk. Concatenating the
chunks now reconstructs the (unicode-normalized) input. Also guards the cursor
against a zero-token advance that could stall the loop.
4a01b13 to
24287e5
Compare
zxqfd555
left a comment
There was a problem hiding this comment.
Thanks for the PR! The bug is real, but the fix has two problems, both coming from kept.startswith(tokenizer.decode(chunk_tokens[:n])).
1. It breaks on non-ASCII text. A token boundary can fall in the middle of a multi-byte character; decoding such a prefix yields U+FFFD, startswith fails and the loop stops early. The cursor then advances by one token while the whole kept text is emitted, so the output repeats the input many times over. The current code handles these inputs correctly, so this is a regression. Both cases below pass on main and fail on this branch:
@pytest.mark.parametrize(
"txt",
[
"Привет, мир. Это проверка разбиения текста! Работает ли оно? Да. " * 5,
"你好,世界。这是一个测试!它有效吗?是的. " * 10,
],
ids=["russian", "chinese"],
)
def test_tokencount_does_not_duplicate_non_ascii(txt):
import unicodedata
splitter = TokenCountSplitter()
chunks = [chunk for chunk, _ in splitter.chunk(txt)]
assert "".join(chunks) == unicodedata.normalize("NFKC", txt)2. It is quadratic in max_tokens. Every prefix of the window is decoded for every chunk. With the default parameters, splitting plain English text takes about 7x longer than the current code (128k characters: 28 ms -> 200 ms).
Both go away if the cut is located by byte offsets: accumulate len(tokenizer.decode_single_token_bytes(t)) over the window's tokens and stop at the last one that still fits in len(kept.encode()). Could you rework the fix this way and add the test above?
|
Implemented the requested byte-offset approach in The regression coverage includes your Russian and Chinese reconstruction cases, plus a first-token punctuation case ( All 8 reported checks on this head currently pass. The PR description records the earlier local tests, the fixed-corpus timing comparison, and the remaining hard-token-cut boundary. AI assistance is disclosed there. Thanks for identifying both regressions. |
|
Addressed in 1. Non-ASCII duplication regression — fixed. The cut is now found by accumulating 2. Quadratic behavior — fixed. The growing-prefix decode loop is gone; each window walks its tokens once (linear in Regression tests added (exactly the RU/CN cases from the review, plus a first-token
Before/after evidence (local, tiktoken 0.14.0, Python 3.11):
Could you take another look? No approval assumed — awaiting re-review. AI assistance was used for implementation and local verification. |
TokenCountSplittercan silently drop source characters when a punctuation cut falls inside a token. For example, splitting"a.b.c.d.e.f.g.h.i.j.k.l."withmin_tokens=1, max_tokens=3and rejoining the chunks losesf,i, andlon the base implementation.The splitter now locates the punctuation cut by accumulating
decode_single_token_bytes()lengths and consumes exactly the whole source tokens it emits. The token straddling the cut stays for the next chunk. If no whole token fits before the cut, the full window is retained, preserving forward progress and text such as...).This follows the byte-offset approach requested in review. It avoids decoding partial UTF-8 prefixes and removes the growing-prefix decoding loop. Public arguments and metadata propagation are unchanged. The changelog entry is under Unreleased.
Validation:
)from...) tail; this change preserves the text and metadata.git diff --checkpass. The test run reports one existing Pydantic deprecation warning fromprompts.py.Local performance comparison on a fixed 128,000-character English corpus, seven warm runs per case (median, milliseconds):
max_tokensAll timed outputs reconstruct that corpus exactly. These are local microbenchmark results on macOS arm64 / Python 3.11.15, not end-to-end RAG timings. The comparison uses base
accf4a40and previous PR head24287e57.AI assistance was used for implementation and local verification. The change stays focused on punctuation-cut behavior; arbitrary hard token cuts through a multibyte character are outside this fix.