Skip to main content

sqlite_graphrag/commands/
related.rs

1//! Handler for the `related` CLI subcommand.
2
3use crate::constants::{
4    DEFAULT_MAX_HOPS, DEFAULT_MIN_WEIGHT, K_RELATED_DEFAULT_LIMIT, TEXT_DESCRIPTION_PREVIEW_LEN,
5};
6use crate::errors::AppError;
7use crate::graph::{GraphWalk, SqlNeighbors};
8use crate::i18n::errors_msg;
9use crate::output::{self, OutputFormat};
10use crate::paths::AppPaths;
11use crate::storage::connection::open_ro;
12use rusqlite::{params, Connection};
13use serde::Serialize;
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#[derive(clap::Args)]
22#[command(after_long_help = "EXAMPLES:\n  \
23    # List memories connected to a memory via the entity graph (default 2 hops)\n  \
24    sqlite-graphrag related onboarding\n\n  \
25    # Increase hop distance and filter by relation type\n  \
26    sqlite-graphrag related onboarding --max-hops 3 --relation related\n\n  \
27    # Cap result count and require minimum edge weight\n  \
28    sqlite-graphrag related onboarding --limit 5 --min-weight 0.5")]
29/// Related args.
30pub struct RelatedArgs {
31    /// Memory name as a positional argument. Alternative to `--name`.
32    #[arg(
33        value_name = "NAME",
34        conflicts_with = "name",
35        help = "Memory name whose neighbours to traverse; alternative to --name"
36    )]
37    pub name_positional: Option<String>,
38    /// Memory name as a flag. Required when the positional form is absent. Also accepts the alias `--from`.
39    #[arg(long, alias = "from")]
40    pub name: Option<String>,
41    /// Maximum graph hop count. Also accepts the alias `--hops`.
42    #[arg(long, alias = "hops", default_value_t = DEFAULT_MAX_HOPS, value_parser = crate::parsers::parse_hops_range_u32)]
43    pub max_hops: u32,
44    /// Filter results to a specific relation type. Canonical values:
45    /// applies-to, uses, depends-on, causes, fixes, contradicts, supports,
46    /// follows, related, mentions, replaces, tracked-in.
47    /// Any kebab-case or snake_case string is also accepted as a custom relation.
48    #[arg(long, value_parser = crate::parsers::parse_relation)]
49    pub relation: Option<String>,
50    /// Min weight.
51    #[arg(long, default_value_t = DEFAULT_MIN_WEIGHT)]
52    pub min_weight: f64,
53    /// Maximum number of items.
54    #[arg(long, default_value_t = K_RELATED_DEFAULT_LIMIT, value_parser = crate::parsers::parse_k_range)]
55    pub limit: usize,
56    /// Namespace scope.
57    #[arg(long)]
58    pub namespace: Option<String>,
59    /// Output format.
60    #[arg(long, value_enum, default_value = "json")]
61    pub format: OutputFormat,
62    /// Emit machine-readable JSON on stdout.
63    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
64    pub json: bool,
65    /// Path to the SQLite database file.
66    #[arg(long)]
67    pub db: Option<String>,
68}
69
70#[derive(Serialize)]
71struct RelatedResponse {
72    /// Echo of the seed memory name resolved from `--name` or the positional argument.
73    /// Added in v1.0.35 for input transparency in JSON output.
74    name: String,
75    /// Echo of the resolved `--max-hops` value (default 2). Added in v1.0.35.
76    max_hops: u32,
77    results: Vec<RelatedMemory>,
78    /// Semantic alias of `results` following the v1.0.66 alias pattern (list has items/memories).
79    related_memories: Vec<RelatedMemory>,
80    elapsed_ms: u64,
81}
82
83#[derive(Serialize, Clone)]
84struct RelatedMemory {
85    memory_id: i64,
86    name: String,
87    namespace: String,
88    #[serde(rename = "type")]
89    memory_type: String,
90    description: String,
91    hop_distance: u32,
92    source_entity: Option<String>,
93    target_entity: Option<String>,
94    /// Alias of `source_entity` for cross-command consistency (graph, link, deep-research use from/to).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    from: Option<String>,
97    /// Alias of `target_entity` for cross-command consistency.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    to: Option<String>,
100    relation: Option<String>,
101    weight: Option<f64>,
102}
103
104/// Run.
105pub fn run(args: RelatedArgs) -> Result<(), AppError> {
106    let started = std::time::Instant::now();
107    let name = args
108        .name_positional
109        .as_deref()
110        .or(args.name.as_deref())
111        .ok_or_else(|| {
112            AppError::Validation(
113                "name required: pass as positional argument or via --name".to_string(),
114            )
115        })?
116        .to_string();
117
118    if name.trim().is_empty() {
119        return Err(AppError::Validation(
120            crate::i18n::validation::name_must_not_be_empty(),
121        ));
122    }
123
124    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
125    let paths = AppPaths::resolve(args.db.as_deref())?;
126
127    crate::storage::connection::ensure_db_ready(&paths)?;
128
129    let conn = open_ro(&paths.db)?;
130
131    // Locate the seed: try memory first, fall back to bare entity.
132    let seed = match conn.query_row(
133        "SELECT id FROM memories WHERE namespace = ?1 AND name = ?2 AND deleted_at IS NULL",
134        params![namespace, name],
135        |r| r.get::<_, i64>(0),
136    ) {
137        Ok(id) => SeedKind::Memory(id),
138        Err(rusqlite::Error::QueryReturnedNoRows) => {
139            match crate::storage::entities::find_entity_id(&conn, &namespace, &name)? {
140                Some(id) => SeedKind::Entity(id),
141                None => {
142                    return Err(AppError::NotFound(errors_msg::memory_or_entity_not_found(
143                        &name, &namespace,
144                    )))
145                }
146            }
147        }
148        Err(e) => return Err(AppError::Database(e)),
149    };
150
151    // Collect seed entity IDs depending on seed kind.
152    let (seed_memory_id, seed_entity_ids): (i64, Vec<i64>) = match &seed {
153        SeedKind::Memory(id) => {
154            let mem_id = *id;
155            let mut stmt =
156                conn.prepare_cached("SELECT entity_id FROM memory_entities WHERE memory_id = ?1")?;
157            let rows: Vec<i64> = stmt
158                .query_map(params![mem_id], |r| r.get(0))?
159                .collect::<Result<Vec<i64>, _>>()?;
160            (mem_id, rows)
161        }
162        SeedKind::Entity(entity_id) => {
163            // For a bare entity seed there is no corresponding memory to skip.
164            // Use a sentinel -1 so dedup never matches a real memory_id.
165            (-1, vec![*entity_id])
166        }
167    };
168
169    let relation_filter = args.relation;
170    if let Some(ref r) = relation_filter {
171        crate::parsers::warn_if_non_canonical(r);
172    }
173    let results = traverse_related(
174        &conn,
175        seed_memory_id,
176        &seed_entity_ids,
177        &namespace,
178        args.max_hops,
179        args.min_weight,
180        relation_filter.as_deref(),
181        args.limit,
182    )?;
183    // GAP-SG-201: `related --limit` stops a breadth-first walk rather than
184    // paging a table, so there is no universe to count and no page to refuse.
185    // Reported so the caller sees the bound instead of inferring completeness.
186    crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
187        applied: args.limit,
188        offset: 0,
189        source: crate::agent_surface::universe::CeilingSource::Flag,
190        kind: crate::agent_surface::universe::CeilingKind::TopK,
191        universe_total: None,
192    });
193
194    match args.format {
195        OutputFormat::Json => {
196            let related_memories = results.clone();
197            output::emit_json(&RelatedResponse {
198                name: name.clone(),
199                max_hops: args.max_hops,
200                results,
201                related_memories,
202                elapsed_ms: started.elapsed().as_millis() as u64,
203            })?;
204        }
205        OutputFormat::Text => {
206            for item in &results {
207                if item.description.is_empty() {
208                    output::emit_text(&format!(
209                        "{}. {} ({})",
210                        item.hop_distance, item.name, item.namespace
211                    ));
212                } else {
213                    let preview: String = item
214                        .description
215                        .chars()
216                        .take(TEXT_DESCRIPTION_PREVIEW_LEN)
217                        .collect();
218                    output::emit_text(&format!(
219                        "{}. {} ({}): {}",
220                        item.hop_distance, item.name, item.namespace, preview
221                    ));
222                }
223            }
224        }
225        OutputFormat::Markdown => {
226            for item in &results {
227                if item.description.is_empty() {
228                    output::emit_text(&format!(
229                        "- **{}** ({}) — hop {}",
230                        item.name, item.namespace, item.hop_distance
231                    ));
232                } else {
233                    let preview: String = item
234                        .description
235                        .chars()
236                        .take(TEXT_DESCRIPTION_PREVIEW_LEN)
237                        .collect();
238                    output::emit_text(&format!(
239                        "- **{}** ({}) — hop {}: {}",
240                        item.name, item.namespace, item.hop_distance, preview
241                    ));
242                }
243            }
244        }
245    }
246
247    Ok(())
248}
249
250// One over, and every parameter is an independent knob the caller reads off
251// `RelatedArgs`: there is no pair here that travels together anywhere else.
252// Four seeds plus four traversal knobs. The knobs come from four unrelated CLI
253// flags with no shared lifetime, so a struct would be a bag named after this one
254// call rather than after a concept the caller already holds.
255#[allow(clippy::too_many_arguments)]
256fn traverse_related(
257    conn: &Connection,
258    seed_memory_id: i64,
259    seed_entity_ids: &[i64],
260    namespace: &str,
261    max_hops: u32,
262    min_weight: f64,
263    relation_filter: Option<&str>,
264    limit: usize,
265) -> Result<Vec<RelatedMemory>, AppError> {
266    if seed_entity_ids.is_empty() || max_hops == 0 {
267        return Ok(Vec::new());
268    }
269
270    // Bidirectional BFS: users reason about "related" without caring which end of
271    // the edge their memory sits on. The walk records the edge that FIRST reached
272    // each entity, which under FIFO order is the shortest-path edge.
273    let walk = GraphWalk::bidirectional(min_weight, max_hops)
274        .with_relation_filter(relation_filter.map(str::to_string));
275    let outcome = walk.run(&SqlNeighbors::with_names(conn, namespace), seed_entity_ids)?;
276
277    let entity_hop = outcome.depth;
278    // Per-entity edge info: source_name, target_name, relation, weight.
279    let entity_edge: crate::hash::AHashMap<i64, (String, String, String, f64)> = outcome
280        .arrival
281        .into_iter()
282        .map(|(id, edge)| {
283            (
284                id,
285                (
286                    edge.source_name.unwrap_or_default(),
287                    edge.target_name.unwrap_or_default(),
288                    edge.relation,
289                    edge.weight,
290                ),
291            )
292        })
293        .collect();
294
295    // For each discovered entity (hop >= 1) find its memories, skipping the seed memory.
296    let mut out: Vec<RelatedMemory> = Vec::with_capacity(limit);
297    let mut dedup_ids: crate::hash::AHashSet<i64> =
298        crate::hash::AHashSet::with_capacity_and_hasher(limit, Default::default());
299    dedup_ids.insert(seed_memory_id);
300
301    // Sort entities by hop ASC, weight DESC, entity_id ASC so we emit closer
302    // entities first — and emit the SAME ones on every run.
303    //
304    // The `entity_id` term is not cosmetic. `entity_hop` is a `HashMap` with
305    // `RandomState`, seeded per PROCESS, so `.iter()` yields a different order
306    // in every invocation. `sort_by` is STABLE, so any tie inherited that order
307    // verbatim — and in a graph `(hop, weight)` ties are the norm, because
308    // weights cluster on 0.5 and 1.0. Measured before this line existed: eight
309    // identical `related` invocations against the same database returned EIGHT
310    // different result sets, not merely reordered, since the top-k cut fell in a
311    // different place each time. Exit 0 throughout, so nothing announced it.
312    //
313    // The tie-break makes the comparator TOTAL, which is what determinism needs;
314    // switching the container to `BTreeMap` would also work and would cost
315    // O(log n) on a walk that crosses hubs of degree 7515 to buy nothing this
316    // does not already buy.
317    let mut ordered_entities: Vec<(i64, u32)> = entity_hop
318        .iter()
319        .filter(|(id, _)| !seed_entity_ids.contains(id))
320        .map(|(id, hop)| (*id, *hop))
321        .collect();
322    ordered_entities.sort_by(|a, b| {
323        let weight_a = entity_edge.get(&a.0).map(|e| e.3).unwrap_or(0.0);
324        let weight_b = entity_edge.get(&b.0).map(|e| e.3).unwrap_or(0.0);
325        a.1.cmp(&b.1)
326            .then_with(|| {
327                weight_b
328                    .partial_cmp(&weight_a)
329                    .unwrap_or(std::cmp::Ordering::Equal)
330            })
331            .then_with(|| a.0.cmp(&b.0))
332    });
333
334    for (entity_id, hop) in ordered_entities {
335        let mut stmt = conn.prepare_cached(
336            // `ORDER BY m.id` states the order this loop already depended on.
337            // Without it the row order is whatever plan SQLite picks, which is
338            // stable for a given database and schema and is NOT a contract —
339            // an index added later reorders the output of a read-only command.
340            "SELECT m.id, m.name, m.namespace, m.type, m.description
341             FROM memory_entities me
342             JOIN memories m ON m.id = me.memory_id
343             WHERE me.entity_id = ?1 AND m.deleted_at IS NULL
344             ORDER BY m.id",
345        )?;
346        let rows = stmt
347            .query_map(params![entity_id], |r| {
348                Ok((
349                    r.get::<_, i64>(0)?,
350                    r.get::<_, String>(1)?,
351                    r.get::<_, String>(2)?,
352                    r.get::<_, String>(3)?,
353                    r.get::<_, String>(4)?,
354                ))
355            })?
356            .collect::<Result<Vec<_>, _>>()?;
357
358        for (mid, name, ns, mtype, desc) in rows {
359            if !dedup_ids.insert(mid) {
360                continue;
361            }
362            let edge = entity_edge.get(&entity_id);
363            let src = edge.map(|e| e.0.clone());
364            let tgt = edge.map(|e| e.1.clone());
365            out.push(RelatedMemory {
366                memory_id: mid,
367                name,
368                namespace: ns,
369                memory_type: mtype,
370                description: desc,
371                hop_distance: hop,
372                source_entity: src.clone(),
373                target_entity: tgt.clone(),
374                from: src,
375                to: tgt,
376                relation: edge.map(|e| e.2.clone()),
377                weight: edge.map(|e| e.3),
378            });
379            if out.len() >= limit {
380                return Ok(out);
381            }
382        }
383    }
384    Ok(out)
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn setup_related_db() -> rusqlite::Connection {
392        let conn = rusqlite::Connection::open_in_memory().expect("failed to open in-memory db");
393        conn.execute_batch(
394            "CREATE TABLE memories (
395                id INTEGER PRIMARY KEY AUTOINCREMENT,
396                name TEXT NOT NULL,
397                namespace TEXT NOT NULL DEFAULT 'global',
398                type TEXT NOT NULL DEFAULT 'fact',
399                description TEXT NOT NULL DEFAULT '',
400                deleted_at INTEGER
401            );
402            CREATE TABLE entities (
403                id INTEGER PRIMARY KEY AUTOINCREMENT,
404                namespace TEXT NOT NULL,
405                name TEXT NOT NULL
406            );
407            CREATE TABLE relationships (
408                id INTEGER PRIMARY KEY AUTOINCREMENT,
409                namespace TEXT NOT NULL,
410                source_id INTEGER NOT NULL,
411                target_id INTEGER NOT NULL,
412                relation TEXT NOT NULL DEFAULT 'related_to',
413                weight REAL NOT NULL DEFAULT 1.0
414            );
415            CREATE TABLE memory_entities (
416                memory_id INTEGER NOT NULL,
417                entity_id INTEGER NOT NULL
418            );",
419        )
420        .expect("failed to create test tables");
421        conn
422    }
423
424    fn insert_memory(conn: &rusqlite::Connection, name: &str, namespace: &str) -> i64 {
425        conn.execute(
426            "INSERT INTO memories (name, namespace) VALUES (?1, ?2)",
427            rusqlite::params![name, namespace],
428        )
429        .expect("failed to insert memory");
430        conn.last_insert_rowid()
431    }
432
433    fn insert_entity(conn: &rusqlite::Connection, name: &str, namespace: &str) -> i64 {
434        conn.execute(
435            "INSERT INTO entities (name, namespace) VALUES (?1, ?2)",
436            rusqlite::params![name, namespace],
437        )
438        .expect("failed to insert entity");
439        conn.last_insert_rowid()
440    }
441
442    fn link_memory_entity(conn: &rusqlite::Connection, memory_id: i64, entity_id: i64) {
443        conn.execute(
444            "INSERT INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
445            rusqlite::params![memory_id, entity_id],
446        )
447        .expect("failed to link memory-entity");
448    }
449
450    fn insert_relationship(
451        conn: &rusqlite::Connection,
452        namespace: &str,
453        source_id: i64,
454        target_id: i64,
455        relation: &str,
456        weight: f64,
457    ) {
458        conn.execute(
459            "INSERT INTO relationships (namespace, source_id, target_id, relation, weight)
460             VALUES (?1, ?2, ?3, ?4, ?5)",
461            rusqlite::params![namespace, source_id, target_id, relation, weight],
462        )
463        .expect("failed to insert relationship");
464    }
465
466    #[test]
467    fn related_response_serializes_results_and_elapsed_ms() {
468        let mem = RelatedMemory {
469            memory_id: 1,
470            name: "neighbor-mem".to_string(),
471            namespace: "global".to_string(),
472            memory_type: "document".to_string(),
473            description: "desc".to_string(),
474            hop_distance: 1,
475            source_entity: Some("entity-a".to_string()),
476            target_entity: Some("entity-b".to_string()),
477            from: Some("entity-a".to_string()),
478            to: Some("entity-b".to_string()),
479            relation: Some("related_to".to_string()),
480            weight: Some(0.9),
481        };
482        let resp = RelatedResponse {
483            name: "seed-mem".to_string(),
484            max_hops: 2,
485            related_memories: vec![mem.clone()],
486            results: vec![mem],
487            elapsed_ms: 7,
488        };
489        let json = serde_json::to_value(&resp).expect("serialization failed");
490        assert!(json["results"].is_array());
491        assert_eq!(json["results"].as_array().unwrap().len(), 1);
492        assert_eq!(json["elapsed_ms"], 7u64);
493        assert_eq!(json["results"][0]["type"], "document");
494        assert_eq!(json["results"][0]["hop_distance"], 1);
495    }
496
497    #[test]
498    fn traverse_related_returns_empty_without_seed_entities() {
499        let conn = setup_related_db();
500        let result = traverse_related(&conn, 1, &[], "global", 2, 0.0, None, 10)
501            .expect("traverse_related failed");
502        assert!(result.is_empty());
503    }
504
505    #[test]
506    fn traverse_related_returns_empty_with_max_hops_zero() {
507        let conn = setup_related_db();
508        let mem_id = insert_memory(&conn, "seed", "global");
509        let ent_id = insert_entity(&conn, "global", "ent");
510        let result = traverse_related(&conn, mem_id, &[ent_id], "global", 0, 0.0, None, 10)
511            .expect("traverse_related failed");
512        assert!(result.is_empty());
513    }
514
515    #[test]
516    fn traverse_related_discovers_neighbor_memory_via_graph() {
517        let conn = setup_related_db();
518        let seed_id = insert_memory(&conn, "seed", "global");
519        let ent_a = insert_entity(&conn, "global", "ent-a");
520        let ent_b = insert_entity(&conn, "global", "ent-b");
521        let neighbor_id = insert_memory(&conn, "neighbor", "global");
522        link_memory_entity(&conn, seed_id, ent_a);
523        link_memory_entity(&conn, neighbor_id, ent_b);
524        insert_relationship(&conn, "global", ent_a, ent_b, "related_to", 1.0);
525        let result = traverse_related(&conn, seed_id, &[ent_a], "global", 2, 0.0, None, 10)
526            .expect("traverse_related failed");
527        assert_eq!(result.len(), 1);
528        assert_eq!(result[0].name, "neighbor");
529    }
530
531    #[test]
532    fn traverse_related_respects_limit() {
533        let conn = setup_related_db();
534        let seed_id = insert_memory(&conn, "seed", "global");
535        let ent_seed = insert_entity(&conn, "global", "ent-seed");
536        link_memory_entity(&conn, seed_id, ent_seed);
537        for i in 0..5 {
538            let ent_id = insert_entity(&conn, "global", &format!("ent-{i}"));
539            let mem_id = insert_memory(&conn, &format!("mem-{i}"), "global");
540            link_memory_entity(&conn, mem_id, ent_id);
541            insert_relationship(&conn, "global", ent_seed, ent_id, "related_to", 1.0);
542        }
543        let result = traverse_related(&conn, seed_id, &[ent_seed], "global", 1, 0.0, None, 3)
544            .expect("traverse_related failed");
545        assert_eq!(
546            result.len(),
547            3,
548            "limit=3 must constrain to at most 3 results"
549        );
550    }
551
552    #[test]
553    fn related_memory_optional_null_fields_serialized() {
554        let mem = RelatedMemory {
555            memory_id: 99,
556            name: "no-relation".to_string(),
557            namespace: "ns".to_string(),
558            memory_type: "concept".to_string(),
559            description: "".to_string(),
560            hop_distance: 2,
561            source_entity: None,
562            target_entity: None,
563            from: None,
564            to: None,
565            relation: None,
566            weight: None,
567        };
568        let json = serde_json::to_value(&mem).expect("serialization failed");
569        assert!(json["source_entity"].is_null());
570        assert!(json["target_entity"].is_null());
571        assert!(json["relation"].is_null());
572        assert!(json["weight"].is_null());
573        assert_eq!(json["hop_distance"], 2);
574    }
575}