use super::db::{SearchResult, Store};
use std::path::Path;
use std::sync::Mutex;
use super::embedding::{embed_query_api, engine_if_ready};
use super::{
COLLECTION_BRAIN, COLLECTION_EXTERNAL, COLLECTION_MEMORY, MemoryResult,
embedding_api_configured,
};
pub async fn search(
store: &'static Mutex<Store>,
query: &str,
n: usize,
) -> Result<Vec<MemoryResult>, String> {
super::freshness::refresh_stale_brain_files().await;
search_core(store, query, n, None).await
}
pub(crate) async fn search_memory(
store: &'static Mutex<Store>,
query: &str,
n: usize,
) -> Result<Vec<MemoryResult>, String> {
search_core(store, query, n, Some(COLLECTION_MEMORY)).await
}
pub(crate) async fn search_external(
store: &'static Mutex<Store>,
query: &str,
n: usize,
) -> Result<Vec<MemoryResult>, String> {
let results = search_core(store, query, n, Some(COLLECTION_EXTERNAL)).await?;
let paths: Vec<String> = results.iter().map(|r| r.path.clone()).collect();
if super::freshness::refresh_stale_external(&paths).await > 0 {
return search_core(store, query, n, Some(COLLECTION_EXTERNAL)).await;
}
Ok(results)
}
async fn search_core(
store: &'static Mutex<Store>,
query: &str,
n: usize,
collection: Option<&'static str>,
) -> Result<Vec<MemoryResult>, String> {
let fts_query = sanitize_fts_query(query);
if fts_query.is_empty() {
return Ok(vec![]);
}
let query_owned = query.to_string();
let api_embedding = if embedding_api_configured() {
match embed_query_api(query).await {
Ok(emb) => Some(emb),
Err(e) => {
tracing::warn!("API embedding failed for query, falling back to FTS-only: {e}");
None
}
}
} else {
None
};
tokio::task::spawn_blocking(move || {
let query_embedding: Option<Vec<f32>> = if !embedding_api_configured() {
engine_if_ready().and_then(|em| {
em.lock()
.ok()
.and_then(|mut e| e.embed_query(&query_owned).ok().map(|r| r.embedding))
})
} else {
api_embedding };
let store = store
.lock()
.map_err(|e| format!("Store lock poisoned: {e}"))?;
let home = crate::config::opencrabs_home();
let fts_results = store
.search_fts(&fts_query, n, collection)
.map_err(|e| format!("FTS search failed: {e}"))?;
if let Some(ref query_emb) = query_embedding {
let db_path = super::store::memory_dir().join("memory.db");
let vec_hits = super::vector_search::search_chunks(&db_path, query_emb, n, collection)
.unwrap_or_default();
if !vec_hits.is_empty() {
let fts_tuples =
results_to_tuples_for(&store, &home, &fts_results, Some(&fts_query));
let vec_tuples = chunk_hits_to_tuples(&store, &home, &vec_hits);
let rrf = hybrid_search_rrf(fts_tuples, vec_tuples, 60);
return Ok(rrf
.into_iter()
.take(n)
.map(|r| MemoryResult {
path: r.file,
snippet: extract_snippet(&r.body, &fts_query, 200),
rank: r.score,
})
.collect());
}
}
Ok(fts_results
.iter()
.map(|r| {
let snippet = match store.get_document(&r.doc.collection_name, &r.doc.path) {
Ok(Some(doc)) => {
let body = doc.body.as_deref().unwrap_or("");
extract_snippet(body, &fts_query, 200)
}
_ => r.doc.title.clone(),
};
MemoryResult {
path: resolve_path(&home, &r.doc.collection_name, &r.doc.path),
snippet,
rank: r.score,
}
})
.collect())
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
pub async fn search_brain(
store: &'static Mutex<Store>,
query: &str,
n: usize,
) -> Result<Vec<MemoryResult>, String> {
super::freshness::refresh_stale_brain_files().await;
let fts_query = sanitize_fts_query(query);
if fts_query.is_empty() {
return Ok(vec![]);
}
tokio::task::spawn_blocking(move || {
let store = store
.lock()
.map_err(|e| format!("Store lock poisoned: {e}"))?;
let home = crate::config::opencrabs_home();
let fts_results = store
.search_fts(&fts_query, n, Some(COLLECTION_BRAIN))
.map_err(|e| format!("FTS search failed: {e}"))?;
Ok(fts_results
.iter()
.map(|r| {
let snippet = match store.get_document(&r.doc.collection_name, &r.doc.path) {
Ok(Some(doc)) => {
let body = doc.body.as_deref().unwrap_or("");
extract_snippet(body, &fts_query, 200)
}
_ => r.doc.title.clone(),
};
MemoryResult {
path: resolve_path(&home, &r.doc.collection_name, &r.doc.path),
snippet,
rank: r.score,
}
})
.collect())
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
fn chunk_hits_to_tuples(
store: &Store,
home: &Path,
hits: &[super::vector_search::ChunkHit],
) -> Vec<(String, String, String, String)> {
hits.iter()
.map(|h| {
let file_path = resolve_path(home, &h.collection, &h.path);
let body = store
.get_document(&h.collection, &h.path)
.ok()
.flatten()
.and_then(|d| d.body)
.unwrap_or_default();
(file_path.clone(), file_path, h.title.clone(), body)
})
.collect()
}
fn results_to_tuples_for(
store: &Store,
home: &Path,
results: &[SearchResult],
query: Option<&str>,
) -> Vec<(String, String, String, String)> {
results
.iter()
.map(|r| {
let file_path = resolve_path(home, &r.doc.collection_name, &r.doc.path);
let full = store
.get_document(&r.doc.collection_name, &r.doc.path)
.ok()
.flatten()
.and_then(|d| d.body)
.unwrap_or_default();
let body = match query.and_then(|q| super::chunk_fts::best_chunk(&full, q)) {
Some((_, chunk)) => chunk,
None => full,
};
(
file_path,
r.doc.display_path.clone(),
r.doc.title.clone(),
body,
)
})
.collect()
}
fn resolve_path(home: &Path, collection: &str, doc_path: &str) -> String {
if collection == COLLECTION_EXTERNAL {
return doc_path.to_string();
}
let p = if collection == COLLECTION_BRAIN {
home.join(doc_path)
} else {
home.join("memory").join(doc_path)
};
p.to_string_lossy().to_string()
}
pub(crate) fn sanitize_fts_query(query: &str) -> String {
query
.split_whitespace()
.map(|w| {
let clean: String = w.chars().filter(|c| *c != '"').collect();
format!("\"{clean}\"")
})
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn extract_snippet(body: &str, query: &str, max_len: usize) -> String {
let query_lower = query.to_lowercase();
let body_lower = body.to_lowercase();
let mut best_pos = 0;
for word in query_lower.split_whitespace() {
let clean: String = word.chars().filter(|c| *c != '"').collect();
if !clean.is_empty()
&& let Some(pos) = body_lower.find(&clean)
{
best_pos = pos;
break;
}
}
let start = best_pos.saturating_sub(50);
let end = (start + max_len).min(body.len());
let start = body.floor_char_boundary(start);
let end = body.ceil_char_boundary(end);
let mut snippet = String::new();
if start > 0 {
snippet.push_str("...");
}
snippet.push_str(body[start..end].trim());
if end < body.len() {
snippet.push_str("...");
}
snippet
}
pub struct RrfResult {
pub file: String,
pub display_path: String,
pub title: String,
pub body: String,
pub score: f64,
}
pub fn hybrid_search_rrf(
fts_results: Vec<(String, String, String, String)>,
vec_results: Vec<(String, String, String, String)>,
k: usize,
) -> Vec<RrfResult> {
use std::collections::HashMap;
let mut scores: HashMap<String, (f64, String, String, String, usize)> = HashMap::new();
for results in [fts_results, vec_results] {
for (rank, (file, display_path, title, body)) in results.iter().enumerate() {
let rrf_score = 1.0 / (k + rank + 1) as f64;
scores
.entry(file.clone())
.and_modify(|(score, _, _, _, best_rank)| {
*score += rrf_score;
*best_rank = (*best_rank).min(rank);
})
.or_insert((
rrf_score,
display_path.clone(),
title.clone(),
body.clone(),
rank,
));
}
}
let mut results: Vec<RrfResult> = scores
.into_iter()
.map(|(file, (score, display_path, title, body, best_rank))| {
let bonus = match best_rank {
0..=2 => 0.08, 3..=9 => 0.04, 10..=19 => 0.01, _ => 0.0,
};
RrfResult {
file,
display_path,
title,
body,
score: score + bonus,
}
})
.collect();
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results
}