//! M8.6 — Simple Hybrid Search (Semantic + Lexical Fusion) //! //! Combines pgvector semantic search with OpenSearch lexical search using RRF. //! Simpler than HybridQueryWorker - uses only existing VectorStore/OpenSearchClient APIs. use anyhow::Result; use mem_store::VectorStore; use pgvector::Vector; use serde::{Deserialize, Serialize}; use std::sync::Arc; use crate::opensearch_client::OpenSearchClient; use crate::query_optimizer::RRFFusion; /// Hybrid search result with score breakdown #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SimpleHybridResult { pub id: String, pub content: String, pub project: String, pub semantic_score: Option, pub lexical_score: Option, pub final_score: f32, pub rank: usize, } /// Simple hybrid search orchestrator pub struct SimpleHybridSearch { vector_store: Arc, opensearch: Option>, rrf: RRFFusion, } impl SimpleHybridSearch { pub fn new( vector_store: Arc, opensearch: Option>, ) -> Self { Self { vector_store, opensearch, rrf: RRFFusion::default(), } } /// Execute hybrid search: semantic + lexical with RRF fusion pub async fn search( &self, project: &str, query: &str, embedding: &Vector, jwt_token: &str, limit: usize, ) -> Result> { // 1. Semantic search (pgvector) let semantic_results = self .vector_store .search_l1(project, embedding, limit as i64) .await?; let semantic_scores: Vec<(String, f32)> = semantic_results .into_iter() .enumerate() .map(|(i, result)| { // Rank to score conversion let rank_score = 1.0 / (i as f32 + 1.0); (result.item.id.to_string(), rank_score) }) .collect(); // 2. Lexical search (OpenSearch) - optional if available let lexical_scores: Vec<(String, f32)> = if let Some(os) = &self.opensearch { match os .search(project, query, jwt_token, limit) .await { Ok(results) => results .into_iter() .enumerate() .map(|(i, _result)| { // Use ID from OpenSearch result let rank_score = 1.0 / (i as f32 + 1.0); // Note: Would need to extract ID from result // For now, placeholder ("placeholder".to_string(), rank_score) }) .collect(), Err(_) => vec![], // Gracefully fallback to semantic-only } } else { vec![] }; // 3. Fuse with RRF let fused = self.rrf.fuse(semantic_scores.clone(), lexical_scores.clone()); // 4. Convert to response format let results = fused .into_iter() .enumerate() .map(|(rank, (id, score))| { let semantic_score = semantic_scores .iter() .find(|(sid, _)| sid == &id) .map(|(_, s)| *s); let lexical_score = lexical_scores .iter() .find(|(sid, _)| sid == &id) .map(|(_, s)| *s); SimpleHybridResult { id: id.clone(), content: String::new(), // Would fetch from store project: project.to_string(), semantic_score, lexical_score, final_score: score, rank: rank + 1, } }) .collect(); Ok(results) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_simple_hybrid_result_creation() { let result = SimpleHybridResult { id: "doc1".to_string(), content: "test".to_string(), project: "test".to_string(), semantic_score: Some(0.95), lexical_score: Some(8.5), final_score: 0.067, rank: 1, }; assert_eq!(result.id, "doc1"); assert_eq!(result.rank, 1); assert!(result.semantic_score.is_some()); } }