1use std::collections::HashMap;
2
3use crate::error::RetrievalError;
4use ahash::{AHashMap, AHashSet};
5use issundb_core::{EdgeId, Graph, NodeId};
6use issundb_text::{TextGraphExt, TextSearchOptions};
7use issundb_vector::{VectorError, VectorGraphExt, VectorSearchOptions};
8
9#[derive(Debug)]
25pub struct Subgraph {
26 pub nodes: Vec<NodeId>,
27 pub edges: Vec<EdgeId>,
28 pub scores: HashMap<NodeId, f32>,
29 pub truncated: bool,
30}
31
32pub struct RetrieveOptions {
34 pub k: usize,
36 pub hops: u8,
39 pub max_distance: f32,
43 pub max_nodes: Option<usize>,
47}
48
49impl Default for RetrieveOptions {
50 fn default() -> Self {
51 Self {
52 k: 10,
53 hops: 2,
54 max_distance: f32::MAX,
55 max_nodes: None,
56 }
57 }
58}
59
60fn bfs_multi_source_undirected(
71 graph: &Graph,
72 seeds: &[NodeId],
73 hops: u8,
74 max_nodes: Option<usize>,
75) -> Result<(Vec<NodeId>, bool), RetrievalError> {
76 let mut visited: AHashSet<NodeId> = AHashSet::new();
77 let mut frontier: Vec<NodeId> = Vec::new();
78 let mut truncated = false;
79 for &seed in seeds {
80 if visited.contains(&seed) || !graph.node_exists(seed)? {
81 continue;
82 }
83 if max_nodes.is_some_and(|max| visited.len() >= max) {
84 truncated = true;
85 break;
86 }
87 visited.insert(seed);
88 frontier.push(seed);
89 }
90 if visited.is_empty() {
91 return Ok((Vec::new(), truncated));
92 }
93
94 for _ in 0..hops {
95 let mut discovered: AHashSet<NodeId> = AHashSet::new();
96 for incoming in [false, true] {
97 for (_, _, other) in graph.expand_bulk(&frontier, None, incoming)? {
98 if !visited.contains(&other) {
99 discovered.insert(other);
100 }
101 }
102 }
103 if discovered.is_empty() {
104 break;
105 }
106 let mut next: Vec<NodeId> = discovered.into_iter().collect();
107 next.sort_unstable();
108 if let Some(max) = max_nodes {
109 if visited.len() >= max {
110 truncated = true;
111 break;
112 }
113 if visited.len() + next.len() > max {
114 truncated = true;
115 next.truncate(max - visited.len());
116 visited.extend(&next);
117 break;
118 }
119 }
120 visited.extend(&next);
121 frontier = next;
122 }
123
124 let mut nodes: Vec<NodeId> = visited.into_iter().collect();
125 nodes.sort_unstable();
126 Ok((nodes, truncated))
127}
128
129pub fn retrieve(graph: &Graph, q: &[f32], k: usize, hops: u8) -> Result<Subgraph, RetrievalError> {
132 retrieve_with(
133 graph,
134 q,
135 &RetrieveOptions {
136 k,
137 hops,
138 ..Default::default()
139 },
140 )
141}
142
143pub fn retrieve_with(
149 graph: &Graph,
150 q: &[f32],
151 opts: &RetrieveOptions,
152) -> Result<Subgraph, RetrievalError> {
153 let hits = graph.vector_search(q, opts.k)?;
154
155 let mut scores: AHashMap<NodeId, f32> = AHashMap::new();
156 let mut seeds = Vec::new();
157 for hit in &hits {
158 if hit.distance <= opts.max_distance {
159 scores.insert(hit.node, hit.distance);
160 seeds.push(hit.node);
161 }
162 }
163
164 if seeds.is_empty() {
165 return Ok(Subgraph {
166 nodes: Vec::new(),
167 edges: Vec::new(),
168 scores: HashMap::new(),
169 truncated: false,
170 });
171 }
172
173 let (node_list, truncated) =
174 bfs_multi_source_undirected(graph, &seeds, opts.hops, opts.max_nodes)?;
175 let node_set: AHashSet<NodeId> = node_list.into_iter().collect();
176
177 scores.retain(|n, _| node_set.contains(n));
181
182 let edges = induced_edges(graph, &node_set)?;
183
184 Ok(Subgraph {
185 nodes: node_set.into_iter().collect(),
186 edges,
187 scores: scores.into_iter().collect(),
188 truncated,
189 })
190}
191
192fn induced_edges(
197 graph: &Graph,
198 node_set: &AHashSet<NodeId>,
199) -> Result<Vec<EdgeId>, RetrievalError> {
200 let mut edge_set: AHashSet<EdgeId> = AHashSet::new();
201 for &node in node_set {
202 for ne in graph.out_neighbors(node)? {
203 if node_set.contains(&ne.node) {
204 edge_set.insert(ne.edge);
205 }
206 }
207 }
208 Ok(edge_set.into_iter().collect())
209}
210
211#[derive(Debug, Clone)]
213pub enum FusionStrategy {
214 Rrf { k: u32 },
218 WeightedSum {
221 vector_weight: f32,
222 text_weight: f32,
223 },
224}
225
226impl Default for FusionStrategy {
227 fn default() -> Self {
228 Self::Rrf { k: 60 }
229 }
230}
231
232pub struct HybridRetrieveOptions {
234 pub vector_k: usize,
236 pub text_k: usize,
238 pub text_label: Option<String>,
240 pub text_property: Option<String>,
242 pub hops: u8,
245 pub max_distance: f32,
247 pub max_nodes: Option<usize>,
249 pub vector_label: Option<String>,
251 pub fusion: FusionStrategy,
253}
254
255impl Default for HybridRetrieveOptions {
256 fn default() -> Self {
257 Self {
258 vector_k: 10,
259 text_k: 10,
260 text_label: None,
261 text_property: None,
262 hops: 2,
263 max_distance: f32::MAX,
264 max_nodes: None,
265 vector_label: None,
266 fusion: FusionStrategy::default(),
267 }
268 }
269}
270
271pub fn retrieve_hybrid(
284 graph: &Graph,
285 q: &[f32],
286 text_query: &str,
287 opts: &HybridRetrieveOptions,
288) -> Result<Subgraph, RetrievalError> {
289 let vector_active = opts.vector_k > 0 && !q.is_empty();
290 let text_active = opts.text_k > 0 && !text_query.is_empty();
291 if !vector_active && !text_active {
292 return Err(RetrievalError::NoQuery);
293 }
294
295 let mut vec_ranks: AHashMap<NodeId, usize> = AHashMap::new();
297 let mut vec_scores: AHashMap<NodeId, f32> = AHashMap::new();
298
299 if vector_active {
300 let search = graph.vector_search_with(
301 q,
302 &VectorSearchOptions {
303 k: opts.vector_k,
304 label: opts.vector_label.clone(),
305 properties: None,
306 rescore_factor: None,
307 },
308 );
309 match search {
310 Ok(hits) => {
311 for (rank, hit) in hits.iter().enumerate() {
312 if hit.distance <= opts.max_distance {
313 vec_ranks.insert(hit.node, rank);
314 vec_scores.insert(hit.node, hit.distance);
315 }
316 }
317 }
318 Err(VectorError::EmptyIndex) if text_active => {}
323 Err(e) => return Err(e.into()),
324 }
325 }
326
327 let mut text_ranks: AHashMap<NodeId, usize> = AHashMap::new();
329
330 if text_active {
331 let text_opts = TextSearchOptions {
332 label: opts.text_label.clone(),
333 property: opts.text_property.clone(),
334 limit: opts.text_k,
335 ..Default::default()
336 };
337 let text_hits = graph.text_search(text_query, &text_opts)?;
338 for (rank, hit) in text_hits.iter().enumerate() {
339 text_ranks.insert(hit.node, rank);
340 }
341 }
342
343 let mut fused: AHashMap<NodeId, f32> = AHashMap::new();
345
346 let all_nodes: AHashSet<NodeId> = vec_ranks.keys().chain(text_ranks.keys()).copied().collect();
347
348 for node in &all_nodes {
349 let score = match &opts.fusion {
350 FusionStrategy::Rrf { k } => {
351 let kf = *k as f32;
352 let vs = vec_ranks
353 .get(node)
354 .map(|r| 1.0 / (kf + *r as f32 + 1.0))
355 .unwrap_or(0.0);
356 let ts = text_ranks
357 .get(node)
358 .map(|r| 1.0 / (kf + *r as f32 + 1.0))
359 .unwrap_or(0.0);
360 vs + ts
361 }
362 FusionStrategy::WeightedSum {
363 vector_weight,
364 text_weight,
365 } => {
366 let total_vec = opts.vector_k.max(1) as f32;
367 let total_txt = opts.text_k.max(1) as f32;
368 let vs = vec_ranks
369 .get(node)
370 .map(|r| (total_vec - *r as f32) / total_vec)
371 .unwrap_or(0.0);
372 let ts = text_ranks
373 .get(node)
374 .map(|r| (total_txt - *r as f32) / total_txt)
375 .unwrap_or(0.0);
376 vector_weight * vs + text_weight * ts
377 }
378 };
379 fused.insert(*node, score);
380 }
381
382 let mut ranked: Vec<(NodeId, f32)> = fused.iter().map(|(n, s)| (*n, *s)).collect();
388 ranked.sort_by(|a, b| {
389 b.1.partial_cmp(&a.1)
390 .unwrap_or(std::cmp::Ordering::Equal)
391 .then(a.0.cmp(&b.0))
392 });
393 let seeds: Vec<NodeId> = ranked.into_iter().map(|(n, _)| n).collect();
394
395 if seeds.is_empty() {
396 return Ok(Subgraph {
397 nodes: Vec::new(),
398 edges: Vec::new(),
399 scores: HashMap::new(),
400 truncated: false,
401 });
402 }
403
404 let (node_list, truncated) =
406 bfs_multi_source_undirected(graph, &seeds, opts.hops, opts.max_nodes)?;
407 let node_set: AHashSet<NodeId> = node_list.into_iter().collect();
408
409 let mut scores: AHashMap<NodeId, f32> = fused;
410 scores.retain(|n, _| node_set.contains(n));
411
412 let edges = induced_edges(graph, &node_set)?;
413
414 Ok(Subgraph {
415 nodes: node_set.into_iter().collect(),
416 edges,
417 scores: scores.into_iter().collect(),
418 truncated,
419 })
420}
421
422#[cfg(test)]
423mod tests {
424 use serde_json::json;
425 use tempfile::TempDir;
426
427 use super::*;
428
429 fn open_tmp() -> (TempDir, Graph) {
430 let dir = TempDir::new().unwrap();
431 let g = Graph::open(dir.path(), 1).unwrap();
432 (dir, g)
433 }
434
435 #[test]
436 fn retrieve_empty_vector_index_is_an_error() {
437 let (_dir, g) = open_tmp();
438 let err = retrieve(&g, &[1.0f32, 0.0], 5, 2).unwrap_err();
439 assert!(
440 matches!(
441 err,
442 RetrievalError::Vector(issundb_vector::VectorError::EmptyIndex)
443 ),
444 "got {err:?}"
445 );
446 }
447
448 #[test]
449 fn hybrid_retrieve_without_any_query_is_an_error() {
450 let (_dir, g) = open_tmp();
451 let err = retrieve_hybrid(&g, &[], "", &HybridRetrieveOptions::default()).unwrap_err();
452 assert!(matches!(err, RetrievalError::NoQuery), "got {err:?}");
453
454 let opts = HybridRetrieveOptions {
456 vector_k: 0,
457 text_k: 0,
458 ..Default::default()
459 };
460 let err = retrieve_hybrid(&g, &[1.0f32, 0.0], "cassava", &opts).unwrap_err();
461 assert!(matches!(err, RetrievalError::NoQuery), "got {err:?}");
462 }
463
464 #[test]
465 fn retrieve_hops_zero_returns_only_seed_nodes() {
466 let (_dir, g) = open_tmp();
467 let a = g.add_node("N", &json!({})).unwrap();
468 let b = g.add_node("N", &json!({})).unwrap();
469 let c = g.add_node("N", &json!({})).unwrap();
470 g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
471 g.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
472 g.add_edge(a, c, "E", &json!({})).unwrap();
473
474 let sub = retrieve(&g, &[1.0f32, 0.0, 0.0], 1, 0).unwrap();
476 assert_eq!(sub.nodes.len(), 1);
477 assert_eq!(sub.nodes[0], a);
478 assert!(!sub.nodes.contains(&c));
479 }
480
481 #[test]
482 fn retrieve_expands_bfs_to_correct_depth() {
483 let (_dir, g) = open_tmp();
484 let a = g.add_node("N", &json!({})).unwrap();
486 let b = g.add_node("N", &json!({})).unwrap();
487 let c = g.add_node("N", &json!({})).unwrap();
488 let d = g.add_node("N", &json!({})).unwrap();
489 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
490 g.add_edge(a, b, "E", &json!({})).unwrap();
491 g.add_edge(b, c, "E", &json!({})).unwrap();
492 g.add_edge(c, d, "E", &json!({})).unwrap();
493
494 let sub1 = retrieve(&g, &[1.0f32, 0.0], 1, 1).unwrap();
495 let sub2 = retrieve(&g, &[1.0f32, 0.0], 1, 2).unwrap();
496
497 let mut n1 = sub1.nodes.clone();
498 n1.sort_unstable();
499 assert_eq!(n1, vec![a, b]);
500
501 let mut n2 = sub2.nodes.clone();
502 n2.sort_unstable();
503 assert_eq!(n2, vec![a, b, c]);
504 }
505
506 #[test]
507 fn retrieve_subgraph_edges_connect_only_nodes_in_set() {
508 let (_dir, g) = open_tmp();
509 let a = g.add_node("N", &json!({})).unwrap();
511 let b = g.add_node("N", &json!({})).unwrap();
512 let c = g.add_node("N", &json!({})).unwrap();
513 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
514 let e_ab = g.add_edge(a, b, "E", &json!({})).unwrap();
515 let _e_bc = g.add_edge(b, c, "E", &json!({})).unwrap();
516
517 let sub = retrieve(&g, &[1.0f32, 0.0], 1, 1).unwrap();
518 assert!(sub.edges.contains(&e_ab));
519 assert_eq!(sub.edges.len(), 1);
521 }
522
523 #[test]
524 fn retrieve_scores_map_contains_seed_distances() {
525 let (_dir, g) = open_tmp();
526 let a = g.add_node("N", &json!({})).unwrap();
527 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
528
529 let sub = retrieve(&g, &[1.0f32, 0.0], 1, 0).unwrap();
530 assert!(sub.scores.contains_key(&a));
531 assert!(sub.scores[&a] < 1e-5);
532 }
533
534 #[test]
535 fn retrieve_with_max_distance_filters_far_seeds() {
536 let (_dir, g) = open_tmp();
537 let a = g.add_node("N", &json!({})).unwrap();
538 let b = g.add_node("N", &json!({})).unwrap();
539 g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
541 g.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
542
543 let sub = retrieve_with(
544 &g,
545 &[1.0f32, 0.0, 0.0],
546 &RetrieveOptions {
547 k: 2,
548 hops: 0,
549 max_distance: 0.1,
550 max_nodes: None,
551 },
552 )
553 .unwrap();
554
555 assert_eq!(sub.nodes.len(), 1);
557 assert_eq!(sub.nodes[0], a);
558 }
559
560 #[test]
561 fn retrieve_with_max_nodes_caps_subgraph() {
562 let (_dir, g) = open_tmp();
563 let a = g.add_node("N", &json!({})).unwrap();
565 let b = g.add_node("N", &json!({})).unwrap();
566 let c = g.add_node("N", &json!({})).unwrap();
567 let d = g.add_node("N", &json!({})).unwrap();
568 let e = g.add_node("N", &json!({})).unwrap();
569 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
570 g.add_edge(a, b, "E", &json!({})).unwrap();
571 g.add_edge(a, c, "E", &json!({})).unwrap();
572 g.add_edge(a, d, "E", &json!({})).unwrap();
573 g.add_edge(a, e, "E", &json!({})).unwrap();
574
575 let sub = retrieve_with(
576 &g,
577 &[1.0f32, 0.0],
578 &RetrieveOptions {
579 k: 1,
580 hops: 1,
581 max_distance: f32::MAX,
582 max_nodes: Some(3),
583 },
584 )
585 .unwrap();
586
587 assert!(sub.nodes.len() <= 3);
588 assert!(
589 sub.truncated,
590 "the cap dropped reachable nodes, so the subgraph must say so"
591 );
592 }
593
594 #[test]
595 fn retrieve_without_a_cap_is_not_truncated() {
596 let (_dir, g) = open_tmp();
597 let a = g.add_node("N", &json!({})).unwrap();
598 let b = g.add_node("N", &json!({})).unwrap();
599 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
600 g.add_edge(a, b, "E", &json!({})).unwrap();
601
602 let sub = retrieve(&g, &[1.0f32, 0.0], 1, 1).unwrap();
603 assert!(!sub.truncated);
604 }
605
606 #[test]
607 fn retrieve_with_multiple_seeds_each_expand_independently() {
608 let (_dir, g) = open_tmp();
609 let a = g.add_node("N", &json!({})).unwrap();
614 let b = g.add_node("N", &json!({})).unwrap();
615 let c = g.add_node("N", &json!({})).unwrap();
616 let d = g.add_node("N", &json!({})).unwrap();
617 let e = g.add_node("N", &json!({})).unwrap();
618 let f = g.add_node("N", &json!({})).unwrap();
619 g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
620 g.upsert_vector(d, &[0.0f32, 1.0, 0.0]).unwrap();
621 g.add_edge(a, b, "E", &json!({})).unwrap();
622 g.add_edge(b, c, "E", &json!({})).unwrap();
623 g.add_edge(d, e, "E", &json!({})).unwrap();
624 g.add_edge(e, f, "E", &json!({})).unwrap();
625
626 let sub1 = retrieve_with(
627 &g,
628 &[1.0f32, 0.0, 0.0],
629 &RetrieveOptions {
630 k: 2,
631 hops: 1,
632 max_distance: f32::MAX,
633 max_nodes: None,
634 },
635 )
636 .unwrap();
637 let mut n1 = sub1.nodes.clone();
638 n1.sort_unstable();
639 assert!(n1.contains(&a), "seed a must be present at hops=1");
640 assert!(n1.contains(&b), "b is 1 hop from seed a");
641 assert!(n1.contains(&d), "seed d must be present at hops=1");
642 assert!(n1.contains(&e), "e is 1 hop from seed d");
643 assert!(!n1.contains(&c), "c is 2 hops from a, out of range");
644 assert!(!n1.contains(&f), "f is 2 hops from d, out of range");
645 assert_eq!(n1.len(), 4);
646
647 let sub2 = retrieve_with(
648 &g,
649 &[1.0f32, 0.0, 0.0],
650 &RetrieveOptions {
651 k: 2,
652 hops: 2,
653 max_distance: f32::MAX,
654 max_nodes: None,
655 },
656 )
657 .unwrap();
658 assert_eq!(sub2.nodes.len(), 6, "all six nodes reachable within 2 hops");
659 assert!(sub2.scores.contains_key(&a));
660 assert!(sub2.scores.contains_key(&d));
661 }
662
663 #[test]
669 fn retrieve_k_hop_expansion() {
670 let (_dir, g) = open_tmp();
671 let a = g.add_node("N", &json!({})).unwrap();
672 let b = g.add_node("N", &json!({})).unwrap();
673 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
674 g.add_edge(a, b, "E", &json!({})).unwrap();
675 g.rebuild_csr().unwrap();
676
677 let sub = retrieve_with(
678 &g,
679 &[1.0f32, 0.0],
680 &RetrieveOptions {
681 k: 1,
682 hops: 1,
683 max_distance: f32::MAX,
684 max_nodes: None,
685 },
686 )
687 .unwrap();
688
689 assert_eq!(sub.nodes.len(), 2);
690 assert!(sub.nodes.contains(&a));
691 assert!(sub.nodes.contains(&b));
692 }
693
694 #[test]
695 fn retrieve_hops_zero_returns_only_seed() {
696 let (_dir, g) = open_tmp();
697 let a = g.add_node("N", &json!({})).unwrap();
698 let b = g.add_node("N", &json!({})).unwrap();
699 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
700 g.add_edge(a, b, "E", &json!({})).unwrap();
701 g.rebuild_csr().unwrap();
702
703 let sub = retrieve_with(
704 &g,
705 &[1.0f32, 0.0],
706 &RetrieveOptions {
707 k: 1,
708 hops: 0,
709 max_distance: f32::MAX,
710 max_nodes: None,
711 },
712 )
713 .unwrap();
714
715 assert_eq!(sub.nodes, vec![a]);
716 assert!(sub.edges.is_empty(), "no edges when hops=0");
717 }
718
719 #[test]
720 fn retrieve_scores_keys_are_subset_of_nodes() {
721 let (_dir, g) = open_tmp();
722 let a = g.add_node("N", &json!({})).unwrap();
723 let b = g.add_node("N", &json!({})).unwrap();
724 let c = g.add_node("N", &json!({})).unwrap();
725 g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
726 g.upsert_vector(b, &[0.9f32, 0.1, 0.0]).unwrap();
727 g.add_edge(a, c, "E", &json!({})).unwrap();
728 g.rebuild_csr().unwrap();
729
730 let sub = retrieve_with(
731 &g,
732 &[1.0f32, 0.0, 0.0],
733 &RetrieveOptions {
734 k: 2,
735 hops: 1,
736 max_distance: f32::MAX,
737 max_nodes: None,
738 },
739 )
740 .unwrap();
741
742 for node_id in sub.scores.keys() {
744 assert!(
745 sub.nodes.contains(node_id),
746 "scores key {node_id:?} is absent from nodes"
747 );
748 }
749 }
750
751 #[test]
752 fn retrieve_edges_connect_only_nodes_in_subgraph() {
753 let (_dir, g) = open_tmp();
754 let a = g.add_node("N", &json!({})).unwrap();
756 let b = g.add_node("N", &json!({})).unwrap();
757 let c = g.add_node("N", &json!({})).unwrap();
758 let d = g.add_node("N", &json!({})).unwrap();
759 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
760 let e_ab = g.add_edge(a, b, "E", &json!({})).unwrap();
761 let _e_bc = g.add_edge(b, c, "E", &json!({})).unwrap();
762 g.add_edge(c, d, "E", &json!({})).unwrap();
763 g.rebuild_csr().unwrap();
764
765 let sub = retrieve_with(
766 &g,
767 &[1.0f32, 0.0],
768 &RetrieveOptions {
769 k: 1,
770 hops: 1,
771 max_distance: f32::MAX,
772 max_nodes: None,
773 },
774 )
775 .unwrap();
776
777 assert!(sub.nodes.contains(&a));
778 assert!(sub.nodes.contains(&b));
779 assert!(!sub.nodes.contains(&c));
780 assert!(sub.edges.contains(&e_ab), "edge a to b must be in subgraph");
781 assert_eq!(
782 sub.edges.len(),
783 1,
784 "only a to b is within the 1-hop subgraph"
785 );
786 }
787
788 #[test]
789 fn retrieve_max_distance_filters_far_seeds() {
790 let (_dir, g) = open_tmp();
791 let a = g.add_node("N", &json!({})).unwrap();
792 let b = g.add_node("N", &json!({})).unwrap();
793 g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
795 g.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
796 g.rebuild_csr().unwrap();
797
798 let sub = retrieve_with(
799 &g,
800 &[1.0f32, 0.0, 0.0],
801 &RetrieveOptions {
802 k: 2,
803 hops: 0,
804 max_distance: 0.1,
805 max_nodes: None,
806 },
807 )
808 .unwrap();
809
810 assert_eq!(sub.nodes.len(), 1);
811 assert_eq!(sub.nodes[0], a);
812 assert!(sub.scores.contains_key(&a));
813 assert!(!sub.scores.contains_key(&b));
814 }
815
816 #[test]
817 fn retrieve_max_nodes_caps_subgraph() {
818 let (_dir, g) = open_tmp();
819 let a = g.add_node("N", &json!({})).unwrap();
821 let b = g.add_node("N", &json!({})).unwrap();
822 let c = g.add_node("N", &json!({})).unwrap();
823 let d = g.add_node("N", &json!({})).unwrap();
824 let e = g.add_node("N", &json!({})).unwrap();
825 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
826 g.add_edge(a, b, "E", &json!({})).unwrap();
827 g.add_edge(a, c, "E", &json!({})).unwrap();
828 g.add_edge(a, d, "E", &json!({})).unwrap();
829 g.add_edge(a, e, "E", &json!({})).unwrap();
830 g.rebuild_csr().unwrap();
831
832 let sub = retrieve_with(
833 &g,
834 &[1.0f32, 0.0],
835 &RetrieveOptions {
836 k: 1,
837 hops: 1,
838 max_distance: f32::MAX,
839 max_nodes: Some(3),
840 },
841 )
842 .unwrap();
843
844 assert!(
845 sub.nodes.len() <= 3,
846 "expected at most 3 nodes, got {}",
847 sub.nodes.len()
848 );
849 assert!(sub.truncated, "the cap dropped reachable nodes");
850 }
851
852 #[test]
853 fn retrieve_scores_contain_seed_distances() {
854 let (_dir, g) = open_tmp();
855 let a = g.add_node("N", &json!({})).unwrap();
856 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
857 g.rebuild_csr().unwrap();
858
859 let sub = retrieve_with(
860 &g,
861 &[1.0f32, 0.0],
862 &RetrieveOptions {
863 k: 1,
864 hops: 0,
865 max_distance: f32::MAX,
866 max_nodes: None,
867 },
868 )
869 .unwrap();
870
871 assert!(sub.scores.contains_key(&a));
872 assert!(
873 sub.scores[&a] < 1e-5,
874 "distance to identical vector must be ~0"
875 );
876 }
877
878 #[test]
879 fn retrieve_with_over_an_empty_vector_index_is_an_error() {
880 let (_dir, g) = open_tmp();
881 g.rebuild_csr().unwrap();
882
883 let err = retrieve_with(&g, &[1.0f32, 0.0], &RetrieveOptions::default()).unwrap_err();
884 assert!(
885 matches!(
886 err,
887 RetrievalError::Vector(issundb_vector::VectorError::EmptyIndex)
888 ),
889 "got {err:?}"
890 );
891 }
892
893 #[test]
894 fn retrieve_multiple_seeds_each_expand_independently() {
895 let (_dir, g) = open_tmp();
896 let a = g.add_node("N", &json!({})).unwrap();
899 let b = g.add_node("N", &json!({})).unwrap();
900 let c = g.add_node("N", &json!({})).unwrap();
901 let d = g.add_node("N", &json!({})).unwrap();
902 let e = g.add_node("N", &json!({})).unwrap();
903 let f = g.add_node("N", &json!({})).unwrap();
904 g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
905 g.upsert_vector(d, &[0.0f32, 1.0, 0.0]).unwrap();
906 g.add_edge(a, b, "E", &json!({})).unwrap();
907 g.add_edge(b, c, "E", &json!({})).unwrap();
908 g.add_edge(d, e, "E", &json!({})).unwrap();
909 g.add_edge(e, f, "E", &json!({})).unwrap();
910 g.rebuild_csr().unwrap();
911
912 let sub1 = retrieve_with(
913 &g,
914 &[1.0f32, 0.0, 0.0],
915 &RetrieveOptions {
916 k: 2,
917 hops: 1,
918 max_distance: f32::MAX,
919 max_nodes: None,
920 },
921 )
922 .unwrap();
923 assert!(sub1.nodes.contains(&a), "seed a must be present at hops=1");
924 assert!(sub1.nodes.contains(&b), "b is 1 hop from seed a");
925 assert!(sub1.nodes.contains(&d), "seed d must be present at hops=1");
926 assert!(sub1.nodes.contains(&e), "e is 1 hop from seed d");
927 assert!(!sub1.nodes.contains(&c), "c is 2 hops from a, out of range");
928 assert!(!sub1.nodes.contains(&f), "f is 2 hops from d, out of range");
929 assert_eq!(sub1.nodes.len(), 4);
930
931 let sub2 = retrieve_with(
932 &g,
933 &[1.0f32, 0.0, 0.0],
934 &RetrieveOptions {
935 k: 2,
936 hops: 2,
937 max_distance: f32::MAX,
938 max_nodes: None,
939 },
940 )
941 .unwrap();
942 assert_eq!(sub2.nodes.len(), 6, "all six nodes reachable within 2 hops");
943 assert!(sub2.scores.contains_key(&a));
944 assert!(sub2.scores.contains_key(&d));
945 }
946
947 #[test]
948 fn hybrid_retrieve_vector_only_matches_pure_vector_search() {
949 let (_dir, g) = open_tmp();
950 let a = g.add_node("N", &json!({})).unwrap();
951 let b = g.add_node("N", &json!({})).unwrap();
952 g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
953 g.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
954 g.rebuild_csr().unwrap();
955
956 let sub = retrieve_hybrid(
957 &g,
958 &[1.0f32, 0.0, 0.0],
959 "",
960 &HybridRetrieveOptions {
961 vector_k: 1,
962 text_k: 0,
963 hops: 0,
964 ..Default::default()
965 },
966 )
967 .unwrap();
968 assert_eq!(sub.nodes.len(), 1);
969 assert_eq!(sub.nodes[0], a);
970 }
971
972 #[test]
976 fn hybrid_retrieve_keeps_top_scored_seeds_under_max_nodes() {
977 let (_dir, g) = open_tmp();
978 let a = g.add_node("N", &json!({})).unwrap();
979 let b = g.add_node("N", &json!({})).unwrap();
980 let c = g.add_node("N", &json!({})).unwrap();
981 let d = g.add_node("N", &json!({})).unwrap();
982 g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap(); g.upsert_vector(b, &[0.9f32, 0.1, 0.0]).unwrap(); g.upsert_vector(c, &[0.2f32, 1.0, 0.0]).unwrap(); g.upsert_vector(d, &[0.0f32, 0.0, 1.0]).unwrap(); g.rebuild_csr().unwrap();
987
988 let opts = HybridRetrieveOptions {
989 vector_k: 4,
990 text_k: 0,
991 hops: 0,
992 max_distance: 2.0, max_nodes: Some(2),
994 ..Default::default()
995 };
996 let sub = retrieve_hybrid(&g, &[1.0f32, 0.0, 0.0], "", &opts).unwrap();
997 let mut nodes = sub.nodes.clone();
998 nodes.sort_unstable();
999 let mut expected = vec![a, b];
1000 expected.sort_unstable();
1001 assert_eq!(
1002 nodes, expected,
1003 "the two highest-scored seeds must survive the max_nodes cap"
1004 );
1005 assert!(sub.truncated, "the cap dropped two of the four seeds");
1006 }
1007
1008 #[test]
1009 fn hybrid_retrieve_fuses_both_sources() {
1010 let (_dir, g) = open_tmp();
1011 let a = g
1012 .add_node("Doc", &json!({"body": "rust graph database storage"}))
1013 .unwrap();
1014 let b = g
1015 .add_node("Doc", &json!({"body": "vector search nearest neighbor"}))
1016 .unwrap();
1017 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1018 g.upsert_vector(b, &[0.0f32, 1.0]).unwrap();
1019 g.update(|txn| txn.create_node_text_index("Doc", "body"))
1020 .unwrap();
1021 g.rebuild_csr().unwrap();
1022
1023 let sub = retrieve_hybrid(
1025 &g,
1026 &[1.0f32, 0.0],
1027 "vector",
1028 &HybridRetrieveOptions {
1029 vector_k: 1,
1030 text_k: 1,
1031 text_label: Some("Doc".into()),
1032 text_property: Some("body".into()),
1033 hops: 0,
1034 ..Default::default()
1035 },
1036 )
1037 .unwrap();
1038 assert!(sub.nodes.contains(&a), "vector hit a must be present");
1039 assert!(sub.nodes.contains(&b), "text hit b must be present");
1040 }
1041
1042 #[test]
1043 fn hybrid_retrieve_weighted_sum_produces_correct_scores() {
1044 let (_dir, g) = open_tmp();
1045 let a = g.add_node("Doc", &json!({"body": "alpha bravo"})).unwrap();
1046 let b = g
1047 .add_node("Doc", &json!({"body": "charlie delta"}))
1048 .unwrap();
1049 g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1050 g.upsert_vector(b, &[0.0f32, 1.0]).unwrap();
1051 g.update(|txn| txn.create_node_text_index("Doc", "body"))
1052 .unwrap();
1053 g.rebuild_csr().unwrap();
1054
1055 let sub = retrieve_hybrid(
1061 &g,
1062 &[1.0f32, 0.0],
1063 "charlie",
1064 &HybridRetrieveOptions {
1065 vector_k: 1,
1066 text_k: 1,
1067 text_label: Some("Doc".into()),
1068 text_property: Some("body".into()),
1069 hops: 0,
1070 fusion: FusionStrategy::WeightedSum {
1071 vector_weight: 0.7,
1072 text_weight: 0.3,
1073 },
1074 ..Default::default()
1075 },
1076 )
1077 .unwrap();
1078
1079 assert!(
1080 sub.scores.contains_key(&a),
1081 "vector seed a must have a score"
1082 );
1083 assert!(sub.scores.contains_key(&b), "text seed b must have a score");
1084 assert!(
1085 (sub.scores[&a] - 0.7).abs() < 1e-5,
1086 "a score should be 0.7, got {}",
1087 sub.scores[&a]
1088 );
1089 assert!(
1090 (sub.scores[&b] - 0.3).abs() < 1e-5,
1091 "b score should be 0.3, got {}",
1092 sub.scores[&b]
1093 );
1094 }
1095
1096 #[test]
1101 fn hybrid_retrieve_with_text_active_survives_an_empty_vector_index() {
1102 let (_dir, g) = open_tmp();
1103 let a = g
1104 .add_node("Doc", &json!({"body": "quantum computing research"}))
1105 .unwrap();
1106 let _b = g
1107 .add_node("Doc", &json!({"body": "classical music orchestra"}))
1108 .unwrap();
1109 g.update(|txn| txn.create_node_text_index("Doc", "body"))
1110 .unwrap();
1111
1112 let sub = retrieve_hybrid(
1113 &g,
1114 &[1.0f32, 0.0],
1115 "quantum",
1116 &HybridRetrieveOptions {
1117 vector_k: 5,
1118 text_k: 5,
1119 text_label: Some("Doc".into()),
1120 text_property: Some("body".into()),
1121 hops: 0,
1122 ..Default::default()
1123 },
1124 )
1125 .unwrap();
1126
1127 assert_eq!(sub.nodes, vec![a], "the text seed alone forms the subgraph");
1128 assert!(sub.scores.contains_key(&a));
1129 }
1130
1131 #[test]
1134 fn hybrid_retrieve_vector_only_over_an_empty_index_still_errors() {
1135 let (_dir, g) = open_tmp();
1136 g.add_node("Doc", &json!({"body": "quantum"})).unwrap();
1137 g.update(|txn| txn.create_node_text_index("Doc", "body"))
1138 .unwrap();
1139
1140 let err = retrieve_hybrid(
1141 &g,
1142 &[1.0f32, 0.0],
1143 "",
1144 &HybridRetrieveOptions {
1145 vector_k: 5,
1146 text_k: 5,
1147 hops: 0,
1148 ..Default::default()
1149 },
1150 )
1151 .unwrap_err();
1152 assert!(
1153 matches!(
1154 err,
1155 RetrievalError::Vector(issundb_vector::VectorError::EmptyIndex)
1156 ),
1157 "got {err:?}"
1158 );
1159 }
1160
1161 #[test]
1165 fn retrieve_expands_over_incoming_edges() {
1166 let (_dir, g) = open_tmp();
1167 let entity = g.add_node("Entity", &json!({})).unwrap();
1168 let chunk = g.add_node("Chunk", &json!({})).unwrap();
1169 let e = g.add_edge(chunk, entity, "MENTIONS", &json!({})).unwrap();
1170 g.upsert_vector(entity, &[1.0f32, 0.0]).unwrap();
1171
1172 let sub = retrieve(&g, &[1.0f32, 0.0], 1, 1).unwrap();
1173 let mut nodes = sub.nodes.clone();
1174 nodes.sort_unstable();
1175 assert_eq!(nodes, vec![entity, chunk]);
1176 assert_eq!(sub.edges, vec![e], "the edge into the seed is collected");
1177 assert!(!sub.truncated);
1178 assert!(sub.scores.contains_key(&entity));
1179 assert!(
1180 !sub.scores.contains_key(&chunk),
1181 "expansion-only nodes carry no score"
1182 );
1183 }
1184
1185 #[test]
1188 fn retrieve_expands_incoming_chain_to_depth() {
1189 let (_dir, g) = open_tmp();
1190 let a = g.add_node("N", &json!({})).unwrap();
1191 let b = g.add_node("N", &json!({})).unwrap();
1192 let c = g.add_node("N", &json!({})).unwrap();
1193 g.add_edge(a, b, "E", &json!({})).unwrap();
1194 g.add_edge(b, c, "E", &json!({})).unwrap();
1195 g.upsert_vector(c, &[1.0f32, 0.0]).unwrap();
1196
1197 let sub1 = retrieve(&g, &[1.0f32, 0.0], 1, 1).unwrap();
1198 let mut n1 = sub1.nodes.clone();
1199 n1.sort_unstable();
1200 assert_eq!(n1, vec![b, c]);
1201
1202 let sub2 = retrieve(&g, &[1.0f32, 0.0], 1, 2).unwrap();
1203 let mut n2 = sub2.nodes.clone();
1204 n2.sort_unstable();
1205 assert_eq!(n2, vec![a, b, c]);
1206 }
1207
1208 #[test]
1211 fn retrieve_undirected_expansion_caps_and_reports_truncation() {
1212 let (_dir, g) = open_tmp();
1213 let hub = g.add_node("N", &json!({})).unwrap();
1214 for _ in 0..4 {
1215 let leaf = g.add_node("N", &json!({})).unwrap();
1216 g.add_edge(leaf, hub, "E", &json!({})).unwrap();
1217 }
1218 g.upsert_vector(hub, &[1.0f32, 0.0]).unwrap();
1219
1220 let sub = retrieve_with(
1221 &g,
1222 &[1.0f32, 0.0],
1223 &RetrieveOptions {
1224 k: 1,
1225 hops: 1,
1226 max_distance: f32::MAX,
1227 max_nodes: Some(3),
1228 },
1229 )
1230 .unwrap();
1231
1232 assert!(sub.nodes.len() <= 3);
1233 assert!(sub.nodes.contains(&hub), "the seed survives the cap");
1234 assert!(
1235 sub.truncated,
1236 "the cap dropped reachable incoming neighbors"
1237 );
1238 }
1239
1240 #[test]
1243 fn hybrid_retrieve_expands_over_incoming_edges() {
1244 let (_dir, g) = open_tmp();
1245 let entity = g
1246 .add_node("Entity", &json!({"name": "cassava root"}))
1247 .unwrap();
1248 let chunk = g.add_node("Chunk", &json!({})).unwrap();
1249 let e = g.add_edge(chunk, entity, "MENTIONS", &json!({})).unwrap();
1250 g.update(|txn| txn.create_node_text_index("Entity", "name"))
1251 .unwrap();
1252
1253 let sub = retrieve_hybrid(
1254 &g,
1255 &[],
1256 "cassava",
1257 &HybridRetrieveOptions {
1258 vector_k: 0,
1259 text_k: 5,
1260 hops: 1,
1261 ..Default::default()
1262 },
1263 )
1264 .unwrap();
1265
1266 let mut nodes = sub.nodes.clone();
1267 nodes.sort_unstable();
1268 assert_eq!(nodes, vec![entity, chunk]);
1269 assert_eq!(sub.edges, vec![e]);
1270 }
1271
1272 #[test]
1273 fn hybrid_retrieve_text_only_returns_text_seeds() {
1274 let (_dir, g) = open_tmp();
1275 let a = g
1276 .add_node("Doc", &json!({"body": "quantum computing research"}))
1277 .unwrap();
1278 let b = g
1279 .add_node("Doc", &json!({"body": "classical music orchestra"}))
1280 .unwrap();
1281 g.update(|txn| txn.create_node_text_index("Doc", "body"))
1282 .unwrap();
1283 g.rebuild_csr().unwrap();
1284
1285 let sub = retrieve_hybrid(
1287 &g,
1288 &[],
1289 "quantum",
1290 &HybridRetrieveOptions {
1291 vector_k: 0,
1292 text_k: 5,
1293 text_label: Some("Doc".into()),
1294 text_property: Some("body".into()),
1295 hops: 0,
1296 ..Default::default()
1297 },
1298 )
1299 .unwrap();
1300
1301 assert_eq!(
1302 sub.nodes.len(),
1303 1,
1304 "only the text-matching node should appear"
1305 );
1306 assert_eq!(sub.nodes[0], a);
1307 assert!(sub.scores.contains_key(&a));
1308 assert!(!sub.nodes.contains(&b), "non-matching node must be absent");
1309 }
1310}