1use 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
15enum SeedKind {
17 Memory(i64),
18 Entity(i64),
19}
20
21type 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")]
33pub struct RelatedArgs {
35 #[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 #[arg(long, alias = "from")]
44 pub name: Option<String>,
45 #[arg(long, alias = "hops", default_value_t = DEFAULT_MAX_HOPS)]
47 pub max_hops: u32,
48 #[arg(long, value_parser = crate::parsers::parse_relation)]
53 pub relation: Option<String>,
54 #[arg(long, default_value_t = DEFAULT_MIN_WEIGHT)]
56 pub min_weight: f64,
57 #[arg(long, default_value_t = DEFAULT_K_RECALL)]
59 pub limit: usize,
60 #[arg(long)]
62 pub namespace: Option<String>,
63 #[arg(long, value_enum, default_value = "json")]
65 pub format: OutputFormat,
66 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
68 pub json: bool,
69 #[arg(long)]
71 pub db: Option<String>,
72}
73
74#[derive(Serialize)]
75struct RelatedResponse {
76 name: String,
79 max_hops: u32,
81 results: Vec<RelatedMemory>,
82 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 #[serde(skip_serializing_if = "Option::is_none")]
100 from: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 to: Option<String>,
104 relation: Option<String>,
105 weight: Option<f64>,
106}
107
108pub 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 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 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 (-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 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 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(¤t_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 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 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 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}