use anyhow::Context as _;
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use dyn_clone::DynClone;
#[cfg(feature = "internal-embed")]
use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions};
use rig_core::client::{EmbeddingsClient, Nothing};
use rig_core::embeddings::EmbeddingsBuilder;
use rig_core::providers::ollama;
#[cfg(feature = "internal-embed")]
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
#[cfg(feature = "internal-embed")]
use crate::sync_util::lock_or_recover;
const NOMIC_EMBED_DIMENSIONS: usize = 768;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EmbeddingMetadata {
pub model_name: String,
pub dimensions: usize,
pub provider: String,
}
impl EmbeddingMetadata {
pub fn id(&self) -> String {
format!("{}/{}", self.provider, self.model_name)
}
pub fn is_compatible_with(&self, other: &EmbeddingMetadata) -> bool {
self.model_name == other.model_name && self.dimensions == other.dimensions
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Embedding {
pub text: String,
pub vector: Vec<f32>,
}
#[async_trait]
pub trait Embedder: Send + Sync + std::fmt::Debug + DynClone {
async fn embed(&self, texts: Vec<String>) -> Result<Vec<Embedding>>;
async fn embed_with_progress(
&self,
texts: Vec<String>,
progress: Option<&dyn crate::progress::Progress>,
cancel: Option<&dyn crate::progress::Cancel>,
) -> Result<Vec<Embedding>> {
let _ = (progress, cancel);
self.embed(texts).await
}
fn backend_name(&self) -> &'static str;
fn model_name(&self) -> &str;
fn dimension(&self) -> usize;
fn is_enabled(&self) -> bool {
self.dimension() > 0
}
fn metadata(&self) -> EmbeddingMetadata {
EmbeddingMetadata {
model_name: self.model_name().to_string(),
dimensions: self.dimension(),
provider: self.backend_name().to_string(),
}
}
}
dyn_clone::clone_trait_object!(Embedder);
#[derive(Clone, Debug)]
pub struct OllamaEmbedder {
model_name: String,
request_timeout_secs: Option<u64>,
}
impl OllamaEmbedder {
pub fn new(model: String, request_timeout_secs: Option<u64>) -> Self {
Self {
model_name: model,
request_timeout_secs,
}
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Embedding>> {
let client = if let Some(secs) = self.request_timeout_secs {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(secs))
.build()
.context("failed to build HTTP client with timeout")?;
ollama::Client::builder()
.http_client(http)
.api_key(Nothing)
.build()
.map_err(|e| {
anyhow!(crate::RagrigError::OllamaUnreachable {
context: format!("embed documents: {e}"),
})
})?
} else {
ollama::Client::new(Nothing).map_err(|e| {
anyhow!(crate::RagrigError::OllamaUnreachable {
context: format!("embed documents: {e}"),
})
})?
};
let model = client.embedding_model(&self.model_name);
let embedded = EmbeddingsBuilder::new(model)
.documents(texts.to_vec())?
.build()
.await
.map_err(|e| {
let is_model_not_found = {
let msg = e.to_string();
msg.contains("not found") || msg.contains("model not found")
};
if is_model_not_found {
anyhow!(crate::RagrigError::EmbedModelNotFound {
model: self.model_name.to_string(),
})
} else {
anyhow!(e).context(format!(
"Ollama embedder: embedding failed for model '{}'",
self.model_name
))
}
})?;
Ok(embedded
.into_iter()
.map(|(text, emb)| {
let vector = emb.into_iter().next().map(|e| e.vec).unwrap_or_default();
Embedding {
text,
vector: vector.into_iter().map(|v| v as f32).collect(),
}
})
.collect())
}
}
#[async_trait]
impl Embedder for OllamaEmbedder {
async fn embed(&self, texts: Vec<String>) -> Result<Vec<Embedding>> {
self.embed_batch(&texts).await
}
async fn embed_with_progress(
&self,
texts: Vec<String>,
progress: Option<&dyn crate::progress::Progress>,
cancel: Option<&dyn crate::progress::Cancel>,
) -> Result<Vec<Embedding>> {
const BATCH: usize = 50;
let total = texts.len();
let mut out: Vec<Embedding> = Vec::with_capacity(total);
for batch in texts.chunks(BATCH) {
if cancel.is_some_and(|c| c.is_cancelled()) {
return Err(anyhow!(crate::Cancelled));
}
out.extend(self.embed_batch(batch).await?);
if let Some(progress) = progress {
progress.on_event(&crate::ProgressEvent::ChunksEmbedded {
done: out.len(),
total,
});
}
}
Ok(out)
}
fn backend_name(&self) -> &'static str {
"Ollama"
}
fn model_name(&self) -> &str {
&self.model_name
}
fn dimension(&self) -> usize {
NOMIC_EMBED_DIMENSIONS
}
}
#[cfg(feature = "internal-embed")]
#[derive(Clone, Debug, Default)]
pub struct FastembedEmbedder;
#[cfg(feature = "internal-embed")]
static FASTEMBED: OnceLock<Result<Mutex<TextEmbedding>, String>> = OnceLock::new();
#[cfg(feature = "internal-embed")]
fn get_fastembed() -> Result<&'static Mutex<TextEmbedding>> {
FASTEMBED
.get_or_init(|| {
log::info!("Initializing fastembed (Nomic-Embed-Text-v1.5) on CPU …");
match TextEmbedding::try_new(TextInitOptions::new(EmbeddingModel::NomicEmbedTextV15)) {
Ok(model) => Ok(Mutex::new(model)),
Err(e) => Err(e.to_string()),
}
})
.as_ref()
.map_err(|e| {
anyhow!(crate::RagrigError::EmbedModelNotFound {
model: format!("Nomic-Embed-Text-v1.5 (fastembed init failed: {e})"),
})
})
}
#[cfg(feature = "internal-embed")]
#[async_trait]
impl Embedder for FastembedEmbedder {
async fn embed(&self, texts: Vec<String>) -> Result<Vec<Embedding>> {
let texts_for_blocking = texts.clone();
let vectors = tokio::task::spawn_blocking(move || {
let mutex = get_fastembed()?;
let mut model = lock_or_recover(mutex);
model.embed(texts_for_blocking, None).context("fastembed")
})
.await??;
Ok(texts
.into_iter()
.zip(vectors)
.map(|(text, vector)| Embedding { text, vector })
.collect())
}
fn backend_name(&self) -> &'static str {
"Fastembed"
}
fn model_name(&self) -> &str {
"Nomic-Embed-Text-v1.5"
}
fn dimension(&self) -> usize {
NOMIC_EMBED_DIMENSIONS
}
}
#[derive(Clone, Debug, Default)]
pub struct NoopEmbedder;
#[async_trait]
impl Embedder for NoopEmbedder {
async fn embed(&self, texts: Vec<String>) -> Result<Vec<Embedding>> {
Ok(texts
.into_iter()
.map(|text| Embedding {
text,
vector: Vec::new(),
})
.collect())
}
fn backend_name(&self) -> &'static str {
"None"
}
fn model_name(&self) -> &str {
"(disabled)"
}
fn dimension(&self) -> usize {
0
}
fn is_enabled(&self) -> bool {
false
}
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub enum EmbedderSpec {
Ollama {
model: String,
request_timeout_secs: Option<u64>,
},
#[cfg(feature = "internal-embed")]
Fastembed,
None,
}
impl EmbedderSpec {
pub fn ollama(model: impl Into<String>) -> Self {
Self::Ollama {
model: model.into(),
request_timeout_secs: None,
}
}
pub fn none() -> Self {
Self::None
}
pub fn parse(backend: &str, model: Option<&str>) -> Result<Self> {
match backend.to_lowercase().as_str() {
"ollama" => {
let model = model.unwrap_or("nomic-embed-text:latest").to_string();
Ok(Self::Ollama {
model,
request_timeout_secs: None,
})
}
#[cfg(feature = "internal-embed")]
"fastembed" => Ok(Self::Fastembed),
"none" | "off" => Ok(Self::None),
other => Err(anyhow!(
"Unknown embedding backend: '{}'. Available: {}",
other,
Self::available_backends().join(", ")
)),
}
}
pub fn available_backends() -> &'static [&'static str] {
&[
"ollama",
#[cfg(feature = "internal-embed")]
"fastembed",
"none",
]
}
pub fn build(&self) -> Result<Box<dyn Embedder>> {
match self {
Self::Ollama {
model,
request_timeout_secs,
} => Ok(Box::new(OllamaEmbedder::new(
model.clone(),
*request_timeout_secs,
))),
#[cfg(feature = "internal-embed")]
Self::Fastembed => Ok(Box::new(FastembedEmbedder)),
Self::None => Ok(Box::new(NoopEmbedder)),
}
}
}
#[cfg(feature = "internal-embed")]
impl EmbedderSpec {
pub fn fastembed() -> Self {
Self::Fastembed
}
}
impl TryFrom<EmbedderSpec> for Box<dyn Embedder> {
type Error = anyhow::Error;
fn try_from(spec: EmbedderSpec) -> Result<Self, Self::Error> {
spec.build()
}
}
impl TryFrom<&crate::types::EmbedConfig> for EmbedderSpec {
type Error = anyhow::Error;
fn try_from(embed: &crate::types::EmbedConfig) -> Result<Self, Self::Error> {
match &embed.provider {
crate::types::EmbeddingProvider::Ollama => Ok(Self::Ollama {
model: embed.model.clone(),
request_timeout_secs: embed.request_timeout_secs,
}),
#[cfg(feature = "internal-embed")]
crate::types::EmbeddingProvider::Fastembed => Ok(Self::Fastembed),
#[cfg(not(feature = "internal-embed"))]
#[allow(unreachable_patterns)]
_ => Err(anyhow!(
"embedding provider {:?} is not supported by this build",
embed.provider
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ollama_default_model() {
let spec = EmbedderSpec::parse("ollama", None).unwrap();
match spec {
EmbedderSpec::Ollama {
model,
request_timeout_secs: _,
} => assert_eq!(model, "nomic-embed-text:latest"),
_ => panic!("expected Ollama"),
}
}
#[test]
fn parse_ollama_custom_model() {
let spec = EmbedderSpec::parse("ollama", Some("custom-model")).unwrap();
match spec {
EmbedderSpec::Ollama {
model,
request_timeout_secs: _,
} => assert_eq!(model, "custom-model"),
_ => panic!("expected Ollama"),
}
}
#[test]
fn parse_none() {
let spec = EmbedderSpec::parse("none", None).unwrap();
assert!(matches!(spec, EmbedderSpec::None));
}
#[test]
fn parse_off_is_none() {
let spec = EmbedderSpec::parse("off", None).unwrap();
assert!(matches!(spec, EmbedderSpec::None));
}
#[test]
fn parse_unknown_is_error() {
assert!(EmbedderSpec::parse("openai", None).is_err());
}
#[test]
fn available_backends_contains_ollama() {
let backs = EmbedderSpec::available_backends();
assert!(backs.contains(&"ollama"));
assert!(backs.contains(&"none"));
}
#[test]
fn build_none_returns_noop() {
let embedder = EmbedderSpec::None.build().unwrap();
assert_eq!(embedder.backend_name(), "None");
assert_eq!(embedder.model_name(), "(disabled)");
assert_eq!(embedder.dimension(), 0);
}
#[test]
fn ollama_embedder_dimension() {
let e = OllamaEmbedder::new("nomic-embed-text:latest".into(), None);
assert_eq!(e.dimension(), 768);
}
#[tokio::test]
async fn noop_embedder_returns_zero_vectors() {
let e = NoopEmbedder;
let result = e.embed(vec!["hello".into(), "world".into()]).await.unwrap();
assert_eq!(result.len(), 2);
for embedding in &result {
assert!(embedding.vector.is_empty());
assert!(!embedding.text.is_empty());
}
}
#[test]
fn try_from_ollama_spec_succeeds() {
use std::convert::TryFrom;
let spec = EmbedderSpec::Ollama {
model: "nomic-embed-text:latest".into(),
request_timeout_secs: None,
};
let embedder = Box::<dyn Embedder>::try_from(spec).unwrap();
assert_eq!(embedder.backend_name(), "Ollama");
assert_eq!(embedder.model_name(), "nomic-embed-text:latest");
assert_eq!(embedder.dimension(), 768);
}
#[test]
fn try_from_none_spec_succeeds() {
use std::convert::TryFrom;
let spec = EmbedderSpec::None;
let embedder = Box::<dyn Embedder>::try_from(spec).unwrap();
assert_eq!(embedder.backend_name(), "None");
assert_eq!(embedder.model_name(), "(disabled)");
assert_eq!(embedder.dimension(), 0);
}
#[test]
fn try_from_unknown_spec_is_error() {
let spec = EmbedderSpec::parse("bogus", None);
assert!(spec.is_err());
}
}