Skip to content

Choosing an API: tokenization

Verbora gives you several ways to split one string into tokens. They are not alternative implementations — there is exactly one implementation of each tokenizer's behaviour, and every convenience method is defined on top of it. They are alternative ways of moving the tokens from the tokenizer to you, and they differ in who owns the memory.

This page is about picking one. For what each tokenizer does, see Tokenizers. For the conventions this page is an instance of, see API shapes.

The three core shapes

Here is the entire trait. Two of the three methods are one line each:

rust
pub trait Tokenize {
    type Token<'a>;

    fn tokens<'a>(&self, text: &'a str) -> impl Iterator<Item = Self::Token<'a>>;

    fn tokenize<'a>(&self, text: &'a str) -> Vec<Self::Token<'a>> {
        self.tokens(text).collect()
    }

    fn tokenize_into<'a>(&self, text: &'a str, out: &mut Vec<Self::Token<'a>>) {
        out.extend(self.tokens(text));
    }
}

There is no behaviour in tokenize or tokenize_into that is not in tokens. Choosing between them is choosing a memory strategy, never a result:

Peak memoryFirst token visibleAllocations
tokens()one tokenimmediately — find/any can stop the scannone
tokenize()the whole token listafter the last token is producedone Vec, grown by doubling
tokenize_into()the whole token listafter the last token is producednone once your buffer is warm
Careful. tokenize_into does not clear out; its body is out.extend(self.tokens(text)). The buf.clear() at the top of a reuse loop is your line, and leaving it out gives you a buffer holding every document at once.

Every entry point, compared

APIBest forLazyBuffer reuseAllocationsToken type
Tokenize::tokenspipelines, folds, early exitn/anone, for the 13 slicing tokenizersSelf::Token<'a>
Tokenize::tokenizeone document, simplest callone Vec, grown by doublingSelf::Token<'a>
Tokenize::tokenize_intoa corpus through one buffernone once the buffer is warmSelf::Token<'a>
verbora_core::Tokenizer::tokenizegeneric code; owned tokensone Vec plus one String per tokenString
verbora_core::Tokenizer::tokenize_intogeneric code, warm Vec✅ (the Vec only)one String per tokenString
verbora_core::Tokenizer::tokenize_batcha slice of documents in one callouter Vec + inner Vec + String per tokenVec<Vec<String>>
verbora_core::BorrowingTokenizer::tokenize_borrowedgeneric code, zero-copyone Vec&'a str
verbora_core::BorrowingTokenizer::tokenize_borrowed_intogeneric code, zero-copy, warm buffernone once warm&'a str
inherent methods on the four regex tokenizersthose four tokenizersmixed✅ (_into)mixedwrapped in Option
Five tokenizers are not lazy. TreebankWordTokenizer, TokenizerJa and SentenceTokenizer build the whole list before tokens() yields its first item, because their algorithms are inherently whole-text; AggressiveTokenizerNo and AggressiveTokenizerSv normalize the text first and then scan it lazily; CaseTokenizer is lazy on ASCII only. Among the regex tokenizers, RegexpTokenizer::tokens also collects, because it has to know whether the scan found no match at all before it can hand you anything. The signature stays uniform so generic code need not special-case them, but tokens() will not save you an allocation there.

Decision table

Your situationCall
I name a concrete tokenizer type, and look at each token oncetokens()
I name a concrete type and want a Vec to keep, index or returntokenize()
I name a concrete type and am in a loop over many documentsbuf.clear(); tokenize_into(doc, &mut buf)
My function takes "some tokenizer" and needs owned Stringsverbora_core::Tokenizer
My function takes "some tokenizer" and only needs slicesverbora_core::BorrowingTokenizer (13 of 24 types)
My tokenizer is RegexpTokenizer / WordTokenizer / OrthographyTokenizer / WordPunctTokenizerthe inherent methods — they return Option, None meaning "no match at all"
I have a slice of documents and want one callverbora_core::Tokenizer::tokenize_batch (a sequential map — it saves typing, not allocation)

One example per variant

tokens() — the primitive

Use it when the tokens flow straight into something else — a counter, a filter, a hash, a writer — and you never need the list itself.

rust
use std::collections::HashMap;

use verbora_tokenizers::{AggressiveTokenizer, Tokenize};

fn main() {
    let t = AggressiveTokenizer::new();
    let doc = "the cat sat on the mat";

    // A word-frequency map with zero token allocations: the keys borrow `doc`.
    let mut freq: HashMap<&str, usize> = HashMap::new();
    for token in t.tokens(doc) {
        *freq.entry(token).or_default() += 1;
    }
    assert_eq!(freq["the"], 2);

    // Early exit: the scan stops at "cat" and never reaches "mat".
    assert!(t.tokens(doc).any(|w| w == "cat"));
}

tokenize() — the simple one

Random access, len(), sorting and returning the tokens all need the Vec. This is the right default: one allocation, and for the thirteen slicing tokenizers it allocates only the Vec — every token inside it points into your original string.

rust
use verbora_tokenizers::{AggressiveTokenizer, Tokenize};

fn first_and_last(doc: &str) -> Option<(&str, &str)> {
    let tokens = AggressiveTokenizer::new().tokenize(doc);
    Some((*tokens.first()?, *tokens.last()?))
}

fn main() {
    assert_eq!(first_and_last("the quick brown fox"), Some(("the", "fox")));
    assert_eq!(first_and_last("!!!"), None);
}

tokenize_into() — the hot loop

One buffer for a whole corpus. Its advantage is one thing only: no Vec per document. That matters at corpus scale and is invisible at document scale.

rust
use verbora_tokenizers::{AggressiveTokenizer, Tokenize};

fn main() {
    let t = AggressiveTokenizer::new();
    let corpus = ["the quick brown fox", "jumps over", "the lazy dog"];

    // One heap buffer for the whole corpus. Reserve if you know the shape.
    let mut buf: Vec<&str> = Vec::with_capacity(64);
    let mut total = 0usize;
    for doc in corpus {
        buf.clear(); // required: tokenize_into appends
        t.tokenize_into(doc, &mut buf);
        total += buf.len();
    }
    assert_eq!(total, 9);
}

Dropping the clear concatenates the corpus into one token list, which is a legitimate use and the reason the method does not clear on your behalf:

rust
use verbora_tokenizers::{AggressiveTokenizer, Tokenize};

fn main() {
    let t = AggressiveTokenizer::new();
    let mut all: Vec<&str> = Vec::new();
    for doc in ["a b", "c d"] {
        t.tokenize_into(doc, &mut all);
    }
    assert_eq!(all, ["a", "b", "c", "d"]);
}
Note. The buffer's element type is Self::Token<'a>, so a Vec<&'a str> ties itself to the lifetime of the text it borrows from. Reusing one buffer across documents requires every document to outlive the buffer. When your documents are read and dropped one at a time, use verbora_core::Tokenizer::tokenize_into, whose Vec<String> owns its contents — you keep the buffer reuse and pay one String per token.

verbora_core::Tokenizer — generic, owned

Tokenize::Token<'a> is a generic associated type, which is what lets a slicing tokenizer say "my token is a slice of your input" — but it also means a function generic over T: Tokenize cannot do much with the tokens without a for<'a> bound that not every token type satisfies (Utf16Token does not implement AsRef<str>). When you need one signature that works for all of them, use verbora_core::Tokenizer and accept the Strings:

rust
use verbora_core::Tokenizer;
use verbora_tokenizers::{AggressiveTokenizer, SentenceTokenizer, TokenizerJa};

fn longest_token<T: Tokenizer>(t: &T, text: &str) -> Option<String> {
    t.tokenize(text).into_iter().max_by_key(String::len)
}

fn main() {
    assert_eq!(
        longest_token(&AggressiveTokenizer::new(), "a bb ccc"),
        Some("ccc".to_string())
    );
    assert_eq!(
        longest_token(&TokenizerJa::new(), "日本語"),
        Some("日本語".to_string())
    );
    assert_eq!(
        longest_token(&SentenceTokenizer::new(), "Hi. Hello there."),
        Some("Hello there.".to_string())
    );
}

Twenty of the twenty-four tokenizer types implement it. Tokenizer is not object-safetokenize_batch is generic — so Box<dyn Tokenizer> does not compile. If you need runtime dispatch, wrap it in a small dyn-compatible trait of your own with a blanket impl.

verbora_core::BorrowingTokenizer — generic, zero-copy

The compromise between the two: still generic, still zero-copy, but only the thirteen tokenizers whose tokens are always contiguous substrings implement it (the twelve character-class variants and AggressiveTokenizerFa).

rust
use verbora_core::BorrowingTokenizer;
use verbora_tokenizers::{AggressiveTokenizer, AggressiveTokenizerRu};

fn count_long<T: BorrowingTokenizer>(t: &T, docs: &[&str], min: usize) -> usize {
    let mut buf: Vec<&str> = Vec::new();
    let mut n = 0;
    for doc in docs {
        buf.clear();
        t.tokenize_borrowed_into(doc, &mut buf);
        // `str::len()` is bytes. Counting characters keeps the threshold
        // meaningful for non-Latin scripts — "мир" is 3 characters and 6 bytes.
        n += buf.iter().filter(|w| w.chars().count() >= min).count();
    }
    n
}

fn main() {
    assert_eq!(count_long(&AggressiveTokenizer::new(), &["a bb ccc", "dddd"], 3), 2);
    assert_eq!(count_long(&AggressiveTokenizerRu::new(), &["мир да"], 3), 1);
}

The four Option-returning tokenizers

RegexpTokenizer, WordTokenizer, OrthographyTokenizer and WordPunctTokenizer implement neither trait. In matching mode, "no match at all" is a distinct outcome from "matched, but produced zero tokens," and no trait in this workspace can express that distinction. They keep the same three method names as inherent methods, wrapped in Option; their tokenize_into returns bool instead, false meaning "no match at all," in which case nothing was appended.

rust
use verbora_tokenizers::WordTokenizer;

fn main() {
    // Splitting mode (the default) always succeeds.
    let split = WordTokenizer::new();
    assert_eq!(split.tokenize("hello, world"), Some(vec!["hello", "world"]));
    assert_eq!(split.tokenize(""), Some(vec![]));

    // Matching mode can return `None` when nothing matches.
    let m = WordTokenizer::matching();
    assert_eq!(m.tokenize("abc def"), Some(vec![" "]));
    assert_eq!(m.tokenize("abcdef"), None);

    // `tokenize_into` reports the same distinction as a bool.
    let mut buf: Vec<&str> = Vec::new();
    assert!(!m.tokenize_into("abcdef", &mut buf));
    assert!(buf.is_empty());
}

If your application does not care about the distinction, collapse it visibly with .unwrap_or_default() rather than letting it disappear into a ?.

tokenize_batch — a shorter call site, not a faster one

tokenize_batch is a provided method on verbora_core::Tokenizer. Its default body, in full, is a sequential map calling tokenize once per document — and tokenize allocates a fresh Vec every time:

rust
fn tokenize_batch<S: AsRef<str>>(&self, texts: &[S]) -> Vec<Vec<String>> {
    texts.iter().map(|t| self.tokenize(t.as_ref())).collect()
}

No tokenizer in this workspace overrides it. Use it when you want Vec<Vec<String>> and a shorter call site; if you are reaching for it to make a corpus faster, write the loop with tokenize_into instead — that is the API that actually reuses memory.

rust
use verbora_core::Tokenizer;
use verbora_tokenizers::AggressiveTokenizer;

fn main() {
    let t = AggressiveTokenizer::new();
    let docs = ["one two", "three four"];

    // Convenient.
    let batch: Vec<Vec<String>> = t.tokenize_batch(&docs);
    assert_eq!(batch[1], ["three", "four"]);

    // Equivalent, and allocates exactly as much.
    let manual: Vec<Vec<String>> = docs.iter().map(|d| t.tokenize(d)).collect();
    assert_eq!(batch, manual);
}

What does not exist

Stated plainly, so you do not go looking:

  • Parallel tokenization is opt-in, not default. Tokenize::par_tokenize_batch is a provided method behind verbora-tokenizers's parallel Cargo feature, and it is exactly texts.par_iter().map(|t| self.tokenize(t)).collect(). Without that feature every tokenizer is single-threaded. Either way, tokenizers are zero-sized (or immutable), stateless and Send + Sync, so parallelising across documents in your own code is straightforward. See Parallelism.
  • There is no streaming reader API. Every entry point takes a &str that is fully in memory. There is no tokenize_read(impl BufRead).
  • There is no buffer-reusing batch call. "Many documents, one buffer" exists only as a loop you write around tokenize_into. None of the four Option-returning tokenizers has a batch method at all.
  • _into never clears anywhere in the tokenizers. (Stemmer::stem_into in verbora_core does clear its String — that difference is documented on API shapes.)

Released under the MIT License.