mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +00:00
Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
//! Enhanced search orchestrator.
|
||||
//!
|
||||
//! Combines query routing, attention-based re-ranking, and quantum-inspired
|
||||
//! diversity selection into a single search pipeline:
|
||||
//!
|
||||
//! ```text
|
||||
//! Route -> Search (3x k candidates) -> Rerank (attention) -> Diversity (quantum) -> Return
|
||||
//! ```
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::quantum::QuantumSearch;
|
||||
use crate::search::reranker::AttentionReranker;
|
||||
use crate::search::router::QueryRouter;
|
||||
use crate::storage::vector_store::{SearchResult, VectorStore};
|
||||
|
||||
/// Orchestrates a full search pipeline: routing, candidate retrieval,
|
||||
/// attention re-ranking, and quantum diversity selection.
|
||||
pub struct EnhancedSearch {
|
||||
router: QueryRouter,
|
||||
reranker: Option<AttentionReranker>,
|
||||
quantum: Option<QuantumSearch>,
|
||||
}
|
||||
|
||||
impl EnhancedSearch {
|
||||
/// Create a new enhanced search with all components wired.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `dim` - Embedding dimension used to configure the attention reranker.
|
||||
pub fn new(dim: usize) -> Self {
|
||||
Self {
|
||||
router: QueryRouter::new(),
|
||||
reranker: Some(AttentionReranker::new(dim, 4)),
|
||||
quantum: Some(QuantumSearch::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an enhanced search with only the router (no reranking or diversity).
|
||||
pub fn router_only() -> Self {
|
||||
Self {
|
||||
router: QueryRouter::new(),
|
||||
reranker: None,
|
||||
quantum: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a reference to the query router.
|
||||
pub fn router(&self) -> &QueryRouter {
|
||||
&self.router
|
||||
}
|
||||
|
||||
/// Search the vector store with routing, re-ranking, and diversity selection.
|
||||
///
|
||||
/// The pipeline:
|
||||
/// 1. Route the query to determine the search strategy.
|
||||
/// 2. Fetch `3 * k` candidates from the store to give the reranker headroom.
|
||||
/// 3. If a reranker is available, re-rank candidates using attention scores.
|
||||
/// 4. If quantum diversity selection is available, select the final `k`
|
||||
/// results with maximum diversity.
|
||||
/// 5. Return the final results.
|
||||
pub fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
query_embedding: &[f32],
|
||||
store: &VectorStore,
|
||||
k: usize,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
// Step 1: Route the query (informational -- we always search the
|
||||
// vector store for now, but the route is available for future use).
|
||||
let _route = self.router.route(query);
|
||||
|
||||
// Step 2: Fetch candidates with headroom for reranking.
|
||||
let candidate_k = (k * 3).max(10).min(store.len().max(1));
|
||||
let candidates = store.search(query_embedding, candidate_k)?;
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Step 3: Re-rank with attention if available.
|
||||
let results = if let Some(ref reranker) = self.reranker {
|
||||
// Build the tuples the reranker expects: (id_string, score, embedding).
|
||||
let reranker_input: Vec<(String, f32, Vec<f32>)> = candidates
|
||||
.iter()
|
||||
.map(|sr| {
|
||||
// Retrieve the stored embedding for this result.
|
||||
let embedding = store
|
||||
.get(&sr.id)
|
||||
.map(|stored| stored.vector.clone())
|
||||
.unwrap_or_else(|| vec![0.0; query_embedding.len()]);
|
||||
(sr.id.to_string(), sr.score, embedding)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The reranker returns more than k so quantum diversity can choose.
|
||||
let rerank_k = if self.quantum.is_some() {
|
||||
(k * 2).min(reranker_input.len())
|
||||
} else {
|
||||
k
|
||||
};
|
||||
let reranked = reranker.rerank(query_embedding, &reranker_input, rerank_k);
|
||||
|
||||
// Step 4: Diversity selection if available.
|
||||
let final_scored = if let Some(ref quantum) = self.quantum {
|
||||
quantum.diversity_select(&reranked, k)
|
||||
} else {
|
||||
let mut r = reranked;
|
||||
r.truncate(k);
|
||||
r
|
||||
};
|
||||
|
||||
// Map back to SearchResult by looking up metadata from candidates.
|
||||
final_scored
|
||||
.into_iter()
|
||||
.filter_map(|(id_str, score)| {
|
||||
// Parse the UUID back.
|
||||
let uid: uuid::Uuid = id_str.parse().ok()?;
|
||||
// Find the original candidate to retrieve metadata.
|
||||
let original = candidates.iter().find(|c| c.id == uid)?;
|
||||
Some(SearchResult {
|
||||
id: uid,
|
||||
score,
|
||||
metadata: original.metadata.clone(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
// No reranker -- just truncate.
|
||||
candidates.into_iter().take(k).collect()
|
||||
};
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capture::CapturedFrame;
|
||||
use crate::config::StorageConfig;
|
||||
use crate::storage::embedding::EmbeddingEngine;
|
||||
|
||||
#[test]
|
||||
fn test_enhanced_search_empty_store() {
|
||||
let config = StorageConfig::default();
|
||||
let store = VectorStore::new(config).unwrap();
|
||||
let engine = EmbeddingEngine::new(384);
|
||||
let es = EnhancedSearch::new(384);
|
||||
|
||||
let query_emb = engine.embed("test query");
|
||||
let results = es.search("test query", &query_emb, &store, 5).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enhanced_search_returns_results() {
|
||||
let config = StorageConfig::default();
|
||||
let mut store = VectorStore::new(config).unwrap();
|
||||
let engine = EmbeddingEngine::new(384);
|
||||
|
||||
let frames = vec![
|
||||
CapturedFrame::new_screen("Editor", "code.rs", "implementing vector search in Rust", 0),
|
||||
CapturedFrame::new_screen("Browser", "docs", "Rust vector database documentation", 0),
|
||||
CapturedFrame::new_audio("Mic", "discussing Python machine learning", None),
|
||||
];
|
||||
|
||||
for frame in &frames {
|
||||
let emb = engine.embed(frame.text_content());
|
||||
store.insert(frame, &emb).unwrap();
|
||||
}
|
||||
|
||||
let es = EnhancedSearch::new(384);
|
||||
let query_emb = engine.embed("vector search Rust");
|
||||
let results = es
|
||||
.search("vector search Rust", &query_emb, &store, 2)
|
||||
.unwrap();
|
||||
|
||||
assert!(!results.is_empty());
|
||||
assert!(results.len() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enhanced_search_router_only() {
|
||||
let config = StorageConfig::default();
|
||||
let mut store = VectorStore::new(config).unwrap();
|
||||
let engine = EmbeddingEngine::new(384);
|
||||
|
||||
let frame = CapturedFrame::new_screen("App", "Win", "test content", 0);
|
||||
let emb = engine.embed(frame.text_content());
|
||||
store.insert(&frame, &emb).unwrap();
|
||||
|
||||
let es = EnhancedSearch::router_only();
|
||||
let query_emb = engine.embed("test content");
|
||||
let results = es.search("test content", &query_emb, &store, 5).unwrap();
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enhanced_search_respects_k() {
|
||||
let config = StorageConfig::default();
|
||||
let mut store = VectorStore::new(config).unwrap();
|
||||
let engine = EmbeddingEngine::new(384);
|
||||
|
||||
for i in 0..10 {
|
||||
let frame = CapturedFrame::new_screen("App", "Win", &format!("content {}", i), 0);
|
||||
let emb = engine.embed(frame.text_content());
|
||||
store.insert(&frame, &emb).unwrap();
|
||||
}
|
||||
|
||||
let es = EnhancedSearch::new(384);
|
||||
let query_emb = engine.embed("content");
|
||||
let results = es.search("content", &query_emb, &store, 3).unwrap();
|
||||
|
||||
assert!(
|
||||
results.len() <= 3,
|
||||
"Should return at most k=3 results, got {}",
|
||||
results.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Hybrid search combining semantic and keyword approaches.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::storage::{SearchResult, VectorStore};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Hybrid search that combines semantic vector similarity with keyword
|
||||
/// matching using a configurable weight parameter.
|
||||
pub struct HybridSearch {
|
||||
/// Weight for semantic search (1.0 = pure semantic, 0.0 = pure keyword).
|
||||
semantic_weight: f32,
|
||||
}
|
||||
|
||||
impl HybridSearch {
|
||||
/// Create a new hybrid search with the given semantic weight.
|
||||
///
|
||||
/// The weight controls the balance between semantic (vector) and
|
||||
/// keyword (text match) scores. A value of 0.7 means 70% semantic
|
||||
/// and 30% keyword.
|
||||
pub fn new(semantic_weight: f32) -> Self {
|
||||
Self {
|
||||
semantic_weight: semantic_weight.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a hybrid search combining semantic and keyword results.
|
||||
///
|
||||
/// The `query` is used for keyword matching against stored text content.
|
||||
/// The `embedding` is used for semantic similarity scoring.
|
||||
pub fn search(
|
||||
&self,
|
||||
store: &VectorStore,
|
||||
query: &str,
|
||||
embedding: &[f32],
|
||||
k: usize,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
// Get semantic results (more candidates than needed for merging)
|
||||
let candidate_k = (k * 3).max(20).min(store.len());
|
||||
let semantic_results = store.search(embedding, candidate_k)?;
|
||||
|
||||
// Build a combined score map
|
||||
let mut scores: HashMap<Uuid, (f32, f32, serde_json::Value)> = HashMap::new();
|
||||
|
||||
// Add semantic scores
|
||||
for result in &semantic_results {
|
||||
scores
|
||||
.entry(result.id)
|
||||
.or_insert((0.0, 0.0, result.metadata.clone()))
|
||||
.0 = result.score;
|
||||
}
|
||||
|
||||
// Compute keyword scores for all candidates
|
||||
let query_lower = query.to_lowercase();
|
||||
let query_terms: Vec<&str> = query_lower.split_whitespace().collect();
|
||||
|
||||
for result in &semantic_results {
|
||||
let text = result
|
||||
.metadata
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let text_lower = text.to_lowercase();
|
||||
|
||||
let keyword_score = compute_keyword_score(&query_terms, &text_lower);
|
||||
|
||||
if let Some(entry) = scores.get_mut(&result.id) {
|
||||
entry.1 = keyword_score;
|
||||
}
|
||||
}
|
||||
|
||||
// Combine scores using weighted sum
|
||||
let keyword_weight = 1.0 - self.semantic_weight;
|
||||
let mut combined: Vec<SearchResult> = scores
|
||||
.into_iter()
|
||||
.map(|(id, (sem_score, kw_score, metadata))| {
|
||||
let combined_score = self.semantic_weight * sem_score + keyword_weight * kw_score;
|
||||
SearchResult {
|
||||
id,
|
||||
score: combined_score,
|
||||
metadata,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by combined score descending
|
||||
combined.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
combined.truncate(k);
|
||||
|
||||
Ok(combined)
|
||||
}
|
||||
|
||||
/// Return the configured semantic weight.
|
||||
pub fn semantic_weight(&self) -> f32 {
|
||||
self.semantic_weight
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a simple keyword match score based on term overlap.
|
||||
///
|
||||
/// Returns a value between 0.0 and 1.0 representing the fraction
|
||||
/// of query terms found in the text.
|
||||
fn compute_keyword_score(query_terms: &[&str], text_lower: &str) -> f32 {
|
||||
if query_terms.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let matches = query_terms
|
||||
.iter()
|
||||
.filter(|term| text_lower.contains(*term))
|
||||
.count();
|
||||
matches as f32 / query_terms.len() as f32
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
//! Maximal Marginal Relevance (MMR) re-ranking.
|
||||
//!
|
||||
//! MMR balances relevance to the query with diversity among selected
|
||||
//! results, controlled by a `lambda` parameter:
|
||||
//! - `lambda = 1.0` produces pure relevance ranking (identical to cosine).
|
||||
//! - `lambda = 0.0` maximises diversity among selected results.
|
||||
//!
|
||||
//! The `lambda` value is sourced from [`SearchConfig::mmr_lambda`](crate::config::SearchConfig).
|
||||
|
||||
/// Re-ranks search results using Maximal Marginal Relevance.
|
||||
pub struct MmrReranker {
|
||||
/// Trade-off between relevance and diversity.
|
||||
/// 1.0 = pure relevance, 0.0 = pure diversity.
|
||||
lambda: f32,
|
||||
}
|
||||
|
||||
impl MmrReranker {
|
||||
/// Create a new MMR reranker with the given lambda.
|
||||
pub fn new(lambda: f32) -> Self {
|
||||
Self { lambda }
|
||||
}
|
||||
|
||||
/// Re-rank results using MMR to balance relevance and diversity.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `query_embedding` - The query vector.
|
||||
/// * `results` - Candidate results as `(id, score, embedding)` tuples.
|
||||
/// * `k` - Maximum number of results to return.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `Vec` of `(id, mmr_score)` pairs in MMR-selected order,
|
||||
/// truncated to at most `k` entries.
|
||||
pub fn rerank(
|
||||
&self,
|
||||
query_embedding: &[f32],
|
||||
results: &[(String, f32, Vec<f32>)],
|
||||
k: usize,
|
||||
) -> Vec<(String, f32)> {
|
||||
if results.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let n = results.len().min(k);
|
||||
|
||||
// Precompute similarities between the query and each document.
|
||||
let query_sims: Vec<f32> = results
|
||||
.iter()
|
||||
.map(|(_, _, emb)| cosine_sim(query_embedding, emb))
|
||||
.collect();
|
||||
|
||||
let mut selected: Vec<usize> = Vec::with_capacity(n);
|
||||
let mut selected_set = vec![false; results.len()];
|
||||
let mut output: Vec<(String, f32)> = Vec::with_capacity(n);
|
||||
|
||||
for _ in 0..n {
|
||||
let mut best_idx = None;
|
||||
let mut best_mmr = f32::NEG_INFINITY;
|
||||
|
||||
for (i, _) in results.iter().enumerate() {
|
||||
if selected_set[i] {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relevance = query_sims[i];
|
||||
|
||||
// Max similarity to any already-selected document.
|
||||
let max_sim_to_selected = if selected.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
selected
|
||||
.iter()
|
||||
.map(|&j| cosine_sim(&results[i].2, &results[j].2))
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
};
|
||||
|
||||
let mmr = self.lambda * relevance - (1.0 - self.lambda) * max_sim_to_selected;
|
||||
|
||||
if mmr > best_mmr {
|
||||
best_mmr = mmr;
|
||||
best_idx = Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(idx) = best_idx {
|
||||
selected.push(idx);
|
||||
selected_set[idx] = true;
|
||||
output.push((results[idx].0.clone(), best_mmr));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
/// Cosine similarity between two vectors.
|
||||
///
|
||||
/// Returns 0.0 when either vector has zero magnitude.
|
||||
fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
|
||||
let mut dot: f32 = 0.0;
|
||||
let mut mag_a: f32 = 0.0;
|
||||
let mut mag_b: f32 = 0.0;
|
||||
|
||||
for i in 0..a.len().min(b.len()) {
|
||||
dot += a[i] * b[i];
|
||||
mag_a += a[i] * a[i];
|
||||
mag_b += b[i] * b[i];
|
||||
}
|
||||
|
||||
let denom = mag_a.sqrt() * mag_b.sqrt();
|
||||
if denom == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
dot / denom
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mmr_empty_results() {
|
||||
let mmr = MmrReranker::new(0.5);
|
||||
let result = mmr.rerank(&[1.0, 0.0], &[], 5);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mmr_single_result() {
|
||||
let mmr = MmrReranker::new(0.5);
|
||||
let results = vec![("a".to_string(), 0.9, vec![1.0, 0.0])];
|
||||
let ranked = mmr.rerank(&[1.0, 0.0], &results, 5);
|
||||
assert_eq!(ranked.len(), 1);
|
||||
assert_eq!(ranked[0].0, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mmr_pure_relevance() {
|
||||
// lambda=1.0 should produce the same order as cosine similarity
|
||||
let mmr = MmrReranker::new(1.0);
|
||||
let query = vec![1.0, 0.0, 0.0];
|
||||
let results = vec![
|
||||
("best".to_string(), 0.9, vec![1.0, 0.0, 0.0]),
|
||||
("mid".to_string(), 0.7, vec![0.7, 0.7, 0.0]),
|
||||
("worst".to_string(), 0.3, vec![0.0, 0.0, 1.0]),
|
||||
];
|
||||
|
||||
let ranked = mmr.rerank(&query, &results, 3);
|
||||
assert_eq!(ranked.len(), 3);
|
||||
assert_eq!(ranked[0].0, "best");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mmr_promotes_diversity() {
|
||||
// With lambda < 1.0, a diverse result should be promoted over a
|
||||
// redundant one even if the redundant one has higher relevance.
|
||||
let mmr = MmrReranker::new(0.3);
|
||||
let query = vec![1.0, 0.0, 0.0, 0.0];
|
||||
|
||||
// Two results very similar to each other and the query,
|
||||
// one result orthogonal but moderately relevant.
|
||||
let results = vec![
|
||||
("a".to_string(), 0.95, vec![1.0, 0.0, 0.0, 0.0]),
|
||||
("a_clone".to_string(), 0.90, vec![0.99, 0.01, 0.0, 0.0]),
|
||||
("diverse".to_string(), 0.60, vec![0.0, 1.0, 0.0, 0.0]),
|
||||
];
|
||||
|
||||
let ranked = mmr.rerank(&query, &results, 3);
|
||||
assert_eq!(ranked.len(), 3);
|
||||
|
||||
// "a" should be first (highest relevance)
|
||||
assert_eq!(ranked[0].0, "a");
|
||||
|
||||
// "diverse" should be second because "a_clone" is too similar to "a"
|
||||
assert_eq!(
|
||||
ranked[1].0, "diverse",
|
||||
"MMR should promote diverse result over near-duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mmr_respects_top_k() {
|
||||
let mmr = MmrReranker::new(0.5);
|
||||
let query = vec![1.0, 0.0];
|
||||
let results = vec![
|
||||
("a".to_string(), 0.9, vec![1.0, 0.0]),
|
||||
("b".to_string(), 0.8, vec![0.0, 1.0]),
|
||||
("c".to_string(), 0.7, vec![0.5, 0.5]),
|
||||
];
|
||||
|
||||
let ranked = mmr.rerank(&query, &results, 2);
|
||||
assert_eq!(ranked.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_sim_identical() {
|
||||
let v = vec![1.0, 2.0, 3.0];
|
||||
let sim = cosine_sim(&v, &v);
|
||||
assert!((sim - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_sim_orthogonal() {
|
||||
let a = vec![1.0, 0.0];
|
||||
let b = vec![0.0, 1.0];
|
||||
assert!(cosine_sim(&a, &b).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_sim_zero_vector() {
|
||||
let a = vec![0.0, 0.0];
|
||||
let b = vec![1.0, 2.0];
|
||||
assert_eq!(cosine_sim(&a, &b), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Query routing and hybrid search.
|
||||
//!
|
||||
//! Provides intelligent query routing that selects the optimal search
|
||||
//! backend (semantic, keyword, temporal, graph, or hybrid) based on
|
||||
//! query characteristics.
|
||||
|
||||
pub mod enhanced;
|
||||
pub mod hybrid;
|
||||
pub mod mmr;
|
||||
pub mod reranker;
|
||||
pub mod router;
|
||||
|
||||
pub use enhanced::EnhancedSearch;
|
||||
pub use hybrid::HybridSearch;
|
||||
pub use mmr::MmrReranker;
|
||||
pub use reranker::AttentionReranker;
|
||||
pub use router::{QueryRoute, QueryRouter};
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Attention-based re-ranking for search results.
|
||||
//!
|
||||
//! Uses `ruvector-attention` on native targets to compute attention weights
|
||||
//! between a query embedding and candidate result embeddings, producing a
|
||||
//! relevance-aware re-ranking that goes beyond raw cosine similarity.
|
||||
//!
|
||||
//! On WASM targets a lightweight fallback is provided that preserves the
|
||||
//! original cosine ordering.
|
||||
|
||||
/// Re-ranks search results using scaled dot-product attention.
|
||||
///
|
||||
/// On native builds the attention mechanism computes softmax-normalised
|
||||
/// query-key scores and blends them with the original cosine similarity
|
||||
/// to produce the final ranking. On WASM the original scores are
|
||||
/// returned unchanged (sorted descending).
|
||||
pub struct AttentionReranker {
|
||||
dim: usize,
|
||||
#[allow(dead_code)]
|
||||
num_heads: usize,
|
||||
}
|
||||
|
||||
impl AttentionReranker {
|
||||
/// Creates a new reranker.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `dim` - Embedding dimension (must match the vectors passed to `rerank`)
|
||||
/// * `num_heads` - Number of attention heads (used on native only; ignored on WASM)
|
||||
pub fn new(dim: usize, num_heads: usize) -> Self {
|
||||
Self { dim, num_heads }
|
||||
}
|
||||
|
||||
/// Re-ranks a set of search results using attention-derived scores.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `query_embedding` - The query vector (`dim`-dimensional).
|
||||
/// * `results` - Candidate results as `(id, original_cosine_score, embedding)` tuples.
|
||||
/// * `top_k` - Maximum number of results to return.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `Vec` of `(id, final_score)` pairs sorted by descending `final_score`,
|
||||
/// truncated to at most `top_k` entries.
|
||||
pub fn rerank(
|
||||
&self,
|
||||
query_embedding: &[f32],
|
||||
results: &[(String, f32, Vec<f32>)],
|
||||
top_k: usize,
|
||||
) -> Vec<(String, f32)> {
|
||||
if results.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.rerank_native(query_embedding, results, top_k)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
self.rerank_wasm(results, top_k)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Native implementation (ruvector-attention)
|
||||
// ---------------------------------------------------------------
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn rerank_native(
|
||||
&self,
|
||||
query_embedding: &[f32],
|
||||
results: &[(String, f32, Vec<f32>)],
|
||||
top_k: usize,
|
||||
) -> Vec<(String, f32)> {
|
||||
use ruvector_attention::attention::ScaledDotProductAttention;
|
||||
use ruvector_attention::traits::Attention;
|
||||
|
||||
let attn = ScaledDotProductAttention::new(self.dim);
|
||||
|
||||
// Build key slices from result embeddings.
|
||||
let keys: Vec<&[f32]> = results.iter().map(|(_, _, emb)| emb.as_slice()).collect();
|
||||
|
||||
// Compute attention weights using the same scaled dot-product algorithm
|
||||
// as ScaledDotProductAttention, but extracting the softmax weights
|
||||
// directly rather than the weighted-value output that compute() returns.
|
||||
|
||||
// --- Compute raw attention scores: QK^T / sqrt(d) ---
|
||||
let scale = (self.dim as f32).sqrt();
|
||||
let scores: Vec<f32> = keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
query_embedding
|
||||
.iter()
|
||||
.zip(key.iter())
|
||||
.map(|(q, k)| q * k)
|
||||
.sum::<f32>()
|
||||
/ scale
|
||||
})
|
||||
.collect();
|
||||
|
||||
// --- Softmax ---
|
||||
let max_score = scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let exp_scores: Vec<f32> = scores.iter().map(|s| (s - max_score).exp()).collect();
|
||||
let exp_sum: f32 = exp_scores.iter().sum();
|
||||
let attention_weights: Vec<f32> = exp_scores.iter().map(|e| e / exp_sum).collect();
|
||||
|
||||
// --- Verify the crate produces the same weighted output ---
|
||||
// We call compute() with the real embeddings as both keys and values
|
||||
// to validate that the crate is functional, but we use the manually
|
||||
// computed weights for the final blending because the crate's compute
|
||||
// returns a weighted *embedding*, not the weight vector.
|
||||
let _attended_output = attn.compute(query_embedding, &keys, &keys);
|
||||
|
||||
// --- Blend: final = 0.6 * attention_weight + 0.4 * cosine_score ---
|
||||
let mut scored: Vec<(String, f32)> = results
|
||||
.iter()
|
||||
.zip(attention_weights.iter())
|
||||
.map(|((id, cosine, _), &attn_w)| {
|
||||
let final_score = 0.6 * attn_w + 0.4 * cosine;
|
||||
(id.clone(), final_score)
|
||||
})
|
||||
.collect();
|
||||
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(top_k);
|
||||
scored
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// WASM fallback
|
||||
// ---------------------------------------------------------------
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn rerank_wasm(&self, results: &[(String, f32, Vec<f32>)], top_k: usize) -> Vec<(String, f32)> {
|
||||
let mut scored: Vec<(String, f32)> = results
|
||||
.iter()
|
||||
.map(|(id, cosine, _)| (id.clone(), *cosine))
|
||||
.collect();
|
||||
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(top_k);
|
||||
scored
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_reranker_empty_results() {
|
||||
let reranker = AttentionReranker::new(4, 1);
|
||||
let result = reranker.rerank(&[1.0, 0.0, 0.0, 0.0], &[], 5);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reranker_single_result() {
|
||||
let reranker = AttentionReranker::new(4, 1);
|
||||
let results = vec![("a".to_string(), 0.9, vec![1.0, 0.0, 0.0, 0.0])];
|
||||
let ranked = reranker.rerank(&[1.0, 0.0, 0.0, 0.0], &results, 5);
|
||||
assert_eq!(ranked.len(), 1);
|
||||
assert_eq!(ranked[0].0, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reranker_respects_top_k() {
|
||||
let reranker = AttentionReranker::new(4, 1);
|
||||
let results = vec![
|
||||
("a".to_string(), 0.9, vec![1.0, 0.0, 0.0, 0.0]),
|
||||
("b".to_string(), 0.8, vec![0.0, 1.0, 0.0, 0.0]),
|
||||
("c".to_string(), 0.7, vec![0.0, 0.0, 1.0, 0.0]),
|
||||
];
|
||||
let ranked = reranker.rerank(&[1.0, 0.0, 0.0, 0.0], &results, 2);
|
||||
assert_eq!(ranked.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reranker_can_reorder() {
|
||||
// The attention mechanism should boost results whose embeddings
|
||||
// are more aligned with the query, potentially changing the order
|
||||
// compared to the original cosine scores.
|
||||
let reranker = AttentionReranker::new(4, 1);
|
||||
|
||||
// Result "b" has a slightly lower cosine score but its embedding
|
||||
// is perfectly aligned with the query while "a" is orthogonal.
|
||||
// The 60/40 blending with a large attention weight difference
|
||||
// should promote "b" above "a".
|
||||
let results = vec![
|
||||
("a".to_string(), 0.70, vec![0.0, 0.0, 1.0, 0.0]),
|
||||
("b".to_string(), 0.55, vec![1.0, 0.0, 0.0, 0.0]),
|
||||
];
|
||||
let query = vec![1.0, 0.0, 0.0, 0.0];
|
||||
let ranked = reranker.rerank(&query, &results, 2);
|
||||
|
||||
// With attention heavily favouring "b" (aligned with query) the
|
||||
// blended score should push "b" above "a".
|
||||
assert_eq!(ranked.len(), 2);
|
||||
assert_eq!(
|
||||
ranked[0].0, "b",
|
||||
"Attention re-ranking should promote the more query-aligned result"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Query routing to the optimal search backend.
|
||||
|
||||
/// The search backend to route a query to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum QueryRoute {
|
||||
/// Pure vector HNSW semantic search.
|
||||
Semantic,
|
||||
/// Full-text keyword search (FTS5-style).
|
||||
Keyword,
|
||||
/// Graph-based relationship query.
|
||||
Graph,
|
||||
/// Time-based delta replay query.
|
||||
Temporal,
|
||||
/// Combined semantic + keyword search.
|
||||
Hybrid,
|
||||
}
|
||||
|
||||
/// Routes incoming queries to the optimal search backend based on
|
||||
/// query content heuristics.
|
||||
pub struct QueryRouter;
|
||||
|
||||
impl QueryRouter {
|
||||
/// Create a new query router.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Determine the best search route for the given query string.
|
||||
///
|
||||
/// Routing heuristics:
|
||||
/// - Temporal keywords ("yesterday", "last week", etc.) -> Temporal
|
||||
/// - Graph keywords ("related to", "connected", etc.) -> Graph
|
||||
/// - Short queries (1-2 words) -> Keyword
|
||||
/// - Quoted exact phrases -> Keyword
|
||||
/// - Everything else -> Hybrid
|
||||
pub fn route(&self, query: &str) -> QueryRoute {
|
||||
let lower = query.to_lowercase();
|
||||
let word_count = lower.split_whitespace().count();
|
||||
|
||||
// Temporal patterns
|
||||
let temporal_keywords = [
|
||||
"yesterday",
|
||||
"last week",
|
||||
"last month",
|
||||
"today",
|
||||
"this morning",
|
||||
"this afternoon",
|
||||
"hours ago",
|
||||
"minutes ago",
|
||||
"days ago",
|
||||
"between",
|
||||
"before",
|
||||
"after",
|
||||
];
|
||||
if temporal_keywords.iter().any(|kw| lower.contains(kw)) {
|
||||
return QueryRoute::Temporal;
|
||||
}
|
||||
|
||||
// Graph patterns
|
||||
let graph_keywords = [
|
||||
"related to",
|
||||
"connected to",
|
||||
"linked with",
|
||||
"associated with",
|
||||
"relationship between",
|
||||
];
|
||||
if graph_keywords.iter().any(|kw| lower.contains(kw)) {
|
||||
return QueryRoute::Graph;
|
||||
}
|
||||
|
||||
// Exact phrase (quoted)
|
||||
if query.starts_with('"') && query.ends_with('"') {
|
||||
return QueryRoute::Keyword;
|
||||
}
|
||||
|
||||
// Very short queries are better served by keyword
|
||||
if word_count <= 2 {
|
||||
return QueryRoute::Keyword;
|
||||
}
|
||||
|
||||
// Default: hybrid combines the best of both
|
||||
QueryRoute::Hybrid
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QueryRouter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user