Skip to main content

lc_rag/graph_rag/
query.rs

1// src/retrieval/graph_rag/query.rs
2//! Query modes for GraphRAG: Global, Local, and Hybrid.
3//!
4//! - **Global**: aggregates community summaries, asks the LLM to answer from them.
5//! - **Local**: finds relevant entities, retrieves their neighborhood subgraph,
6//!   and asks the LLM.
7//! - **Hybrid**: combines both global and local context.
8
9use super::graph_store::GraphStore;
10use super::matcher::{EntityMatcher, KeywordMatcher};
11use lc_core::language_models::{BaseChatModel, LLMResult};
12use lc_core::token_counter::count_tokens;
13use lc_prompts::PromptTemplate;
14use lc_schema::Message;
15use std::collections::{HashMap, HashSet};
16
17/// Helper: format a relation using entity names instead of IDs.
18fn format_relation(r: &super::graph_store::Relation, store: &GraphStore) -> String {
19    let source_name = store
20        .get_entity(&r.source)
21        .map(|e| e.name.as_str())
22        .unwrap_or(&r.source);
23    let target_name = store
24        .get_entity(&r.target)
25        .map(|e| e.name.as_str())
26        .unwrap_or(&r.target);
27    format!(
28        "- {} --[{}]--> {}{}",
29        source_name,
30        r.relation_type,
31        target_name,
32        if r.description.is_empty() {
33            String::new()
34        } else {
35            format!(": {}", r.description)
36        }
37    )
38}
39
40/// Query mode selector.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum QueryMode {
43    /// Global query over the coarsest (top of each hierarchy subtree)
44    /// community summaries.
45    Global,
46    /// Local query: retrieves a neighborhood subgraph around relevant entities.
47    Local,
48    /// Hybrid query: combines coarsest community summaries with local context.
49    Hybrid,
50    /// Global query restricted to summaries at a chosen [`GlobalLevel`].
51    GlobalAt(GlobalLevel),
52    /// Hybrid query using summaries at a chosen [`GlobalLevel`].
53    HybridAt(GlobalLevel),
54}
55
56/// Selects which hierarchy level's community summaries a global/hybrid
57/// query answers from.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum GlobalLevel {
60    /// Coarsest level only: communities with no parent. This covers every
61    /// entity exactly once (each hierarchy subtree contributes its root)
62    /// with the fewest, broadest summaries — the map-reduce default.
63    #[default]
64    Coarsest,
65    /// Exactly the given level (0 is the base Leiden partition).
66    Level(usize),
67    /// Every level's summaries (broad overviews together with fine-grained
68    /// base communities; costs more tokens).
69    All,
70}
71
72/// Returns the ids of communities selected by `level`, in storage order.
73/// Summary vectors are indexed by [`super::graph_store::Community::id`].
74fn select_summary_ids(store: &GraphStore, level: GlobalLevel) -> Vec<usize> {
75    store
76        .communities()
77        .iter()
78        .filter(|c| match level {
79            GlobalLevel::Coarsest => c.parent.is_none(),
80            GlobalLevel::Level(l) => c.level == l,
81            GlobalLevel::All => true,
82        })
83        .map(|c| c.id)
84        .collect()
85}
86
87/// Human-readable level name for error messages.
88fn level_name(level: GlobalLevel) -> String {
89    match level {
90        GlobalLevel::Coarsest => "coarsest".to_string(),
91        GlobalLevel::Level(l) => format!("level {l}"),
92        GlobalLevel::All => "any level".to_string(),
93    }
94}
95
96/// Result of a GraphRAG query.
97#[derive(Debug, Clone)]
98pub struct GraphRAGResult {
99    /// The final answer generated by the LLM.
100    pub answer: String,
101    /// Source entity names or context used for the answer.
102    pub sources: Vec<String>,
103    /// Query mode used to produce this result.
104    pub mode: QueryMode,
105}
106
107const GLOBAL_QUERY_PROMPT: &str = r#"You are a helpful assistant answering questions based on community summaries from a knowledge graph.
108
109Community Summaries:
110{summaries}
111
112Question: {question}
113
114Provide a comprehensive answer based on the community summaries above. If the summaries do not contain enough information, say so.
115
116Answer:"#;
117
118const LOCAL_QUERY_PROMPT: &str = r#"You are a helpful assistant answering questions based on a local subgraph from a knowledge graph.
119
120Entities:
121{entities}
122
123Relations:
124{relations}
125
126Question: {question}
127
128Provide a detailed answer based on the local subgraph information above. If the subgraph does not contain enough information, say so.
129
130Answer:"#;
131
132const HYBRID_QUERY_PROMPT: &str = r#"You are a helpful assistant answering questions based on both community summaries and a local subgraph from a knowledge graph.
133
134Community Summaries:
135{summaries}
136
137Local Subgraph Entities:
138{entities}
139
140Local Subgraph Relations:
141{relations}
142
143Question: {question}
144
145Provide a comprehensive answer synthesizing both the community-level and local-level information. If there is not enough information, say so.
146
147Answer:"#;
148
149/// Executes a **global** query: aggregates the selected hierarchy level's
150/// community summaries and asks the LLM.
151pub async fn global_query<M: BaseChatModel>(
152    llm: &M,
153    store: &GraphStore,
154    question: &str,
155    max_context_tokens: Option<usize>,
156    level: GlobalLevel,
157) -> Result<GraphRAGResult, super::GraphRAGError> {
158    let all_summaries = store.community_summaries();
159    if all_summaries.is_empty() {
160        return Err(super::GraphRAGError::QueryError(
161            "No community summaries available. Call build_communities() first.".into(),
162        ));
163    }
164
165    let selected_ids = select_summary_ids(store, level);
166    if selected_ids.is_empty() {
167        return Err(super::GraphRAGError::QueryError(format!(
168            "No communities at the {} hierarchy level.",
169            level_name(level)
170        )));
171    }
172
173    let selected: Vec<String> = selected_ids
174        .iter()
175        .filter_map(|id| all_summaries.get(*id).cloned())
176        .collect();
177    let summaries_text = truncate_summaries(&selected, max_context_tokens);
178    let question_str = question.to_string();
179    let prompt = format_template(
180        GLOBAL_QUERY_PROMPT,
181        &[("summaries", &summaries_text), ("question", &question_str)],
182    );
183
184    let messages = vec![Message::human(prompt)];
185    let response: LLMResult = llm
186        .chat(messages, None)
187        .await
188        .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
189
190    let sources: Vec<String> = selected_ids
191        .iter()
192        .flat_map(|id| {
193            store
194                .communities()
195                .iter()
196                .find(|c| c.id == *id)
197                .into_iter()
198                .flat_map(|c| c.entities.iter().cloned())
199        })
200        .collect::<HashSet<_>>()
201        .into_iter()
202        .collect();
203
204    Ok(GraphRAGResult {
205        answer: response.content.trim().to_string(),
206        sources,
207        mode: if level == GlobalLevel::Coarsest {
208            QueryMode::Global
209        } else {
210            QueryMode::GlobalAt(level)
211        },
212    })
213}
214
215/// Executes a **local** query: finds relevant entities by keyword match,
216/// retrieves their neighborhood subgraph, and asks the LLM.
217pub async fn local_query<M: BaseChatModel>(
218    llm: &M,
219    store: &GraphStore,
220    question: &str,
221    max_context_tokens: Option<usize>,
222    entity_matcher: Option<&dyn super::matcher::EntityMatcher>,
223) -> Result<GraphRAGResult, super::GraphRAGError> {
224    let seed_entities = match entity_matcher {
225        Some(matcher) => matcher.find_relevant(question, store, 10),
226        None => find_relevant_entities(store, question),
227    };
228    if seed_entities.is_empty() {
229        return Err(super::GraphRAGError::QueryError(
230            "No relevant entities found for the query.".into(),
231        ));
232    }
233
234    // Collect subgraph from all seed entities (depth 1).
235    let mut all_entity_ids: HashSet<String> = HashSet::new();
236    let mut all_entities = Vec::new();
237    let mut all_relations = Vec::new();
238    let mut seen_relations: HashSet<(String, String, String)> = HashSet::new();
239
240    for seed in &seed_entities {
241        let (ents, rels) = store.subgraph(seed, 1);
242        for e in ents {
243            if all_entity_ids.insert(e.id.clone()) {
244                all_entities.push(e);
245            }
246        }
247        for r in rels {
248            // M56: O(1) HashSet dedup instead of O(n^2) Vec::iter::any
249            let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
250            if seen_relations.insert(key) {
251                all_relations.push(r);
252            }
253        }
254    }
255
256    let entity_lines: Vec<String> = all_entities
257        .iter()
258        .map(|e| format!("- {} ({}): {}", e.name, e.entity_type, e.description))
259        .collect();
260
261    let relation_lines: Vec<String> = all_relations
262        .iter()
263        .map(|r| format_relation(r, store))
264        .collect();
265
266    let entities_str = entity_lines.join("\n");
267    let relations_str = relation_lines.join("\n");
268    let question_str = question.to_string();
269    let prompt = format_template(
270        LOCAL_QUERY_PROMPT,
271        &[
272            ("entities", &entities_str),
273            ("relations", &relations_str),
274            ("question", &question_str),
275        ],
276    );
277
278    let prompt = truncate_prompt(&prompt, max_context_tokens);
279
280    let messages = vec![Message::human(prompt)];
281    let response: LLMResult = llm
282        .chat(messages, None)
283        .await
284        .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
285
286    Ok(GraphRAGResult {
287        answer: response.content.trim().to_string(),
288        sources: seed_entities,
289        mode: QueryMode::Local,
290    })
291}
292
293/// Executes a **hybrid** query: combines community summaries at the selected
294/// hierarchy level with local subgraph context.
295pub async fn hybrid_query<M: BaseChatModel>(
296    llm: &M,
297    store: &GraphStore,
298    question: &str,
299    max_context_tokens: Option<usize>,
300    entity_matcher: Option<&dyn super::matcher::EntityMatcher>,
301    level: GlobalLevel,
302) -> Result<GraphRAGResult, super::GraphRAGError> {
303    let all_summaries = store.community_summaries();
304    let selected_ids = if all_summaries.is_empty() {
305        Vec::new()
306    } else {
307        select_summary_ids(store, level)
308    };
309    let summaries_text = if all_summaries.is_empty() {
310        "No community summaries available.".to_string()
311    } else if selected_ids.is_empty() {
312        // Summaries exist but none at the requested level: answer with the
313        // local subgraph only rather than silently mixing in another level.
314        format!("No community summaries at the {} level.", level_name(level))
315    } else {
316        let selected: Vec<String> = selected_ids
317            .iter()
318            .filter_map(|id| all_summaries.get(*id).cloned())
319            .collect();
320        truncate_summaries(&selected, max_context_tokens)
321    };
322
323    let seed_entities = match entity_matcher {
324        Some(matcher) => matcher.find_relevant(question, store, 10),
325        None => find_relevant_entities(store, question),
326    };
327
328    let (entity_lines, relation_lines) = if seed_entities.is_empty() {
329        (String::from("No relevant entities found."), String::new())
330    } else {
331        let mut all_entity_ids: HashSet<String> = HashSet::new();
332        let mut all_entities = Vec::new();
333        let mut all_relations = Vec::new();
334        let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
335
336        for seed in &seed_entities {
337            let (ents, rels) = store.subgraph(seed, 1);
338            for e in ents {
339                if all_entity_ids.insert(e.id.clone()) {
340                    all_entities.push(e);
341                }
342            }
343            for r in rels {
344                let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
345                if seen_rel_keys.insert(key) {
346                    all_relations.push(r);
347                }
348            }
349        }
350
351        let el: Vec<String> = all_entities
352            .iter()
353            .map(|e| format!("- {} ({}): {}", e.name, e.entity_type, e.description))
354            .collect();
355        let rl: Vec<String> = all_relations
356            .iter()
357            .map(|r| format_relation(r, store))
358            .collect();
359        (el.join("\n"), rl.join("\n"))
360    };
361
362    let question_str = question.to_string();
363    let prompt = format_template(
364        HYBRID_QUERY_PROMPT,
365        &[
366            ("summaries", &summaries_text),
367            ("entities", &entity_lines),
368            ("relations", &relation_lines),
369            ("question", &question_str),
370        ],
371    );
372
373    let prompt = truncate_prompt(&prompt, max_context_tokens);
374
375    let messages = vec![Message::human(prompt)];
376    let response: LLMResult = llm
377        .chat(messages, None)
378        .await
379        .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
380
381    let mut sources: Vec<String> = seed_entities;
382    if !selected_ids.is_empty() {
383        sources.push(format!(
384            "{} community summaries ({})",
385            selected_ids.len(),
386            level_name(level)
387        ));
388    }
389
390    Ok(GraphRAGResult {
391        answer: response.content.trim().to_string(),
392        sources,
393        mode: if level == GlobalLevel::Coarsest {
394            QueryMode::Hybrid
395        } else {
396            QueryMode::HybridAt(level)
397        },
398    })
399}
400
401/// Helper: format a PromptTemplate with the given key-value pairs.
402fn format_template(template_str: &str, vars: &[(&str, &str)]) -> String {
403    let template = PromptTemplate::new(template_str);
404    let mut map = HashMap::new();
405    for (k, v) in vars {
406        map.insert(*k, *v);
407    }
408    template
409        .format(&map)
410        .unwrap_or_else(|_| template_str.to_string())
411}
412
413/// Counts tokens; when the encoder fails to load, overestimates with the byte length
414/// (better to slightly exceed the budget than to silently count 0 and break truncation).
415fn count_tokens_estimate(text: &str) -> usize {
416    count_tokens(text).unwrap_or_else(|e| {
417        log::warn!("token counting failed, falling back to byte-length estimate: {e}");
418        text.len()
419    })
420}
421
422/// Truncates community summaries to fit within a token budget.
423///
424/// Keeps summaries from the beginning (highest-priority, largest communities)
425/// until the budget is exceeded, then drops the rest.
426fn truncate_summaries(summaries: &[String], max_tokens: Option<usize>) -> String {
427    let all_text = summaries.join("\n\n");
428
429    match max_tokens {
430        Some(budget) => {
431            let mut result = String::new();
432            let mut used_tokens = 0usize;
433
434            for summary in summaries {
435                let summary_tokens = count_tokens_estimate(summary);
436                if used_tokens + summary_tokens > budget {
437                    break;
438                }
439                if !result.is_empty() {
440                    result.push_str("\n\n");
441                }
442                result.push_str(summary);
443                used_tokens += summary_tokens;
444            }
445
446            if result.is_empty() {
447                // If even the first summary exceeds the budget, include it truncated
448                summaries.first().cloned().unwrap_or_default()
449            } else {
450                result
451            }
452        }
453        None => all_text,
454    }
455}
456
457/// Truncates a full prompt to fit within a token budget.
458///
459/// Keeps the prompt prefix (before the context) intact and truncates
460/// the context portion. If the prompt is already within budget, returns
461/// it unchanged.
462fn truncate_prompt(prompt: &str, max_tokens: Option<usize>) -> String {
463    match max_tokens {
464        Some(budget) => {
465            let current_tokens = count_tokens_estimate(prompt);
466            if current_tokens <= budget {
467                return prompt.to_string();
468            }
469
470            // Truncate from the end, keeping character boundaries
471            let ratio = budget as f64 / current_tokens as f64;
472            let target_chars = (prompt.len() as f64 * ratio) as usize;
473            // Find a safe char boundary
474            let truncated: String = prompt.chars().take(target_chars).collect();
475            format!("{}\n\n[Context truncated to fit token budget]", truncated)
476        }
477        None => prompt.to_string(),
478    }
479}
480
481/// Finds entity ids whose name or description contains query keywords.
482///
483/// P1-6: delegates to [`KeywordMatcher`](super::matcher::KeywordMatcher), eliminating the
484/// duplicated name+3/type+2/desc+1 keyword-weighting implementation that used to live in
485/// both query.rs and matcher.rs. The old function returned all hits (no top_k limit), so a
486/// sufficiently large k is passed to keep the behavior unchanged.
487/// P2-4: improvements such as synonyms / Chinese-English normalization / CJK bigrams /
488/// TF-IDF weighting all land on `KeywordMatcher` through the delegation; nothing to change here.
489fn find_relevant_entities(store: &GraphStore, query: &str) -> Vec<String> {
490    let matcher = KeywordMatcher::new();
491    matcher.find_relevant(query, store, usize::MAX)
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::graph_rag::graph_store::{Entity, Relation};
498
499    #[test]
500    fn test_find_relevant_entities() {
501        let mut store = GraphStore::new();
502        store.add_entity(Entity {
503            id: "e1".into(),
504            name: "Rust".into(),
505            entity_type: "Technology".into(),
506            description: "A systems programming language".into(),
507        });
508        store.add_entity(Entity {
509            id: "e2".into(),
510            name: "Python".into(),
511            entity_type: "Technology".into(),
512            description: "A scripting language".into(),
513        });
514        store.add_entity(Entity {
515            id: "e3".into(),
516            name: "Alice".into(),
517            entity_type: "Person".into(),
518            description: "A developer who uses Rust".into(),
519        });
520
521        let results = find_relevant_entities(&store, "Rust programming");
522        assert!(!results.is_empty());
523        // "Rust" entity should rank first (name match + description match)
524        assert_eq!(results[0], "e1");
525    }
526
527    #[test]
528    fn test_find_relevant_entities_no_match() {
529        let mut store = GraphStore::new();
530        store.add_entity(Entity {
531            id: "e1".into(),
532            name: "Rust".into(),
533            entity_type: "Technology".into(),
534            description: "A systems programming language".into(),
535        });
536
537        let results = find_relevant_entities(&store, "cooking recipe");
538        assert!(results.is_empty());
539    }
540
541    /// P1-6: `find_relevant_entities` delegates to `KeywordMatcher`; the results must match.
542    #[test]
543    fn test_find_relevant_entities_matches_keyword_matcher() {
544        let mut store = GraphStore::new();
545        store.add_entity(Entity {
546            id: "e1".into(),
547            name: "Rust".into(),
548            entity_type: "Technology".into(),
549            description: "A systems programming language".into(),
550        });
551        store.add_entity(Entity {
552            id: "e2".into(),
553            name: "Python".into(),
554            entity_type: "Technology".into(),
555            description: "A scripting language".into(),
556        });
557        store.add_entity(Entity {
558            id: "e3".into(),
559            name: "Alice".into(),
560            entity_type: "Person".into(),
561            description: "A developer who uses Rust".into(),
562        });
563
564        let via_delegate = find_relevant_entities(&store, "Rust programming");
565        let via_matcher =
566            KeywordMatcher::new().find_relevant("Rust programming", &store, usize::MAX);
567        assert_eq!(via_delegate, via_matcher);
568    }
569
570    fn hierarchy_store() -> GraphStore {
571        let mut store = GraphStore::new();
572        // Level 0: ids 0,1,2; level 1: id 3 = {0,1}; id 2 stays unparented.
573        store.set_communities(vec![
574            super::super::graph_store::Community {
575                id: 0,
576                entities: vec!["a".into(), "b".into()],
577                level: 0,
578                parent: Some(3),
579            },
580            super::super::graph_store::Community {
581                id: 1,
582                entities: vec!["c".into(), "d".into()],
583                level: 0,
584                parent: Some(3),
585            },
586            super::super::graph_store::Community {
587                id: 2,
588                entities: vec!["e".into(), "f".into()],
589                level: 0,
590                parent: None,
591            },
592            super::super::graph_store::Community {
593                id: 3,
594                entities: vec!["a".into(), "b".into(), "c".into(), "d".into()],
595                level: 1,
596                parent: None,
597            },
598        ]);
599        store.set_community_summaries(vec![
600            "base-0".into(),
601            "base-1".into(),
602            "base-2".into(),
603            "rollup-3".into(),
604        ]);
605        store
606    }
607
608    #[test]
609    fn select_coarsest_picks_every_subtree_root() {
610        let store = hierarchy_store();
611        // The rolled-up root (id 3) plus the level-0 community that never
612        // merged (id 2): every entity is covered exactly once.
613        assert_eq!(
614            select_summary_ids(&store, GlobalLevel::Coarsest),
615            vec![2, 3]
616        );
617    }
618
619    #[test]
620    fn select_exact_level_filters_by_level() {
621        let store = hierarchy_store();
622        assert_eq!(
623            select_summary_ids(&store, GlobalLevel::Level(0)),
624            vec![0, 1, 2]
625        );
626        assert_eq!(select_summary_ids(&store, GlobalLevel::Level(1)), vec![3]);
627        assert!(select_summary_ids(&store, GlobalLevel::Level(9)).is_empty());
628    }
629
630    #[test]
631    fn select_all_returns_every_community() {
632        let store = hierarchy_store();
633        assert_eq!(
634            select_summary_ids(&store, GlobalLevel::All),
635            vec![0, 1, 2, 3]
636        );
637    }
638
639    #[test]
640    fn test_truncate_summaries_no_limit() {
641        let summaries = vec!["Summary 1".to_string(), "Summary 2".to_string()];
642        let result = truncate_summaries(&summaries, None);
643        assert_eq!(result, "Summary 1\n\nSummary 2");
644    }
645
646    #[test]
647    fn test_truncate_summaries_within_budget() {
648        let summaries = vec!["Short summary".to_string()];
649        let result = truncate_summaries(&summaries, Some(100));
650        assert_eq!(result, "Short summary");
651    }
652
653    #[test]
654    fn test_truncate_summaries_exceeds_budget() {
655        let summaries = vec![
656            "First summary that is reasonably long".to_string(),
657            "Second summary that should be dropped".to_string(),
658        ];
659        // Budget of 5 tokens — only the first summary fits
660        let result = truncate_summaries(&summaries, Some(5));
661        assert!(result.contains("First summary"));
662        assert!(!result.contains("Second summary"));
663    }
664
665    #[test]
666    fn test_truncate_prompt_no_limit() {
667        let prompt = "This is a long prompt with lots of context".to_string();
668        let result = truncate_prompt(&prompt, None);
669        assert_eq!(result, prompt);
670    }
671
672    #[test]
673    fn test_truncate_prompt_within_budget() {
674        let prompt = "Short prompt".to_string();
675        let result = truncate_prompt(&prompt, Some(100));
676        assert_eq!(result, "Short prompt");
677    }
678
679    /// Verify that the HashSet-based dedup logic correctly removes duplicate relations.
680    /// This tests the pattern used in both local_query and hybrid_query.
681    #[test]
682    fn test_hybrid_query_relation_dedup_with_hashset() {
683        let mut store = GraphStore::new();
684        store.add_entity(Entity {
685            id: "e1".into(),
686            name: "Rust".into(),
687            entity_type: "Technology".into(),
688            description: "A systems programming language".into(),
689        });
690        store.add_entity(Entity {
691            id: "e2".into(),
692            name: "Mozilla".into(),
693            entity_type: "Organization".into(),
694            description: "Organization behind Rust".into(),
695        });
696        // Add the same relation twice — the HashSet dedup should keep only one
697        store.add_relation(Relation {
698            source: "e1".into(),
699            target: "e2".into(),
700            relation_type: "created_by".into(),
701            description: String::new(),
702            doc_id: None,
703        });
704        store.add_relation(Relation {
705            source: "e1".into(),
706            target: "e2".into(),
707            relation_type: "created_by".into(),
708            description: String::new(),
709            doc_id: None,
710        });
711
712        // Simulate the dedup logic from hybrid_query (HashSet outside the loop)
713        let seed_entities = vec!["e1".to_string()];
714        let mut all_relations = Vec::new();
715        let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
716        for seed in &seed_entities {
717            let (_, rels) = store.subgraph(seed, 1);
718            for r in rels {
719                let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
720                if seen_rel_keys.insert(key) {
721                    all_relations.push(r);
722                }
723            }
724        }
725
726        // Even though we added 2 identical relations, the HashSet dedup should keep only 1
727        // (Note: GraphStore internally deduplicates too, so we may get 1 or 2 from subgraph.
728        //  The key test is that the HashSet pattern works correctly.)
729        let unique_keys: HashSet<(String, String, String)> = all_relations
730            .iter()
731            .map(|r| (r.source.clone(), r.target.clone(), r.relation_type.clone()))
732            .collect();
733        assert_eq!(
734            unique_keys.len(),
735            1,
736            "should have exactly 1 unique relation after HashSet dedup"
737        );
738    }
739}