Skip to main content

agentdb/
hybrid.rs

1use crate::error::{AgentDbError, Result};
2use crate::fts::FullTextStore;
3use crate::memory::MemoryGraph;
4use crate::memory::TraversalOptions;
5use crate::vectors::collection::{Collection, SearchOptions, SearchResult};
6use crate::vectors::hnsw::DistanceMetric;
7use rusqlite::Connection;
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::{Arc, Mutex};
11
12/// A single result from a hybrid graph + vector query.
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct HybridResult {
15    /// ID of the matched entity.
16    pub id: String,
17    /// Raw cosine distance from the vector ANN search (lower = more similar).
18    pub vector_score: f32,
19    /// Maximum edge weight along any graph path from the anchor to this node.
20    /// `0.0` if the node is not reachable from the anchor.
21    pub graph_weight: f64,
22    /// Final blended rank score: `alpha × vec_similarity + (1 − alpha) × graph_weight`.
23    /// Higher is better.
24    pub rank_score: f64,
25    /// Metadata stored alongside the vector, if any.
26    pub metadata: Option<Value>,
27}
28
29/// Parameters for a hybrid graph + vector query.
30pub struct HybridQuery<'a> {
31    /// The memory-graph node to start traversal from.
32    pub anchor_node: &'a str,
33    /// Query embedding to rank against the vector collection.
34    pub embedding: &'a [f32],
35    /// Name of the vector collection to search.
36    pub collection: &'a str,
37    /// Maximum graph traversal depth from `anchor_node`.
38    pub graph_depth: usize,
39    /// Maximum number of results to return after blending.
40    pub top_k: usize,
41    /// Interpolation factor between vector similarity and graph proximity.
42    /// `0.0` = pure graph weight, `1.0` = pure vector similarity.
43    pub alpha: f64,
44    /// Optional metadata filter applied before vector scoring.
45    pub filter: Option<Value>,
46}
47
48/// Parameters for a tri-modal graph + vector + FTS query.
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50pub struct TriModalQuery {
51    /// The memory-graph node to start traversal from.
52    pub anchor_node: String,
53    /// Query embedding to rank against the vector collection.
54    pub embedding: Vec<f32>,
55    /// Text query for full-text search.
56    pub text_query: String,
57    /// Name of the vector collection to search.
58    pub collection: String,
59    /// Maximum graph traversal depth from `anchor_node`.
60    pub graph_depth: usize,
61    /// Maximum number of results to return after blending.
62    pub top_k: usize,
63    /// Weight for vector similarity (must satisfy alpha + beta + gamma ≈ 1.0).
64    pub alpha: f32,
65    /// Weight for graph proximity.
66    pub beta: f32,
67    /// Weight for FTS BM25 score.
68    pub gamma: f32,
69    /// Optional metadata filter applied before vector scoring.
70    pub filter: Option<Value>,
71}
72
73/// A single result from a tri-modal graph + vector + FTS query.
74#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
75pub struct TriModalResult {
76    /// ID of the matched entity.
77    pub id: String,
78    /// Final blended rank score (higher is better).
79    pub rank_score: f32,
80    /// Normalized vector similarity score in [0, 1], if the item appeared in ANN results.
81    pub vector_score: Option<f32>,
82    /// Normalized graph proximity weight in [0, 1], if the item is reachable from the anchor.
83    pub graph_weight: Option<f32>,
84    /// Normalized FTS BM25 score in [0, 1], if the item appeared in FTS results.
85    pub fts_rank: Option<f32>,
86    /// Metadata stored alongside the vector, if any.
87    pub metadata: Option<Value>,
88}
89
90/// Executes hybrid graph + vector queries.
91pub struct HybridStore {
92    conn: Arc<Mutex<Connection>>,
93}
94
95impl HybridStore {
96    pub(crate) fn new(conn: Arc<Mutex<Connection>>) -> Self {
97        Self { conn }
98    }
99
100    /// Run a hybrid graph + vector query.
101    ///
102    /// The algorithm proceeds in three stages:
103    ///
104    /// 1. **Graph traversal** — walks the memory graph from `q.anchor_node` up to
105    ///    `q.graph_depth` hops, recording the maximum edge weight seen for each
106    ///    reachable node.
107    /// 2. **Vector search** — retrieves the top `q.top_k × 20` approximate nearest
108    ///    neighbours from the named collection.
109    /// 3. **Score blending** — for each candidate, computes
110    ///    `rank = q.alpha × vec_similarity + (1 − q.alpha) × graph_weight`,
111    ///    then returns the top `q.top_k` results sorted by rank descending.
112    pub fn query(&self, q: HybridQuery, col: &Collection) -> Result<Vec<HybridResult>> {
113        // Step 1: graph traversal
114        let graph = MemoryGraph::new(Arc::clone(&self.conn));
115        let traversal = graph
116            .neighbors(
117                q.anchor_node,
118                TraversalOptions {
119                    relation: None,
120                    max_depth: q.graph_depth,
121                    min_weight: Some(0.0),
122                },
123            )
124            .unwrap_or_default();
125
126        let mut graph_weights: HashMap<String, f64> = HashMap::new();
127        for t in &traversal {
128            let e = graph_weights.entry(t.node.id.clone()).or_insert(0.0);
129            if t.weight > *e {
130                *e = t.weight;
131            }
132        }
133
134        // Step 2: vector search
135        let fetch_k = (q.top_k * 20).max(100);
136        let vec_results: Vec<SearchResult> = col.search(
137            q.embedding,
138            SearchOptions {
139                top_k: fetch_k,
140                metric: DistanceMetric::Cosine,
141                filter: q.filter.clone(),
142            },
143        )?;
144
145        if vec_results.is_empty() {
146            return Ok(vec![]);
147        }
148
149        // Step 3: normalize vector scores (distance -> similarity)
150        let max_s = vec_results
151            .iter()
152            .map(|r| r.score)
153            .fold(f32::NEG_INFINITY, f32::max);
154        let min_s = vec_results
155            .iter()
156            .map(|r| r.score)
157            .fold(f32::INFINITY, f32::min);
158        let range = (max_s - min_s).max(1e-6);
159
160        // Step 4: blend and rank
161        let mut blended: Vec<HybridResult> = vec_results
162            .into_iter()
163            .map(|r| {
164                let vec_sim = 1.0 - ((r.score - min_s) / range) as f64;
165                let gw = graph_weights.get(&r.id).copied().unwrap_or(0.0);
166                let rank = q.alpha * vec_sim + (1.0 - q.alpha) * gw;
167                HybridResult {
168                    id: r.id,
169                    vector_score: r.score,
170                    graph_weight: gw,
171                    rank_score: rank,
172                    metadata: r.metadata,
173                }
174            })
175            .collect();
176
177        blended.sort_by(|a, b| {
178            b.rank_score
179                .partial_cmp(&a.rank_score)
180                .unwrap_or(std::cmp::Ordering::Equal)
181        });
182        blended.truncate(q.top_k);
183        Ok(blended)
184    }
185
186    /// Run a tri-modal graph + vector + FTS query.
187    ///
188    /// The algorithm runs three searches and blends results:
189    ///
190    /// 1. **Vector ANN** — retrieves `top_k × 20` approximate nearest neighbours.
191    /// 2. **Graph traversal** — walks the memory graph from `q.anchor_node` up to
192    ///    `q.graph_depth` hops, recording the maximum edge weight per reachable node.
193    /// 3. **FTS keyword search** — BM25 full-text search over the collection's FTS index.
194    ///
195    /// Each component is min-max normalised to [0, 1] within its own result set, then
196    /// blended as `final_score = alpha × vec_score + beta × graph_weight + gamma × fts_score`.
197    ///
198    /// The weights must satisfy `alpha + beta + gamma ≈ 1.0` (tolerance ±0.01).
199    pub fn tri_modal_query(
200        &self,
201        q: &TriModalQuery,
202        col: &Collection,
203    ) -> Result<Vec<TriModalResult>> {
204        // Validate weights
205        let weight_sum = q.alpha + q.beta + q.gamma;
206        if (weight_sum - 1.0_f32).abs() > 0.01 {
207            return Err(AgentDbError::InvalidArgument(format!(
208                "tri_modal_query: alpha + beta + gamma must equal 1.0, got {weight_sum:.4}"
209            )));
210        }
211
212        // ── Step 1: Graph traversal ────────────────────────────────────────
213        let mut graph_weights: HashMap<String, f64> = HashMap::new();
214        if q.beta > 0.0 {
215            let graph = MemoryGraph::new(Arc::clone(&self.conn));
216            let traversal = graph
217                .neighbors(
218                    &q.anchor_node,
219                    TraversalOptions {
220                        relation: None,
221                        max_depth: q.graph_depth,
222                        min_weight: Some(0.0),
223                    },
224                )
225                .unwrap_or_default();
226            for t in &traversal {
227                let e = graph_weights.entry(t.node.id.clone()).or_insert(0.0);
228                if t.weight > *e {
229                    *e = t.weight;
230                }
231            }
232        }
233
234        // ── Step 2: Vector ANN search ──────────────────────────────────────
235        let fetch_k = (q.top_k * 20).max(100);
236        let vec_results: Vec<SearchResult> = if q.alpha > 0.0 && !q.embedding.is_empty() {
237            col.search(
238                &q.embedding,
239                SearchOptions {
240                    top_k: fetch_k,
241                    metric: DistanceMetric::Cosine,
242                    filter: q.filter.clone(),
243                },
244            )
245            .unwrap_or_default()
246        } else {
247            vec![]
248        };
249
250        // ── Step 3: FTS search ─────────────────────────────────────────────
251        let fts_results = if q.gamma > 0.0 && !q.text_query.is_empty() {
252            let fts = FullTextStore::new(Arc::clone(&self.conn));
253            fts.search(&q.collection, &q.text_query, fetch_k)
254                .unwrap_or_default()
255        } else {
256            vec![]
257        };
258
259        // ── Collect candidate IDs from all three sources ───────────────────
260        let mut candidate_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
261        for r in &vec_results {
262            candidate_ids.insert(r.id.clone());
263        }
264        for r in &fts_results {
265            candidate_ids.insert(r.id.clone());
266        }
267        for id in graph_weights.keys() {
268            candidate_ids.insert(id.clone());
269        }
270
271        if candidate_ids.is_empty() {
272            return Ok(vec![]);
273        }
274
275        // ── Normalise vector scores ────────────────────────────────────────
276        // ANN returns cosine distance (lower = more similar). Convert to similarity.
277        let vec_map: HashMap<String, f32> = if !vec_results.is_empty() {
278            let max_s = vec_results
279                .iter()
280                .map(|r| r.score)
281                .fold(f32::NEG_INFINITY, f32::max);
282            let min_s = vec_results
283                .iter()
284                .map(|r| r.score)
285                .fold(f32::INFINITY, f32::min);
286            let range = (max_s - min_s).max(1e-6);
287            vec_results
288                .iter()
289                .map(|r| {
290                    let normalised = 1.0 - (r.score - min_s) / range;
291                    (r.id.clone(), normalised)
292                })
293                .collect()
294        } else {
295            HashMap::new()
296        };
297
298        // ── Normalise graph weights ────────────────────────────────────────
299        let graph_norm: HashMap<String, f32> = if !graph_weights.is_empty() {
300            let max_g = graph_weights
301                .values()
302                .copied()
303                .fold(f64::NEG_INFINITY, f64::max);
304            let min_g = graph_weights
305                .values()
306                .copied()
307                .fold(f64::INFINITY, f64::min);
308            let range_g = (max_g - min_g).max(1e-9);
309            graph_weights
310                .iter()
311                .map(|(id, &w)| {
312                    let normalised = ((w - min_g) / range_g) as f32;
313                    (id.clone(), normalised)
314                })
315                .collect()
316        } else {
317            HashMap::new()
318        };
319
320        // ── Normalise FTS ranks ────────────────────────────────────────────
321        // BM25 scores from SQLite FTS5 are negative (more negative = better).
322        // Invert and normalise so that higher is better.
323        let fts_map: HashMap<String, f32> = if !fts_results.is_empty() {
324            // Negate so the best result (most negative BM25) becomes the largest value.
325            let negated: Vec<f64> = fts_results.iter().map(|r| -r.rank).collect();
326            let max_f = negated.iter().copied().fold(f64::NEG_INFINITY, f64::max);
327            let min_f = negated.iter().copied().fold(f64::INFINITY, f64::min);
328            let range_f = (max_f - min_f).max(1e-9);
329            fts_results
330                .iter()
331                .zip(negated.iter())
332                .map(|(r, &neg)| {
333                    let normalised = ((neg - min_f) / range_f) as f32;
334                    (r.id.clone(), normalised)
335                })
336                .collect()
337        } else {
338            HashMap::new()
339        };
340
341        // ── Collect metadata from vector results ───────────────────────────
342        let meta_map: HashMap<String, Option<Value>> = vec_results
343            .iter()
344            .map(|r| (r.id.clone(), r.metadata.clone()))
345            .collect();
346
347        // ── Blend scores ───────────────────────────────────────────────────
348        let mut blended: Vec<TriModalResult> = candidate_ids
349            .into_iter()
350            .map(|id| {
351                let vs = vec_map.get(&id).copied();
352                let gw = graph_norm.get(&id).copied();
353                let fr = fts_map.get(&id).copied();
354
355                let rank = q.alpha * vs.unwrap_or(0.0)
356                    + q.beta * gw.unwrap_or(0.0)
357                    + q.gamma * fr.unwrap_or(0.0);
358
359                let metadata = meta_map.get(&id).cloned().flatten();
360
361                TriModalResult {
362                    id,
363                    rank_score: rank,
364                    vector_score: vs,
365                    graph_weight: gw,
366                    fts_rank: fr,
367                    metadata,
368                }
369            })
370            .collect();
371
372        blended.sort_by(|a, b| {
373            b.rank_score
374                .partial_cmp(&a.rank_score)
375                .unwrap_or(std::cmp::Ordering::Equal)
376        });
377        blended.truncate(q.top_k);
378        Ok(blended)
379    }
380}