candding 0.1.0

Pure candle embeddings for Rust with no ONNX: dense, sparse, multi-vector and rerank models today, image models on the roadmap
Documentation
use std::fs;

use candle_core::DType;
use candle_core::Device;

use super::SparseTextEmbedding;
use super::build;
use crate::device;
use crate::embed::builder::reject_unsupported_dtype;
use crate::embed::builder::resolve_dtype;
use crate::embed::fit;
use crate::error::CanddingError;
use crate::error::Result;
use crate::hub;
use crate::hub::ModelSource;
use crate::loader;
use crate::models;
use crate::registry;
use crate::registry::Catalog;
use crate::registry::sparse::SparseDescriptor;
use crate::tokenizer::TextTokenizer;
use crate::tokenizer::read_pad_token;
use crate::weights;

/// Eight keeps SPLADE's transient `(batch, 64, vocab)` logits chunk (59.61 MiB) near the dense pipeline's peak
/// hidden states at its default of 32 (48 MiB); it is a comparability choice, not a memory ceiling.
const DEFAULT_BATCH_SIZE: usize = 8;

pub struct SparseTextEmbeddingBuilder {
  spec: String,
  revision: String,
  device: Option<Device>,
  dtype: Option<DType>,
  max_length: Option<usize>,
  batch_size: usize,
  descriptor: Option<SparseDescriptor>,
}

impl SparseTextEmbeddingBuilder {
  pub(crate) fn new(spec: impl Into<String>) -> Self {
    Self {
      spec: spec.into(),
      revision: "main".to_string(),
      device: None,
      dtype: None,
      max_length: None,
      batch_size: DEFAULT_BATCH_SIZE,
      descriptor: None,
    }
  }

  pub fn device(mut self, device: Device) -> Self {
    self.device = Some(device);
    self
  }

  pub fn dtype(mut self, dtype: DType) -> Self {
    self.dtype = Some(dtype);
    self
  }

  /// Capped at the model's `max_position_embeddings` when building.
  pub fn max_length(mut self, max_length: usize) -> Self {
    self.max_length = Some(max_length);
    self
  }

  pub fn batch_size(mut self, batch_size: usize) -> Self {
    self.batch_size = batch_size.max(1);
    self
  }

  pub fn revision(mut self, revision: impl Into<String>) -> Self {
    self.revision = revision.into();
    self
  }

  /// Supplies the descriptor for a sparse head outside the registry, or replaces a registered one; without it an
  /// unregistered id is refused, since no file names a checkpoint's head or vocabulary size.
  pub fn descriptor(mut self, descriptor: SparseDescriptor) -> Self {
    self.descriptor = Some(descriptor);
    self
  }

  pub fn build(self) -> Result<SparseTextEmbedding> {
    let overridden = self.descriptor.is_some();
    let descriptor = match self.descriptor {
      Some(descriptor) => descriptor,
      None => {
        registry::sparse::lookup(&self.spec).ok_or_else(|| CanddingError::UnregisteredModel {
          id: self.spec.clone(),
          catalog: Catalog::Sparse,
        })?
      }
    };
    // A registry hit carries the entry's canonical spelling; an override keeps the caller's spec.
    let spec = if overridden {
      self.spec.as_str()
    } else {
      descriptor.id.as_str()
    };
    build::reject_computed_entry(&descriptor)?;
    let device = match self.device {
      Some(device) => device,
      None => device::auto()?,
    };
    let dtype = resolve_dtype(&self.spec, self.dtype, DType::F32, &device)?;
    reject_unsupported_dtype(&self.spec, dtype, descriptor.dtype_backends, &device)?;
    let source = ModelSource::parse(spec, &self.revision);
    let size_gb = descriptor.size_gb;
    fit::check_download(&self.spec, &device, &source, size_gb, DType::F32, dtype)?;
    let dir = hub::resolve(&source, descriptor.weights)?;
    let files = loader::discover(&dir, loader::Pipeline::Sparse)?;
    let config_json = fs::read_to_string(&files.config)?;
    let minicoil_side = build::resolve_minicoil_side(&descriptor)?;
    let weight_files = build::weight_files(&descriptor, &files, minicoil_side.as_ref(), dtype);
    fit::check_files(&self.spec, &device, &weight_files)?;
    tracing::info!(model = %self.spec, device = device::describe(&device), dtype = dtype.as_str(), "loading sparse model");
    let vb = unsafe { weights::var_builder(&files.weights, dtype, &device)? };
    let minicoil_files = minicoil_side.as_ref().map(|side| models::MiniCoilFiles {
      matrix: &side.matrix,
      vocab: &side.vocab,
      stopwords: &side.stopwords,
    });
    let model = models::load_sparse(
      descriptor.head,
      &config_json,
      vb,
      &files.tokenizer,
      files.sparse_linear.as_deref(),
      minicoil_files,
    )?;
    let max_length = self
      .max_length
      .unwrap_or(descriptor.default_max_length)
      .min(model.max_position_embeddings());
    let pad = read_pad_token(&config_json, files.tokenizer_config.as_deref())?;
    let tokenizer = TextTokenizer::from_file(&files.tokenizer, max_length, pad)?;
    Ok(SparseTextEmbedding::new(
      model,
      tokenizer,
      descriptor,
      device,
      dtype,
      self.batch_size,
    ))
  }
}

#[cfg(test)]
mod tests;