Skip to main content

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(&current).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}