tinyzchunk
A GPU-free, tokenizer-free chunker for RAG pipelines, distilled from the zChunk idea of using an LLM's split-token probabilities to decide where a document breaks. The teacher LLM runs once, offline, to produce labels; what ships is two small MLPs that read raw characters and run on any CPU with nothing but numpy.
Tuned for English and Brazilian Portuguese, and specifically hardened for the messy text a real pipeline actually sees: PDF extractions with mid-word line wrapping, page numbers and form feeds, OCR-mangled words, CRLF files, markdown, code fences, tables, chat logs and legal enumerations.
pip install tinyzchunk
from tinyzchunk import Chunker
chunker = Chunker() # weights fetched from HuggingFace, then cached
chunks = chunker.chunk(document) # -> list[str]
python -m tinyzchunk document.txt --json
cat document.txt | python -m tinyzchunk
Guarantees
Every chunk is an exact substring of the input, and these properties are enforced by the test suite for any input at all:
- no content is ever dropped or duplicated;
- no chunk begins in the middle of a word;
max_chunk_charsis never exceeded;- a CRLF file chunks identically to the same file with unix line endings;
- fenced code blocks and markdown tables are never cut apart;
- degenerate inputs (empty, whitespace-only, one 5 MB line) do not raise.
How it works
┌──────────────────────────┐
corpus ─────► │ LLM teacher (GPU, once) │──► boundary labels ──► training
└──────────────────────────┘
┌──────────────────────────┐ ┌──────────────────────┐
text ───────► │ char features (numpy) │──►│ two tiny MLPs │──► chunks
└──────────────────────────┘ └──────────────────────┘
Feature extraction (features.py) computes 102 features per character with
no tokenizer: character classes, sentence structure, and line-level signals
broadcast across each line. Crucially the line signals are mostly relative —
does this line share a layout signature with its neighbours, how long is it
compared with the document average, does the previous line end mid-sentence —
so unseen formats still produce usable evidence instead of falling off a cliff.
Input is first normalized: unicode spaces, quotes, dashes, bullets, ellipses and form feeds are folded to ASCII equivalents one character at a time, so offsets stay valid. The only character ever deleted is the CR of a CRLF pair, which is tracked with an index map.
Two students, both numpy-only at inference:
line_model.py— the primary detector. For each line it sees a window of ±5 neighbouring lines (11 × 102 features) and predicts whether a new unit starts there. This is what finds Q&A pairs, headings, schedule entries, list items and section starts — and what knows not to split wrapped prose, dense field lists, table rows or roster blocks.model.py— a character-level model predicting sentence/paragraph boundaries. It is blended into the line score, drives the fallback for text with no line structure, and supplies the split points when a chunk must be cut down tomax_chunk_chars.
Assembly (chunker.py): line scores → veto anything inside a code fence or
table → snap off mid-word cuts → drop boundaries that would create continuation
fragments or undersized chunks (headings merge forward, so a heading stays with
its body) → enforce max_chunk_chars → slice the original text.
Tuning
Chunker(
big_threshold=0.50, # line-level unit detector sensitivity
small_threshold=0.50, # char-level sensitivity
max_chunk_chars=2500, # hard ceiling
min_chunk_chars=100, # smaller chunks are merged away
char_blend=0.15, # weight of the char model in the line score
adaptive=True, # relax the threshold rather than return nothing
)
min_chunk_chars is the strongest knob. The default of 100 biases toward
fewer, larger chunks: a Q&A script with 80-character turns comes back as
merged pairs rather than one chunk per line. Lower it to ~40 if you want one
chunk per structural unit.
Evaluation
scripts/eval_matrix.py scores the chunker across 95 held-out scenario
buckets — synthetic document families, boundary-preserving degradations of
them, simulated PDF/OCR noise, plus real documents — and reports boundary F1
together with the failure modes that actually hurt retrieval.
| document family | buckets | boundary F1 |
|---|---|---|
| markdown, code, tables | 12 | 0.97 |
| sectioned prose, headings, bios | 15 | 0.97 |
| legal articles and enumerations | 4 | 0.87 |
| schedules and field blocks | 15 | 0.79 |
| Q&A and FAQ | 12 | 0.78 |
| wrapped / OCR-noisy prose | 14 | 0.72 |
| lists that must not split | 12 | 0.70 |
Macro F1 0.795 across all 95 buckets; 0.77 across the 36 noisy-text buckets alone. Fragment chunks (a chunk starting mid-sentence) are 0.08% and oversized chunks 0%.
Held-out real-world documents score 0.53 against a generation-teacher reference, but that reference is itself inconsistent — some documents are labelled far more coarsely than others — so treat it as a lower bound and read the dumped chunks.
Speed on one CPU core: ~26 ms for a 3 kB document, ~120 ms for a 21 kB one. Weights total ≈2.1 MB.
Reproducing the distillation
python scripts/build_synth.py # synthetic EN+PT document families
python scripts/build_augment.py # boundary-preserving degradations
python scripts/build_noisy.py # simulated PDF/OCR noise
# label real prose with the LLM teachers (GPU, optional - see data/ for outputs)
python scripts/teacher.py --in data/corpus.jsonl --out data/labels/labels.jsonl
python scripts/teacher_gen.py --in data/struct_corpus.jsonl --out data/struct_labels/labels.jsonl
python scripts/train.py # char model -> weights.npz
python scripts/train_line.py # line model -> line_weights.npz
python scripts/eval_matrix.py # the regression matrix
pytest tests/
Both students train in seconds per epoch on a consumer GPU; the corpus is ~19k labelled documents, most of them constructed on CPU without an LLM.
Two notes for anyone extending this, both learned the hard way:
- The log-probability teacher places a large share of its boundaries inside
words.
labels.py::snap_positionspulls every label onto a line start or word start; without it the student learns to cut mid-word and every corpus derived from those labels inherits the damage. - Training-data balance is a tightrope — adding one negative pattern routinely
breaks a positive one. Run the full matrix (
--compareagainst the previous run) before shipping weights; never judge a change on one document family.
Layout
tinyzchunk/ the library (numpy only at inference)
chunker.py chunk() API and assembly rules
features.py 102 per-character features + normalization
line_model.py line-level unit-start model
model.py char-level boundary model
labels.py teacher labels -> clean boundary labels
scripts/ corpus builders, LLM teachers, training, evaluation
tests/ behavioural guarantees
Weights live in the HuggingFace repo
cnmoro/tinyzchunk and are fetched
on first use; the pip package itself ships no weights.
Licence: Apache-2.0.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tinyzchunk-0.3.0.tar.gz.
File metadata
- Download URL: tinyzchunk-0.3.0.tar.gz
- Upload date:
- Size: 27.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.12.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6867da31d70dbc449b858b2d7140ac740728ab2533e9f422d7bc6912a25d5333
|
|
| MD5 |
bda21cc65376f891f0f82c41e7a1c58b
|
|
| BLAKE2b-256 |
7b45d5cb9bce5b64464e71dbf98d1df53b32bcf06407fb2768367860b0929e90
|
File details
Details for the file tinyzchunk-0.3.0-py3-none-any.whl.
File metadata
- Download URL: tinyzchunk-0.3.0-py3-none-any.whl
- Upload date:
- Size: 24.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.12.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d6ea7b797de6b5b49b6badd160806f4f77f80a646afa395b7247c7494a2899ed
|
|
| MD5 |
6f62a96001ae08313d025222d2bf9587
|
|
| BLAKE2b-256 |
d36b6b01e9fa618eb8f65cd96d15b87f988d71f1bcb0ef72809c4ac3c7d87255
|