Files
catena/crates/textkit/README.md
T

122 lines
4.2 KiB
Markdown
Raw Normal View History

# textkit
Token counting, sentence and paragraph splitting.
Part of [catena](../../README.md).
## What it is
A library. Two jobs, both of which have to give the same answer everywhere or
chunk boundaries move: counting tokens the way an embedding stack counts them,
and cutting text into sentences and paragraphs.
## When you would use it
- **Deciding how much text fits** in a model's context, before sending it.
- **Writing your own chunker** that has to agree with
[`chunkmd`](../chunkmd/README.md) about where a boundary is.
- **Porting anything from Python** where the exact behaviour of `\s`, `\w` or
`\b` matters more than it looks like it should.
## Counting tokens
```rust
let tokens = textkit::count("the mill stood at the northern end");
let ids = textkit::encode("…");
let text = textkit::decode_lossy(&ids[..100]);
```
`cl100k_base`, loaded from data compiled into the binary — no download, no
cache directory, nothing to go stale. Counting against a different tokeniser
than the one an index was built with silently moves every chunk boundary,
which is why this is one function and not a parameter.
Special-token spellings are treated as ordinary text. Text out of a PDF can
contain anything, including the literal string `<|endoftext|>`, and encoding
it as a control token would be both wrong and a way for a document to reach
into a model's input.
Cutting a list of tokens at an arbitrary index can split a character in half,
so decoding a slice is lossy rather than fallible:
```rust
// The last resort of a chunker: guarantees a maximum, respects nothing else.
for piece in textkit::split_at_token_limit(&text, 512) {
assert!(textkit::count(&piece) <= 512);
}
```
## Splitting text
```rust
for paragraph in textkit::paragraphs(&markdown) {
for sentence in textkit::sentences(paragraph) {
// …
}
}
```
Both return borrowed slices, trimmed, with the empty pieces dropped.
A paragraph break is a newline, any run of whitespace, and a newline — so one
blank line and four of them separate equally, and trailing spaces on an
otherwise blank line do not stop it being blank.
A sentence break is whitespace following `.`, `!` or `?`. Crude on purpose: it
does not know that `Mr.` or `p. 41` are not sentence ends. It is the fallback
path for a paragraph too large to chunk whole, where a wrong cut costs a
little context and a clever cut that disagrees with an existing index costs a
re-embedding.
## Whitespace
```rust
let trimmed = text.trim_matches(textkit::is_space);
```
`is_space` is whitespace by Python's definition: the Unicode `White_Space`
property *and* the four ASCII information separators `U+001C`–`U+001F`.
Trimming with the narrower definition leaves a separator on the front of a
piece, which changes its token count, which moves a chunk boundary.
## Numbers
```rust
assert_eq!(textkit::decimal("<!-- Page 412 -->"), 412);
assert_eq!(textkit::decimal("٤٢"), 42);
```
Every decimal digit, not only the ASCII ones. Every Unicode decimal block runs
zero to nine in consecutive code points, which is what makes this possible
without a lookup table.
## Patterns
Character classes that mean in Rust what they mean in Python:
```rust
use textkit::pattern::{SPACE, WORD, WORD_START, WORD_END};
let year = fancy_regex::Regex::new(&format!(r"{WORD_START}(19|20)\d{{2}}{WORD_END}"))?;
let heading = regex::Regex::new(&format!(r"(?m)^(#{{1,3}}){SPACE}+(.+)$"))?;
```
The two regex engines disagree, and every disagreement is a heuristic that
fires on one side and not the other:
- Python's `\s` also matches `U+001C`–`U+001F`.
- Python's `\w` is the letters, the numbers and the underscore. Rust's adds
every combining mark and every connector punctuation mark, and leaves out
the letter-like and other numbers — so `Gödel` written in decomposed form is
one word to Rust and two to Python, and `²` is a word character to Python
and not to Rust.
- `\b` is defined in terms of `\w`, so it inherits all of that.
The boundary forms are lookarounds, so a pattern using them needs an engine
that has them.
## See also
- [`corpus-core`](../corpus-core/README.md) — the records these help produce.
- [`chunkmd`](../chunkmd/README.md) — the chunker built on this.