Skip to content

fix: preserve Unicode token chunk offsets - #646

Open
devesssi wants to merge 1 commit into
feyninc:mainfrom
devesssi:fix/token-chunker-unicode-offsets
Open

fix: preserve Unicode token chunk offsets#646
devesssi wants to merge 1 commit into
feyninc:mainfrom
devesssi:fix/token-chunker-unicode-offsets

Conversation

@devesssi

@devesssi devesssi commented Aug 3, 2026

Copy link
Copy Markdown

Summary

  • preserve source-text offsets when byte-level tokenizer boundaries fall inside a multi-byte character
  • prevent empty chunks and incorrect indices for Unicode input
  • retain the existing decode-based behavior when an adapter does not provide offset mappings

Root cause

TokenChunker decoded each token group independently and accumulated decoded-string lengths. A group beginning in the middle of a UTF-8 character could decode as empty, which lost text and desynchronized later indices.

Validation

  • pytest tests/chunkers/test_token_chunker.py tests/test_tokenizer.py -q (111 passed)
  • Ruff lint, formatting, and diff whitespace checks
  • regression coverage for single and batch chunking across tiktoken, tokenizers, and Transformers GPT-2 adapters

Summary by CodeRabbit

  • Bug Fixes
    • Improved token-based chunking for text containing Unicode and multi-byte characters.
    • Preserved accurate source-text boundaries and overlap information when creating chunks.
    • Ensured consistent results between single-text and batch chunking across supported tokenizers.
  • Tests
    • Added coverage verifying Unicode text preservation and source-range accuracy across multiple tokenizer types.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

πŸ“ Walkthrough

Walkthrough

TokenChunker now uses tokenizer offsets to preserve Unicode source-text boundaries. Supported tokenizer adapters expose offset encoding, while unsupported or invalid offset data uses the existing decode-based fallback. Single and batch chunking share the same implementation.

Changes

Unicode-safe token chunking

Layer / File(s) Summary
Tokenizer offset encoding
src/chonkie/tokenizer.py
Tokenizer adapters add encode_with_offsets. Tiktoken validates round-trip decoding. Tokie converts UTF-8 byte offsets to Python character offsets.
Offset-aware chunk construction and validation
src/chonkie/chunker/token.py, tests/chunkers/test_token_chunker.py
TokenChunker creates chunks from safe character spans and retains the decode-based fallback. Single and batch paths use the shared method. Tests cover Unicode text across supported tokenizers.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • feyninc/chonkie#639: Both changes preserve contiguous source-text spans in chunk index calculations.

Suggested reviewers: chonk-lain

Sequence Diagram(s)

sequenceDiagram
  participant TokenChunker
  participant TokenizerAdapter
  participant SourceText
  participant Chunk
  TokenChunker->>TokenizerAdapter: encode_with_offsets(text)
  TokenizerAdapter-->>TokenChunker: token IDs and character offsets
  TokenChunker->>SourceText: select safe token-boundary spans
  SourceText-->>TokenChunker: sliced chunk text
  TokenChunker->>Chunk: store text, token count, indices, and overlap
Loading

Poem

A rabbit hops through tokens bright,
UTF-8 spans now land just right.
Chunks keep every character,
Batch and single paths share the mapper.
πŸ‡ Boundaries hold; no glyphs take flight.

πŸš₯ Pre-merge checks | βœ… 5
βœ… Passed checks (5 passed)
Check name Status Explanation
Description Check βœ… Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check βœ… Passed The title clearly and concisely describes the main change: preserving Unicode token chunk offsets.
Docstring Coverage βœ… Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@devesssi
devesssi marked this pull request as ready for review August 3, 2026 12:49
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai
coderabbitai Bot requested a review from chonk-lain August 3, 2026 12:49

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/chonkie/tokenizer.py (1)

676-680: 🩺 Stability & Availability | πŸ”΅ Trivial | πŸ’€ Low value

Cast .ids to list for consistency with the other adapters.

TiktokenAutoTokenizer.encode_with_offsets and TransformersAutoTokenizer.encode_with_offsets both explicitly wrap their token IDs in list(...). TokenizersAutoTokenizer.encode_with_offsets (Line 679) and TokieAutoTokenizer.encode_with_offsets (Line 697) return encoding.ids directly. The consumer in chunker/token.py (_chunk_tokens) does list(tokens) == offset_tokens. If either binding's .ids is not a plain list, this equality silently fails and the offset path silently falls back to decode-based chunking on every call, defeating the feature without any error.

Cast explicitly for consistency and defensive robustness.

♻️ Proposed fix
     def encode_with_offsets(self, text: str) -> tuple[list[int], list[tuple[int, int]]]:
         """Encode text and return character offsets for each token."""
         encoding = self.tokenizer.encode(text, add_special_tokens=False)
-        return encoding.ids, encoding.offsets
+        return list(encoding.ids), list(encoding.offsets)

Also applies to: 694-698

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chonkie/tokenizer.py` around lines 676 - 680, Update encode_with_offsets
in TokenizersAutoTokenizer and TokieAutoTokenizer to wrap encoding.ids in
list(...) before returning it, while preserving the existing offsets unchanged
and maintaining the declared tuple return shape.
πŸ€– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/chonkie/chunker/token.py`:
- Around line 106-169: Restructure the tokenization flow so offset-aware
tokenizers are attempted first, using the tokens and offsets returned by
encode_with_offsets as the working data without re-encoding. Update chunk() and
_process_batch to avoid unconditional encode/encode_batch calls, while
preserving batched tokenization where offsets are unavailable or fail. Adjust
_chunk_tokens to consume the successful offset result directly and retain the
existing decode-based fallback.

In `@tests/chunkers/test_token_chunker.py`:
- Around line 278-280: Extend the tokenizer parametrization for
test_unicode_boundary_offsets to include the tokie fixture if
TokieAutoTokenizer.encode_with_offsets supports the expected offset API;
otherwise explicitly mark or document the fixture as unsupported for this test.

---

Nitpick comments:
In `@src/chonkie/tokenizer.py`:
- Around line 676-680: Update encode_with_offsets in TokenizersAutoTokenizer and
TokieAutoTokenizer to wrap encoding.ids in list(...) before returning it, while
preserving the existing offsets unchanged and maintaining the declared tuple
return shape.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b54765b-c8ef-4189-b493-141bca4f9e49

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 0a6baea and 482c91d.

πŸ“’ Files selected for processing (3)
  • src/chonkie/chunker/token.py
  • src/chonkie/tokenizer.py
  • tests/chunkers/test_token_chunker.py

Comment on lines +106 to +169
def _create_chunks_with_offsets(
self,
text: str,
tokens: Sequence[int],
offsets: Sequence[tuple[int, int]],
) -> list[Chunk]:
"""Create character-safe chunks from tokenizer offsets."""
safe_boundaries = [0]
for index in range(1, len(tokens)):
previous_start, previous_end = offsets[index - 1]
current_start, _ = offsets[index]
if previous_end > previous_start and previous_end == current_start:
safe_boundaries.append(index)
safe_boundaries.append(len(tokens))

chunks = []
start = 0
while start < len(tokens):
requested_end = min(start + self.chunk_size, len(tokens))
end = safe_boundaries[bisect_right(safe_boundaries, requested_end) - 1]
if end <= start:
end = safe_boundaries[bisect_right(safe_boundaries, start)]

start_index = offsets[start][0]
end_index = offsets[end - 1][1]
chunks.append(
Chunk(
text=text[start_index:end_index],
start_index=start_index,
end_index=end_index,
token_count=end - start,
)
)

if end == len(tokens):
break

requested_start = max(0, end - self.chunk_overlap)
next_start = safe_boundaries[bisect_right(safe_boundaries, requested_start) - 1]
if next_start <= start:
next_start = safe_boundaries[bisect_right(safe_boundaries, start)]
start = next_start

return chunks

def _chunk_tokens(self, text: str, tokens: Sequence[int]) -> list[Chunk]:
"""Chunk tokens, using source offsets when the tokenizer provides them."""
encode_with_offsets = getattr(self.tokenizer, "encode_with_offsets", None)
if callable(encode_with_offsets):
try:
offset_tokens, offsets = encode_with_offsets(text)
except (NotImplementedError, ValueError):
# Some tokenizer backends do not expose offset mappings. Keep
# their existing decode-based behavior in that case.
pass
else:
if list(tokens) == offset_tokens and len(offsets) == len(tokens):
return self._create_chunks_with_offsets(text, tokens, offsets)

token_groups = list(self._token_group_generator(tokens))
token_counts = [len(token_group) for token_group in token_groups]
chunk_texts = self.tokenizer.decode_batch(token_groups)
return self._create_chunks(chunk_texts, token_groups, token_counts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸš€ Performance & Scalability | 🟠 Major | πŸ—οΈ Heavy lift

Offset-supporting tokenizers now tokenize the text twice per chunk, and batching is lost for the offset path.

chunk() (Line 194) always calls self.tokenizer.encode(text) first. Then, when the tokenizer supports offsets, _chunk_tokens calls self.tokenizer.encode_with_offsets(text) again (Line 156). Every offset-aware adapter re-runs a full tokenization internally: TiktokenAutoTokenizer.encode_with_offsets calls self.encode(text) plus a full decode_with_offsets; TransformersAutoTokenizer.encode_with_offsets and TokenizersAutoTokenizer.encode_with_offsets each re-invoke the underlying tokenizer. The first-pass tokens value is then only used for an equality check, not as the working token list on the offset path.

This doubles tokenization cost for exactly the adapters this PR targets (tiktoken, tokenizers, transformers fast). For batch chunking it is worse: _process_batch calls self.tokenizer.encode_batch(texts) once (batched), then _chunk_tokens calls encode_with_offsets per text individually β€” the batching benefit of encode_batch is lost for every text that takes the offset path.

Consider restructuring so encode_with_offsets is tried first as the primary tokenization step, and the plain encode/encode_batch call is only made when offsets are unavailable or fail, instead of always doing both.

♻️ Illustrative restructuring (adjust call sites accordingly)
-    def _chunk_tokens(self, text: str, tokens: Sequence[int]) -> list[Chunk]:
-        """Chunk tokens, using source offsets when the tokenizer provides them."""
-        encode_with_offsets = getattr(self.tokenizer, "encode_with_offsets", None)
-        if callable(encode_with_offsets):
-            try:
-                offset_tokens, offsets = encode_with_offsets(text)
-            except (NotImplementedError, ValueError):
-                # Some tokenizer backends do not expose offset mappings. Keep
-                # their existing decode-based behavior in that case.
-                pass
-            else:
-                if list(tokens) == offset_tokens and len(offsets) == len(tokens):
-                    return self._create_chunks_with_offsets(text, tokens, offsets)
-
-        token_groups = list(self._token_group_generator(tokens))
-        token_counts = [len(token_group) for token_group in token_groups]
-        chunk_texts = self.tokenizer.decode_batch(token_groups)
-        return self._create_chunks(chunk_texts, token_groups, token_counts)
+    def _chunk_text_with_tokens(self, text: str, tokens: Optional[Sequence[int]]) -> tuple[list[Chunk], int]:
+        """Chunk text, preferring a single offset-aware tokenization pass."""
+        encode_with_offsets = getattr(self.tokenizer, "encode_with_offsets", None)
+        if callable(encode_with_offsets):
+            try:
+                offset_tokens, offsets = encode_with_offsets(text)
+            except (NotImplementedError, ValueError):
+                pass
+            else:
+                if len(offsets) == len(offset_tokens):
+                    return self._create_chunks_with_offsets(text, offset_tokens, offsets), len(offset_tokens)
+
+        tokens = tokens if tokens is not None else self.tokenizer.encode(text)
+        token_groups = list(self._token_group_generator(tokens))
+        token_counts = [len(token_group) for token_group in token_groups]
+        chunk_texts = self.tokenizer.decode_batch(token_groups)
+        return self._create_chunks(chunk_texts, token_groups, token_counts), len(tokens)
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chonkie/chunker/token.py` around lines 106 - 169, Restructure the
tokenization flow so offset-aware tokenizers are attempted first, using the
tokens and offsets returned by encode_with_offsets as the working data without
re-encoding. Update chunk() and _process_batch to avoid unconditional
encode/encode_batch calls, while preserving batched tokenization where offsets
are unavailable or fail. Adjust _chunk_tokens to consume the successful offset
result directly and retain the existing decode-based fallback.

Comment on lines +278 to +280
@pytest.mark.parametrize(
"tokenizer_fixture", ["tiktokenizer", "tokenizer", "transformers_tokenizer"]
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check for any existing tokie-backed test coverage of encode_with_offsets / byte-offset conversion.
rg -n -C5 'tokie' --type=py -g '!*/node_modules/*'

Repository: feyninc/chonkie

Length of output: 153


Add tokie coverage for Unicode byte-offset conversion.

No Python tests reference tokie, so TokieAutoTokenizer.encode_with_offsets is not covered here. Add a tokie tokenizer fixture parametrization for test_unicode_boundary_offsets if that adapter supports the same offset API, otherwise document/exclude it explicitly.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/chunkers/test_token_chunker.py` around lines 278 - 280, Extend the
tokenizer parametrization for test_unicode_boundary_offsets to include the tokie
fixture if TokieAutoTokenizer.encode_with_offsets supports the expected offset
API; otherwise explicitly mark or document the fixture as unsupported for this
test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant