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(crate::i18n::validation::name_must_not_be_empty()));
124    }
125
126    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
127    let paths = AppPaths::resolve(args.db.as_deref())?;
128
129    crate::storage::connection::ensure_db_ready(&paths)?;
130
131    let conn = open_ro(&paths.db)?;
132
133    // Locate the seed: try memory first, fall back to bare entity.
134    let seed = match conn.query_row(
135        "SELECT id FROM memories WHERE namespace = ?1 AND name = ?2 AND deleted_at IS NULL",
136        params![namespace, name],
137        |r| r.get::<_, i64>(0),
138    ) {
139        Ok(id) => SeedKind::Memory(id),
140        Err(rusqlite::Error::QueryReturnedNoRows) => {
141            match crate::storage::entities::find_entity_id(&conn, &namespace, &name)? {
142                Some(id) => SeedKind::Entity(id),
143                None => {
144                    return Err(AppError::NotFound(errors_msg::memory_or_entity_not_found(
145                        &name, &namespace,
146                    )))
147                }
148            }
149        }
150        Err(e) => return Err(AppError::Database(e)),
151    };
152
153    // Collect seed entity IDs depending on seed kind.
154    let (seed_memory_id, seed_entity_ids): (i64, Vec<i64>) = match &seed {
155        SeedKind::Memory(id) => {
156            let mem_id = *id;
157            let mut stmt =
158                conn.prepare_cached("SELECT entity_id FROM memory_entities WHERE memory_id = ?1")?;
159            let rows: Vec<i64> = stmt
160                .query_map(params![mem_id], |r| r.get(0))?
161                .collect::<Result<Vec<i64>, _>>()?;
162            (mem_id, rows)
163        }
164        SeedKind::Entity(entity_id) => {
165            // For a bare entity seed there is no corresponding memory to skip.
166            // Use a sentinel -1 so dedup never matches a real memory_id.
167            (-1, vec![*entity_id])
168        }
169    };
170
171    let relation_filter = args.relation;
172    if let Some(ref r) = relation_filter {
173        crate::parsers::warn_if_non_canonical(r);
174    }
175    let results = traverse_related(
176        &conn,
177        seed_memory_id,
178        &seed_entity_ids,
179        &namespace,
180        args.max_hops,
181        args.min_weight,
182        relation_filter.as_deref(),
183        args.limit,
184    )?;
185
186    match args.format {
187        OutputFormat::Json => {
188            let related_memories = results.clone();
189            output::emit_json(&RelatedResponse {
190                name: name.clone(),
191                max_hops: args.max_hops,
192                results,
193                related_memories,
194                elapsed_ms: inicio.elapsed().as_millis() as u64,
195            })?;
196        }
197        OutputFormat::Text => {
198            for item in &results {
199                if item.description.is_empty() {
200                    output::emit_text(&format!(
201                        "{}. {} ({})",
202                        item.hop_distance, item.name, item.namespace
203                    ));
204                } else {
205                    let preview: String = item
206                        .description
207                        .chars()
208                        .take(TEXT_DESCRIPTION_PREVIEW_LEN)
209                        .collect();
210                    output::emit_text(&format!(
211                        "{}. {} ({}): {}",
212                        item.hop_distance, item.name, item.namespace, preview
213                    ));
214                }
215            }
216        }
217        OutputFormat::Markdown => {
218            for item in &results {
219                if item.description.is_empty() {
220                    output::emit_text(&format!(
221                        "- **{}** ({}) — hop {}",
222                        item.name, item.namespace, item.hop_distance
223                    ));
224                } else {
225                    let preview: String = item
226                        .description
227                        .chars()
228                        .take(TEXT_DESCRIPTION_PREVIEW_LEN)
229                        .collect();
230                    output::emit_text(&format!(
231                        "- **{}** ({}) — hop {}: {}",
232                        item.name, item.namespace, item.hop_distance, preview
233                    ));
234                }
235            }
236        }
237    }
238
239    Ok(())
240}
241
242#[allow(clippy::too_many_arguments)]
243fn traverse_related(
244    conn: &Connection,
245    seed_memory_id: i64,
246    seed_entity_ids: &[i64],
247    namespace: &str,
248    max_hops: u32,
249    min_weight: f64,
250    relation_filter: Option<&str>,
251    limit: usize,
252) -> Result<Vec<RelatedMemory>, AppError> {
253    if seed_entity_ids.is_empty() || max_hops == 0 {
254        return Ok(Vec::new());
255    }
256
257    // BFS over entities keeping track of hop distance and the (source, target, relation, weight)
258    // of the edge that first reached each entity.
259    let mut visited: HashSet<i64> = seed_entity_ids.iter().copied().collect();
260    let mut entity_hop: crate::hash::AHashMap<i64, u32> =
261        crate::hash::AHashMap::with_capacity_and_hasher(max_hops as usize * 10, Default::default());
262    for &e in seed_entity_ids {
263        entity_hop.insert(e, 0);
264    }
265    // Per-entity edge info: source_name, target_name, relation, weight (captures the FIRST edge
266    // that reached this entity — equivalent to BFS shortest path recall edge).
267    let mut entity_edge: crate::hash::AHashMap<i64, (String, String, String, f64)> =
268        crate::hash::AHashMap::with_capacity_and_hasher(max_hops as usize * 10, Default::default());
269
270    let mut queue: VecDeque<i64> = seed_entity_ids.iter().copied().collect();
271
272    while let Some(current_entity) = queue.pop_front() {
273        let current_hop = *entity_hop.get(&current_entity).unwrap_or(&0);
274        if current_hop >= max_hops {
275            continue;
276        }
277
278        let neighbours =
279            fetch_neighbours(conn, current_entity, namespace, min_weight, relation_filter)?;
280
281        for (neighbour_id, source_name, target_name, relation, weight) in neighbours {
282            if visited.insert(neighbour_id) {
283                entity_hop.insert(neighbour_id, current_hop + 1);
284                entity_edge.insert(neighbour_id, (source_name, target_name, relation, weight));
285                queue.push_back(neighbour_id);
286            }
287        }
288    }
289
290    // For each discovered entity (hop >= 1) find its memories, skipping the seed memory.
291    let mut out: Vec<RelatedMemory> = Vec::with_capacity(limit);
292    let mut dedup_ids: crate::hash::AHashSet<i64> =
293        crate::hash::AHashSet::with_capacity_and_hasher(limit, Default::default());
294    dedup_ids.insert(seed_memory_id);
295
296    // Sort entities by hop ASC, weight DESC so we emit closer entities first.
297    let mut ordered_entities: Vec<(i64, u32)> = entity_hop
298        .iter()
299        .filter(|(id, _)| !seed_entity_ids.contains(id))
300        .map(|(id, hop)| (*id, *hop))
301        .collect();
302    ordered_entities.sort_by(|a, b| {
303        let weight_a = entity_edge.get(&a.0).map(|e| e.3).unwrap_or(0.0);
304        let weight_b = entity_edge.get(&b.0).map(|e| e.3).unwrap_or(0.0);
305        a.1.cmp(&b.1).then_with(|| {
306            weight_b
307                .partial_cmp(&weight_a)
308                .unwrap_or(std::cmp::Ordering::Equal)
309        })
310    });
311
312    for (entity_id, hop) in ordered_entities {
313        let mut stmt = conn.prepare_cached(
314            "SELECT m.id, m.name, m.namespace, m.type, m.description
315             FROM memory_entities me
316             JOIN memories m ON m.id = me.memory_id
317             WHERE me.entity_id = ?1 AND m.deleted_at IS NULL",
318        )?;
319        let rows = stmt
320            .query_map(params![entity_id], |r| {
321                Ok((
322                    r.get::<_, i64>(0)?,
323                    r.get::<_, String>(1)?,
324                    r.get::<_, String>(2)?,
325                    r.get::<_, String>(3)?,
326                    r.get::<_, String>(4)?,
327                ))
328            })?
329            .collect::<Result<Vec<_>, _>>()?;
330
331        for (mid, name, ns, mtype, desc) in rows {
332            if !dedup_ids.insert(mid) {
333                continue;
334            }
335            let edge = entity_edge.get(&entity_id);
336            let src = edge.map(|e| e.0.clone());
337            let tgt = edge.map(|e| e.1.clone());
338            out.push(RelatedMemory {
339                memory_id: mid,
340                name,
341                namespace: ns,
342                memory_type: mtype,
343                description: desc,
344                hop_distance: hop,
345                source_entity: src.clone(),
346                target_entity: tgt.clone(),
347                from: src,
348                to: tgt,
349                relation: edge.map(|e| e.2.clone()),
350                weight: edge.map(|e| e.3),
351            });
352            if out.len() >= limit {
353                return Ok(out);
354            }
355        }
356    }
357    Ok(out)
358}
359
360fn fetch_neighbours(
361    conn: &Connection,
362    entity_id: i64,
363    namespace: &str,
364    min_weight: f64,
365    relation_filter: Option<&str>,
366) -> Result<Vec<Neighbour>, AppError> {
367    // Follow edges in both directions: source -> target and target -> source so traversal is
368    // undirected, which is how users typically reason about "related" memories.
369    let base_sql = "\
370        SELECT r.target_id, se.name, te.name, r.relation, r.weight
371        FROM relationships r
372        JOIN entities se ON se.id = r.source_id
373        JOIN entities te ON te.id = r.target_id
374        WHERE r.source_id = ?1 AND r.weight >= ?2 AND r.namespace = ?3";
375
376    let reverse_sql = "\
377        SELECT r.source_id, se.name, te.name, r.relation, r.weight
378        FROM relationships r
379        JOIN entities se ON se.id = r.source_id
380        JOIN entities te ON te.id = r.target_id
381        WHERE r.target_id = ?1 AND r.weight >= ?2 AND r.namespace = ?3";
382
383    let mut results: Vec<Neighbour> = Vec::with_capacity(16);
384
385    let forward_sql = match relation_filter {
386        Some(_) => format!("{base_sql} AND r.relation = ?4"),
387        None => base_sql.to_string(),
388    };
389    let rev_sql = match relation_filter {
390        Some(_) => format!("{reverse_sql} AND r.relation = ?4"),
391        None => reverse_sql.to_string(),
392    };
393
394    let mut stmt = conn.prepare_cached(&forward_sql)?;
395    let rows: Vec<_> = if let Some(rel) = relation_filter {
396        stmt.query_map(params![entity_id, min_weight, namespace, rel], |r| {
397            Ok((
398                r.get::<_, i64>(0)?,
399                r.get::<_, String>(1)?,
400                r.get::<_, String>(2)?,
401                r.get::<_, String>(3)?,
402                r.get::<_, f64>(4)?,
403            ))
404        })?
405        .collect::<Result<Vec<_>, _>>()?
406    } else {
407        stmt.query_map(params![entity_id, min_weight, namespace], |r| {
408            Ok((
409                r.get::<_, i64>(0)?,
410                r.get::<_, String>(1)?,
411                r.get::<_, String>(2)?,
412                r.get::<_, String>(3)?,
413                r.get::<_, f64>(4)?,
414            ))
415        })?
416        .collect::<Result<Vec<_>, _>>()?
417    };
418    results.extend(rows);
419
420    let mut stmt = conn.prepare_cached(&rev_sql)?;
421    let rows: Vec<_> = if let Some(rel) = relation_filter {
422        stmt.query_map(params![entity_id, min_weight, namespace, rel], |r| {
423            Ok((
424                r.get::<_, i64>(0)?,
425                r.get::<_, String>(1)?,
426                r.get::<_, String>(2)?,
427                r.get::<_, String>(3)?,
428                r.get::<_, f64>(4)?,
429            ))
430        })?
431        .collect::<Result<Vec<_>, _>>()?
432    } else {
433        stmt.query_map(params![entity_id, min_weight, namespace], |r| {
434            Ok((
435                r.get::<_, i64>(0)?,
436                r.get::<_, String>(1)?,
437                r.get::<_, String>(2)?,
438                r.get::<_, String>(3)?,
439                r.get::<_, f64>(4)?,
440            ))
441        })?
442        .collect::<Result<Vec<_>, _>>()?
443    };
444    results.extend(rows);
445
446    Ok(results)
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    fn setup_related_db() -> rusqlite::Connection {
454        let conn = rusqlite::Connection::open_in_memory().expect("failed to open in-memory db");
455        conn.execute_batch(
456            "CREATE TABLE memories (
457                id INTEGER PRIMARY KEY AUTOINCREMENT,
458                name TEXT NOT NULL,
459                namespace TEXT NOT NULL DEFAULT 'global',
460                type TEXT NOT NULL DEFAULT 'fact',
461                description TEXT NOT NULL DEFAULT '',
462                deleted_at INTEGER
463            );
464            CREATE TABLE entities (
465                id INTEGER PRIMARY KEY AUTOINCREMENT,
466                namespace TEXT NOT NULL,
467                name TEXT NOT NULL
468            );
469            CREATE TABLE relationships (
470                id INTEGER PRIMARY KEY AUTOINCREMENT,
471                namespace TEXT NOT NULL,
472                source_id INTEGER NOT NULL,
473                target_id INTEGER NOT NULL,
474                relation TEXT NOT NULL DEFAULT 'related_to',
475                weight REAL NOT NULL DEFAULT 1.0
476            );
477            CREATE TABLE memory_entities (
478                memory_id INTEGER NOT NULL,
479                entity_id INTEGER NOT NULL
480            );",
481        )
482        .expect("failed to create test tables");
483        conn
484    }
485
486    fn insert_memory(conn: &rusqlite::Connection, name: &str, namespace: &str) -> i64 {
487        conn.execute(
488            "INSERT INTO memories (name, namespace) VALUES (?1, ?2)",
489            rusqlite::params![name, namespace],
490        )
491        .expect("failed to insert memory");
492        conn.last_insert_rowid()
493    }
494
495    fn insert_entity(conn: &rusqlite::Connection, name: &str, namespace: &str) -> i64 {
496        conn.execute(
497            "INSERT INTO entities (name, namespace) VALUES (?1, ?2)",
498            rusqlite::params![name, namespace],
499        )
500        .expect("failed to insert entity");
501        conn.last_insert_rowid()
502    }
503
504    fn link_memory_entity(conn: &rusqlite::Connection, memory_id: i64, entity_id: i64) {
505        conn.execute(
506            "INSERT INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
507            rusqlite::params![memory_id, entity_id],
508        )
509        .expect("failed to link memory-entity");
510    }
511
512    fn insert_relationship(
513        conn: &rusqlite::Connection,
514        namespace: &str,
515        source_id: i64,
516        target_id: i64,
517        relation: &str,
518        weight: f64,
519    ) {
520        conn.execute(
521            "INSERT INTO relationships (namespace, source_id, target_id, relation, weight)
522             VALUES (?1, ?2, ?3, ?4, ?5)",
523            rusqlite::params![namespace, source_id, target_id, relation, weight],
524        )
525        .expect("failed to insert relationship");
526    }
527
528    #[test]
529    fn related_response_serializes_results_and_elapsed_ms() {
530        let mem = RelatedMemory {
531            memory_id: 1,
532            name: "neighbor-mem".to_string(),
533            namespace: "global".to_string(),
534            memory_type: "document".to_string(),
535            description: "desc".to_string(),
536            hop_distance: 1,
537            source_entity: Some("entity-a".to_string()),
538            target_entity: Some("entity-b".to_string()),
539            from: Some("entity-a".to_string()),
540            to: Some("entity-b".to_string()),
541            relation: Some("related_to".to_string()),
542            weight: Some(0.9),
543        };
544        let resp = RelatedResponse {
545            name: "seed-mem".to_string(),
546            max_hops: 2,
547            related_memories: vec![mem.clone()],
548            results: vec![mem],
549            elapsed_ms: 7,
550        };
551        let json = serde_json::to_value(&resp).expect("serialization failed");
552        assert!(json["results"].is_array());
553        assert_eq!(json["results"].as_array().unwrap().len(), 1);
554        assert_eq!(json["elapsed_ms"], 7u64);
555        assert_eq!(json["results"][0]["type"], "document");
556        assert_eq!(json["results"][0]["hop_distance"], 1);
557    }
558
559    #[test]
560    fn traverse_related_returns_empty_without_seed_entities() {
561        let conn = setup_related_db();
562        let result = traverse_related(&conn, 1, &[], "global", 2, 0.0, None, 10)
563            .expect("traverse_related failed");
564        assert!(result.is_empty());
565    }
566
567    #[test]
568    fn traverse_related_returns_empty_with_max_hops_zero() {
569        let conn = setup_related_db();
570        let mem_id = insert_memory(&conn, "seed", "global");
571        let ent_id = insert_entity(&conn, "global", "ent");
572        let result = traverse_related(&conn, mem_id, &[ent_id], "global", 0, 0.0, None, 10)
573            .expect("traverse_related failed");
574        assert!(result.is_empty());
575    }
576
577    #[test]
578    fn traverse_related_discovers_neighbor_memory_via_graph() {
579        let conn = setup_related_db();
580        let seed_id = insert_memory(&conn, "seed", "global");
581        let ent_a = insert_entity(&conn, "global", "ent-a");
582        let ent_b = insert_entity(&conn, "global", "ent-b");
583        let neighbor_id = insert_memory(&conn, "neighbor", "global");
584        link_memory_entity(&conn, seed_id, ent_a);
585        link_memory_entity(&conn, neighbor_id, ent_b);
586        insert_relationship(&conn, "global", ent_a, ent_b, "related_to", 1.0);
587        let result = traverse_related(&conn, seed_id, &[ent_a], "global", 2, 0.0, None, 10)
588            .expect("traverse_related failed");
589        assert_eq!(result.len(), 1);
590        assert_eq!(result[0].name, "neighbor");
591    }
592
593    #[test]
594    fn traverse_related_respects_limit() {
595        let conn = setup_related_db();
596        let seed_id = insert_memory(&conn, "seed", "global");
597        let ent_seed = insert_entity(&conn, "global", "ent-seed");
598        link_memory_entity(&conn, seed_id, ent_seed);
599        for i in 0..5 {
600            let ent_id = insert_entity(&conn, "global", &format!("ent-{i}"));
601            let mem_id = insert_memory(&conn, &format!("mem-{i}"), "global");
602            link_memory_entity(&conn, mem_id, ent_id);
603            insert_relationship(&conn, "global", ent_seed, ent_id, "related_to", 1.0);
604        }
605        let result = traverse_related(&conn, seed_id, &[ent_seed], "global", 1, 0.0, None, 3)
606            .expect("traverse_related failed");
607        assert_eq!(
608            result.len(),
609            3,
610            "limit=3 must constrain to at most 3 results"
611        );
612    }
613
614    #[test]
615    fn related_memory_optional_null_fields_serialized() {
616        let mem = RelatedMemory {
617            memory_id: 99,
618            name: "no-relation".to_string(),
619            namespace: "ns".to_string(),
620            memory_type: "concept".to_string(),
621            description: "".to_string(),
622            hop_distance: 2,
623            source_entity: None,
624            target_entity: None,
625            from: None,
626            to: None,
627            relation: None,
628            weight: None,
629        };
630        let json = serde_json::to_value(&mem).expect("serialization failed");
631        assert!(json["source_entity"].is_null());
632        assert!(json["target_entity"].is_null());
633        assert!(json["relation"].is_null());
634        assert!(json["weight"].is_null());
635        assert_eq!(json["hop_distance"], 2);
636    }
637}