Skip to main content

sqlite_graphrag/commands/
related.rs

1//! Handler for the `related` CLI subcommand.
2
3use crate::constants::{
4    DEFAULT_K_RECALL, DEFAULT_MAX_HOPS, DEFAULT_MIN_WEIGHT, TEXT_DESCRIPTION_PREVIEW_LEN,
5};
6use crate::errors::AppError;
7use crate::i18n::errors_msg;
8use crate::output::{self, OutputFormat};
9use crate::paths::AppPaths;
10use crate::storage::connection::open_ro;
11use rusqlite::{params, Connection};
12use serde::Serialize;
13use std::collections::{HashSet, VecDeque};
14
15/// Identifies whether the seed resolved to a memory or a bare entity.
16enum SeedKind {
17    Memory(i64),
18    Entity(i64),
19}
20
21/// Tuple returned by the adjacency fetch: (neighbour_entity_id, source_name,
22/// target_name, relation, weight).
23type Neighbour = (i64, String, String, String, f64);
24
25#[derive(clap::Args)]
26#[command(after_long_help = "EXAMPLES:\n  \
27    # List memories connected to a memory via the entity graph (default 2 hops)\n  \
28    sqlite-graphrag related onboarding\n\n  \
29    # Increase hop distance and filter by relation type\n  \
30    sqlite-graphrag related onboarding --max-hops 3 --relation related\n\n  \
31    # Cap result count and require minimum edge weight\n  \
32    sqlite-graphrag related onboarding --limit 5 --min-weight 0.5")]
33/// Related args.
34pub struct RelatedArgs {
35    /// Memory name as a positional argument. Alternative to `--name`.
36    #[arg(
37        value_name = "NAME",
38        conflicts_with = "name",
39        help = "Memory name whose neighbours to traverse; alternative to --name"
40    )]
41    pub name_positional: Option<String>,
42    /// Memory name as a flag. Required when the positional form is absent. Also accepts the alias `--from`.
43    #[arg(long, alias = "from")]
44    pub name: Option<String>,
45    /// Maximum graph hop count. Also accepts the alias `--hops`.
46    #[arg(long, alias = "hops", default_value_t = DEFAULT_MAX_HOPS)]
47    pub max_hops: u32,
48    /// Filter results to a specific relation type. Canonical values:
49    /// applies-to, uses, depends-on, causes, fixes, contradicts, supports,
50    /// follows, related, mentions, replaces, tracked-in.
51    /// Any kebab-case or snake_case string is also accepted as a custom relation.
52    #[arg(long, value_parser = crate::parsers::parse_relation)]
53    pub relation: Option<String>,
54    /// Min weight.
55    #[arg(long, default_value_t = DEFAULT_MIN_WEIGHT)]
56    pub min_weight: f64,
57    /// Maximum number of items.
58    #[arg(long, default_value_t = DEFAULT_K_RECALL)]
59    pub limit: usize,
60    /// Namespace scope.
61    #[arg(long)]
62    pub namespace: Option<String>,
63    /// Output format.
64    #[arg(long, value_enum, default_value = "json")]
65    pub format: OutputFormat,
66    /// Emit machine-readable JSON on stdout.
67    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
68    pub json: bool,
69    /// Path to the SQLite database file.
70    #[arg(long)]
71    pub db: Option<String>,
72}
73
74#[derive(Serialize)]
75struct RelatedResponse {
76    /// Echo of the seed memory name resolved from `--name` or the positional argument.
77    /// Added in v1.0.35 for input transparency in JSON output.
78    name: String,
79    /// Echo of the resolved `--max-hops` value (default 2). Added in v1.0.35.
80    max_hops: u32,
81    results: Vec<RelatedMemory>,
82    /// Semantic alias of `results` following the v1.0.66 alias pattern (list has items/memories).
83    related_memories: Vec<RelatedMemory>,
84    elapsed_ms: u64,
85}
86
87#[derive(Serialize, Clone)]
88struct RelatedMemory {
89    memory_id: i64,
90    name: String,
91    namespace: String,
92    #[serde(rename = "type")]
93    memory_type: String,
94    description: String,
95    hop_distance: u32,
96    source_entity: Option<String>,
97    target_entity: Option<String>,
98    /// Alias of `source_entity` for cross-command consistency (graph, link, deep-research use from/to).
99    #[serde(skip_serializing_if = "Option::is_none")]
100    from: Option<String>,
101    /// Alias of `target_entity` for cross-command consistency.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    to: Option<String>,
104    relation: Option<String>,
105    weight: Option<f64>,
106}
107
108/// Run.
109pub fn run(args: RelatedArgs) -> Result<(), AppError> {
110    let inicio = std::time::Instant::now();
111    let name = args
112        .name_positional
113        .as_deref()
114        .or(args.name.as_deref())
115        .ok_or_else(|| {
116            AppError::Validation(
117                "name required: pass as positional argument or via --name".to_string(),
118            )
119        })?
120        .to_string();
121
122    if name.trim().is_empty() {
123        return Err(AppError::Validation(
124            crate::i18n::validation::name_must_not_be_empty(),
125        ));
126    }
127
128    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
129    let paths = AppPaths::resolve(args.db.as_deref())?;
130
131    crate::storage::connection::ensure_db_ready(&paths)?;
132
133    let conn = open_ro(&paths.db)?;
134
135    // Locate the seed: try memory first, fall back to bare entity.
136    let seed = match conn.query_row(
137        "SELECT id FROM memories WHERE namespace = ?1 AND name = ?2 AND deleted_at IS NULL",
138        params![namespace, name],
139        |r| r.get::<_, i64>(0),
140    ) {
141        Ok(id) => SeedKind::Memory(id),
142        Err(rusqlite::Error::QueryReturnedNoRows) => {
143            match crate::storage::entities::find_entity_id(&conn, &namespace, &name)? {
144                Some(id) => SeedKind::Entity(id),
145                None => {
146                    return Err(AppError::NotFound(errors_msg::memory_or_entity_not_found(
147                        &name, &namespace,
148                    )))
149                }
150            }
151        }
152        Err(e) => return Err(AppError::Database(e)),
153    };
154
155    // Collect seed entity IDs depending on seed kind.
156    let (seed_memory_id, seed_entity_ids): (i64, Vec<i64>) = match &seed {
157        SeedKind::Memory(id) => {
158            let mem_id = *id;
159            let mut stmt =
160                conn.prepare_cached("SELECT entity_id FROM memory_entities WHERE memory_id = ?1")?;
161            let rows: Vec<i64> = stmt
162                .query_map(params![mem_id], |r| r.get(0))?
163                .collect::<Result<Vec<i64>, _>>()?;
164            (mem_id, rows)
165        }
166        SeedKind::Entity(entity_id) => {
167            // For a bare entity seed there is no corresponding memory to skip.
168            // Use a sentinel -1 so dedup never matches a real memory_id.
169            (-1, vec![*entity_id])
170        }
171    };
172
173    let relation_filter = args.relation;
174    if let Some(ref r) = relation_filter {
175        crate::parsers::warn_if_non_canonical(r);
176    }
177    let results = traverse_related(
178        &conn,
179        seed_memory_id,
180        &seed_entity_ids,
181        &namespace,
182        args.max_hops,
183        args.min_weight,
184        relation_filter.as_deref(),
185        args.limit,
186    )?;
187
188    match args.format {
189        OutputFormat::Json => {
190            let related_memories = results.clone();
191            output::emit_json(&RelatedResponse {
192                name: name.clone(),
193                max_hops: args.max_hops,
194                results,
195                related_memories,
196                elapsed_ms: inicio.elapsed().as_millis() as u64,
197            })?;
198        }
199        OutputFormat::Text => {
200            for item in &results {
201                if item.description.is_empty() {
202                    output::emit_text(&format!(
203                        "{}. {} ({})",
204                        item.hop_distance, item.name, item.namespace
205                    ));
206                } else {
207                    let preview: String = item
208                        .description
209                        .chars()
210                        .take(TEXT_DESCRIPTION_PREVIEW_LEN)
211                        .collect();
212                    output::emit_text(&format!(
213                        "{}. {} ({}): {}",
214                        item.hop_distance, item.name, item.namespace, preview
215                    ));
216                }
217            }
218        }
219        OutputFormat::Markdown => {
220            for item in &results {
221                if item.description.is_empty() {
222                    output::emit_text(&format!(
223                        "- **{}** ({}) — hop {}",
224                        item.name, item.namespace, item.hop_distance
225                    ));
226                } else {
227                    let preview: String = item
228                        .description
229                        .chars()
230                        .take(TEXT_DESCRIPTION_PREVIEW_LEN)
231                        .collect();
232                    output::emit_text(&format!(
233                        "- **{}** ({}) — hop {}: {}",
234                        item.name, item.namespace, item.hop_distance, preview
235                    ));
236                }
237            }
238        }
239    }
240
241    Ok(())
242}
243
244#[allow(clippy::too_many_arguments)]
245fn traverse_related(
246    conn: &Connection,
247    seed_memory_id: i64,
248    seed_entity_ids: &[i64],
249    namespace: &str,
250    max_hops: u32,
251    min_weight: f64,
252    relation_filter: Option<&str>,
253    limit: usize,
254) -> Result<Vec<RelatedMemory>, AppError> {
255    if seed_entity_ids.is_empty() || max_hops == 0 {
256        return Ok(Vec::new());
257    }
258
259    // BFS over entities keeping track of hop distance and the (source, target, relation, weight)
260    // of the edge that first reached each entity.
261    let mut visited: HashSet<i64> = seed_entity_ids.iter().copied().collect();
262    let mut entity_hop: crate::hash::AHashMap<i64, u32> =
263        crate::hash::AHashMap::with_capacity_and_hasher(max_hops as usize * 10, Default::default());
264    for &e in seed_entity_ids {
265        entity_hop.insert(e, 0);
266    }
267    // Per-entity edge info: source_name, target_name, relation, weight (captures the FIRST edge
268    // that reached this entity — equivalent to BFS shortest path recall edge).
269    let mut entity_edge: crate::hash::AHashMap<i64, (String, String, String, f64)> =
270        crate::hash::AHashMap::with_capacity_and_hasher(max_hops as usize * 10, Default::default());
271
272    let mut queue: VecDeque<i64> = seed_entity_ids.iter().copied().collect();
273
274    while let Some(current_entity) = queue.pop_front() {
275        let current_hop = *entity_hop.get(&current_entity).unwrap_or(&0);
276        if current_hop >= max_hops {
277            continue;
278        }
279
280        let neighbours =
281            fetch_neighbours(conn, current_entity, namespace, min_weight, relation_filter)?;
282
283        for (neighbour_id, source_name, target_name, relation, weight) in neighbours {
284            if visited.insert(neighbour_id) {
285                entity_hop.insert(neighbour_id, current_hop + 1);
286                entity_edge.insert(neighbour_id, (source_name, target_name, relation, weight));
287                queue.push_back(neighbour_id);
288            }
289        }
290    }
291
292    // For each discovered entity (hop >= 1) find its memories, skipping the seed memory.
293    let mut out: Vec<RelatedMemory> = Vec::with_capacity(limit);
294    let mut dedup_ids: crate::hash::AHashSet<i64> =
295        crate::hash::AHashSet::with_capacity_and_hasher(limit, Default::default());
296    dedup_ids.insert(seed_memory_id);
297
298    // Sort entities by hop ASC, weight DESC so we emit closer entities first.
299    let mut ordered_entities: Vec<(i64, u32)> = entity_hop
300        .iter()
301        .filter(|(id, _)| !seed_entity_ids.contains(id))
302        .map(|(id, hop)| (*id, *hop))
303        .collect();
304    ordered_entities.sort_by(|a, b| {
305        let weight_a = entity_edge.get(&a.0).map(|e| e.3).unwrap_or(0.0);
306        let weight_b = entity_edge.get(&b.0).map(|e| e.3).unwrap_or(0.0);
307        a.1.cmp(&b.1).then_with(|| {
308            weight_b
309                .partial_cmp(&weight_a)
310                .unwrap_or(std::cmp::Ordering::Equal)
311        })
312    });
313
314    for (entity_id, hop) in ordered_entities {
315        let mut stmt = conn.prepare_cached(
316            "SELECT m.id, m.name, m.namespace, m.type, m.description
317             FROM memory_entities me
318             JOIN memories m ON m.id = me.memory_id
319             WHERE me.entity_id = ?1 AND m.deleted_at IS NULL",
320        )?;
321        let rows = stmt
322            .query_map(params![entity_id], |r| {
323                Ok((
324                    r.get::<_, i64>(0)?,
325                    r.get::<_, String>(1)?,
326                    r.get::<_, String>(2)?,
327                    r.get::<_, String>(3)?,
328                    r.get::<_, String>(4)?,
329                ))
330            })?
331            .collect::<Result<Vec<_>, _>>()?;
332
333        for (mid, name, ns, mtype, desc) in rows {
334            if !dedup_ids.insert(mid) {
335                continue;
336            }
337            let edge = entity_edge.get(&entity_id);
338            let src = edge.map(|e| e.0.clone());
339            let tgt = edge.map(|e| e.1.clone());
340            out.push(RelatedMemory {
341                memory_id: mid,
342                name,
343                namespace: ns,
344                memory_type: mtype,
345                description: desc,
346                hop_distance: hop,
347                source_entity: src.clone(),
348                target_entity: tgt.clone(),
349                from: src,
350                to: tgt,
351                relation: edge.map(|e| e.2.clone()),
352                weight: edge.map(|e| e.3),
353            });
354            if out.len() >= limit {
355                return Ok(out);
356            }
357        }
358    }
359    Ok(out)
360}
361
362fn fetch_neighbours(
363    conn: &Connection,
364    entity_id: i64,
365    namespace: &str,
366    min_weight: f64,
367    relation_filter: Option<&str>,
368) -> Result<Vec<Neighbour>, AppError> {
369    // Follow edges in both directions: source -> target and target -> source so traversal is
370    // undirected, which is how users typically reason about "related" memories.
371    let base_sql = "\
372        SELECT r.target_id, se.name, te.name, r.relation, r.weight
373        FROM relationships r
374        JOIN entities se ON se.id = r.source_id
375        JOIN entities te ON te.id = r.target_id
376        WHERE r.source_id = ?1 AND r.weight >= ?2 AND r.namespace = ?3";
377
378    let reverse_sql = "\
379        SELECT r.source_id, se.name, te.name, r.relation, r.weight
380        FROM relationships r
381        JOIN entities se ON se.id = r.source_id
382        JOIN entities te ON te.id = r.target_id
383        WHERE r.target_id = ?1 AND r.weight >= ?2 AND r.namespace = ?3";
384
385    let mut results: Vec<Neighbour> = Vec::with_capacity(16);
386
387    let forward_sql = match relation_filter {
388        Some(_) => format!("{base_sql} AND r.relation = ?4"),
389        None => base_sql.to_string(),
390    };
391    let rev_sql = match relation_filter {
392        Some(_) => format!("{reverse_sql} AND r.relation = ?4"),
393        None => reverse_sql.to_string(),
394    };
395
396    let mut stmt = conn.prepare_cached(&forward_sql)?;
397    let rows: Vec<_> = if let Some(rel) = relation_filter {
398        stmt.query_map(params![entity_id, min_weight, namespace, rel], |r| {
399            Ok((
400                r.get::<_, i64>(0)?,
401                r.get::<_, String>(1)?,
402                r.get::<_, String>(2)?,
403                r.get::<_, String>(3)?,
404                r.get::<_, f64>(4)?,
405            ))
406        })?
407        .collect::<Result<Vec<_>, _>>()?
408    } else {
409        stmt.query_map(params![entity_id, min_weight, namespace], |r| {
410            Ok((
411                r.get::<_, i64>(0)?,
412                r.get::<_, String>(1)?,
413                r.get::<_, String>(2)?,
414                r.get::<_, String>(3)?,
415                r.get::<_, f64>(4)?,
416            ))
417        })?
418        .collect::<Result<Vec<_>, _>>()?
419    };
420    results.extend(rows);
421
422    let mut stmt = conn.prepare_cached(&rev_sql)?;
423    let rows: Vec<_> = if let Some(rel) = relation_filter {
424        stmt.query_map(params![entity_id, min_weight, namespace, rel], |r| {
425            Ok((
426                r.get::<_, i64>(0)?,
427                r.get::<_, String>(1)?,
428                r.get::<_, String>(2)?,
429                r.get::<_, String>(3)?,
430                r.get::<_, f64>(4)?,
431            ))
432        })?
433        .collect::<Result<Vec<_>, _>>()?
434    } else {
435        stmt.query_map(params![entity_id, min_weight, namespace], |r| {
436            Ok((
437                r.get::<_, i64>(0)?,
438                r.get::<_, String>(1)?,
439                r.get::<_, String>(2)?,
440                r.get::<_, String>(3)?,
441                r.get::<_, f64>(4)?,
442            ))
443        })?
444        .collect::<Result<Vec<_>, _>>()?
445    };
446    results.extend(rows);
447
448    Ok(results)
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    fn setup_related_db() -> rusqlite::Connection {
456        let conn = rusqlite::Connection::open_in_memory().expect("failed to open in-memory db");
457        conn.execute_batch(
458            "CREATE TABLE memories (
459                id INTEGER PRIMARY KEY AUTOINCREMENT,
460                name TEXT NOT NULL,
461                namespace TEXT NOT NULL DEFAULT 'global',
462                type TEXT NOT NULL DEFAULT 'fact',
463                description TEXT NOT NULL DEFAULT '',
464                deleted_at INTEGER
465            );
466            CREATE TABLE entities (
467                id INTEGER PRIMARY KEY AUTOINCREMENT,
468                namespace TEXT NOT NULL,
469                name TEXT NOT NULL
470            );
471            CREATE TABLE relationships (
472                id INTEGER PRIMARY KEY AUTOINCREMENT,
473                namespace TEXT NOT NULL,
474                source_id INTEGER NOT NULL,
475                target_id INTEGER NOT NULL,
476                relation TEXT NOT NULL DEFAULT 'related_to',
477                weight REAL NOT NULL DEFAULT 1.0
478            );
479            CREATE TABLE memory_entities (
480                memory_id INTEGER NOT NULL,
481                entity_id INTEGER NOT NULL
482            );",
483        )
484        .expect("failed to create test tables");
485        conn
486    }
487
488    fn insert_memory(conn: &rusqlite::Connection, name: &str, namespace: &str) -> i64 {
489        conn.execute(
490            "INSERT INTO memories (name, namespace) VALUES (?1, ?2)",
491            rusqlite::params![name, namespace],
492        )
493        .expect("failed to insert memory");
494        conn.last_insert_rowid()
495    }
496
497    fn insert_entity(conn: &rusqlite::Connection, name: &str, namespace: &str) -> i64 {
498        conn.execute(
499            "INSERT INTO entities (name, namespace) VALUES (?1, ?2)",
500            rusqlite::params![name, namespace],
501        )
502        .expect("failed to insert entity");
503        conn.last_insert_rowid()
504    }
505
506    fn link_memory_entity(conn: &rusqlite::Connection, memory_id: i64, entity_id: i64) {
507        conn.execute(
508            "INSERT INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
509            rusqlite::params![memory_id, entity_id],
510        )
511        .expect("failed to link memory-entity");
512    }
513
514    fn insert_relationship(
515        conn: &rusqlite::Connection,
516        namespace: &str,
517        source_id: i64,
518        target_id: i64,
519        relation: &str,
520        weight: f64,
521    ) {
522        conn.execute(
523            "INSERT INTO relationships (namespace, source_id, target_id, relation, weight)
524             VALUES (?1, ?2, ?3, ?4, ?5)",
525            rusqlite::params![namespace, source_id, target_id, relation, weight],
526        )
527        .expect("failed to insert relationship");
528    }
529
530    #[test]
531    fn related_response_serializes_results_and_elapsed_ms() {
532        let mem = RelatedMemory {
533            memory_id: 1,
534            name: "neighbor-mem".to_string(),
535            namespace: "global".to_string(),
536            memory_type: "document".to_string(),
537            description: "desc".to_string(),
538            hop_distance: 1,
539            source_entity: Some("entity-a".to_string()),
540            target_entity: Some("entity-b".to_string()),
541            from: Some("entity-a".to_string()),
542            to: Some("entity-b".to_string()),
543            relation: Some("related_to".to_string()),
544            weight: Some(0.9),
545        };
546        let resp = RelatedResponse {
547            name: "seed-mem".to_string(),
548            max_hops: 2,
549            related_memories: vec![mem.clone()],
550            results: vec![mem],
551            elapsed_ms: 7,
552        };
553        let json = serde_json::to_value(&resp).expect("serialization failed");
554        assert!(json["results"].is_array());
555        assert_eq!(json["results"].as_array().unwrap().len(), 1);
556        assert_eq!(json["elapsed_ms"], 7u64);
557        assert_eq!(json["results"][0]["type"], "document");
558        assert_eq!(json["results"][0]["hop_distance"], 1);
559    }
560
561    #[test]
562    fn traverse_related_returns_empty_without_seed_entities() {
563        let conn = setup_related_db();
564        let result = traverse_related(&conn, 1, &[], "global", 2, 0.0, None, 10)
565            .expect("traverse_related failed");
566        assert!(result.is_empty());
567    }
568
569    #[test]
570    fn traverse_related_returns_empty_with_max_hops_zero() {
571        let conn = setup_related_db();
572        let mem_id = insert_memory(&conn, "seed", "global");
573        let ent_id = insert_entity(&conn, "global", "ent");
574        let result = traverse_related(&conn, mem_id, &[ent_id], "global", 0, 0.0, None, 10)
575            .expect("traverse_related failed");
576        assert!(result.is_empty());
577    }
578
579    #[test]
580    fn traverse_related_discovers_neighbor_memory_via_graph() {
581        let conn = setup_related_db();
582        let seed_id = insert_memory(&conn, "seed", "global");
583        let ent_a = insert_entity(&conn, "global", "ent-a");
584        let ent_b = insert_entity(&conn, "global", "ent-b");
585        let neighbor_id = insert_memory(&conn, "neighbor", "global");
586        link_memory_entity(&conn, seed_id, ent_a);
587        link_memory_entity(&conn, neighbor_id, ent_b);
588        insert_relationship(&conn, "global", ent_a, ent_b, "related_to", 1.0);
589        let result = traverse_related(&conn, seed_id, &[ent_a], "global", 2, 0.0, None, 10)
590            .expect("traverse_related failed");
591        assert_eq!(result.len(), 1);
592        assert_eq!(result[0].name, "neighbor");
593    }
594
595    #[test]
596    fn traverse_related_respects_limit() {
597        let conn = setup_related_db();
598        let seed_id = insert_memory(&conn, "seed", "global");
599        let ent_seed = insert_entity(&conn, "global", "ent-seed");
600        link_memory_entity(&conn, seed_id, ent_seed);
601        for i in 0..5 {
602            let ent_id = insert_entity(&conn, "global", &format!("ent-{i}"));
603            let mem_id = insert_memory(&conn, &format!("mem-{i}"), "global");
604            link_memory_entity(&conn, mem_id, ent_id);
605            insert_relationship(&conn, "global", ent_seed, ent_id, "related_to", 1.0);
606        }
607        let result = traverse_related(&conn, seed_id, &[ent_seed], "global", 1, 0.0, None, 3)
608            .expect("traverse_related failed");
609        assert_eq!(
610            result.len(),
611            3,
612            "limit=3 must constrain to at most 3 results"
613        );
614    }
615
616    #[test]
617    fn related_memory_optional_null_fields_serialized() {
618        let mem = RelatedMemory {
619            memory_id: 99,
620            name: "no-relation".to_string(),
621            namespace: "ns".to_string(),
622            memory_type: "concept".to_string(),
623            description: "".to_string(),
624            hop_distance: 2,
625            source_entity: None,
626            target_entity: None,
627            from: None,
628            to: None,
629            relation: None,
630            weight: None,
631        };
632        let json = serde_json::to_value(&mem).expect("serialization failed");
633        assert!(json["source_entity"].is_null());
634        assert!(json["target_entity"].is_null());
635        assert!(json["relation"].is_null());
636        assert!(json["weight"].is_null());
637        assert_eq!(json["hop_distance"], 2);
638    }
639}