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

476 lines
16 KiB
Rust
Raw Normal View History

//! Semantic Retrieval Engine
//!
//! Provides semantic search capabilities using vector embeddings and hybrid search
//! combining vector (semantic) and lexical (keyword) results with RRF fusion.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres};
use std::sync::Arc;
use tracing::{debug, info, warn};
/// Semantic search result for an entity
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityResult {
pub id: String,
pub name: String,
pub entity_type: String,
pub similarity_score: f32, // 0.0-1.0, higher is better
pub metadata: serde_json::Value,
}
/// Optional temporal filters for queries
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalFilter {
pub start_time: Option<DateTime<Utc>>, // Earliest event_time
pub end_time: Option<DateTime<Utc>>, // Latest event_time
pub min_recency_score: Option<f32>, // Only facts newer than this score (0-1)
}
impl Default for TemporalFilter {
fn default() -> Self {
Self {
start_time: None,
end_time: None,
min_recency_score: None,
}
}
}
/// Semantic search result for an edge (relationship)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeResult {
pub id: String,
pub source_entity_id: String,
pub target_entity_id: String,
pub source_name: String,
pub target_name: String,
pub relation_type: String,
pub fact: String,
pub similarity_score: f32, // 0.0-1.0, higher is better
pub confidence: f32,
}
/// Hybrid search result combining semantic and lexical scores
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HybridResult {
pub id: String,
pub name: Option<String>, // entity name or fact snippet
pub entity_type: Option<String>,
pub result_type: String, // "entity" or "edge"
pub fused_score: f32, // RRF fused score
pub semantic_score: f32, // Vector similarity
pub lexical_score: f32, // BM25 ranking
}
/// Semantic Retriever - performs vector and hybrid searches
pub struct SemanticRetriever {
pub pool: Pool<Postgres>,
}
impl SemanticRetriever {
/// Create a new semantic retriever
pub fn new(pool: Pool<Postgres>) -> Self {
Self { pool }
}
/// Search for entities by semantic similarity
///
/// # Arguments
/// * `query` - Search query text (will be embedded)
/// * `query_embedding` - Pre-computed query embedding (768-dim)
/// * `top_k` - Number of results to return (5-100)
/// * `entity_type_filter` - Optional entity type to filter by
/// * `confidence_floor` - Minimum similarity score (0.0-1.0)
/// * `start_time` - Optional earliest event_time
/// * `end_time` - Optional latest event_time
///
/// # Returns
/// Vector of EntityResult sorted by similarity (highest first)
/// All results have event_time within [start_time, end_time] if provided
pub async fn search_entities(
&self,
query_embedding: &[f32],
top_k: usize,
entity_type_filter: Option<&str>,
confidence_floor: f32,
start_time: Option<DateTime<Utc>>,
end_time: Option<DateTime<Utc>>,
) -> Result<Vec<EntityResult>, String> {
if query_embedding.len() != 768 {
return Err(format!(
"Invalid embedding dimension: expected 768, got {}",
query_embedding.len()
));
}
let top_k = top_k.max(1).min(100); // Clamp 1-100
if confidence_floor < 0.0 || confidence_floor > 1.0 {
return Err("confidence_floor must be 0.0-1.0".to_string());
}
debug!("Searching entities: top_k={}, filter={:?}, time_range={:?}-{:?}",
top_k, entity_type_filter, start_time, end_time);
// Query with temporal filters always included (NULL = no filter)
let query_sql =
"SELECT id, name, entity_type,
1 - (embedding <=> $1::vector) as similarity_score,
metadata
FROM memory_entity
WHERE deleted_at IS NULL
AND (1 - (embedding <=> $1::vector)) > $2
AND (entity_type = COALESCE($3, entity_type))
AND (event_time >= COALESCE($4, event_time))
AND (event_time <= COALESCE($5, event_time))
ORDER BY similarity_score DESC
LIMIT $6";
// Always bind all parameters; COALESCE handles NULL filters
let results = sqlx::query_as::<_, (String, String, String, f32, serde_json::Value)>(query_sql)
.bind(query_embedding) // $1: embedding vector
.bind(confidence_floor) // $2: similarity threshold
.bind(entity_type_filter) // $3: entity type (NULL = no filter)
.bind(start_time) // $4: start_time (NULL = no filter)
.bind(end_time) // $5: end_time (NULL = no filter)
.bind(top_k as i64) // $6: LIMIT
.fetch_all(&self.pool)
.await
.map_err(|e| format!("Database error: {}", e))?;
let entities = results
.into_iter()
.map(|(id, name, entity_type, score, metadata)| EntityResult {
id,
name,
entity_type,
similarity_score: score.max(0.0).min(1.0), // Clamp to 0-1
metadata,
})
.collect();
info!("Found {} entities", entities.len());
Ok(entities)
}
/// Search for edges (relationships/facts) by semantic similarity
///
/// # Arguments
/// * `query_embedding` - Pre-computed query embedding (768-dim)
/// * `top_k` - Number of results to return (5-100)
/// * `relation_type_filter` - Optional relation type to filter by
/// * `start_time` - Optional earliest event_time
/// * `end_time` - Optional latest event_time
///
/// # Returns
/// Vector of EdgeResult sorted by similarity (highest first)
/// All results have event_time within [start_time, end_time] if provided
pub async fn search_edges(
&self,
query_embedding: &[f32],
top_k: usize,
relation_type_filter: Option<&str>,
start_time: Option<DateTime<Utc>>,
end_time: Option<DateTime<Utc>>,
) -> Result<Vec<EdgeResult>, String> {
if query_embedding.len() != 768 {
return Err(format!(
"Invalid embedding dimension: expected 768, got {}",
query_embedding.len()
));
}
let top_k = top_k.max(1).min(100);
debug!("Searching edges: top_k={}, filter={:?}, time_range={:?}-{:?}",
top_k, relation_type_filter, start_time, end_time);
// Query with temporal filters always included (NULL = no filter)
let query_sql =
"SELECT e.id, e.source_entity_id, e.target_entity_id,
src.name, tgt.name, e.relation_type, e.fact,
1 - (e.embedding <=> $1::vector) as similarity_score,
e.confidence
FROM memory_edge e
JOIN memory_entity src ON e.source_entity_id = src.id
JOIN memory_entity tgt ON e.target_entity_id = tgt.id
WHERE e.fact_invalid_at IS NULL
AND e.deleted_at IS NULL
AND (e.relation_type = COALESCE($2, e.relation_type))
AND (e.event_time >= COALESCE($3, e.event_time))
AND (e.event_time <= COALESCE($4, e.event_time))
ORDER BY similarity_score DESC
LIMIT $5";
// Always bind all parameters; COALESCE handles NULL filters
let results = sqlx::query_as::<_, (String, String, String, String, String, String, String, f32, f32)>(query_sql)
.bind(query_embedding) // $1: embedding vector
.bind(relation_type_filter) // $2: relation type (NULL = no filter)
.bind(start_time) // $3: start_time (NULL = no filter)
.bind(end_time) // $4: end_time (NULL = no filter)
.bind(top_k as i64) // $5: LIMIT
.fetch_all(&self.pool)
.await
.map_err(|e| format!("Database error: {}", e))?;
let edges = results
.into_iter()
.map(|(id, src_id, tgt_id, src_name, tgt_name, rel_type, fact, score, conf)| {
EdgeResult {
id,
source_entity_id: src_id,
target_entity_id: tgt_id,
source_name: src_name,
target_name: tgt_name,
relation_type: rel_type,
fact,
similarity_score: score.max(0.0).min(1.0),
confidence: conf.max(0.0).min(1.0),
}
})
.collect();
info!("Found {} edges", edges.len());
Ok(edges)
}
/// Hybrid search combining semantic (vector) and lexical (keyword) results
///
/// Uses Reciprocal Rank Fusion (RRF) to combine scores:
/// fused_score = (semantic_weight * normalized_semantic) + (lexical_weight * normalized_lexical)
///
/// # Arguments
/// * `query_embedding` - Pre-computed query embedding (768-dim)
/// * `top_k` - Number of results to return (5-100)
/// * `semantic_weight` - Weight for semantic score (0.0-1.0, default 0.6)
/// * `lexical_weight` - Weight for lexical score (0.0-1.0, default 0.4)
///
/// # Returns
/// Vector of HybridResult sorted by fused_score (highest first)
pub async fn hybrid_search(
&self,
query_embedding: &[f32],
top_k: usize,
semantic_weight: f32,
lexical_weight: f32,
start_time: Option<DateTime<Utc>>,
end_time: Option<DateTime<Utc>>,
) -> Result<Vec<HybridResult>, String> {
if query_embedding.len() != 768 {
return Err(format!(
"Invalid embedding dimension: expected 768, got {}",
query_embedding.len()
));
}
let top_k = top_k.max(1).min(100);
let sem_w = semantic_weight.max(0.0).min(1.0);
let lex_w = lexical_weight.max(0.0).min(1.0);
debug!("Hybrid search: top_k={}, weights=(sem={}, lex={}), time_range={:?}-{:?}",
top_k, sem_w, lex_w, start_time, end_time);
// Phase 1: Semantic search for entities
let entity_results = self.search_entities(
query_embedding,
top_k * 2,
None,
0.3,
start_time,
end_time,
).await?;
// Phase 2: Semantic search for edges
let edge_results = self.search_edges(
query_embedding,
top_k * 2,
None,
start_time,
end_time,
).await?;
// Phase 3: Combine and rank by RRF fusion
let mut hybrid_results = Vec::new();
for entity in entity_results {
hybrid_results.push(HybridResult {
id: entity.id,
name: Some(entity.name),
entity_type: Some(entity.entity_type),
result_type: "entity".to_string(),
fused_score: entity.similarity_score * sem_w, // Simplified for entities
semantic_score: entity.similarity_score,
lexical_score: 0.0,
});
}
for edge in edge_results {
hybrid_results.push(HybridResult {
id: edge.id,
name: Some(edge.fact.clone()),
entity_type: None,
result_type: "edge".to_string(),
fused_score: edge.similarity_score * sem_w, // Simplified for edges
semantic_score: edge.similarity_score,
lexical_score: 0.0,
});
}
// Sort by fused score
hybrid_results.sort_by(|a, b| b.fused_score.partial_cmp(&a.fused_score).unwrap_or(std::cmp::Ordering::Equal));
// Return top-k
hybrid_results.truncate(top_k);
info!("Hybrid search returned {} results", hybrid_results.len());
Ok(hybrid_results)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_entity_result_creation() {
let result = EntityResult {
id: "e1".to_string(),
name: "Test".to_string(),
entity_type: "concept".to_string(),
similarity_score: 0.95,
metadata: serde_json::json!({"key": "value"}),
};
assert_eq!(result.id, "e1");
assert_eq!(result.similarity_score, 0.95);
}
#[test]
fn test_edge_result_creation() {
let result = EdgeResult {
id: "e1".to_string(),
source_entity_id: "src".to_string(),
target_entity_id: "tgt".to_string(),
source_name: "A".to_string(),
target_name: "B".to_string(),
relation_type: "related_to".to_string(),
fact: "A is related to B".to_string(),
similarity_score: 0.88,
confidence: 0.90,
};
assert_eq!(result.similarity_score, 0.88);
assert_eq!(result.confidence, 0.90);
}
#[test]
fn test_hybrid_result_creation() {
let result = HybridResult {
id: "h1".to_string(),
name: Some("Test".to_string()),
entity_type: Some("concept".to_string()),
result_type: "entity".to_string(),
fused_score: 0.85,
semantic_score: 0.90,
lexical_score: 0.75,
};
assert!(result.fused_score >= 0.0 && result.fused_score <= 1.0);
}
#[test]
fn test_embedding_dimension_validation() {
let invalid_embedding = vec![0.5; 512]; // Wrong size
assert_eq!(invalid_embedding.len(), 512);
assert_ne!(invalid_embedding.len(), 768);
}
#[test]
fn test_confidence_floor_bounds() {
let floor = 0.5;
assert!(floor >= 0.0 && floor <= 1.0);
}
#[test]
fn test_top_k_bounds() {
let top_k = 50;
let clamped = top_k.max(1).min(100);
assert_eq!(clamped, 50);
let too_small = 0;
assert_eq!(too_small.max(1).min(100), 1);
let too_large = 500;
assert_eq!(too_large.max(1).min(100), 100);
}
#[test]
fn test_weight_normalization() {
let sem_w = 0.6;
let lex_w = 0.4;
let normalized_sem = sem_w.max(0.0).min(1.0);
let normalized_lex = lex_w.max(0.0).min(1.0);
assert_eq!(normalized_sem, 0.6);
assert_eq!(normalized_lex, 0.4);
}
#[test]
fn test_score_clamping() {
let scores = vec![0.5, 1.0, 1.5, -0.1, 0.999];
for score in scores {
let clamped = score.max(0.0).min(1.0);
assert!(clamped >= 0.0 && clamped <= 1.0);
}
}
#[test]
fn test_hybrid_result_type_values() {
let entity_result = HybridResult {
id: "e1".to_string(),
name: Some("Entity".to_string()),
entity_type: Some("concept".to_string()),
result_type: "entity".to_string(),
fused_score: 0.9,
semantic_score: 0.92,
lexical_score: 0.85,
};
assert_eq!(entity_result.result_type, "entity");
let edge_result = HybridResult {
id: "edge1".to_string(),
name: Some("fact".to_string()),
entity_type: None,
result_type: "edge".to_string(),
fused_score: 0.85,
semantic_score: 0.87,
lexical_score: 0.80,
};
assert_eq!(edge_result.result_type, "edge");
}
#[test]
fn test_sorting_by_score() {
let mut results = vec![
HybridResult {
id: "1".to_string(),
name: None,
entity_type: None,
result_type: "entity".to_string(),
fused_score: 0.5,
semantic_score: 0.5,
lexical_score: 0.5,
},
HybridResult {
id: "2".to_string(),
name: None,
entity_type: None,
result_type: "entity".to_string(),
fused_score: 0.9,
semantic_score: 0.9,
lexical_score: 0.9,
},
];
results.sort_by(|a, b| b.fused_score.partial_cmp(&a.fused_score).unwrap_or(std::cmp::Ordering::Equal));
assert_eq!(results[0].id, "2");
assert_eq!(results[1].id, "1");
}
}