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}