diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6edab..e2c3718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog ## Unreleased +- Search results are now selectable by which type, knowledge entity or ingested content ## 1.0.2 (2026-02-15) - Fix: edge case where navigation back to a chat page could trigger a new response generation diff --git a/html-router/src/routes/search/handlers.rs b/html-router/src/routes/search/handlers.rs index 826060f..4fe6093 100644 --- a/html-router/src/routes/search/handlers.rs +++ b/html-router/src/routes/search/handlers.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use axum::{ extract::{Query, State}, }; +use axum_htmx::{HxBoosted, HxRequest}; use common::storage::types::{text_content::TextContent, user::User}; use retrieval_pipeline::{retrieve, RetrievalConfig, RetrievalOutput, RetrievedChunk, RetrievedEntity}; use serde::{de, Deserialize, Deserializer, Serialize}; @@ -30,10 +31,47 @@ where } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "snake_case")] +enum SearchView { + #[default] + All, + Chunks, + Entities, +} + +impl<'de> Deserialize<'de> for SearchView { + fn deserialize>(deserializer: D) -> Result { + let opt = Option::::deserialize(deserializer)?; + Ok(match opt.as_deref() { + None | Some("" | "all") => SearchView::All, + Some("chunks") => SearchView::Chunks, + Some("entities") => SearchView::Entities, + Some(other) => { + return Err(de::Error::custom(format!( + "invalid search view: {other}" + ))); + } + }) + } +} + +impl SearchView { + fn as_str(self) -> &'static str { + match self { + SearchView::All => "all", + SearchView::Chunks => "chunks", + SearchView::Entities => "entities", + } + } +} + #[derive(Deserialize)] pub struct SearchParams { #[serde(default, deserialize_with = "empty_string_as_none")] query: Option, + #[serde(default)] + view: SearchView, } /// Chunk result for template rendering @@ -72,34 +110,47 @@ struct SearchResultForTemplate { pub struct AnswerData { search_result: Vec, query_param: String, + view_param: String, } pub async fn search_result_handler( State(state): State, Query(params): Query, RequireUser(user): RequireUser, + HxRequest(is_htmx): HxRequest, + HxBoosted(is_boosted): HxBoosted, ) -> TemplateResult { + let view = params.view; let (search_results_for_template, final_query_param_for_template) = if let Some(actual_query) = params.query { - perform_search(&state, &user, actual_query).await? + perform_search(&state, &user, actual_query, view).await? } else { (Vec::::new(), String::new()) }; - Ok(TemplateResponse::new_template( - "search/base.html", - AnswerData { - search_result: search_results_for_template, - query_param: final_query_param_for_template, - }, - )) + let data = AnswerData { + search_result: search_results_for_template, + query_param: final_query_param_for_template, + view_param: view.as_str().to_string(), + }; + + if is_htmx && !is_boosted { + Ok(TemplateResponse::new_partial( + "search/base.html", + "main", + data, + )) + } else { + Ok(TemplateResponse::new_template("search/base.html", data)) + } } async fn perform_search( state: &HtmlState, user: &User, query: String, + view: SearchView, ) -> Result<(Vec, String), HtmlError> { const TOTAL_LIMIT: usize = 10; @@ -108,7 +159,10 @@ async fn perform_search( return Ok((Vec::new(), String::new())); } - let config = RetrievalConfig::with_entities(); + let config = match view { + SearchView::Chunks => RetrievalConfig::default(), + SearchView::All | SearchView::Entities => RetrievalConfig::with_entities(), + }; let reranker_lease = match &state.reranker_pool { Some(pool) => pool.checkout().await, @@ -126,62 +180,101 @@ async fn perform_search( ) .await?; - let (chunks, entities) = match result { - RetrievalOutput::WithEntities { chunks, entities } => (chunks, entities), - RetrievalOutput::Chunks(chunks) => (chunks, Vec::new()), + let mut results = match view { + SearchView::Chunks => { + let chunks = match result { + RetrievalOutput::Chunks(chunks) | RetrievalOutput::WithEntities { chunks, .. } => { + chunks + } + }; + let source_label_map = collect_source_label_map(state, user, &chunks, &[]).await?; + chunk_results_for_template(&chunks, &source_label_map) + } + SearchView::Entities => { + let entities = match result { + RetrievalOutput::WithEntities { entities, .. } => entities, + RetrievalOutput::Chunks(_) => Vec::new(), + }; + let source_label_map = collect_source_label_map(state, user, &[], &entities).await?; + entity_results_for_template(&entities, &source_label_map) + } + SearchView::All => { + let (chunks, entities) = match result { + RetrievalOutput::WithEntities { chunks, entities } => (chunks, entities), + RetrievalOutput::Chunks(chunks) => (chunks, Vec::new()), + }; + let source_label_map = + collect_source_label_map(state, user, &chunks, &entities).await?; + let mut combined = chunk_results_for_template(&chunks, &source_label_map); + combined.extend(entity_results_for_template(&entities, &source_label_map)); + combined + } }; - let source_label_map = collect_source_label_map(state, user, &chunks, &entities).await?; + results.sort_by(|a, b| b.score.total_cmp(&a.score)); + results.truncate(TOTAL_LIMIT); - let mut combined_results: Vec = - Vec::with_capacity(chunks.len().saturating_add(entities.len())); + Ok((results, trimmed_query.to_string())) +} - for chunk_result in chunks { - let source_label = source_label_map - .get(&chunk_result.chunk.source_id) - .cloned() - .unwrap_or_else(|| TextContent::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, +fn chunk_results_for_template( + chunks: &[RetrievedChunk], + source_label_map: &std::collections::HashMap, +) -> Vec { + chunks + .iter() + .map(|chunk_result| { + let source_label = source_label_map + .get(&chunk_result.chunk.source_id) + .cloned() + .unwrap_or_else(|| { + TextContent::fallback_source_label(&chunk_result.chunk.source_id) + }); + SearchResultForTemplate { + result_type: "text_chunk".to_string(), score: chunk_result.score, - }), - knowledge_entity: None, - }); - } + text_chunk: Some(TextChunkForTemplate { + id: chunk_result.chunk.id.clone(), + source_id: chunk_result.chunk.source_id.clone(), + source_label, + chunk: chunk_result.chunk.chunk.clone(), + score: chunk_result.score, + }), + knowledge_entity: None, + } + }) + .collect() +} - for entity_result in entities { - let source_label = source_label_map - .get(&entity_result.entity.source_id) - .cloned() - .unwrap_or_else(|| { - TextContent::fallback_source_label(&entity_result.entity.source_id) - }); - combined_results.push(SearchResultForTemplate { - result_type: "knowledge_entity".to_string(), - score: entity_result.score, - text_chunk: None, - knowledge_entity: Some(KnowledgeEntityForTemplate { - id: entity_result.entity.id, - name: entity_result.entity.name, - description: entity_result.entity.description, - entity_type: format!("{:?}", entity_result.entity.entity_type), - source_id: entity_result.entity.source_id, - source_label, +fn entity_results_for_template( + entities: &[RetrievedEntity], + source_label_map: &std::collections::HashMap, +) -> Vec { + entities + .iter() + .map(|entity_result| { + let source_label = source_label_map + .get(&entity_result.entity.source_id) + .cloned() + .unwrap_or_else(|| { + TextContent::fallback_source_label(&entity_result.entity.source_id) + }); + SearchResultForTemplate { + result_type: "knowledge_entity".to_string(), score: entity_result.score, - }), - }); - } - - combined_results.sort_by(|a, b| b.score.total_cmp(&a.score)); - combined_results.truncate(TOTAL_LIMIT); - - Ok((combined_results, trimmed_query.to_string())) + text_chunk: None, + knowledge_entity: Some(KnowledgeEntityForTemplate { + id: entity_result.entity.id.clone(), + name: entity_result.entity.name.clone(), + description: entity_result.entity.description.clone(), + entity_type: format!("{:?}", entity_result.entity.entity_type), + source_id: entity_result.entity.source_id.clone(), + source_label, + score: entity_result.score, + }), + } + }) + .collect() } async fn collect_source_label_map( diff --git a/html-router/templates/search/_layout.html b/html-router/templates/search/_layout.html index 5c77e8d..81e5d92 100644 --- a/html-router/templates/search/_layout.html +++ b/html-router/templates/search/_layout.html @@ -3,7 +3,7 @@ {% block title %}Minne - Search{% endblock %} {% block main %} -
+
diff --git a/html-router/templates/search/base.html b/html-router/templates/search/base.html index f68e038..375e772 100644 --- a/html-router/templates/search/base.html +++ b/html-router/templates/search/base.html @@ -1,8 +1,28 @@ {% extends "search/_layout.html" %} {% block search_header %} -

Search

-
Find documents, entities, and snippets
+
+

Search

+
Find document snippets and knowledge entities
+
+
+ {% if query_param %} + + {% endif %} + + + +
{% endblock %} {% block search_results %} diff --git a/html-router/templates/search/response.html b/html-router/templates/search/response.html index e994443..ac8d92d 100644 --- a/html-router/templates/search/response.html +++ b/html-router/templates/search/response.html @@ -160,8 +160,8 @@ {% elif query_param is defined and query_param | trim != "" %}
-

No results for “{{ query_param | escape }}”.

-

Try different keywords or check for typos.

+

No {% if view_param == 'entities' %}entities{% elif view_param == 'chunks' %}chunks{% else %}results{% endif %} for “{{ query_param | escape }}”.

+

Try different keywords, check for typos, or switch result type.

{% else %}
diff --git a/html-router/templates/searchbar.html b/html-router/templates/searchbar.html index b839cd5..4e32b82 100644 --- a/html-router/templates/searchbar.html +++ b/html-router/templates/searchbar.html @@ -1,6 +1,9 @@
+ {% if view_param is defined and view_param and view_param != 'all' %} + + {% endif %}