oxirs_graphrag/graph/
traversal.rs1use crate::{GraphRAGResult, ScoredEntity, Triple};
4use std::collections::{HashMap, HashSet};
5
6#[derive(Debug, Clone)]
8pub struct TraversalConfig {
9 pub max_hops: usize,
11 pub max_edges_per_node: usize,
13 pub max_triples: usize,
15 pub follow_predicates: HashSet<String>,
17 pub exclude_predicates: HashSet<String>,
19 pub traverse_inverse: bool,
21}
22
23impl Default for TraversalConfig {
24 fn default() -> Self {
25 Self {
26 max_hops: 2,
27 max_edges_per_node: 50,
28 max_triples: 500,
29 follow_predicates: HashSet::new(),
30 exclude_predicates: HashSet::new(),
31 traverse_inverse: true,
32 }
33 }
34}
35
36pub struct GraphTraversal {
38 config: TraversalConfig,
39}
40
41impl Default for GraphTraversal {
42 fn default() -> Self {
43 Self::new(TraversalConfig::default())
44 }
45}
46
47impl GraphTraversal {
48 pub fn new(config: TraversalConfig) -> Self {
49 Self { config }
50 }
51
52 pub fn generate_expansion_query(&self, seeds: &[ScoredEntity]) -> String {
54 if seeds.is_empty() {
55 return String::new();
56 }
57
58 let seed_uris: Vec<String> = seeds.iter().map(|s| format!("<{}>", s.uri)).collect();
59 let values = seed_uris.join(" ");
60
61 let predicate_filter = if !self.config.exclude_predicates.is_empty() {
63 let excluded: Vec<String> = self
64 .config
65 .exclude_predicates
66 .iter()
67 .map(|p| format!("<{}>", p))
68 .collect();
69 format!("FILTER(?p NOT IN ({}))", excluded.join(", "))
70 } else {
71 String::new()
72 };
73
74 let path_pattern = match self.config.max_hops {
76 1 => "?seed ?p ?o".to_string(),
77 2 => "?seed ?p1 ?mid . ?mid ?p2 ?o".to_string(),
78 n => format!("?seed (:|!:){{1,{}}} ?o", n),
79 };
80
81 format!(
83 r#"
84CONSTRUCT {{
85 ?seed ?p ?o .
86 ?s ?p2 ?seed .
87}}
88WHERE {{
89 VALUES ?seed {{ {} }}
90 {{
91 ?seed ?p ?o .
92 {}
93 }}
94 {}
95}}
96LIMIT {}
97"#,
98 values,
99 predicate_filter,
100 if self.config.traverse_inverse {
101 "UNION { ?s ?p2 ?seed . }"
102 } else {
103 ""
104 },
105 self.config.max_triples
106 )
107 }
108
109 pub fn expand_local(
111 &self,
112 seeds: &[ScoredEntity],
113 all_triples: &[Triple],
114 ) -> GraphRAGResult<Vec<Triple>> {
115 let seed_uris: HashSet<String> = seeds.iter().map(|s| s.uri.clone()).collect();
116
117 let mut subject_index: HashMap<String, Vec<&Triple>> = HashMap::new();
119 let mut object_index: HashMap<String, Vec<&Triple>> = HashMap::new();
120
121 for triple in all_triples {
122 subject_index
123 .entry(triple.subject.clone())
124 .or_default()
125 .push(triple);
126 object_index
127 .entry(triple.object.clone())
128 .or_default()
129 .push(triple);
130 }
131
132 let mut visited: HashSet<String> = HashSet::new();
133 let mut result: Vec<Triple> = Vec::new();
134 let mut frontier: Vec<String> = seed_uris.iter().cloned().collect();
135
136 for hop in 0..self.config.max_hops {
137 if frontier.is_empty() || result.len() >= self.config.max_triples {
138 break;
139 }
140
141 let mut next_frontier: Vec<String> = Vec::new();
142
143 for node in &frontier {
144 if visited.contains(node) {
145 continue;
146 }
147 visited.insert(node.clone());
148
149 if let Some(triples) = subject_index.get(node) {
151 for triple in triples.iter().take(self.config.max_edges_per_node) {
152 if self.should_follow_predicate(&triple.predicate) {
153 result.push((*triple).clone());
154 if hop < self.config.max_hops - 1 && !visited.contains(&triple.object) {
155 next_frontier.push(triple.object.clone());
156 }
157 }
158 }
159 }
160
161 if self.config.traverse_inverse {
163 if let Some(triples) = object_index.get(node) {
164 for triple in triples.iter().take(self.config.max_edges_per_node) {
165 if self.should_follow_predicate(&triple.predicate) {
166 result.push((*triple).clone());
167 if hop < self.config.max_hops - 1
168 && !visited.contains(&triple.subject)
169 {
170 next_frontier.push(triple.subject.clone());
171 }
172 }
173 }
174 }
175 }
176
177 if result.len() >= self.config.max_triples {
178 break;
179 }
180 }
181
182 frontier = next_frontier;
183 }
184
185 let mut seen: HashSet<(String, String, String)> = HashSet::new();
187 let deduped: Vec<Triple> = result
188 .into_iter()
189 .filter(|t| {
190 let key = (t.subject.clone(), t.predicate.clone(), t.object.clone());
191 if seen.contains(&key) {
192 false
193 } else {
194 seen.insert(key);
195 true
196 }
197 })
198 .take(self.config.max_triples)
199 .collect();
200
201 Ok(deduped)
202 }
203
204 fn should_follow_predicate(&self, predicate: &str) -> bool {
206 if !self.config.follow_predicates.is_empty() {
208 return self.config.follow_predicates.contains(predicate);
209 }
210
211 !self.config.exclude_predicates.contains(predicate)
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use std::collections::HashMap;
220
221 #[test]
222 fn test_expansion_query_generation() {
223 let config = TraversalConfig {
224 max_hops: 2,
225 ..Default::default()
226 };
227 let traversal = GraphTraversal::new(config);
228
229 let seeds = vec![ScoredEntity {
230 uri: "http://example.org/entity1".to_string(),
231 score: 0.9,
232 source: crate::ScoreSource::Vector,
233 metadata: HashMap::new(),
234 }];
235
236 let query = traversal.generate_expansion_query(&seeds);
237 assert!(query.contains("http://example.org/entity1"));
238 assert!(query.contains("CONSTRUCT"));
239 }
240
241 #[test]
242 fn test_local_expansion() {
243 let traversal = GraphTraversal::default();
244
245 let seeds = vec![ScoredEntity {
246 uri: "http://a".to_string(),
247 score: 0.9,
248 source: crate::ScoreSource::Vector,
249 metadata: HashMap::new(),
250 }];
251
252 let triples = vec![
253 Triple::new("http://a", "http://rel", "http://b"),
254 Triple::new("http://b", "http://rel", "http://c"),
255 Triple::new("http://x", "http://rel", "http://y"),
256 ];
257
258 let result = traversal
259 .expand_local(&seeds, &triples)
260 .expect("should succeed");
261
262 assert!(result.iter().any(|t| t.subject == "http://a"));
264 assert!(result.iter().any(|t| t.subject == "http://b"));
265 assert!(!result.iter().any(|t| t.subject == "http://x"));
267 }
268}