chunkwise 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Python ${{ matrix.python-version }}
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+
22
+ - name: Install dependencies
23
+ run: pip install -e ".[dev]"
24
+
25
+ - name: Lint (ruff)
26
+ run: ruff check .
27
+
28
+ - name: Type check (mypy)
29
+ run: mypy src/chunkwise
30
+
31
+ - name: Test (pytest)
32
+ run: pytest
@@ -0,0 +1,28 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build-and-publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+
21
+ - name: Install build tool
22
+ run: pip install build
23
+
24
+ - name: Build package
25
+ run: python -m build
26
+
27
+ - name: Publish to PyPI
28
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ .venv/
8
+ venv/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .pytest_cache/
12
+ .hypothesis/
13
+ .coverage
14
+ htmlcov/
15
+ .DS_Store
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-11-04
9
+
10
+ ### Added
11
+
12
+ - Initial release.
13
+ - `chunk()` public API with `fixed`, `recursive`, and `markdown` strategies.
14
+ - `Chunk` dataclass enforcing `document[chunk.start:chunk.end] == chunk.text`.
15
+ - Tokenizer protocol with a character-counting default; zero runtime dependencies.
16
+ - Hypothesis-based property tests for the offset invariant.
@@ -0,0 +1,32 @@
1
+ # Contributing
2
+
3
+ Thanks for considering a contribution to chunkwise.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ git clone https://github.com/amin-albooyeh/chunkwise.git
9
+ cd chunkwise
10
+ pip install -e ".[dev]"
11
+ ```
12
+
13
+ ## Running checks
14
+
15
+ ```bash
16
+ ruff check .
17
+ mypy src/chunkwise
18
+ pytest
19
+ ```
20
+
21
+ All three run in CI on every pull request, across Python 3.10–3.13.
22
+
23
+ ## Guidelines
24
+
25
+ - The offset invariant (`document[chunk.start:chunk.end] == chunk.text`) must hold for every strategy on every input. Any new strategy needs a corresponding Hypothesis property test in `tests/test_invariants.py`.
26
+ - Strategies operate on `(start, end)` spans internally and only materialize text via `_spans.materialize`. Don't build chunks by slicing and reassembling strings inside a strategy.
27
+ - Keep the base install dependency-free. Anything that requires a third-party package (e.g. `semantic`) ships as an optional extra.
28
+ - Add tests for new behavior in `tests/test_strategies.py` or `tests/test_tokenizers.py` as appropriate.
29
+
30
+ ## Reporting issues
31
+
32
+ Open a GitHub issue with a minimal reproducible example — ideally the document text, the strategy, and the parameters used.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amin Albooyeh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.5
2
+ Name: chunkwise
3
+ Version: 0.1.0
4
+ Summary: Text chunking for RAG with reversible offsets — every chunk is an exact slice of the source document.
5
+ Project-URL: Homepage, https://github.com/amin-albooyeh/chunkwise
6
+ Project-URL: Repository, https://github.com/amin-albooyeh/chunkwise
7
+ Project-URL: Documentation, https://github.com/amin-albooyeh/chunkwise/tree/main/docs
8
+ Project-URL: Changelog, https://github.com/amin-albooyeh/chunkwise/blob/main/CHANGELOG.md
9
+ Author-email: Amin Albooyeh <amin.albooyeh@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: chunking,embeddings,nlp,rag,retrieval
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Text Processing :: Linguistic
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Provides-Extra: dev
25
+ Requires-Dist: hypothesis>=6.0; extra == 'dev'
26
+ Requires-Dist: mypy>=1.10; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Provides-Extra: semantic
30
+ Requires-Dist: numpy; extra == 'semantic'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # chunkwise
34
+
35
+ **Text chunking for RAG with reversible offsets — every chunk is an exact slice of the source document.**
36
+
37
+ [![CI](https://github.com/amin-albooyeh/chunkwise/actions/workflows/ci.yml/badge.svg)](https://github.com/amin-albooyeh/chunkwise/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/chunkwise.svg)](https://pypi.org/project/chunkwise/)
39
+ [![Python versions](https://img.shields.io/pypi/pyversions/chunkwise.svg)](https://pypi.org/project/chunkwise/)
40
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
41
+
42
+ chunkwise splits documents into embeddable chunks for retrieval-augmented generation. Unlike most splitters, which return detached strings, every chunk carries the exact character range it came from, so `document[chunk.start:chunk.end] == chunk.text` always holds — making source highlighting, citation, and context expansion possible. Zero runtime dependencies, bring-your-own tokenizer, and structure-aware strategies for plain text and Markdown.
43
+
44
+ ## Why chunkwise
45
+
46
+ Most chunking libraries hand back a `list[str]`. The moment text is detached from its origin, three things become impossible:
47
+
48
+ - You can't **highlight** the passage a retrieval hit came from in the original document.
49
+ - You can't **expand** a result to show the surrounding paragraph.
50
+ - You can't tell **which section or heading** a passage lived under.
51
+
52
+ Worse, splitters that strip or normalize whitespace silently drop characters — a failure mode that shows up only as retrieval quietly missing content, which is hard to notice and harder to debug.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install chunkwise
58
+ ```
59
+
60
+ No dependencies are pulled in. (An optional `semantic` extra will be added in v0.3 for embedding-based chunking.)
61
+
62
+ ## Quickstart
63
+
64
+ ```python
65
+ from chunkwise import chunk
66
+
67
+ document = open("report.md").read()
68
+ chunks = chunk(document, strategy="recursive", max_tokens=512, overlap=50)
69
+
70
+ for c in chunks:
71
+ assert document[c.start:c.end] == c.text # always true, for every chunk
72
+ print(c.index, c.start, c.end, c.heading_path)
73
+ ```
74
+
75
+ ## The guarantee
76
+
77
+ chunkwise holds one invariant across every strategy and every input:
78
+
79
+ ```python
80
+ document[chunk.start:chunk.end] == chunk.text
81
+ ```
82
+
83
+ This is enforced three ways:
84
+
85
+ 1. **Structurally** — internal logic operates on `(start, end)` integer spans and only materializes text at the final step, so no strategy (present or future) can break the invariant even by accident.
86
+ 2. **At runtime** — the `Chunk` type validates its own span on construction.
87
+ 3. **In CI** — a Hypothesis property test runs against generated documents on every commit, alongside a test that concatenating non-overlapping chunks reproduces the source byte for byte.
88
+
89
+ The consequences are deliberate: chunks are never stripped, normalized, or rewritten, and overlap widens a chunk backwards into real preceding text rather than gluing on a copied prefix.
90
+
91
+ See [docs/design.md](docs/design.md) for the full rationale.
92
+
93
+ ## Strategies
94
+
95
+ | Strategy | Behavior |
96
+ |---|---|
97
+ | `fixed` | Hard windows of N tokens with configurable overlap. Fast, boundary-blind. Suited to logs and unstructured text. |
98
+ | `recursive` | Falls back through a separator chain — paragraphs, then lines, then sentences, then words — breaking on the coarsest boundary that fits the budget. **The default.** |
99
+ | `markdown` | Keeps fenced code blocks intact and tags every chunk with its heading path (e.g. `["Guide", "Install"]`), giving retrieval results a sense of place. |
100
+
101
+ Planned: `code` (v0.2 — AST / tree-sitter boundaries) and `semantic` (v0.3 — embedding similarity cuts), the latter shipping as an optional extra so the base install stays dependency-free.
102
+
103
+ See [docs/strategies.md](docs/strategies.md) for guidance on choosing one.
104
+
105
+ ## Tokenizer independence
106
+
107
+ Token budgets depend on which model you're embedding for, so chunkwise doesn't hard-code one. The `tokenizer` parameter accepts any callable from `str` to `int`:
108
+
109
+ ```python
110
+ from chunkwise import chunk
111
+ import tiktoken
112
+
113
+ enc = tiktoken.get_encoding("cl100k_base")
114
+ chunks = chunk(document, max_tokens=512, tokenizer=lambda text: len(enc.encode(text)))
115
+ ```
116
+
117
+ The default counts characters, keeping the base install at zero dependencies. The only requirement on a tokenizer is that its count is monotonic in string length — every real tokenizer satisfies this. See [docs/tokenizers.md](docs/tokenizers.md).
118
+
119
+ ## API
120
+
121
+ ```python
122
+ def chunk(
123
+ document: str,
124
+ strategy: str = "recursive", # "fixed" | "recursive" | "markdown"
125
+ max_tokens: int = 512,
126
+ overlap: int = 0,
127
+ tokenizer: Tokenizer = ..., # str -> int, defaults to len
128
+ ) -> list[Chunk]: ...
129
+ ```
130
+
131
+ Each `Chunk` is a frozen dataclass:
132
+
133
+ ```python
134
+ @dataclass(frozen=True, slots=True)
135
+ class Chunk:
136
+ text: str
137
+ start: int
138
+ end: int
139
+ index: int
140
+ heading_path: tuple[str, ...] # populated by the markdown strategy
141
+ ```
142
+
143
+ ## Status
144
+
145
+ v0.1.0 · Python 3.10+ · MIT licensed · zero runtime dependencies · tested on CI across Python 3.10–3.13.
146
+
147
+ ## Documentation
148
+
149
+ - [docs/index.md](docs/index.md) — overview and quickstart
150
+ - [docs/strategies.md](docs/strategies.md) — strategy reference and how to choose
151
+ - [docs/tokenizers.md](docs/tokenizers.md) — bringing your own tokenizer
152
+ - [docs/design.md](docs/design.md) — why spans, not strings
153
+
154
+ ## Benchmarks
155
+
156
+ ```bash
157
+ python benchmarks/throughput.py
158
+ ```
159
+
160
+ Compares chunkwise strategies against a naive fixed-width splitter on a synthetic document.
161
+
162
+ ## Contributing
163
+
164
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, checks, and guidelines (in particular, the offset invariant that any new strategy must preserve).
165
+
166
+ ## License
167
+
168
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,136 @@
1
+ # chunkwise
2
+
3
+ **Text chunking for RAG with reversible offsets — every chunk is an exact slice of the source document.**
4
+
5
+ [![CI](https://github.com/amin-albooyeh/chunkwise/actions/workflows/ci.yml/badge.svg)](https://github.com/amin-albooyeh/chunkwise/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/chunkwise.svg)](https://pypi.org/project/chunkwise/)
7
+ [![Python versions](https://img.shields.io/pypi/pyversions/chunkwise.svg)](https://pypi.org/project/chunkwise/)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
9
+
10
+ chunkwise splits documents into embeddable chunks for retrieval-augmented generation. Unlike most splitters, which return detached strings, every chunk carries the exact character range it came from, so `document[chunk.start:chunk.end] == chunk.text` always holds — making source highlighting, citation, and context expansion possible. Zero runtime dependencies, bring-your-own tokenizer, and structure-aware strategies for plain text and Markdown.
11
+
12
+ ## Why chunkwise
13
+
14
+ Most chunking libraries hand back a `list[str]`. The moment text is detached from its origin, three things become impossible:
15
+
16
+ - You can't **highlight** the passage a retrieval hit came from in the original document.
17
+ - You can't **expand** a result to show the surrounding paragraph.
18
+ - You can't tell **which section or heading** a passage lived under.
19
+
20
+ Worse, splitters that strip or normalize whitespace silently drop characters — a failure mode that shows up only as retrieval quietly missing content, which is hard to notice and harder to debug.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install chunkwise
26
+ ```
27
+
28
+ No dependencies are pulled in. (An optional `semantic` extra will be added in v0.3 for embedding-based chunking.)
29
+
30
+ ## Quickstart
31
+
32
+ ```python
33
+ from chunkwise import chunk
34
+
35
+ document = open("report.md").read()
36
+ chunks = chunk(document, strategy="recursive", max_tokens=512, overlap=50)
37
+
38
+ for c in chunks:
39
+ assert document[c.start:c.end] == c.text # always true, for every chunk
40
+ print(c.index, c.start, c.end, c.heading_path)
41
+ ```
42
+
43
+ ## The guarantee
44
+
45
+ chunkwise holds one invariant across every strategy and every input:
46
+
47
+ ```python
48
+ document[chunk.start:chunk.end] == chunk.text
49
+ ```
50
+
51
+ This is enforced three ways:
52
+
53
+ 1. **Structurally** — internal logic operates on `(start, end)` integer spans and only materializes text at the final step, so no strategy (present or future) can break the invariant even by accident.
54
+ 2. **At runtime** — the `Chunk` type validates its own span on construction.
55
+ 3. **In CI** — a Hypothesis property test runs against generated documents on every commit, alongside a test that concatenating non-overlapping chunks reproduces the source byte for byte.
56
+
57
+ The consequences are deliberate: chunks are never stripped, normalized, or rewritten, and overlap widens a chunk backwards into real preceding text rather than gluing on a copied prefix.
58
+
59
+ See [docs/design.md](docs/design.md) for the full rationale.
60
+
61
+ ## Strategies
62
+
63
+ | Strategy | Behavior |
64
+ |---|---|
65
+ | `fixed` | Hard windows of N tokens with configurable overlap. Fast, boundary-blind. Suited to logs and unstructured text. |
66
+ | `recursive` | Falls back through a separator chain — paragraphs, then lines, then sentences, then words — breaking on the coarsest boundary that fits the budget. **The default.** |
67
+ | `markdown` | Keeps fenced code blocks intact and tags every chunk with its heading path (e.g. `["Guide", "Install"]`), giving retrieval results a sense of place. |
68
+
69
+ Planned: `code` (v0.2 — AST / tree-sitter boundaries) and `semantic` (v0.3 — embedding similarity cuts), the latter shipping as an optional extra so the base install stays dependency-free.
70
+
71
+ See [docs/strategies.md](docs/strategies.md) for guidance on choosing one.
72
+
73
+ ## Tokenizer independence
74
+
75
+ Token budgets depend on which model you're embedding for, so chunkwise doesn't hard-code one. The `tokenizer` parameter accepts any callable from `str` to `int`:
76
+
77
+ ```python
78
+ from chunkwise import chunk
79
+ import tiktoken
80
+
81
+ enc = tiktoken.get_encoding("cl100k_base")
82
+ chunks = chunk(document, max_tokens=512, tokenizer=lambda text: len(enc.encode(text)))
83
+ ```
84
+
85
+ The default counts characters, keeping the base install at zero dependencies. The only requirement on a tokenizer is that its count is monotonic in string length — every real tokenizer satisfies this. See [docs/tokenizers.md](docs/tokenizers.md).
86
+
87
+ ## API
88
+
89
+ ```python
90
+ def chunk(
91
+ document: str,
92
+ strategy: str = "recursive", # "fixed" | "recursive" | "markdown"
93
+ max_tokens: int = 512,
94
+ overlap: int = 0,
95
+ tokenizer: Tokenizer = ..., # str -> int, defaults to len
96
+ ) -> list[Chunk]: ...
97
+ ```
98
+
99
+ Each `Chunk` is a frozen dataclass:
100
+
101
+ ```python
102
+ @dataclass(frozen=True, slots=True)
103
+ class Chunk:
104
+ text: str
105
+ start: int
106
+ end: int
107
+ index: int
108
+ heading_path: tuple[str, ...] # populated by the markdown strategy
109
+ ```
110
+
111
+ ## Status
112
+
113
+ v0.1.0 · Python 3.10+ · MIT licensed · zero runtime dependencies · tested on CI across Python 3.10–3.13.
114
+
115
+ ## Documentation
116
+
117
+ - [docs/index.md](docs/index.md) — overview and quickstart
118
+ - [docs/strategies.md](docs/strategies.md) — strategy reference and how to choose
119
+ - [docs/tokenizers.md](docs/tokenizers.md) — bringing your own tokenizer
120
+ - [docs/design.md](docs/design.md) — why spans, not strings
121
+
122
+ ## Benchmarks
123
+
124
+ ```bash
125
+ python benchmarks/throughput.py
126
+ ```
127
+
128
+ Compares chunkwise strategies against a naive fixed-width splitter on a synthetic document.
129
+
130
+ ## Contributing
131
+
132
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, checks, and guidelines (in particular, the offset invariant that any new strategy must preserve).
133
+
134
+ ## License
135
+
136
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,47 @@
1
+ """Throughput benchmark: chunkwise strategies vs. a naive splitter.
2
+
3
+ Run with: python benchmarks/throughput.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from collections.abc import Callable
10
+
11
+ from chunkwise import chunk
12
+
13
+ SAMPLE_SIZE_CHARS = 200_000
14
+
15
+
16
+ def naive_split(document: str, max_tokens: int) -> list[str]:
17
+ return [document[i : i + max_tokens] for i in range(0, len(document), max_tokens)]
18
+
19
+
20
+ def make_sample() -> str:
21
+ paragraph = (
22
+ "This is a benchmark paragraph used to measure chunking throughput. "
23
+ "It repeats to simulate a long document.\n\n"
24
+ )
25
+ return paragraph * (SAMPLE_SIZE_CHARS // len(paragraph) + 1)
26
+
27
+
28
+ def time_it(label: str, fn: Callable[[], object]) -> None:
29
+ start = time.perf_counter()
30
+ result = fn()
31
+ elapsed = time.perf_counter() - start
32
+ count = len(result) if isinstance(result, list) else -1
33
+ print(f"{label:24s} {elapsed*1000:8.2f} ms ({count} chunks)")
34
+
35
+
36
+ def main() -> None:
37
+ document = make_sample()
38
+ print(f"document size: {len(document):,} chars\n")
39
+
40
+ time_it("naive_split", lambda: naive_split(document, 512))
41
+ time_it("chunkwise fixed", lambda: chunk(document, strategy="fixed", max_tokens=512))
42
+ time_it("chunkwise recursive", lambda: chunk(document, strategy="recursive", max_tokens=512))
43
+ time_it("chunkwise markdown", lambda: chunk(document, strategy="markdown", max_tokens=512))
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
@@ -0,0 +1,33 @@
1
+ # Design notes: why spans, not strings
2
+
3
+ ## The problem with returning strings
4
+
5
+ Most chunking libraries return `list[str]`. Once text is detached from its origin:
6
+
7
+ - You can't highlight the passage a retrieval hit came from in the original document.
8
+ - You can't expand a result to show the surrounding paragraph.
9
+ - You can't tell which section or heading a passage lived under.
10
+
11
+ Worse, splitters that strip or normalize whitespace silently drop characters — a failure mode that shows up only as retrieval quietly missing content, which is hard to notice and harder to debug.
12
+
13
+ ## The guarantee
14
+
15
+ chunkwise holds one invariant across every strategy and every input:
16
+
17
+ ```python
18
+ document[chunk.start:chunk.end] == chunk.text
19
+ ```
20
+
21
+ This is enforced three ways:
22
+
23
+ 1. **Structurally** — internal logic operates on `(start, end)` integer pairs ([`_spans.py`](../src/chunkwise/_spans.py)) and only materializes text at the final step. No strategy, present or future, can break the invariant even by accident.
24
+ 2. **At runtime** — the `Chunk` type validates its own span on construction (see `Chunk.__post_init__` in [`types.py`](../src/chunkwise/types.py)).
25
+ 3. **In CI** — a Hypothesis property test ([`test_invariants.py`](../tests/test_invariants.py)) runs against generated documents on every commit, alongside a test that concatenating non-overlapping chunks reproduces the source byte for byte.
26
+
27
+ ## Consequences
28
+
29
+ These are deliberate, not accidental:
30
+
31
+ - Chunks are never stripped, normalized, or rewritten.
32
+ - Overlap widens a chunk backwards into real preceding text rather than gluing on a copied prefix.
33
+ - Window boundaries are found by binary search over character offsets (`find_boundary` in `_spans.py`), so the library works with any opaque tokenizer rather than assuming a fixed characters-to-tokens ratio.
@@ -0,0 +1,32 @@
1
+ # chunkwise
2
+
3
+ Text chunking for RAG with reversible offsets — every chunk is an exact slice of the source document.
4
+
5
+ ## Why chunkwise
6
+
7
+ Most chunking libraries hand back a `list[str]`. The moment the text is detached from its origin, you can't highlight the passage a retrieval hit came from, expand a result to show surrounding context, or tell which section a passage lived under.
8
+
9
+ chunkwise holds one invariant across every strategy and every input:
10
+
11
+ ```python
12
+ document[chunk.start:chunk.end] == chunk.text
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```python
18
+ from chunkwise import chunk
19
+
20
+ document = open("report.md").read()
21
+ chunks = chunk(document, strategy="markdown", max_tokens=512)
22
+
23
+ for c in chunks:
24
+ assert document[c.start:c.end] == c.text
25
+ print(c.heading_path, len(c.text))
26
+ ```
27
+
28
+ ## Contents
29
+
30
+ - [Strategies](strategies.md) — fixed, recursive, markdown, and what's planned
31
+ - [Tokenizers](tokenizers.md) — bringing your own token counter
32
+ - [Design](design.md) — why spans, not strings
@@ -0,0 +1,26 @@
1
+ # Strategies
2
+
3
+ | Strategy | Behavior |
4
+ |---|---|
5
+ | `fixed` | Hard windows of N tokens with configurable overlap. Fast, boundary-blind. Suited to logs and unstructured text. |
6
+ | `recursive` | Falls back through a separator chain — paragraphs, then lines, then sentences, then words — breaking on the coarsest boundary that fits the budget. The default. |
7
+ | `markdown` | Keeps fenced code blocks intact and tags every chunk with its heading path (e.g. `["Guide", "Install"]`), giving retrieval results a sense of place. |
8
+
9
+ ## Planned
10
+
11
+ - `code` (v0.2) — splits on function and class boundaries via `ast` and `tree-sitter`.
12
+ - `semantic` (v0.3) — cuts where sentence-embedding similarity drops. Ships as an optional extra (`pip install chunkwise[semantic]`) so the base install stays dependency-free.
13
+
14
+ ## Choosing a strategy
15
+
16
+ - Use `fixed` when the input has no structure to exploit (logs, transcripts) or when you need predictable, uniform chunk sizes.
17
+ - Use `recursive` as the default for prose and general text.
18
+ - Use `markdown` for documentation, READMEs, or any Markdown source where heading context and code fences matter.
19
+
20
+ ## Common parameters
21
+
22
+ Every strategy takes the same three parameters:
23
+
24
+ - `max_tokens` — the token budget per chunk, as measured by the tokenizer.
25
+ - `overlap` — how many tokens of overlap between consecutive chunks. Overlap widens a chunk backwards into real preceding text; it never duplicates a copied prefix.
26
+ - `tokenizer` — see [tokenizers.md](tokenizers.md).
@@ -0,0 +1,46 @@
1
+ # Tokenizers
2
+
3
+ Token budgets depend on which model you're embedding for, so chunkwise doesn't hard-code one.
4
+
5
+ The `tokenizer` parameter accepts any callable from `str` to `int`:
6
+
7
+ ```python
8
+ class Tokenizer(Protocol):
9
+ def __call__(self, text: str) -> int: ...
10
+ ```
11
+
12
+ ## Default
13
+
14
+ The default counts characters, keeping the base install at zero dependencies:
15
+
16
+ ```python
17
+ DEFAULT_TOKENIZER = len
18
+ ```
19
+
20
+ ## Bring your own
21
+
22
+ Pass a real token counter when you need accurate budgets for a specific model.
23
+
24
+ ### tiktoken
25
+
26
+ ```python
27
+ import tiktoken
28
+ from chunkwise import chunk
29
+
30
+ enc = tiktoken.get_encoding("cl100k_base")
31
+ chunks = chunk(document, max_tokens=512, tokenizer=lambda text: len(enc.encode(text)))
32
+ ```
33
+
34
+ ### transformers
35
+
36
+ ```python
37
+ from transformers import AutoTokenizer
38
+ from chunkwise import chunk
39
+
40
+ tok = AutoTokenizer.from_pretrained("bert-base-uncased")
41
+ chunks = chunk(document, max_tokens=512, tokenizer=lambda text: len(tok.encode(text)))
42
+ ```
43
+
44
+ ## The only requirement
45
+
46
+ The token count must be monotonic in string length — a longer string never produces fewer tokens. Every real tokenizer satisfies this, and it's what lets chunkwise find chunk boundaries with binary search instead of assuming a fixed characters-to-tokens ratio.
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chunkwise"
7
+ version = "0.1.0"
8
+ description = "Text chunking for RAG with reversible offsets — every chunk is an exact slice of the source document."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Amin Albooyeh", email = "amin.albooyeh@gmail.com" },
14
+ ]
15
+ keywords = ["rag", "chunking", "nlp", "embeddings", "retrieval"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Text Processing :: Linguistic",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = []
29
+
30
+ [project.optional-dependencies]
31
+ semantic = ["numpy"]
32
+ dev = [
33
+ "pytest>=8.0",
34
+ "hypothesis>=6.0",
35
+ "ruff>=0.6",
36
+ "mypy>=1.10",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/amin-albooyeh/chunkwise"
41
+ Repository = "https://github.com/amin-albooyeh/chunkwise"
42
+ Documentation = "https://github.com/amin-albooyeh/chunkwise/tree/main/docs"
43
+ Changelog = "https://github.com/amin-albooyeh/chunkwise/blob/main/CHANGELOG.md"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/chunkwise"]
47
+
48
+ [tool.ruff]
49
+ line-length = 100
50
+ target-version = "py310"
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "UP", "B"]
54
+
55
+ [tool.mypy]
56
+ python_version = "3.10"
57
+ strict = true
58
+ packages = ["chunkwise"]
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]
@@ -0,0 +1,48 @@
1
+ """chunkwise: text chunking for RAG with reversible offsets.
2
+
3
+ Every chunk carries the exact character range it came from, so
4
+ ``document[chunk.start:chunk.end] == chunk.text`` always holds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._spans import DEFAULT_TOKENIZER
10
+ from .strategies import chunk_fixed, chunk_markdown, chunk_recursive
11
+ from .types import Chunk, Tokenizer
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ _STRATEGIES = {
16
+ "fixed": chunk_fixed,
17
+ "recursive": chunk_recursive,
18
+ "markdown": chunk_markdown,
19
+ }
20
+
21
+
22
+ def chunk(
23
+ document: str,
24
+ strategy: str = "recursive",
25
+ max_tokens: int = 512,
26
+ overlap: int = 0,
27
+ tokenizer: Tokenizer = DEFAULT_TOKENIZER,
28
+ ) -> list[Chunk]:
29
+ """Split `document` into a list of Chunk objects.
30
+
31
+ Args:
32
+ document: the source text to split.
33
+ strategy: one of "fixed", "recursive" (default), "markdown".
34
+ max_tokens: the token budget per chunk, as measured by `tokenizer`.
35
+ overlap: how many tokens of overlap between consecutive chunks.
36
+ tokenizer: a callable from str to int token count. Defaults to
37
+ character counting, keeping the base install dependency-free.
38
+ """
39
+ try:
40
+ strategy_fn = _STRATEGIES[strategy]
41
+ except KeyError:
42
+ raise ValueError(
43
+ f"Unknown strategy {strategy!r}. Available: {sorted(_STRATEGIES)}"
44
+ ) from None
45
+ return strategy_fn(document, max_tokens, overlap, tokenizer)
46
+
47
+
48
+ __all__ = ["chunk", "Chunk", "Tokenizer", "__version__"]
@@ -0,0 +1,47 @@
1
+ """Internal offset arithmetic shared by strategies.
2
+
3
+ Strategies operate on (start, end) integer spans, not strings, until the
4
+ final materialization step. This is what makes the offset invariant
5
+ structurally impossible to break.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .types import Chunk, Tokenizer
11
+
12
+ def _char_count(text: str) -> int:
13
+ return len(text)
14
+
15
+
16
+ DEFAULT_TOKENIZER: Tokenizer = _char_count
17
+
18
+
19
+ def materialize(document: str, spans: list[tuple[int, int]]) -> list[Chunk]:
20
+ """Turn (start, end) spans into Chunk objects with text sliced from document."""
21
+ return [
22
+ Chunk(text=document[start:end], start=start, end=end, index=i)
23
+ for i, (start, end) in enumerate(spans)
24
+ ]
25
+
26
+
27
+ def find_boundary(
28
+ document: str,
29
+ start: int,
30
+ budget: int,
31
+ tokenizer: Tokenizer,
32
+ ) -> int:
33
+ """Binary search for the largest end offset such that tokenizer(document[start:end]) <= budget.
34
+
35
+ Works with any opaque tokenizer rather than assuming a fixed characters-to-tokens ratio.
36
+ """
37
+ lo, hi = start, len(document)
38
+ if tokenizer(document[start:hi]) <= budget:
39
+ return hi
40
+
41
+ while hi - lo > 1:
42
+ mid = (lo + hi) // 2
43
+ if tokenizer(document[start:mid]) <= budget:
44
+ lo = mid
45
+ else:
46
+ hi = mid
47
+ return lo if lo > start else min(start + 1, len(document))
File without changes
@@ -0,0 +1,7 @@
1
+ """Chunking strategies."""
2
+
3
+ from .fixed import chunk_fixed
4
+ from .markdown import chunk_markdown
5
+ from .recursive import chunk_recursive
6
+
7
+ __all__ = ["chunk_fixed", "chunk_recursive", "chunk_markdown"]
@@ -0,0 +1,19 @@
1
+ """Code-aware strategy: splits on function and class boundaries.
2
+
3
+ Planned for v0.2. Will use `ast` for Python and `tree-sitter` for other
4
+ languages to find boundaries, then chunk on those spans the same way
5
+ `recursive` chunks on separators.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..types import Chunk, Tokenizer
11
+
12
+
13
+ def chunk_code(
14
+ document: str,
15
+ max_tokens: int,
16
+ overlap: int,
17
+ tokenizer: Tokenizer,
18
+ ) -> list[Chunk]:
19
+ raise NotImplementedError("chunk_code is planned for v0.2")
@@ -0,0 +1,36 @@
1
+ """Fixed-window strategy: hard windows of N tokens with configurable overlap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .._spans import find_boundary, materialize
6
+ from ..types import Chunk, Tokenizer
7
+
8
+
9
+ def chunk_fixed(
10
+ document: str,
11
+ max_tokens: int,
12
+ overlap: int,
13
+ tokenizer: Tokenizer,
14
+ ) -> list[Chunk]:
15
+ """Split document into hard windows of at most max_tokens, overlapping by `overlap` tokens.
16
+
17
+ Overlap widens a chunk backwards into real preceding text rather than
18
+ gluing on a copied prefix, preserving the offset invariant.
19
+ """
20
+ if max_tokens <= 0:
21
+ raise ValueError("max_tokens must be positive")
22
+ if overlap < 0 or overlap >= max_tokens:
23
+ raise ValueError("overlap must be in [0, max_tokens)")
24
+
25
+ spans: list[tuple[int, int]] = []
26
+ start = 0
27
+ n = len(document)
28
+ while start < n:
29
+ end = find_boundary(document, start, max_tokens, tokenizer)
30
+ spans.append((start, end))
31
+ if end >= n:
32
+ break
33
+ back_start = find_boundary(document, start, max_tokens - overlap, tokenizer)
34
+ start = max(back_start, start + 1)
35
+
36
+ return materialize(document, spans)
@@ -0,0 +1,75 @@
1
+ """Markdown-aware strategy: keeps fenced code blocks intact, tags chunks with heading paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .._spans import find_boundary
8
+ from ..types import Chunk, Tokenizer
9
+
10
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$", re.MULTILINE)
11
+ FENCE_RE = re.compile(r"^```.*?$.*?^```$", re.MULTILINE | re.DOTALL)
12
+
13
+
14
+ def _code_fence_spans(document: str) -> list[tuple[int, int]]:
15
+ return [(m.start(), m.end()) for m in FENCE_RE.finditer(document)]
16
+
17
+
18
+ def _heading_path_at(document: str, offset: int) -> tuple[str, ...]:
19
+ stack: list[tuple[int, str]] = []
20
+ for match in HEADING_RE.finditer(document):
21
+ if match.start() > offset:
22
+ break
23
+ level = len(match.group(1))
24
+ title = match.group(2).strip()
25
+ stack = [(lvl, t) for lvl, t in stack if lvl < level]
26
+ stack.append((level, title))
27
+ return tuple(title for _, title in stack)
28
+
29
+
30
+ def chunk_markdown(
31
+ document: str,
32
+ max_tokens: int,
33
+ overlap: int,
34
+ tokenizer: Tokenizer,
35
+ ) -> list[Chunk]:
36
+ """Split markdown document, never breaking a fenced code block, tagging heading paths."""
37
+ if max_tokens <= 0:
38
+ raise ValueError("max_tokens must be positive")
39
+ if overlap < 0 or overlap >= max_tokens:
40
+ raise ValueError("overlap must be in [0, max_tokens)")
41
+
42
+ fences = _code_fence_spans(document)
43
+ spans: list[tuple[int, int]] = []
44
+ start = 0
45
+ n = len(document)
46
+
47
+ while start < n:
48
+ hard_limit = find_boundary(document, start, max_tokens, tokenizer)
49
+
50
+ for fence_start, fence_end in fences:
51
+ if fence_start < hard_limit < fence_end:
52
+ hard_limit = fence_end
53
+ break
54
+
55
+ end = max(hard_limit, start + 1)
56
+ end = min(end, n)
57
+ spans.append((start, end))
58
+ if end >= n:
59
+ break
60
+ if overlap == 0:
61
+ start = end
62
+ else:
63
+ back_start = find_boundary(document, start, max_tokens - overlap, tokenizer)
64
+ start = max(min(back_start, end), start + 1)
65
+
66
+ return [
67
+ Chunk(
68
+ text=document[s:e],
69
+ start=s,
70
+ end=e,
71
+ index=i,
72
+ heading_path=_heading_path_at(document, s),
73
+ )
74
+ for i, (s, e) in enumerate(spans)
75
+ ]
@@ -0,0 +1,61 @@
1
+ """Recursive strategy: falls back through a separator chain to find natural breaks.
2
+
3
+ Breaks on the coarsest boundary that fits the budget — paragraphs, then lines,
4
+ then sentences, then words. This is the default strategy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from .._spans import find_boundary, materialize
12
+ from ..types import Chunk, Tokenizer
13
+
14
+ SEPARATOR_CHAIN = [
15
+ re.compile(r"\n\s*\n"), # paragraphs
16
+ re.compile(r"\n"), # lines
17
+ re.compile(r"(?<=[.!?])\s+"), # sentences
18
+ re.compile(r"\s+"), # words
19
+ ]
20
+
21
+
22
+ def _best_split_point(document: str, start: int, limit: int) -> int:
23
+ """Find the best separator-aligned split point within (start, limit]."""
24
+ window = document[start:limit]
25
+ for separator in SEPARATOR_CHAIN:
26
+ matches = list(separator.finditer(window))
27
+ if matches:
28
+ last = matches[-1]
29
+ split = start + last.end()
30
+ if split > start:
31
+ return split
32
+ return limit
33
+
34
+
35
+ def chunk_recursive(
36
+ document: str,
37
+ max_tokens: int,
38
+ overlap: int,
39
+ tokenizer: Tokenizer,
40
+ ) -> list[Chunk]:
41
+ """Split document on the coarsest separator that keeps each chunk within budget."""
42
+ if max_tokens <= 0:
43
+ raise ValueError("max_tokens must be positive")
44
+ if overlap < 0 or overlap >= max_tokens:
45
+ raise ValueError("overlap must be in [0, max_tokens)")
46
+
47
+ spans: list[tuple[int, int]] = []
48
+ start = 0
49
+ n = len(document)
50
+ while start < n:
51
+ hard_limit = find_boundary(document, start, max_tokens, tokenizer)
52
+ end = _best_split_point(document, start, hard_limit) if hard_limit < n else hard_limit
53
+ if end <= start:
54
+ end = hard_limit
55
+ spans.append((start, end))
56
+ if end >= n:
57
+ break
58
+ back_start = find_boundary(document, start, max_tokens - overlap, tokenizer)
59
+ start = max(min(back_start, end), start + 1)
60
+
61
+ return materialize(document, spans)
@@ -0,0 +1,20 @@
1
+ """Semantic strategy: cuts where sentence-embedding similarity drops.
2
+
3
+ Planned for v0.3, shipping as an optional extra (`pip install chunkwise[semantic]`)
4
+ so the base install stays dependency-free.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..types import Chunk, Tokenizer
10
+
11
+
12
+ def chunk_semantic(
13
+ document: str,
14
+ max_tokens: int,
15
+ overlap: int,
16
+ tokenizer: Tokenizer,
17
+ ) -> list[Chunk]:
18
+ raise NotImplementedError(
19
+ "chunk_semantic is planned for v0.3 and requires the 'semantic' extra"
20
+ )
@@ -0,0 +1,41 @@
1
+ """Core types: the Chunk dataclass and the Tokenizer protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Protocol, runtime_checkable
7
+
8
+
9
+ @runtime_checkable
10
+ class Tokenizer(Protocol):
11
+ """Any callable from str to an integer token count.
12
+
13
+ The only requirement is that the count is monotonic in string length —
14
+ every real tokenizer (character counting, tiktoken, transformers) satisfies this.
15
+ """
16
+
17
+ def __call__(self, text: str) -> int: ...
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Chunk:
22
+ """A chunk of a source document.
23
+
24
+ The invariant ``document[chunk.start:chunk.end] == chunk.text`` is enforced
25
+ on construction: a Chunk cannot exist with a span that doesn't match its text.
26
+ """
27
+
28
+ text: str
29
+ start: int
30
+ end: int
31
+ index: int = 0
32
+ heading_path: tuple[str, ...] = field(default_factory=tuple)
33
+
34
+ def __post_init__(self) -> None:
35
+ if self.end - self.start != len(self.text):
36
+ raise ValueError(
37
+ f"Chunk span ({self.start}, {self.end}) does not match "
38
+ f"text length {len(self.text)}"
39
+ )
40
+ if self.start < 0 or self.end < self.start:
41
+ raise ValueError(f"Invalid span: ({self.start}, {self.end})")
@@ -0,0 +1,26 @@
1
+ # Guide
2
+
3
+ Welcome to the guide. This section introduces the project.
4
+
5
+ ## Install
6
+
7
+ Run the following command to install the package:
8
+
9
+ ```bash
10
+ pip install chunkwise
11
+ ```
12
+
13
+ That's all you need for the base install.
14
+
15
+ ## Usage
16
+
17
+ ```python
18
+ from chunkwise import chunk
19
+
20
+ chunks = chunk("some long document", strategy="markdown")
21
+ ```
22
+
23
+ ### Advanced
24
+
25
+ Heading paths let you tag each chunk with its place in the document, such
26
+ as `["Guide", "Usage", "Advanced"]`.
@@ -0,0 +1,8 @@
1
+ This is the first paragraph of the sample text fixture. It contains a few
2
+ sentences to give chunking strategies something realistic to split.
3
+
4
+ This is the second paragraph. It is separated from the first by a blank
5
+ line, which the recursive strategy should treat as the coarsest boundary.
6
+
7
+ The third and final paragraph is here to make sure multi-chunk splits have
8
+ at least three natural breakpoints to work with during tests.
@@ -0,0 +1,49 @@
1
+ """Hypothesis property tests for the core offset invariant.
2
+
3
+ The invariant: document[chunk.start:chunk.end] == chunk.text, for every
4
+ chunk, from every strategy, on every input.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from hypothesis import given
10
+ from hypothesis import strategies as st
11
+
12
+ from chunkwise import chunk
13
+
14
+ STRATEGIES = ["fixed", "recursive", "markdown"]
15
+
16
+
17
+ @given(
18
+ document=st.text(min_size=0, max_size=500),
19
+ strategy=st.sampled_from(STRATEGIES),
20
+ max_tokens=st.integers(min_value=1, max_value=200),
21
+ )
22
+ def test_offset_invariant_holds(document: str, strategy: str, max_tokens: int) -> None:
23
+ chunks = chunk(document, strategy=strategy, max_tokens=max_tokens, overlap=0)
24
+ for c in chunks:
25
+ assert document[c.start : c.end] == c.text
26
+
27
+
28
+ @given(
29
+ document=st.text(min_size=1, max_size=500),
30
+ max_tokens=st.integers(min_value=1, max_value=200),
31
+ )
32
+ def test_non_overlapping_chunks_reconstruct_source(document: str, max_tokens: int) -> None:
33
+ chunks = chunk(document, strategy="fixed", max_tokens=max_tokens, overlap=0)
34
+ reconstructed = "".join(c.text for c in chunks)
35
+ assert reconstructed == document
36
+
37
+
38
+ @given(
39
+ document=st.text(min_size=0, max_size=300),
40
+ max_tokens=st.integers(min_value=1, max_value=100),
41
+ )
42
+ def test_chunks_are_never_empty_except_for_empty_document(
43
+ document: str, max_tokens: int
44
+ ) -> None:
45
+ chunks = chunk(document, strategy="recursive", max_tokens=max_tokens, overlap=0)
46
+ if document == "":
47
+ assert chunks == []
48
+ else:
49
+ assert all(len(c.text) > 0 for c in chunks)
@@ -0,0 +1,59 @@
1
+ """Per-strategy behaviour tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from chunkwise import chunk
10
+
11
+ FIXTURES = Path(__file__).parent / "fixtures"
12
+
13
+
14
+ def test_fixed_respects_max_tokens() -> None:
15
+ document = "a" * 1000
16
+ chunks = chunk(document, strategy="fixed", max_tokens=100, overlap=0)
17
+ assert all(len(c.text) <= 100 for c in chunks)
18
+ assert "".join(c.text for c in chunks) == document
19
+
20
+
21
+ def test_fixed_overlap_widens_backwards() -> None:
22
+ document = "abcdefghijklmnopqrstuvwxyz" * 10
23
+ chunks = chunk(document, strategy="fixed", max_tokens=20, overlap=5)
24
+ assert len(chunks) > 1
25
+ for prev, nxt in zip(chunks, chunks[1:], strict=False):
26
+ assert nxt.start < prev.end
27
+
28
+
29
+ def test_fixed_rejects_invalid_overlap() -> None:
30
+ with pytest.raises(ValueError):
31
+ chunk("some text", strategy="fixed", max_tokens=10, overlap=10)
32
+
33
+
34
+ def test_recursive_prefers_paragraph_breaks() -> None:
35
+ document = (FIXTURES / "sample.txt").read_text()
36
+ chunks = chunk(document, strategy="recursive", max_tokens=200, overlap=0)
37
+ assert len(chunks) >= 1
38
+ for c in chunks:
39
+ assert document[c.start : c.end] == c.text
40
+
41
+
42
+ def test_markdown_tags_heading_path() -> None:
43
+ document = (FIXTURES / "sample.md").read_text()
44
+ chunks = chunk(document, strategy="markdown", max_tokens=80, overlap=0)
45
+ assert any(c.heading_path for c in chunks)
46
+ install_chunks = [c for c in chunks if c.heading_path and c.heading_path[-1] == "Install"]
47
+ assert install_chunks
48
+
49
+
50
+ def test_markdown_keeps_code_fence_intact() -> None:
51
+ document = (FIXTURES / "sample.md").read_text()
52
+ chunks = chunk(document, strategy="markdown", max_tokens=15, overlap=0)
53
+ for c in chunks:
54
+ assert c.text.count("```") % 2 == 0
55
+
56
+
57
+ def test_unknown_strategy_raises() -> None:
58
+ with pytest.raises(ValueError):
59
+ chunk("text", strategy="nonexistent")
@@ -0,0 +1,42 @@
1
+ """Custom tokenizer contract tests.
2
+
3
+ The only requirement on a tokenizer is that it's a callable from str to int,
4
+ monotonic in string length. These tests check chunkwise honors whatever
5
+ tokenizer it's given rather than assuming a characters-to-tokens ratio.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from chunkwise import chunk
11
+
12
+
13
+ def word_count_tokenizer(text: str) -> int:
14
+ return len(text.split())
15
+
16
+
17
+ def test_default_tokenizer_counts_characters() -> None:
18
+ document = "x" * 50
19
+ chunks = chunk(document, strategy="fixed", max_tokens=10, overlap=0)
20
+ assert all(len(c.text) <= 10 for c in chunks)
21
+
22
+
23
+ def test_custom_tokenizer_is_respected() -> None:
24
+ document = " ".join(["word"] * 100)
25
+ chunks = chunk(
26
+ document,
27
+ strategy="fixed",
28
+ max_tokens=10,
29
+ overlap=0,
30
+ tokenizer=word_count_tokenizer,
31
+ )
32
+ for c in chunks:
33
+ assert word_count_tokenizer(c.text) <= 10
34
+
35
+
36
+ def test_tokenizer_affects_chunk_count() -> None:
37
+ document = " ".join(["word"] * 100)
38
+ char_chunks = chunk(document, strategy="fixed", max_tokens=50, overlap=0)
39
+ word_chunks = chunk(
40
+ document, strategy="fixed", max_tokens=50, overlap=0, tokenizer=word_count_tokenizer
41
+ )
42
+ assert len(char_chunks) != len(word_chunks) or len(document) == 0