mirror of
https://github.com/perstarkse/minne.git
synced 2026-06-12 17:24:26 +02:00
feat: can now choose search result types
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let opt = Option::<String>::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<String>,
|
||||
#[serde(default)]
|
||||
view: SearchView,
|
||||
}
|
||||
|
||||
/// Chunk result for template rendering
|
||||
@@ -72,34 +110,47 @@ struct SearchResultForTemplate {
|
||||
pub struct AnswerData {
|
||||
search_result: Vec<SearchResultForTemplate>,
|
||||
query_param: String,
|
||||
view_param: String,
|
||||
}
|
||||
|
||||
pub async fn search_result_handler(
|
||||
State(state): State<HtmlState>,
|
||||
Query(params): Query<SearchParams>,
|
||||
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::<SearchResultForTemplate>::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<SearchResultForTemplate>, 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<SearchResultForTemplate> =
|
||||
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<String, String>,
|
||||
) -> Vec<SearchResultForTemplate> {
|
||||
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<String, String>,
|
||||
) -> Vec<SearchResultForTemplate> {
|
||||
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(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}Minne - Search{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="flex justify-center grow mt-2 sm:mt-4">
|
||||
<div id="search_pane" class="flex justify-center grow mt-2 sm:mt-4">
|
||||
<div class="container">
|
||||
<section class="mb-4">
|
||||
<div class="nb-panel p-3 flex items-center justify-between">
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
{% extends "search/_layout.html" %}
|
||||
|
||||
{% block search_header %}
|
||||
<h1 class="text-xl font-extrabold tracking-tight">Search</h1>
|
||||
<div class="text-xs opacity-70">Find documents, entities, and snippets</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-extrabold tracking-tight">Search</h1>
|
||||
<div class="text-xs opacity-70">Find document snippets and knowledge entities</div>
|
||||
</div>
|
||||
<form hx-get="/search" hx-target="#search_pane" hx-swap="outerHTML" hx-push-url="true"
|
||||
class="flex items-center gap-1">
|
||||
{% if query_param %}
|
||||
<input type="hidden" name="query" value="{{ query_param }}" />
|
||||
{% endif %}
|
||||
<button type="submit" name="view" value="all"
|
||||
class="nb-btn btn-sm {% if view_param == 'all' %}nb-cta{% else %}btn-ghost{% endif %}">
|
||||
All
|
||||
</button>
|
||||
<button type="submit" name="view" value="chunks"
|
||||
class="nb-btn btn-sm {% if view_param == 'chunks' %}nb-cta{% else %}btn-ghost{% endif %}">
|
||||
Chunks
|
||||
</button>
|
||||
<button type="submit" name="view" value="entities"
|
||||
class="nb-btn btn-sm {% if view_param == 'entities' %}nb-cta{% else %}btn-ghost{% endif %}">
|
||||
Entities
|
||||
</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block search_results %}
|
||||
|
||||
@@ -160,8 +160,8 @@
|
||||
|
||||
{% elif query_param is defined and query_param | trim != "" %}
|
||||
<div class="nb-panel p-5 text-center">
|
||||
<p class="text-xl font-extrabold mb-2">No results for “{{ query_param | escape }}”.</p>
|
||||
<p class="text-sm opacity-70">Try different keywords or check for typos.</p>
|
||||
<p class="text-xl font-extrabold mb-2">No {% if view_param == 'entities' %}entities{% elif view_param == 'chunks' %}chunks{% else %}results{% endif %} for “{{ query_param | escape }}”.</p>
|
||||
<p class="text-sm opacity-70">Try different keywords, check for typos, or switch result type.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="nb-panel p-5 text-center">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<div class="flex items-center gap-2 min-w-[90px] w-full">
|
||||
<form class="w-full relative" hx-boost="true" method="get" action="/search"
|
||||
hx-trigger="keyup changed delay:500ms from:#search-input, search from:#search-input" hx-push-url="true">
|
||||
{% if view_param is defined and view_param and view_param != 'all' %}
|
||||
<input type="hidden" name="view" value="{{ view_param }}" />
|
||||
{% endif %}
|
||||
<input id="search-input" type="search" aria-label="Search" class=" nb-input w-full pl-9 ml-2" name="query"
|
||||
autocomplete="off" value="{{ query_param | default('', true) }}" />
|
||||
<button type="submit"
|
||||
|
||||
Reference in New Issue
Block a user