diff --git a/Cargo.lock b/Cargo.lock index 07f1700..d0884a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2194,7 +2194,7 @@ dependencies = [ ] [[package]] -name = "eval" +name = "evaluations" version = "0.1.0" dependencies = [ "anyhow", diff --git a/Cargo.toml b/Cargo.toml index a857efa..d1ed6c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ "ingestion-pipeline", "retrieval-pipeline", "json-stream-parser", - "eval" + "evaluations" ] resolver = "2" @@ -80,7 +80,7 @@ implicit_clone = "warn" redundant_clone = "warn" # Security-focused lints -integer_arithmetic = "warn" +arithmetic_side_effects = "warn" indexing_slicing = "warn" unwrap_used = "warn" expect_used = "warn" @@ -90,7 +90,7 @@ todo = "warn" # Async/Network lints async_yields_async = "warn" -await_holding_invalid_state = "warn" +await_holding_invalid_type = "warn" rc_buffer = "warn" # Maintainability-focused lints @@ -109,6 +109,8 @@ wildcard_dependencies = "warn" missing_docs_in_private_items = "warn" # Allow noisy lints that don't add value for this project -manual_must_use = "allow" needless_raw_string_hashes = "allow" multiple_bound_locations = "allow" +cargo_common_metadata = "allow" +multiple-crate-versions = "allow" +module_name_repetition = "allow" diff --git a/common/migrations/20251210_add_embedding_backend_to_system_settings.surql b/common/migrations/20251210_add_embedding_backend_to_system_settings.surql new file mode 100644 index 0000000..ef68181 --- /dev/null +++ b/common/migrations/20251210_add_embedding_backend_to_system_settings.surql @@ -0,0 +1,8 @@ +-- Add embedding_backend field to system_settings for visibility of active backend + +DEFINE FIELD IF NOT EXISTS embedding_backend ON system_settings TYPE option; + +-- Set default to 'openai' for existing installs to preserve backward compatibility +UPDATE system_settings:current SET + embedding_backend = 'openai' +WHERE embedding_backend == NONE; diff --git a/common/src/storage/types/knowledge_entity.rs b/common/src/storage/types/knowledge_entity.rs index 9205792..b95da14 100644 --- a/common/src/storage/types/knowledge_entity.rs +++ b/common/src/storage/types/knowledge_entity.rs @@ -428,6 +428,103 @@ impl KnowledgeEntity { info!("Re-embedding process for knowledge entities completed successfully."); Ok(()) } + + /// Re-creates embeddings for all knowledge entities using an `EmbeddingProvider`. + /// + /// This variant uses the application's configured embedding provider (FastEmbed, OpenAI, etc.) + /// instead of directly calling OpenAI. Used during startup when embedding configuration changes. + pub async fn update_all_embeddings_with_provider( + db: &SurrealDbClient, + provider: &crate::utils::embedding::EmbeddingProvider, + ) -> Result<(), AppError> { + let new_dimensions = provider.dimension(); + info!( + dimensions = new_dimensions, + backend = provider.backend_label(), + "Starting re-embedding process for all knowledge entities" + ); + + // Fetch all entities first + let all_entities: Vec = db.select(Self::table_name()).await?; + let total_entities = all_entities.len(); + if total_entities == 0 { + info!("No knowledge entities to update. Just updating the index."); + KnowledgeEntityEmbedding::redefine_hnsw_index(db, new_dimensions).await?; + return Ok(()); + } + info!(entities = total_entities, "Found entities to process"); + + // Generate all new embeddings in memory + let mut new_embeddings: HashMap, String)> = HashMap::new(); + info!("Generating new embeddings for all entities..."); + + for (i, entity) in all_entities.iter().enumerate() { + if i > 0 && i % 100 == 0 { + info!(progress = i, total = total_entities, "Re-embedding progress"); + } + + let embedding_input = format!( + "name: {}, description: {}, type: {:?}", + entity.name, entity.description, entity.entity_type + ); + + let embedding = provider + .embed(&embedding_input) + .await + .map_err(|e| AppError::InternalError(format!("Embedding failed: {e}")))?; + + // Safety check: ensure the generated embedding has the correct dimension. + if embedding.len() != new_dimensions { + let err_msg = format!( + "CRITICAL: Generated embedding for entity {} has incorrect dimension ({}). Expected {}. Aborting.", + entity.id, embedding.len(), new_dimensions + ); + error!("{}", err_msg); + return Err(AppError::InternalError(err_msg)); + } + new_embeddings.insert(entity.id.clone(), (embedding, entity.user_id.clone())); + } + info!("Successfully generated all new embeddings."); + + // Perform DB updates in a single transaction + info!("Applying embedding updates in a transaction..."); + let mut transaction_query = String::from("BEGIN TRANSACTION;"); + + for (id, (embedding, user_id)) in new_embeddings { + let embedding_str = format!( + "[{}]", + embedding + .iter() + .map(|f| f.to_string()) + .collect::>() + .join(",") + ); + transaction_query.push_str(&format!( + "UPSERT type::thing('knowledge_entity_embedding', '{id}') SET \ + entity_id = type::thing('knowledge_entity', '{id}'), \ + embedding = {embedding}, \ + user_id = '{user_id}', \ + created_at = IF created_at != NONE THEN created_at ELSE time::now() END, \ + updated_at = time::now();", + id = id, + embedding = embedding_str, + user_id = user_id + )); + } + + transaction_query.push_str(&format!( + "DEFINE INDEX OVERWRITE idx_embedding_knowledge_entity_embedding ON TABLE knowledge_entity_embedding FIELDS embedding HNSW DIMENSION {};", + new_dimensions + )); + + transaction_query.push_str("COMMIT TRANSACTION;"); + + // Execute the entire atomic operation + db.query(transaction_query).await?; + + info!("Re-embedding process for knowledge entities completed successfully."); + Ok(()) + } } #[cfg(test)] diff --git a/common/src/storage/types/system_settings.rs b/common/src/storage/types/system_settings.rs index 9df9ccc..f387f43 100644 --- a/common/src/storage/types/system_settings.rs +++ b/common/src/storage/types/system_settings.rs @@ -13,6 +13,9 @@ pub struct SystemSettings { pub processing_model: String, pub embedding_model: String, pub embedding_dimensions: u32, + /// Active embedding backend ("openai", "fastembed", "hashed"). Read-only, synced from config. + #[serde(default)] + pub embedding_backend: Option, pub query_system_prompt: String, pub ingestion_system_prompt: String, pub image_processing_model: String, @@ -49,6 +52,57 @@ impl SystemSettings { "Something went wrong updating the settings".into(), )) } + + /// Syncs SystemSettings with the active embedding provider's properties. + /// Updates embedding_backend, embedding_model, and embedding_dimensions if they differ. + /// Returns true if any settings were changed. + pub async fn sync_from_embedding_provider( + db: &SurrealDbClient, + provider: &crate::utils::embedding::EmbeddingProvider, + ) -> Result<(Self, bool), AppError> { + let mut settings = Self::get_current(db).await?; + let mut needs_update = false; + + let backend_label = provider.backend_label().to_string(); + let provider_dimensions = provider.dimension() as u32; + let provider_model = provider.model_code(); + + // Sync backend label + if settings.embedding_backend.as_deref() != Some(&backend_label) { + settings.embedding_backend = Some(backend_label); + needs_update = true; + } + + // Sync dimensions + if settings.embedding_dimensions != provider_dimensions { + tracing::info!( + old_dimensions = settings.embedding_dimensions, + new_dimensions = provider_dimensions, + "Embedding dimensions changed, updating SystemSettings" + ); + settings.embedding_dimensions = provider_dimensions; + needs_update = true; + } + + // Sync model if provider has one + if let Some(model) = provider_model { + if settings.embedding_model != model { + tracing::info!( + old_model = %settings.embedding_model, + new_model = %model, + "Embedding model changed, updating SystemSettings" + ); + settings.embedding_model = model; + needs_update = true; + } + } + + if needs_update { + settings = Self::update(db, settings).await?; + } + + Ok((settings, needs_update)) + } } #[cfg(test)] diff --git a/common/src/storage/types/text_chunk.rs b/common/src/storage/types/text_chunk.rs index 517f81a..ef04a5c 100644 --- a/common/src/storage/types/text_chunk.rs +++ b/common/src/storage/types/text_chunk.rs @@ -323,6 +323,106 @@ impl TextChunk { info!("Re-embedding process for text chunks completed successfully."); Ok(()) } + + /// Re-creates embeddings for all text chunks using an `EmbeddingProvider`. + /// + /// This variant uses the application's configured embedding provider (FastEmbed, OpenAI, etc.) + /// instead of directly calling OpenAI. Used during startup when embedding configuration changes. + pub async fn update_all_embeddings_with_provider( + db: &SurrealDbClient, + provider: &crate::utils::embedding::EmbeddingProvider, + ) -> Result<(), AppError> { + let new_dimensions = provider.dimension(); + info!( + dimensions = new_dimensions, + backend = provider.backend_label(), + "Starting re-embedding process for all text chunks" + ); + + // Fetch all chunks first + let all_chunks: Vec = db.select(Self::table_name()).await?; + let total_chunks = all_chunks.len(); + if total_chunks == 0 { + info!("No text chunks to update. Just updating the index."); + TextChunkEmbedding::redefine_hnsw_index(db, new_dimensions).await?; + return Ok(()); + } + info!(chunks = total_chunks, "Found chunks to process"); + + // Generate all new embeddings in memory + let mut new_embeddings: HashMap, String, String)> = HashMap::new(); + info!("Generating new embeddings for all chunks..."); + + for (i, chunk) in all_chunks.iter().enumerate() { + if i > 0 && i % 100 == 0 { + info!(progress = i, total = total_chunks, "Re-embedding progress"); + } + + let embedding = provider + .embed(&chunk.chunk) + .await + .map_err(|e| AppError::InternalError(format!("Embedding failed: {e}")))?; + + // Safety check: ensure the generated embedding has the correct dimension. + if embedding.len() != new_dimensions { + let err_msg = format!( + "CRITICAL: Generated embedding for chunk {} has incorrect dimension ({}). Expected {}. Aborting.", + chunk.id, embedding.len(), new_dimensions + ); + error!("{}", err_msg); + return Err(AppError::InternalError(err_msg)); + } + new_embeddings.insert( + chunk.id.clone(), + (embedding, chunk.user_id.clone(), chunk.source_id.clone()), + ); + } + info!("Successfully generated all new embeddings."); + + // Perform DB updates in a single transaction against the embedding table + info!("Applying embedding updates in a transaction..."); + let mut transaction_query = String::from("BEGIN TRANSACTION;"); + + for (id, (embedding, user_id, source_id)) in new_embeddings { + let embedding_str = format!( + "[{}]", + embedding + .iter() + .map(ToString::to_string) + .collect::>() + .join(",") + ); + write!( + &mut transaction_query, + "UPSERT type::thing('text_chunk_embedding', '{id}') SET \ + chunk_id = type::thing('text_chunk', '{id}'), \ + source_id = '{source_id}', \ + embedding = {embedding}, \ + user_id = '{user_id}', \ + created_at = IF created_at != NONE THEN created_at ELSE time::now() END, \ + updated_at = time::now();", + id = id, + embedding = embedding_str, + user_id = user_id, + source_id = source_id + ) + .map_err(|e| AppError::InternalError(e.to_string()))?; + } + + write!( + &mut transaction_query, + "DEFINE INDEX OVERWRITE idx_embedding_text_chunk_embedding ON TABLE text_chunk_embedding FIELDS embedding HNSW DIMENSION {};", + new_dimensions + ) + .map_err(|e| AppError::InternalError(e.to_string()))?; + + transaction_query.push_str("COMMIT TRANSACTION;"); + + db.query(transaction_query).await?; + + info!("Re-embedding process for text chunks completed successfully."); + Ok(()) + } } #[cfg(test)] diff --git a/common/src/utils/config.rs b/common/src/utils/config.rs index b1329a2..a3e6356 100644 --- a/common/src/utils/config.rs +++ b/common/src/utils/config.rs @@ -2,6 +2,19 @@ use config::{Config, ConfigError, Environment, File}; use serde::Deserialize; use std::env; +/// Selects the embedding backend for vector generation. +#[derive(Clone, Deserialize, Debug, Default, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum EmbeddingBackend { + /// Use OpenAI-compatible API for embeddings. + OpenAI, + /// Use FastEmbed local embeddings (default). + #[default] + FastEmbed, + /// Use deterministic hashed embeddings (for testing). + Hashed, +} + #[derive(Clone, Deserialize, Debug, PartialEq)] #[serde(rename_all = "lowercase")] pub enum StorageKind { @@ -60,6 +73,8 @@ pub struct AppConfig { pub fastembed_max_length: Option, #[serde(default)] pub retrieval_strategy: Option, + #[serde(default)] + pub embedding_backend: EmbeddingBackend, } /// Default data directory for persisted assets. @@ -127,6 +142,7 @@ impl Default for AppConfig { fastembed_show_download_progress: None, fastembed_max_length: None, retrieval_strategy: None, + embedding_backend: EmbeddingBackend::default(), } } } diff --git a/common/src/utils/embedding.rs b/common/src/utils/embedding.rs index 63d4c67..c3b5cd3 100644 --- a/common/src/utils/embedding.rs +++ b/common/src/utils/embedding.rs @@ -235,6 +235,34 @@ impl EmbeddingProvider { }, }) } + + /// Creates an embedding provider based on application configuration. + /// + /// Dispatches to the appropriate constructor based on `config.embedding_backend`: + /// - `OpenAI`: Requires a valid OpenAI client + /// - `FastEmbed`: Uses local embedding model + /// - `Hashed`: Uses deterministic hashed embeddings (for testing) + pub async fn from_config( + config: &crate::utils::config::AppConfig, + openai_client: Option>>, + ) -> Result { + use crate::utils::config::EmbeddingBackend; + + match config.embedding_backend { + EmbeddingBackend::OpenAI => { + let client = openai_client.ok_or_else(|| { + anyhow!("OpenAI embedding backend requires an OpenAI client") + })?; + // Use defaults that match SystemSettings initial values + Self::new_openai(client, "text-embedding-3-small".to_string(), 1536) + } + EmbeddingBackend::FastEmbed => { + // Use nomic-embed-text-v1.5 as the default FastEmbed model + Self::new_fastembed(Some("nomic-ai/nomic-embed-text-v1.5".to_string())).await + } + EmbeddingBackend::Hashed => Self::new_hashed(384), + } + } } // Helper functions for hashed embeddings diff --git a/html-router/src/html_state.rs b/html-router/src/html_state.rs index 615b834..f05453e 100644 --- a/html-router/src/html_state.rs +++ b/html-router/src/html_state.rs @@ -1,4 +1,5 @@ use common::storage::{db::SurrealDbClient, store::StorageManager}; +use common::utils::embedding::EmbeddingProvider; use common::utils::template_engine::{ProvidesTemplateEngine, TemplateEngine}; use common::{create_template_engine, storage::db::ProvidesDb, utils::config::AppConfig}; use retrieval_pipeline::{reranking::RerankerPool, RetrievalStrategy}; @@ -16,6 +17,7 @@ pub struct HtmlState { pub config: AppConfig, pub storage: StorageManager, pub reranker_pool: Option>, + pub embedding_provider: Arc, } impl HtmlState { @@ -26,6 +28,7 @@ impl HtmlState { storage: StorageManager, config: AppConfig, reranker_pool: Option>, + embedding_provider: Arc, ) -> Result> { let template_engine = create_template_engine!("templates"); debug!("Template engine created for html_router."); @@ -38,6 +41,7 @@ impl HtmlState { config, storage, reranker_pool, + embedding_provider, }) } diff --git a/html-router/src/routes/admin/handlers.rs b/html-router/src/routes/admin/handlers.rs index 8744bce..e3f2e0e 100644 --- a/html-router/src/routes/admin/handlers.rs +++ b/html-router/src/routes/admin/handlers.rs @@ -151,21 +151,46 @@ pub async fn update_model_settings( let current_settings = SystemSettings::get_current(&state.db).await?; - // Determine if re-embedding is required - let reembedding_needed = input - .embedding_dimensions - .is_some_and(|new_dims| new_dims != current_settings.embedding_dimensions); + // Check if using FastEmbed - if so, embedding model/dimensions cannot be changed via UI + let uses_local_embeddings = current_settings + .embedding_backend + .as_deref() + .is_some_and(|b| b == "fastembed" || b == "hashed"); + + // For local embeddings, ignore any embedding model/dimension changes from the form + let (final_embedding_model, final_embedding_dimensions, reembedding_needed) = + if uses_local_embeddings { + // Keep current values - they're controlled by config, not the admin UI + info!( + backend = ?current_settings.embedding_backend, + "Embedding model/dimensions controlled by config, ignoring form input" + ); + ( + current_settings.embedding_model.clone(), + current_settings.embedding_dimensions, + false, + ) + } else { + // OpenAI backend - allow changes from form + let reembedding_needed = input + .embedding_dimensions + .is_some_and(|new_dims| new_dims != current_settings.embedding_dimensions); + ( + input.embedding_model, + input + .embedding_dimensions + .unwrap_or(current_settings.embedding_dimensions), + reembedding_needed, + ) + }; let new_settings = SystemSettings { query_model: input.query_model, processing_model: input.processing_model, image_processing_model: input.image_processing_model, voice_processing_model: input.voice_processing_model, - embedding_model: input.embedding_model, - // Use new dimensions if provided, otherwise retain the current ones. - embedding_dimensions: input - .embedding_dimensions - .unwrap_or(current_settings.embedding_dimensions), + embedding_model: final_embedding_model, + embedding_dimensions: final_embedding_dimensions, ..current_settings.clone() }; diff --git a/html-router/src/routes/chat/message_response_stream.rs b/html-router/src/routes/chat/message_response_stream.rs index e6c5f9c..3eda0ce 100644 --- a/html-router/src/routes/chat/message_response_stream.rs +++ b/html-router/src/routes/chat/message_response_stream.rs @@ -132,6 +132,7 @@ pub async fn get_response_stream( let retrieval_result = match retrieval_pipeline::retrieve_entities( &state.db, &state.openai_client, + Some(&*state.embedding_provider), &user_message.content, &user.id, config, diff --git a/html-router/src/routes/knowledge/handlers.rs b/html-router/src/routes/knowledge/handlers.rs index fac71e3..93bc3e5 100644 --- a/html-router/src/routes/knowledge/handlers.rs +++ b/html-router/src/routes/knowledge/handlers.rs @@ -288,6 +288,7 @@ pub async fn suggest_knowledge_relationships( retrieval_pipeline::retrieve_entities( &state.db, &state.openai_client, + Some(&*state.embedding_provider), &query, &user.id, config, diff --git a/html-router/src/routes/search/handlers.rs b/html-router/src/routes/search/handlers.rs index eca8e38..af7d8e9 100644 --- a/html-router/src/routes/search/handlers.rs +++ b/html-router/src/routes/search/handlers.rs @@ -1,13 +1,21 @@ -use std::{fmt, str::FromStr, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + fmt, str::FromStr, +}; use axum::{ extract::{Query, State}, response::IntoResponse, }; -use common::storage::types::{conversation::Conversation, user::User}; +use common::storage::types::{ + conversation::Conversation, + text_content::{deserialize_flexible_id, TextContent}, + user::User, + StoredObject, +}; use retrieval_pipeline::{RetrievalConfig, SearchResult, SearchTarget, StrategyOutput}; use serde::{de, Deserialize, Deserializer, Serialize}; -use tokio::time::error::Elapsed; +use surrealdb::RecordId; use crate::{ html_state::HtmlState, @@ -31,6 +39,113 @@ where } } +fn source_id_suffix(source_id: &str) -> String { + let start = source_id.len().saturating_sub(8); + source_id[start..].to_string() +} + +fn truncate_label(value: &str, max_chars: usize) -> String { + let mut end = None; + let mut count = 0; + for (idx, _) in value.char_indices() { + if count == max_chars { + end = Some(idx); + break; + } + count += 1; + } + + match end { + Some(idx) => format!("{}...", &value[..idx]), + None => value.to_string(), + } +} + +fn first_non_empty_line(text: &str, max_chars: usize) -> Option { + for line in text.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() { + return Some(truncate_label(trimmed, max_chars)); + } + } + None +} + +#[derive(Deserialize)] +struct UrlInfoLabel { + #[serde(default)] + title: String, + #[serde(default)] + url: String, +} + +#[derive(Deserialize)] +struct FileInfoLabel { + #[serde(default)] + file_name: String, +} + +#[derive(Deserialize)] +struct SourceLabelRow { + #[serde(deserialize_with = "deserialize_flexible_id")] + id: String, + #[serde(default)] + url_info: Option, + #[serde(default)] + file_info: Option, + #[serde(default)] + context: Option, + #[serde(default)] + category: String, + #[serde(default)] + text: String, +} + +fn build_source_label(row: &SourceLabelRow) -> String { + const MAX_LABEL_CHARS: usize = 80; + + if let Some(url_info) = row.url_info.as_ref() { + let title = url_info.title.trim(); + if !title.is_empty() { + return title.to_string(); + } + + let url = url_info.url.trim(); + if !url.is_empty() { + return url.to_string(); + } + } + + if let Some(file_info) = row.file_info.as_ref() { + let name = file_info.file_name.trim(); + if !name.is_empty() { + return name.to_string(); + } + } + + if let Some(context) = row.context.as_ref() { + let trimmed = context.trim(); + if !trimmed.is_empty() { + return truncate_label(trimmed, MAX_LABEL_CHARS); + } + } + + if let Some(text_label) = first_non_empty_line(&row.text, MAX_LABEL_CHARS) { + return text_label; + } + + let category = row.category.trim(); + if !category.is_empty() { + return truncate_label(category, MAX_LABEL_CHARS); + } + + format!("Text snippet: {}", source_id_suffix(&row.id)) +} + +fn fallback_source_label(source_id: &str) -> String { + format!("Text snippet: {}", source_id_suffix(source_id)) +} + #[derive(Deserialize)] pub struct SearchParams { #[serde(default, deserialize_with = "empty_string_as_none")] @@ -42,6 +157,7 @@ pub struct SearchParams { struct TextChunkForTemplate { id: String, source_id: String, + source_label: String, chunk: String, score: f32, } @@ -54,6 +170,7 @@ struct KnowledgeEntityForTemplate { description: String, entity_type: String, source_id: String, + source_label: String, score: f32, } @@ -89,14 +206,21 @@ pub async fn search_result_handler( } else { // Use retrieval pipeline Search strategy let config = RetrievalConfig::for_search(SearchTarget::Both); + + // Checkout a reranker lease if pool is available + let reranker_lease = match &state.reranker_pool { + Some(pool) => Some(pool.checkout().await), + None => None, + }; + let result = retrieval_pipeline::pipeline::run_pipeline( &state.db, &state.openai_client, - None, // No embedding provider in HtmlState + Some(&state.embedding_provider), trimmed_query, &user.id, config, - None, // No reranker for now + reranker_lease, ) .await?; @@ -105,17 +229,74 @@ pub async fn search_result_handler( _ => SearchResult::new(vec![], vec![]), }; + let mut source_ids = HashSet::new(); + for chunk_result in &search_result.chunks { + source_ids.insert(chunk_result.chunk.source_id.clone()); + } + for entity_result in &search_result.entities { + source_ids.insert(entity_result.entity.source_id.clone()); + } + + let source_label_map = if source_ids.is_empty() { + HashMap::new() + } else { + let record_ids: Vec = source_ids + .iter() + .filter_map(|id| { + if id.contains(':') { + RecordId::from_str(id).ok() + } else { + Some(RecordId::from_table_key(TextContent::table_name(), id)) + } + }) + .collect(); + let mut response = state + .db + .client + .query( + "SELECT id, url_info, file_info, context, category, text FROM type::table($table_name) WHERE user_id = $user_id AND id INSIDE $record_ids", + ) + .bind(("table_name", TextContent::table_name())) + .bind(("user_id", user.id.clone())) + .bind(("record_ids", record_ids)) + .await?; + let contents: Vec = response.take(0)?; + + tracing::debug!( + source_id_count = source_ids.len(), + label_row_count = contents.len(), + "Resolved search source labels" + ); + + let mut labels = HashMap::new(); + for content in contents { + let label = build_source_label(&content); + labels.insert(content.id.clone(), label.clone()); + labels.insert( + format!("{}:{}", TextContent::table_name(), content.id), + label, + ); + } + + labels + }; + let mut combined_results: Vec = Vec::with_capacity(search_result.chunks.len() + search_result.entities.len()); // Add chunk results for chunk_result in search_result.chunks { + let source_label = source_label_map + .get(&chunk_result.chunk.source_id) + .cloned() + .unwrap_or_else(|| fallback_source_label(&chunk_result.chunk.source_id)); combined_results.push(SearchResultForTemplate { result_type: "text_chunk".to_string(), score: chunk_result.score, text_chunk: Some(TextChunkForTemplate { id: chunk_result.chunk.id, source_id: chunk_result.chunk.source_id, + source_label, chunk: chunk_result.chunk.chunk, score: chunk_result.score, }), @@ -125,6 +306,10 @@ pub async fn search_result_handler( // Add entity results for entity_result in search_result.entities { + let source_label = source_label_map + .get(&entity_result.entity.source_id) + .cloned() + .unwrap_or_else(|| fallback_source_label(&entity_result.entity.source_id)); combined_results.push(SearchResultForTemplate { result_type: "knowledge_entity".to_string(), score: entity_result.score, @@ -135,6 +320,7 @@ pub async fn search_result_handler( description: entity_result.entity.description, entity_type: format!("{:?}", entity_result.entity.entity_type), source_id: entity_result.entity.source_id, + source_label, score: entity_result.score, }), }); diff --git a/html-router/templates/admin/base.html b/html-router/templates/admin/base.html index 6d10cfc..0aed54e 100644 --- a/html-router/templates/admin/base.html +++ b/html-router/templates/admin/base.html @@ -36,9 +36,12 @@
System Prompts
- - - + + +
{% endblock %} @@ -52,7 +55,8 @@
Query Model

Current: {{settings.query_model}}

@@ -63,7 +67,8 @@
Processing Model

Current: {{settings.processing_model}}

@@ -74,10 +79,12 @@
Image Processing Model
-

Current: {{settings.image_processing_model}}

+

Current: {{settings.image_processing_model}}

@@ -85,39 +92,66 @@
Voice Processing Model
-

Current: {{settings.voice_processing_model}}

+

Current: {{settings.voice_processing_model}}

Embedding Model
+ {% if settings.embedding_backend == "fastembed" or settings.embedding_backend == "hashed" %} + +

Model: {{settings.embedding_model}} + ({{settings.embedding_dimensions}} dims)

+

ℹ️ Embedding model is controlled by config when using {{settings.embedding_backend}} backend.

+ {% else %} -

Current: {{settings.embedding_model}} ({{settings.embedding_dimensions}} dims)

+

Current: {{settings.embedding_model}} + ({{settings.embedding_dimensions}} dims)

+ {% endif %}
Embedding Dimensions
- + {% if settings.embedding_backend == "fastembed" or settings.embedding_backend == "hashed" %} + +

ℹ️ Dimensions are fixed for {{settings.embedding_backend}} backend. Set EMBEDDING_BACKEND=openai to use OpenAI embeddings.

+ {% else %} + + {% endif %}
+ {% if settings.embedding_backend != "fastembed" and settings.embedding_backend != "hashed" %} + {% endif %}
+ {% if settings.embedding_backend != "fastembed" and settings.embedding_backend != "hashed" %} + {% endif %} {% endblock %} @@ -143,7 +178,8 @@