use crate::config::{PoolingStrategy, TruncateTokens};
use crate::error::{Error, Result};
use crate::model::EmbeddingModel;
use llama_cpp_2::token::LlamaToken;
use rayon::prelude::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::{debug, instrument};
pub struct BatchProcessor {
#[allow(dead_code)]
max_batch_size: usize,
progress_callback: Option<Arc<dyn Fn(usize, usize) + Send + Sync>>,
#[allow(dead_code)]
normalize: bool,
#[allow(dead_code)]
pooling_strategy: PoolingStrategy,
}
impl BatchProcessor {
pub fn new(max_batch_size: usize) -> Self {
BatchProcessor {
max_batch_size,
progress_callback: None,
normalize: true,
pooling_strategy: PoolingStrategy::Mean,
}
}
pub fn builder() -> BatchProcessorBuilder {
BatchProcessorBuilder::default()
}
#[instrument(skip(self, model, texts), fields(batch_size = texts.len()))]
pub fn process_batch(
&self,
model: &mut EmbeddingModel,
texts: &[&str],
truncate: TruncateTokens,
) -> Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
debug!("Processing batch of {} texts", texts.len());
let progress_counter = Arc::new(AtomicUsize::new(0));
let total = texts.len();
Self::parallel_validate(texts)?;
let token_sequences = Self::parallel_tokenize_real(model, texts)?;
let n_seq_max = model.n_seq_max() as usize;
debug!(
"Batch validation: {} sequences, n_seq_max: {}",
token_sequences.len(),
n_seq_max
);
let embeddings = if token_sequences.len() <= n_seq_max {
debug!(
"Processing {} sequences in single batch",
token_sequences.len()
);
let batch_embeddings = model.process_batch_tokens(&token_sequences, truncate)?;
let current = progress_counter.fetch_add(texts.len(), Ordering::Relaxed);
if let Some(ref callback) = self.progress_callback {
callback(current + texts.len(), total);
}
batch_embeddings
} else {
debug!(
"Chunking batch: {} sequences (n_seq_max {})",
token_sequences.len(),
n_seq_max
);
let mut all_embeddings = Vec::with_capacity(texts.len());
let mut current_batch = Vec::new();
for seq in token_sequences {
if !current_batch.is_empty() && current_batch.len() >= n_seq_max {
let batch_embeddings = model.process_batch_tokens(¤t_batch, truncate)?;
let batch_len = batch_embeddings.len();
all_embeddings.extend(batch_embeddings);
let current = progress_counter.fetch_add(batch_len, Ordering::Relaxed);
if let Some(ref callback) = self.progress_callback {
callback(current + batch_len, total);
}
current_batch = vec![seq];
} else {
current_batch.push(seq);
}
}
if !current_batch.is_empty() {
let batch_embeddings = model.process_batch_tokens(¤t_batch, truncate)?;
let batch_len = batch_embeddings.len();
let current = progress_counter.fetch_add(batch_len, Ordering::Relaxed);
if let Some(ref callback) = self.progress_callback {
callback(current + batch_len, total);
}
all_embeddings.extend(batch_embeddings);
}
all_embeddings
};
debug!("Completed batch processing of {} texts", texts.len());
Ok(embeddings)
}
pub fn set_progress_callback<F>(&mut self, callback: F)
where
F: Fn(usize, usize) + Send + Sync + 'static,
{
self.progress_callback = Some(Arc::new(callback));
}
#[instrument(skip(texts), fields(count = texts.len()))]
fn parallel_validate(texts: &[&str]) -> Result<()> {
debug!("Validating {} texts in parallel", texts.len());
texts.par_iter().try_for_each(|text| {
if text.is_empty() {
return Err(Error::InvalidInput {
message: "Cannot process empty text".to_string(),
});
}
Ok(())
})?;
Ok(())
}
#[instrument(skip(model, texts), fields(count = texts.len()))]
fn parallel_tokenize_real(
model: &EmbeddingModel,
texts: &[&str],
) -> Result<Vec<Vec<LlamaToken>>> {
debug!("Starting tokenization of {} texts", texts.len());
let max_seq_len = model.max_sequence_length();
let validation_results: Result<Vec<_>> = texts
.par_iter()
.map(|text| {
if text.is_empty() {
Err(Error::InvalidInput {
message: "Cannot tokenize empty text".to_string(),
})
} else {
Ok(())
}
})
.collect();
validation_results?;
let mut results = Vec::with_capacity(texts.len());
for text in texts {
let tokens = model.tokenize(text)?;
if tokens.len() > max_seq_len {
return Err(Error::InvalidInput {
message: format!(
"Text exceeds maximum token limit: {} > {}",
tokens.len(),
max_seq_len
),
});
}
results.push(tokens);
}
debug!("Completed tokenization");
Ok(results)
}
}
#[derive(Default)]
pub struct BatchProcessorBuilder {
max_batch_size: Option<usize>,
normalize: bool,
pooling_strategy: PoolingStrategy,
progress_callback: Option<Arc<dyn Fn(usize, usize) + Send + Sync>>,
}
impl BatchProcessorBuilder {
#[must_use]
pub fn with_max_batch_size(mut self, size: usize) -> Self {
self.max_batch_size = Some(size);
self
}
#[must_use]
pub fn with_normalization(mut self, normalize: bool) -> Self {
self.normalize = normalize;
self
}
#[must_use]
pub fn with_pooling_strategy(mut self, strategy: PoolingStrategy) -> Self {
self.pooling_strategy = strategy;
self
}
#[must_use]
pub fn with_progress_callback<F>(mut self, callback: F) -> Self
where
F: Fn(usize, usize) + Send + Sync + 'static,
{
self.progress_callback = Some(Arc::new(callback));
self
}
pub fn build(self) -> BatchProcessor {
BatchProcessor {
max_batch_size: self.max_batch_size.unwrap_or(32),
progress_callback: self.progress_callback,
normalize: self.normalize,
pooling_strategy: self.pooling_strategy,
}
}
}
pub mod utils {
#[allow(dead_code)]
pub fn calculate_optimal_batch_size(
_model_size_mb: usize,
_embedding_dim: usize,
_available_memory_mb: usize,
) -> usize {
32 }
#[allow(dead_code)]
pub fn chunk_texts<'a>(
texts: &'a [&'a str],
chunk_size: usize,
) -> impl Iterator<Item = &'a [&'a str]> {
texts.chunks(chunk_size)
}
}
#[cfg(test)]
mod tests {
use super::utils;
#[test]
fn test_chunk_texts() {
let texts = vec!["a", "b", "c", "d", "e"];
let chunks: Vec<_> = utils::chunk_texts(&texts, 2).collect();
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0], &["a", "b"]);
assert_eq!(chunks[1], &["c", "d"]);
assert_eq!(chunks[2], &["e"]);
}
#[test]
#[ignore = "Will be enabled in Phase 4"]
fn test_batch_processing() {
}
}