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.
- chunkwise-0.1.0/.github/workflows/ci.yml +32 -0
- chunkwise-0.1.0/.github/workflows/publish.yml +28 -0
- chunkwise-0.1.0/.gitignore +15 -0
- chunkwise-0.1.0/CHANGELOG.md +16 -0
- chunkwise-0.1.0/CONTRIBUTING.md +32 -0
- chunkwise-0.1.0/LICENSE +21 -0
- chunkwise-0.1.0/PKG-INFO +168 -0
- chunkwise-0.1.0/README.md +136 -0
- chunkwise-0.1.0/benchmarks/throughput.py +47 -0
- chunkwise-0.1.0/docs/design.md +33 -0
- chunkwise-0.1.0/docs/index.md +32 -0
- chunkwise-0.1.0/docs/strategies.md +26 -0
- chunkwise-0.1.0/docs/tokenizers.md +46 -0
- chunkwise-0.1.0/pyproject.toml +61 -0
- chunkwise-0.1.0/src/chunkwise/__init__.py +48 -0
- chunkwise-0.1.0/src/chunkwise/_spans.py +47 -0
- chunkwise-0.1.0/src/chunkwise/py.typed +0 -0
- chunkwise-0.1.0/src/chunkwise/strategies/__init__.py +7 -0
- chunkwise-0.1.0/src/chunkwise/strategies/code.py +19 -0
- chunkwise-0.1.0/src/chunkwise/strategies/fixed.py +36 -0
- chunkwise-0.1.0/src/chunkwise/strategies/markdown.py +75 -0
- chunkwise-0.1.0/src/chunkwise/strategies/recursive.py +61 -0
- chunkwise-0.1.0/src/chunkwise/strategies/semantic.py +20 -0
- chunkwise-0.1.0/src/chunkwise/types.py +41 -0
- chunkwise-0.1.0/tests/fixtures/sample.md +26 -0
- chunkwise-0.1.0/tests/fixtures/sample.txt +8 -0
- chunkwise-0.1.0/tests/test_invariants.py +49 -0
- chunkwise-0.1.0/tests/test_strategies.py +59 -0
- chunkwise-0.1.0/tests/test_tokenizers.py +42 -0
|
@@ -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,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.
|
chunkwise-0.1.0/LICENSE
ADDED
|
@@ -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.
|
chunkwise-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/amin-albooyeh/chunkwise/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/chunkwise/)
|
|
39
|
+
[](https://pypi.org/project/chunkwise/)
|
|
40
|
+
[](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
|
+
[](https://github.com/amin-albooyeh/chunkwise/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/chunkwise/)
|
|
7
|
+
[](https://pypi.org/project/chunkwise/)
|
|
8
|
+
[](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,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
|