use std::ops::Range;
use std::path::{Path, PathBuf};
use fastembed_tokenizers::{AddedToken, Encoding, Tokenizer};
use super::fast_embedder::FASTEMBED_MAX_LENGTH;
use super::types::resolve_fastembed_cache_dir;
pub const CHUNK_OVERLAP_PERCENT: usize = 10;
#[derive(Debug, thiserror::Error)]
pub enum ChunkError {
#[error("tokenizer file {} is missing; load the model through fastembed first", path.display())]
TokenizerMissing { path: PathBuf },
#[error("tokenizer file {} could not be loaded: {reason}", path.display())]
TokenizerLoad { path: PathBuf, reason: String },
#[error("tokenizer failed to encode text: {0}")]
Encode(String),
#[error("window {window} leaves no room past {overhead} special tokens")]
WindowTooSmall { window: usize, overhead: usize },
#[error("text at bytes {start}..{end} cannot be split to fit {budget} tokens")]
Unsplittable {
start: usize,
end: usize,
budget: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextChunk<'a> {
pub byte_range: Range<usize>,
pub text: &'a str,
}
#[derive(Debug, Clone)]
pub struct ChunkTokenizer {
tokenizer: Tokenizer,
special_overhead: usize,
max_length: Option<usize>,
}
impl ChunkTokenizer {
pub fn from_bytes(tokenizer_json: &[u8]) -> Result<Self, ChunkError> {
let tokenizer =
Tokenizer::from_bytes(tokenizer_json).map_err(|e| ChunkError::TokenizerLoad {
path: PathBuf::from("<bytes>"),
reason: e.to_string(),
})?;
Self::from_tokenizer(tokenizer, None)
}
pub fn for_fastembed_model(model_code: &str) -> Result<Self, ChunkError> {
let cache_dir = std::env::var("HF_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| resolve_fastembed_cache_dir());
Self::for_fastembed_model_in(&cache_dir, model_code)
}
pub fn for_fastembed_model_in(cache_dir: &Path, model_code: &str) -> Result<Self, ChunkError> {
let repo = cache_dir.join(format!("models--{}", model_code.replace('/', "--")));
let revision = read_file(&repo.join("refs").join("main"))?;
let snapshot = repo.join("snapshots").join(revision.trim());
let tokenizer_path = snapshot.join("tokenizer.json");
let mut tokenizer = Tokenizer::from_bytes(read_file(&tokenizer_path)?).map_err(|e| {
ChunkError::TokenizerLoad {
path: tokenizer_path.clone(),
reason: e.to_string(),
}
})?;
let config_path = snapshot.join("tokenizer_config.json");
let config = read_json(&config_path)?;
let model_max =
config["model_max_length"]
.as_f64()
.ok_or_else(|| ChunkError::TokenizerLoad {
path: config_path.clone(),
reason: "model_max_length is not a number".to_string(),
})?;
let max_length = FASTEMBED_MAX_LENGTH.min(model_max as usize);
let specials = read_json(&snapshot.join("special_tokens_map.json"))?;
let added: Vec<AddedToken> = specials
.as_object()
.into_iter()
.flat_map(|map| map.values())
.filter_map(special_token)
.collect();
tokenizer.add_special_tokens(&added);
Self::from_tokenizer(tokenizer, Some(max_length))
}
fn from_tokenizer(
mut tokenizer: Tokenizer,
max_length: Option<usize>,
) -> Result<Self, ChunkError> {
tokenizer
.with_truncation(None)
.map_err(|e| ChunkError::Encode(e.to_string()))?;
tokenizer.with_padding(None);
let special_overhead = tokenizer
.encode("", true)
.map_err(|e| ChunkError::Encode(e.to_string()))?
.len();
Ok(Self {
tokenizer,
special_overhead,
max_length,
})
}
pub fn count_tokens(&self, text: &str) -> Result<usize, ChunkError> {
Ok(self.encode(text)?.len())
}
pub fn special_overhead(&self) -> usize {
self.special_overhead
}
pub fn max_length(&self) -> Option<usize> {
self.max_length
}
fn encode(&self, text: &str) -> Result<Encoding, ChunkError> {
self.tokenizer
.encode(text, false)
.map_err(|e| ChunkError::Encode(e.to_string()))
}
}
pub fn chunk_text<'a>(
tokenizer: &ChunkTokenizer,
text: &'a str,
window: usize,
) -> Result<Vec<TextChunk<'a>>, ChunkError> {
let budget = window
.checked_sub(tokenizer.special_overhead)
.filter(|b| *b > 0)
.ok_or(ChunkError::WindowTooSmall {
window,
overhead: tokenizer.special_overhead,
})?;
let encoding = tokenizer.encode(text)?;
let total = encoding.len();
if total <= budget {
return Ok(vec![TextChunk {
byte_range: 0..text.len(),
text,
}]);
}
let offsets = encoding.get_offsets();
let word_ids = encoding.get_word_ids();
let overlap = overlap_tokens(budget);
let mut chunks = Vec::new();
let (mut start_tok, mut start_byte, mut prev_end_tok) = (0, 0, 0);
loop {
let lo = start_tok.max(prev_end_tok);
let mut end_tok = (start_tok + budget).min(total);
if end_tok < total {
end_tok = word_start_at_or_before(word_ids, lo, end_tok);
}
let fitted = loop {
let end_byte = if end_tok == total {
text.len()
} else {
offsets[end_tok].0
};
if let Some(s) = text.get(start_byte..end_byte)
&& end_byte > start_byte
&& tokenizer.count_tokens(s)? <= budget
{
break Ok((end_byte, s));
}
if end_tok <= lo + 1 {
break Err(end_byte);
}
end_tok = word_start_at_or_before(word_ids, lo, end_tok - 1);
};
let (end_byte, slice) = match fitted {
Ok(fit) => fit,
Err(_) if start_tok < prev_end_tok => {
start_tok = prev_end_tok;
start_byte = offsets[start_tok].0;
continue;
}
Err(end) => {
return Err(ChunkError::Unsplittable {
start: start_byte,
end,
budget,
});
}
};
chunks.push(TextChunk {
byte_range: start_byte..end_byte,
text: slice,
});
if end_tok == total {
return Ok(chunks);
}
prev_end_tok = end_tok;
start_tok = end_tok.saturating_sub(overlap).max(start_tok + 1);
start_byte = offsets[start_tok].0;
}
}
pub(super) fn overlap_tokens(budget: usize) -> usize {
budget * CHUNK_OVERLAP_PERCENT / 100
}
fn word_start_at_or_before(word_ids: &[Option<u32>], lo: usize, hi: usize) -> usize {
(lo + 1..=hi)
.rev()
.find(|&t| match (word_ids.get(t - 1), word_ids.get(t)) {
(Some(Some(prev)), Some(Some(cur))) => prev != cur,
_ => true,
})
.unwrap_or(hi)
}
fn read_file(path: &Path) -> Result<String, ChunkError> {
std::fs::read_to_string(path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ChunkError::TokenizerMissing {
path: path.to_path_buf(),
},
_ => ChunkError::TokenizerLoad {
path: path.to_path_buf(),
reason: e.to_string(),
},
})
}
fn read_json(path: &Path) -> Result<serde_json::Value, ChunkError> {
serde_json::from_str(&read_file(path)?).map_err(|e| ChunkError::TokenizerLoad {
path: path.to_path_buf(),
reason: e.to_string(),
})
}
fn special_token(value: &serde_json::Value) -> Option<AddedToken> {
if let Some(content) = value.as_str() {
return Some(AddedToken {
content: content.to_string(),
special: true,
..Default::default()
});
}
Some(AddedToken {
content: value["content"].as_str()?.to_string(),
single_word: value["single_word"].as_bool()?,
lstrip: value["lstrip"].as_bool()?,
rstrip: value["rstrip"].as_bool()?,
normalized: value["normalized"].as_bool()?,
special: true,
})
}