sqlite_graphrag/graph/walk.rs
1//! Single bounded-BFS engine behind every graph traversal in the codebase.
2//!
3//! Four traversals used to exist side by side — directed with a weight floor,
4//! bidirectional with a weight floor, bidirectional in memory without one, and
5//! a predecessor-tracking variant — each with its own frontier handling and its
6//! own drift. They now all declare their parameters through [`GraphWalk`] and
7//! share this driver, so hop distance means the same thing everywhere.
8//!
9//! The driver is a strict FIFO breadth-first search: the depth recorded for an
10//! entity is its *minimum* distance from the seed set. A LIFO frontier would
11//! silently turn the walk into a depth-first search and report distances that
12//! are not distances.
13
14use crate::errors::AppError;
15use rusqlite::{params, Connection};
16use std::collections::{HashMap, HashSet, VecDeque};
17
18/// Which way edges are followed during the walk.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum WalkDirection {
21 /// Follow `source_id -> target_id` only.
22 Directed,
23 /// Follow edges both ways, as users reason about "related".
24 Bidirectional,
25}
26
27/// The edge through which an entity was reached.
28#[derive(Debug, Clone)]
29pub struct EdgeArrival {
30 /// Entity the edge was followed *from*.
31 pub from_id: i64,
32 /// Entity the edge was followed *to* (the discovered neighbour).
33 pub neighbor_id: i64,
34 /// `entities.name` of the edge's `source_id`, when the source was queried with names.
35 pub source_name: Option<String>,
36 /// `entities.name` of the edge's `target_id`, when the source was queried with names.
37 pub target_name: Option<String>,
38 /// Relation label carried by the edge.
39 pub relation: String,
40 /// Edge weight.
41 pub weight: f64,
42 /// `true` when the edge was traversed backwards (`target_id -> source_id`).
43 pub inbound: bool,
44}
45
46/// Traversal parameters. Every caller states its own explicitly.
47#[derive(Debug, Clone)]
48pub struct GraphWalk {
49 /// Directed or bidirectional edge following.
50 pub direction: WalkDirection,
51 /// Minimum edge weight; `None` follows every edge regardless of weight.
52 pub weight_floor: Option<f64>,
53 /// Maximum hop distance from the seeds.
54 pub max_hops: u32,
55 /// Keep only the first `k` unvisited neighbours of each expansion.
56 pub max_neighbors_per_hop: Option<usize>,
57 /// Follow only edges carrying this relation label.
58 pub relation_filter: Option<String>,
59}
60
61impl GraphWalk {
62 /// Directed walk with a weight floor — the shape used by recall, hybrid-search
63 /// and deep-research.
64 #[must_use]
65 pub fn directed(min_weight: f64, max_hops: u32) -> Self {
66 Self {
67 direction: WalkDirection::Directed,
68 weight_floor: Some(min_weight),
69 max_hops,
70 max_neighbors_per_hop: None,
71 relation_filter: None,
72 }
73 }
74
75 /// Bidirectional walk with a weight floor — the shape used by `related`.
76 #[must_use]
77 pub fn bidirectional(min_weight: f64, max_hops: u32) -> Self {
78 Self {
79 direction: WalkDirection::Bidirectional,
80 weight_floor: Some(min_weight),
81 max_hops,
82 max_neighbors_per_hop: None,
83 relation_filter: None,
84 }
85 }
86
87 /// Sets the per-expansion neighbour cap.
88 #[must_use]
89 pub fn with_neighbor_cap(mut self, cap: Option<usize>) -> Self {
90 self.max_neighbors_per_hop = cap;
91 self
92 }
93
94 /// Restricts the walk to a single relation label.
95 #[must_use]
96 pub fn with_relation_filter(mut self, relation: Option<String>) -> Self {
97 self.relation_filter = relation;
98 self
99 }
100
101 /// Runs the walk, discarding per-edge observations.
102 ///
103 /// # Errors
104 ///
105 /// Propagates [`AppError::Database`] (exit 10) on SQLite query failures.
106 pub fn run<S: NeighborSource>(
107 &self,
108 source: &S,
109 seed_entity_ids: &[i64],
110 ) -> Result<WalkOutcome, AppError> {
111 self.run_observed(source, seed_entity_ids, |_, _| {})
112 }
113
114 /// Runs the walk, invoking `on_edge(edge, depth_of_neighbour)` for **every**
115 /// edge examined — including edges that lead back to an already-visited
116 /// entity. Callers that render an edge list (`graph traverse`) need those;
117 /// callers that only need reachable entities ignore them.
118 ///
119 /// # Errors
120 ///
121 /// Propagates [`AppError::Database`] (exit 10) on SQLite query failures.
122 pub fn run_observed<S, F>(
123 &self,
124 source: &S,
125 seed_entity_ids: &[i64],
126 mut on_edge: F,
127 ) -> Result<WalkOutcome, AppError>
128 where
129 S: NeighborSource,
130 F: FnMut(&EdgeArrival, u32),
131 {
132 let mut depth: HashMap<i64, u32> = seed_entity_ids.iter().map(|&id| (id, 0)).collect();
133 let mut arrival: HashMap<i64, EdgeArrival> = HashMap::new();
134 let mut expanded: HashSet<i64> = HashSet::with_capacity(depth.len());
135 let mut queue: VecDeque<i64> = seed_entity_ids.iter().copied().collect();
136
137 // FIFO: the first time an entity is dequeued it carries its minimum depth.
138 while let Some(current) = queue.pop_front() {
139 let current_depth = depth.get(¤t).copied().unwrap_or(0);
140 if current_depth >= self.max_hops || !expanded.insert(current) {
141 continue;
142 }
143 let next_depth = current_depth + 1;
144
145 let neighbors = source.neighbors(current, self)?;
146
147 // Cap counts only unvisited candidates, matching the pre-unification
148 // behaviour of the capped deep-research walk.
149 let mut admitted = 0usize;
150 for edge in neighbors {
151 on_edge(&edge, next_depth);
152
153 if depth.contains_key(&edge.neighbor_id) {
154 continue;
155 }
156 if let Some(cap) = self.max_neighbors_per_hop {
157 if admitted >= cap {
158 continue;
159 }
160 }
161 admitted += 1;
162 depth.insert(edge.neighbor_id, next_depth);
163 queue.push_back(edge.neighbor_id);
164 arrival.insert(edge.neighbor_id, edge);
165 }
166 }
167
168 Ok(WalkOutcome { depth, arrival })
169 }
170}
171
172/// The same relation written in the other convention.
173///
174/// Only the multi-word relations differ at all: `applies-to` ↔ `applies_to`,
175/// `depends-on`, `tracked-in`. For every single-word relation this returns the
176/// input unchanged, so the `IN (?4, ?5)` filter degenerates to the equality it
177/// replaced and costs nothing.
178///
179/// Deliberately NOT `parsers::normalize_relation`: that function converges on
180/// the canonical spelling, and reaching legacy rows requires the divergent one.
181fn alternate_relation_spelling(relation: &str) -> String {
182 if relation.contains('-') {
183 relation.replace('-', "_")
184 } else {
185 relation.replace('_', "-")
186 }
187}
188
189/// Result of a walk.
190pub struct WalkOutcome {
191 /// entity_id → minimum hop distance from the seed set (seeds map to 0).
192 pub depth: HashMap<i64, u32>,
193 /// entity_id → the edge that first reached it. Seeds are absent.
194 pub arrival: HashMap<i64, EdgeArrival>,
195}
196
197/// Supplies the neighbours of an entity to the walk driver.
198pub trait NeighborSource {
199 /// Returns the neighbours of `entity_id` honouring `walk`'s direction,
200 /// weight floor and relation filter.
201 ///
202 /// # Errors
203 ///
204 /// Propagates [`AppError::Database`] (exit 10) on SQLite query failures.
205 fn neighbors(&self, entity_id: i64, walk: &GraphWalk) -> Result<Vec<EdgeArrival>, AppError>;
206}
207
208/// Neighbours read straight from the `relationships` table.
209pub struct SqlNeighbors<'a> {
210 conn: &'a Connection,
211 namespace: &'a str,
212 with_names: bool,
213}
214
215impl<'a> SqlNeighbors<'a> {
216 /// Reads neighbours without joining `entities`; edge names stay `None`.
217 #[must_use]
218 pub fn new(conn: &'a Connection, namespace: &'a str) -> Self {
219 Self {
220 conn,
221 namespace,
222 with_names: false,
223 }
224 }
225
226 /// Reads neighbours joining `entities` so edge endpoint names are populated.
227 #[must_use]
228 pub fn with_names(conn: &'a Connection, namespace: &'a str) -> Self {
229 Self {
230 conn,
231 namespace,
232 with_names: true,
233 }
234 }
235
236 fn query(
237 &self,
238 entity_id: i64,
239 walk: &GraphWalk,
240 inbound: bool,
241 ) -> Result<Vec<EdgeArrival>, AppError> {
242 let pivot = if inbound { "target_id" } else { "source_id" };
243 let reached = if inbound { "source_id" } else { "target_id" };
244
245 let mut sql = if self.with_names {
246 format!(
247 "SELECT r.{reached}, se.name, te.name, r.relation, r.weight
248 FROM relationships r
249 JOIN entities se ON se.id = r.source_id
250 JOIN entities te ON te.id = r.target_id
251 WHERE r.{pivot} = ?1 AND r.weight >= ?2 AND r.namespace = ?3"
252 )
253 } else {
254 format!(
255 "SELECT r.{reached}, r.relation, r.weight FROM relationships r
256 WHERE r.{pivot} = ?1 AND r.weight >= ?2 AND r.namespace = ?3"
257 )
258 };
259 // v1.2.8: the filter matches BOTH spellings of the same relation.
260 //
261 // Callers hand this an already-normalised label, and until v1.2.8 the
262 // crate normalised toward snake_case while the bulk write path stored
263 // kebab-case. `related --relation applies-to` therefore returned zero
264 // rows with exit 0 on a hub that has `applies-to` edges: the filter was
265 // blind to 95% of the graph and reported that blindness as an empty
266 // result. Canonicalising the vocabulary fixes new writes; this line
267 // reaches the ~3 578 rows already stored the other way, and any
268 // database written by an older binary.
269 if walk.relation_filter.is_some() {
270 sql.push_str(" AND r.relation IN (?4, ?5)");
271 }
272 // A directed walk prunes by weight, so the strongest edges must come
273 // first for `max_neighbors_per_hop` to keep the strongest ones.
274 if walk.direction == WalkDirection::Directed {
275 sql.push_str(" ORDER BY r.weight DESC");
276 }
277
278 let floor = walk.weight_floor.unwrap_or(f64::NEG_INFINITY);
279 let mut stmt = self.conn.prepare_cached(&sql)?;
280
281 let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<EdgeArrival> {
282 let (neighbor_id, source_name, target_name, relation, weight) = if self.with_names {
283 (
284 row.get::<_, i64>(0)?,
285 Some(row.get::<_, String>(1)?),
286 Some(row.get::<_, String>(2)?),
287 row.get::<_, String>(3)?,
288 row.get::<_, f64>(4)?,
289 )
290 } else {
291 (
292 row.get::<_, i64>(0)?,
293 None,
294 None,
295 row.get::<_, String>(1)?,
296 row.get::<_, f64>(2)?,
297 )
298 };
299 Ok(EdgeArrival {
300 from_id: entity_id,
301 neighbor_id,
302 source_name,
303 target_name,
304 relation,
305 weight,
306 inbound,
307 })
308 };
309
310 let rows = match walk.relation_filter.as_deref() {
311 Some(rel) => stmt
312 .query_map(
313 params![
314 entity_id,
315 floor,
316 self.namespace,
317 rel,
318 alternate_relation_spelling(rel)
319 ],
320 map_row,
321 )?
322 .filter_map(std::result::Result::ok)
323 .collect(),
324 None => stmt
325 .query_map(params![entity_id, floor, self.namespace], map_row)?
326 .filter_map(std::result::Result::ok)
327 .collect(),
328 };
329 Ok(rows)
330 }
331}
332
333impl NeighborSource for SqlNeighbors<'_> {
334 fn neighbors(&self, entity_id: i64, walk: &GraphWalk) -> Result<Vec<EdgeArrival>, AppError> {
335 let mut out = self.query(entity_id, walk, false)?;
336 if walk.direction == WalkDirection::Bidirectional {
337 out.extend(self.query(entity_id, walk, true)?);
338 }
339 Ok(out)
340 }
341}
342
343/// A relationship already loaded in memory.
344#[derive(Debug, Clone)]
345pub struct MemoryEdge {
346 /// Edge source entity id.
347 pub source_id: i64,
348 /// Edge target entity id.
349 pub target_id: i64,
350 /// Relation label.
351 pub relation: String,
352 /// Edge weight.
353 pub weight: f64,
354}
355
356/// Neighbours resolved against an in-memory edge list.
357///
358/// `graph export`/`graph traverse` already load the whole namespace to render
359/// nodes and edges, so re-querying SQLite per hop would be pure waste.
360pub struct InMemoryNeighbors<'a> {
361 edges: &'a [MemoryEdge],
362 id_to_name: &'a HashMap<i64, String>,
363}
364
365impl<'a> InMemoryNeighbors<'a> {
366 /// Builds a source over a preloaded edge list and its id→name index.
367 #[must_use]
368 pub fn new(edges: &'a [MemoryEdge], id_to_name: &'a HashMap<i64, String>) -> Self {
369 Self { edges, id_to_name }
370 }
371}
372
373impl NeighborSource for InMemoryNeighbors<'_> {
374 fn neighbors(&self, entity_id: i64, walk: &GraphWalk) -> Result<Vec<EdgeArrival>, AppError> {
375 let floor = walk.weight_floor.unwrap_or(f64::NEG_INFINITY);
376 let mut out = Vec::with_capacity(8);
377
378 for edge in self.edges {
379 if edge.weight < floor {
380 continue;
381 }
382 if let Some(rel) = walk.relation_filter.as_deref() {
383 if edge.relation != rel {
384 continue;
385 }
386 }
387 let (neighbor_id, inbound) = if edge.source_id == entity_id {
388 (edge.target_id, false)
389 } else if edge.target_id == entity_id && walk.direction == WalkDirection::Bidirectional
390 {
391 (edge.source_id, true)
392 } else {
393 continue;
394 };
395 // An edge pointing at an entity absent from the index cannot be rendered.
396 let Some(neighbor_name) = self.id_to_name.get(&neighbor_id) else {
397 continue;
398 };
399 let self_name = self.id_to_name.get(&entity_id).cloned();
400 let (source_name, target_name) = if inbound {
401 (Some(neighbor_name.clone()), self_name)
402 } else {
403 (self_name, Some(neighbor_name.clone()))
404 };
405 out.push(EdgeArrival {
406 from_id: entity_id,
407 neighbor_id,
408 source_name,
409 target_name,
410 relation: edge.relation.clone(),
411 weight: edge.weight,
412 inbound,
413 });
414 }
415 Ok(out)
416 }
417}