Files
poimen-memory/crates/mem-cli/src/simple_hybrid_search.rs
T

138 lines
3.9 KiB
Rust

//! 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, RRFConfig};
/// 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<f32>,
pub lexical_score: Option<f32>,
pub final_score: f32,
pub rank: usize,
}
/// Simple hybrid search orchestrator
pub struct SimpleHybridSearch {
vector_store: Arc<VectorStore>,
opensearch: Option<Arc<OpenSearchClient>>,
rrf: RRFFusion,
}
impl SimpleHybridSearch {
pub fn new(
vector_store: Arc<VectorStore>,
opensearch: Option<Arc<OpenSearchClient>>,
) -> Self {
// Create RRF with default config (k=60 per academic standards)
let rrf_config = RRFConfig {
k: 60.0,
retrieve_k: 50,
final_k: 10,
};
let rrf = RRFFusion::new(rrf_config);
Self {
vector_store,
opensearch,
rrf,
}
}
/// 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<Vec<SimpleHybridResult>> {
// 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
// TODO: Implement OpenSearchClient.search() method
let lexical_scores: Vec<(String, f32)> = 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());
}
}