embedd 0.3.0

Embedding interfaces + local backends (Candle/HF).
Documentation
//! Compose embedding policies without downloading a model.
//!
//! This example uses a tiny deterministic embedder so the behavior is visible:
//! scoping prefixes make query/document vectors differ, output truncation sets
//! the dimension, normalization makes cosine equal dot product, batching limits
//! call size, and caching avoids repeat work.
//!
//! Run:
//!
//! ```sh
//! cargo run -p embedd --example policy_pipeline
//! ```

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use embedd::{
    apply_normalization_policy, apply_output_dim, apply_scoping_policy, BatchingTextEmbedder,
    CachingTextEmbedder, EmbedMode, Normalization, NormalizationPolicy, PromptApplication,
    PromptTemplate, ScopingPolicy, TextEmbedder, TextEmbedderCapabilities, TruncationPolicy,
};

#[derive(Clone)]
struct ToyEmbedder {
    calls: Arc<AtomicUsize>,
}

impl ToyEmbedder {
    fn new() -> Self {
        Self {
            calls: Arc::new(AtomicUsize::new(0)),
        }
    }

    fn calls(&self) -> usize {
        self.calls.load(Ordering::Relaxed)
    }

    fn embed_one(text: &str) -> Vec<f32> {
        let mut v = vec![0.0; 6];
        for (i, b) in text.bytes().enumerate() {
            let lane = i % v.len();
            v[lane] += (f32::from(b) / 255.0) + (lane as f32 * 0.01);
        }
        v
    }
}

impl TextEmbedder for ToyEmbedder {
    fn embed_texts(&self, texts: &[String], _mode: EmbedMode) -> anyhow::Result<Vec<Vec<f32>>> {
        self.calls.fetch_add(1, Ordering::Relaxed);
        Ok(texts.iter().map(|text| Self::embed_one(text)).collect())
    }

    fn model_id(&self) -> Option<&str> {
        Some("toy-hash-vectors")
    }

    fn dimension(&self) -> Option<usize> {
        Some(6)
    }

    fn capabilities(&self) -> TextEmbedderCapabilities {
        TextEmbedderCapabilities {
            uses_embed_mode: PromptApplication::None,
            normalization: Normalization::NotNormalized,
            truncation: TruncationPolicy::None,
        }
    }
}

fn l2_norm(v: &[f32]) -> f32 {
    v.iter().map(|x| x * x).sum::<f32>().sqrt()
}

fn main() -> anyhow::Result<()> {
    let toy = ToyEmbedder::new();
    let call_counter = toy.clone();

    let prompt = PromptTemplate {
        query_prefix: "query: ".to_string(),
        doc_prefix: "passage: ".to_string(),
    };

    let scoped = apply_scoping_policy(toy, ScopingPolicy::ClientPrefix(prompt))?;
    let truncated = apply_output_dim(scoped, Some(4))?;
    let normalized = apply_normalization_policy(truncated, NormalizationPolicy::RequireL2)?;
    let batched = BatchingTextEmbedder::new(normalized, 2);
    let cached = CachingTextEmbedder::new(batched);

    println!("model: {}", cached.model_id().unwrap_or("?"));
    println!("reported dimension: {:?}", cached.dimension());
    println!();

    let docs = vec![
        "hybrid retrieval combines lexical and dense scores".to_string(),
        "matryoshka embeddings allow shorter vectors at query time".to_string(),
        "rerankers score query document pairs".to_string(),
    ];

    let doc_vecs = cached.embed_texts(&docs, EmbedMode::Document)?;
    println!("document embeddings");
    for (doc, v) in docs.iter().zip(doc_vecs.iter()) {
        println!("  dim={} norm={:.3} text=\"{}\"", v.len(), l2_norm(v), doc);
        assert_eq!(v.len(), 4);
        assert!((l2_norm(v) - 1.0).abs() < 1e-5);
    }
    println!();

    let text = "matryoshka embeddings";
    let query_vec = cached.embed_text(text, EmbedMode::Query)?;
    let doc_vec = cached.embed_text(text, EmbedMode::Document)?;
    let same_text_similarity = embedd::vector::cosine_f32(&query_vec, &doc_vec);

    println!("same text under different scopes");
    println!("  cosine(query, document) = {same_text_similarity:.4}");
    assert!(same_text_similarity < 0.999);
    println!();

    let calls_after_first_pass = call_counter.calls();
    let _again = cached.embed_texts(&docs, EmbedMode::Document)?;
    println!("cache");
    println!("  backend calls after first pass: {calls_after_first_pass}");
    println!("  backend calls after cache hit:  {}", call_counter.calls());
    println!("  cache entries: {}", cached.cache_len());
    assert_eq!(calls_after_first_pass, call_counter.calls());

    Ok(())
}