//! Módulo de Embeddings ONNX para ranking de relevância //! //! Usa o modelo all-MiniLM-L6-v2 para gerar embeddings de texto //! e calcular similaridade de cosseno para ranking de resultados de busca. use anyhow::{Result, anyhow}; use ndarray::Array2; use ort::session::{Session, builder::GraphOptimizationLevel}; use ort::value::Value; <<<<<<< HEAD ======= use std::io::Read; >>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5 use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; use tokenizers::Tokenizer; /// Flag para controlar se o ort já foi inicializado static ORT_INITIALIZED: OnceLock> = OnceLock::new(); /// Dimensão dos embeddings do modelo all-MiniLM-L6-v2 pub const EMBEDDING_DIM: usize = 384; /// Tamanho máximo de tokens para o modelo const MAX_SEQ_LENGTH: usize = 256; /// Global lazy-initialized embedding model (com Mutex para permitir mutabilidade) static EMBEDDING_MODEL: OnceLock>, String>> = OnceLock::new(); /// Modelo de embeddings para cálculo de similaridade semântica pub struct EmbeddingModel { session: Session, tokenizer: Tokenizer, } impl EmbeddingModel { /// Carrega o modelo ONNX e tokenizer pub fn new(model_path: &str, tokenizer_path: &str) -> Result { // Verificar se arquivos existem if !Path::new(model_path).exists() { return Err(anyhow!("Model file not found: {}", model_path)); } if !Path::new(tokenizer_path).exists() { return Err(anyhow!("Tokenizer file not found: {}", tokenizer_path)); } log::info!("[Embeddings] Loading ONNX model from: {}", model_path); // Criar sessão ONNX let session = Session::builder()? .with_optimization_level(GraphOptimizationLevel::Level3)? .commit_from_file(model_path)?; // Carregar tokenizer let tokenizer = Tokenizer::from_file(tokenizer_path) .map_err(|e| anyhow!("Failed to load tokenizer: {}", e))?; log::info!("[Embeddings] Model loaded successfully"); Ok(Self { session, tokenizer }) } /// Gera embedding para um texto pub fn embed(&mut self, text: &str) -> Result> { // Tokenizar texto let encoding = self.tokenizer .encode(text, true) .map_err(|e| anyhow!("Tokenization failed: {}", e))?; let mut input_ids: Vec = encoding.get_ids() .iter() .map(|&id| id as i64) .collect(); let mut attention_mask: Vec = encoding.get_attention_mask() .iter() .map(|&m| m as i64) .collect(); let mut token_type_ids: Vec = encoding.get_type_ids() .iter() .map(|&t| t as i64) .collect(); // Truncar/pad para MAX_SEQ_LENGTH input_ids.truncate(MAX_SEQ_LENGTH); attention_mask.truncate(MAX_SEQ_LENGTH); token_type_ids.truncate(MAX_SEQ_LENGTH); while input_ids.len() < MAX_SEQ_LENGTH { input_ids.push(0); attention_mask.push(0); token_type_ids.push(0); } // Criar arrays para inferência (batch size = 1) let input_ids_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), input_ids)?; let attention_mask_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), attention_mask)?; let token_type_ids_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), token_type_ids)?; // Criar inputs ONNX let input_ids_value = Value::from_array(input_ids_array)?; let attention_mask_value = Value::from_array(attention_mask_array)?; let token_type_ids_value = Value::from_array(token_type_ids_array)?; // Executar inferência usando vetor de inputs let inputs: Vec<(std::borrow::Cow, ort::session::SessionInputValue)> = vec![ ("input_ids".into(), input_ids_value.into()), ("attention_mask".into(), attention_mask_value.into()), ("token_type_ids".into(), token_type_ids_value.into()), ]; let outputs = self.session.run(inputs)?; // Extrair output (last_hidden_state ou sentence_embedding dependendo do modelo) // Para all-MiniLM-L6-v2, fazemos mean pooling do last_hidden_state let output = outputs.get("last_hidden_state") .or_else(|| outputs.get("sentence_embedding")) .ok_or_else(|| anyhow!("Output tensor not found"))?; let (shape, raw_data) = output.try_extract_tensor::()?; // Shape implementa Deref para [i64], então podemos usar diretamente let dims: &[i64] = &*shape; let data: &[f32] = raw_data; // Mean pooling: média ao longo da dimensão de sequência let embedding = if dims.len() == 3 { // Shape: (batch, seq_len, hidden_dim) let seq_len = dims[1] as usize; let hidden_dim = dims[2] as usize; let mut pooled = vec![0.0f32; hidden_dim]; for i in 0..seq_len { for j in 0..hidden_dim { let idx = i * hidden_dim + j; pooled[j] += data[idx]; } } for v in &mut pooled { *v /= seq_len as f32; } // Normalizar L2 let norm: f32 = pooled.iter().map(|x| x * x).sum::().sqrt(); if norm > 0.0 { for v in &mut pooled { *v /= norm; } } pooled } else { // Shape: (batch, hidden_dim) - já pooled let hidden_dim = dims[1] as usize; let mut embedding = Vec::with_capacity(hidden_dim); for j in 0..hidden_dim { embedding.push(data[j]); } // Normalizar L2 let norm: f32 = embedding.iter().map(|x| x * x).sum::().sqrt(); if norm > 0.0 { for v in &mut embedding { *v /= norm; } } embedding }; Ok(embedding) } /// Calcula embeddings em batch (mais eficiente para múltiplos textos) pub fn embed_batch(&mut self, texts: &[&str]) -> Result>> { // Para simplificar, processa um por um (pode ser otimizado para batch real) let mut results = Vec::with_capacity(texts.len()); for text in texts { results.push(self.embed(text)?); } Ok(results) } } /// Calcula similaridade de cosseno entre dois vetores pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { if a.len() != b.len() || a.is_empty() { return 0.0; } let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); let norm_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); let norm_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); if norm_a == 0.0 || norm_b == 0.0 { return 0.0; } dot / (norm_a * norm_b) } /// URLs para download do modelo (Hugging Face) const MODEL_URL: &str = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx"; const TOKENIZER_URL: &str = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json"; /// URL para download da biblioteca ONNX Runtime (Windows x64) #[cfg(target_os = "windows")] const ORT_DLL_URL: &str = "https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-win-x64-1.20.1.zip"; /// Inicializa o ONNX Runtime com a biblioteca dinâmica fn init_ort_runtime(app_data_dir: &Path) -> Result<()> { let result = ORT_INITIALIZED.get_or_init(|| { let ort_dir = app_data_dir.join("ort"); #[cfg(target_os = "windows")] let dll_path = ort_dir.join("onnxruntime.dll"); #[cfg(target_os = "macos")] let dll_path = ort_dir.join("libonnxruntime.dylib"); #[cfg(target_os = "linux")] let dll_path = ort_dir.join("libonnxruntime.so"); if dll_path.exists() { // Definir variável de ambiente para o ort encontrar a DLL std::env::set_var("ORT_DYLIB_PATH", &dll_path); log::info!("[Embeddings] ORT_DYLIB_PATH set to: {:?}", dll_path); Ok(()) } else { // Tentar usar a biblioteca do sistema log::warn!("[Embeddings] ONNX Runtime library not found at {:?}, will try system path", dll_path); Ok(()) } }); match result { Ok(()) => Ok(()), Err(e) => Err(anyhow!("{}", e)) } } /// Baixa um arquivo de uma URL para o caminho especificado async fn download_file(url: &str, path: &Path) -> Result<()> { log::info!("[Embeddings] Downloading: {} -> {:?}", url, path); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(300)) // 5 min timeout .build()?; let response = client.get(url).send().await?; if !response.status().is_success() { return Err(anyhow!("Download failed with status: {}", response.status())); } let bytes = response.bytes().await?; // Criar diretório pai se não existir if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(path, bytes)?; log::info!("[Embeddings] Downloaded successfully: {:?}", path); Ok(()) } /// Baixa e extrai a biblioteca ONNX Runtime #[cfg(target_os = "windows")] async fn ensure_ort_library(app_data_dir: &Path) -> Result<()> { let ort_dir = app_data_dir.join("ort"); let dll_path = ort_dir.join("onnxruntime.dll"); if dll_path.exists() { log::info!("[Embeddings] ONNX Runtime library already exists"); return Ok(()); } log::info!("[Embeddings] Downloading ONNX Runtime library..."); // Criar diretório std::fs::create_dir_all(&ort_dir)?; // Baixar arquivo zip let zip_path = ort_dir.join("onnxruntime.zip"); download_file(ORT_DLL_URL, &zip_path).await?; // Extrair DLL do zip log::info!("[Embeddings] Extracting ONNX Runtime library..."); let file = std::fs::File::open(&zip_path)?; let mut archive = zip::ZipArchive::new(file)?; // Procurar pela DLL dentro do zip for i in 0..archive.len() { let mut entry = archive.by_index(i)?; let name = entry.name().to_string(); if name.ends_with("onnxruntime.dll") { let mut outfile = std::fs::File::create(&dll_path)?; std::io::copy(&mut entry, &mut outfile)?; log::info!("[Embeddings] Extracted onnxruntime.dll"); break; } } // Remover arquivo zip let _ = std::fs::remove_file(&zip_path); if !dll_path.exists() { return Err(anyhow!("Failed to extract onnxruntime.dll from archive")); } Ok(()) } #[cfg(not(target_os = "windows"))] async fn ensure_ort_library(_app_data_dir: &Path) -> Result<()> { // Em outros sistemas, assumimos que a biblioteca está no sistema log::info!("[Embeddings] Using system ONNX Runtime library"); Ok(()) } /// Garante que os arquivos do modelo existem, baixando se necessário pub async fn ensure_model_files(app_data_dir: &Path) -> Result<(std::path::PathBuf, std::path::PathBuf)> { let models_dir = app_data_dir.join("models"); let model_path = models_dir.join("all-MiniLM-L6-v2.onnx"); let tokenizer_path = models_dir.join("tokenizer.json"); // Criar diretório de modelos std::fs::create_dir_all(&models_dir)?; // Baixar biblioteca ONNX Runtime se necessário (apenas Windows) ensure_ort_library(app_data_dir).await?; // Inicializar ort com o caminho da DLL init_ort_runtime(app_data_dir)?; // Baixar modelo se não existir if !model_path.exists() { log::info!("[Embeddings] Model not found, downloading..."); download_file(MODEL_URL, &model_path).await?; } // Baixar tokenizer se não existir if !tokenizer_path.exists() { log::info!("[Embeddings] Tokenizer not found, downloading..."); download_file(TOKENIZER_URL, &tokenizer_path).await?; } Ok((model_path, tokenizer_path)) } /// Verifica se o modelo está disponível pub fn is_model_available(app_data_dir: &Path) -> bool { let models_dir = app_data_dir.join("models"); let model_path = models_dir.join("all-MiniLM-L6-v2.onnx"); let tokenizer_path = models_dir.join("tokenizer.json"); model_path.exists() && tokenizer_path.exists() } /// Obtém ou inicializa o modelo global de embeddings pub fn get_or_init_model(app_data_dir: &Path) -> Result>> { // Inicializar ort com o caminho da DLL antes de criar o modelo init_ort_runtime(app_data_dir)?; let result = EMBEDDING_MODEL.get_or_init(|| { let model_path = app_data_dir.join("models").join("all-MiniLM-L6-v2.onnx"); let tokenizer_path = app_data_dir.join("models").join("tokenizer.json"); match EmbeddingModel::new( model_path.to_str().unwrap_or(""), tokenizer_path.to_str().unwrap_or("") ) { Ok(model) => Ok(Arc::new(Mutex::new(model))), Err(e) => Err(format!("Failed to load embedding model: {}", e)) } }); match result { Ok(model) => Ok(model.clone()), Err(e) => Err(anyhow!("{}", e)) } } /// Calcula scores de relevância para múltiplos textos em relação a uma query pub fn rank_by_relevance( model: &mut EmbeddingModel, query: &str, texts: &[&str], ) -> Result> { let query_embedding = model.embed(query)?; let mut scores: Vec<(usize, f32)> = Vec::with_capacity(texts.len()); for (idx, text) in texts.iter().enumerate() { let text_embedding = model.embed(text).unwrap_or_else(|_| vec![0.0; EMBEDDING_DIM]); let score = cosine_similarity(&query_embedding, &text_embedding); scores.push((idx, score)); } // Ordenar por score decrescente scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); Ok(scores) } /// Poda o contexto mantendo apenas os parágrafos mais relevantes /// /// Argumentos: /// - model: Modelo de embeddings /// - query: Query do usuário /// - context: Contexto completo (texto separado por parágrafos) /// - max_tokens: Número máximo de tokens (aproximado por palavras) /// - min_score: Score mínimo de relevância (0.0 a 1.0) /// /// Retorna: Contexto podado com os parágrafos mais relevantes pub fn prune_context( model: &mut EmbeddingModel, query: &str, context: &str, max_tokens: usize, min_score: f32, ) -> Result { // Dividir contexto em parágrafos let paragraphs: Vec<&str> = context .split("\n\n") .map(|p| p.trim()) .filter(|p| !p.is_empty() && p.len() > 20) // Filtrar parágrafos muito curtos .collect(); if paragraphs.is_empty() { return Ok(context.to_string()); } // Calcular embedding da query let query_embedding = model.embed(query)?; // Calcular scores para cada parágrafo let mut scored_paragraphs: Vec<(f32, &str, usize)> = Vec::with_capacity(paragraphs.len()); for (idx, &p) in paragraphs.iter().enumerate() { let embedding = model.embed(p).unwrap_or_else(|_| vec![0.0; EMBEDDING_DIM]); let score = cosine_similarity(&query_embedding, &embedding); scored_paragraphs.push((score, p, idx)); } // Ordenar por score decrescente scored_paragraphs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); // Filtrar por score mínimo scored_paragraphs.retain(|(score, _, _)| *score >= min_score); // Selecionar parágrafos até atingir max_tokens let mut result = Vec::new(); let mut total_tokens = 0; <<<<<<< HEAD for (_score, paragraph, original_idx) in scored_paragraphs { ======= for (score, paragraph, original_idx) in scored_paragraphs { >>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5 let paragraph_tokens = paragraph.split_whitespace().count(); if total_tokens + paragraph_tokens > max_tokens { // Se ainda não temos nenhum parágrafo, incluir o primeiro mesmo que exceda if result.is_empty() { result.push((original_idx, paragraph)); } break; } result.push((original_idx, paragraph)); total_tokens += paragraph_tokens; } // Ordenar pelo índice original para manter a ordem do contexto result.sort_by_key(|(idx, _)| *idx); // Reconstruir contexto let pruned: Vec<&str> = result.into_iter().map(|(_, p)| p).collect(); log::info!( "[Embeddings] Context pruned: {} paragraphs -> {} paragraphs, {} tokens", paragraphs.len(), pruned.len(), total_tokens ); Ok(pruned.join("\n\n")) } /// Versão simplificada de poda usando apenas BM25-like (sem embeddings) /// Útil quando o modelo não está disponível pub fn prune_context_bm25( query: &str, context: &str, max_tokens: usize, ) -> String { let paragraphs: Vec<&str> = context .split("\n\n") .map(|p| p.trim()) .filter(|p| !p.is_empty() && p.len() > 20) .collect(); if paragraphs.is_empty() { return context.to_string(); } let query_lower = query.to_lowercase(); let query_terms: Vec<&str> = query_lower .split_whitespace() .filter(|w| w.len() > 2) .collect(); // Calcular score BM25-like simples let mut scored: Vec<(f32, &str, usize)> = paragraphs.iter() .enumerate() .map(|(idx, &p)| { let p_lower = p.to_lowercase(); let mut score = 0.0f32; for term in &query_terms { let count = p_lower.matches(term).count(); if count > 0 { // TF com saturação logarítmica score += (1.0 + (count as f32).ln()) * (1.0 / (1.0 + query_terms.len() as f32)); } } (score, p, idx) }) .collect(); scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); let mut result = Vec::new(); let mut total_tokens = 0; for (_, paragraph, original_idx) in scored { let paragraph_tokens = paragraph.split_whitespace().count(); if total_tokens + paragraph_tokens > max_tokens { if result.is_empty() { result.push((original_idx, paragraph)); } break; } result.push((original_idx, paragraph)); total_tokens += paragraph_tokens; } result.sort_by_key(|(idx, _)| *idx); let pruned: Vec<&str> = result.into_iter().map(|(_, p)| p).collect(); pruned.join("\n\n") } #[cfg(test)] mod tests { use super::*; #[test] fn test_cosine_similarity() { let a = vec![1.0, 0.0, 0.0]; let b = vec![1.0, 0.0, 0.0]; assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001); let c = vec![0.0, 1.0, 0.0]; assert!((cosine_similarity(&a, &c) - 0.0).abs() < 0.001); let d = vec![-1.0, 0.0, 0.0]; assert!((cosine_similarity(&a, &d) - (-1.0)).abs() < 0.001); } }