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 lc_core::language_models::{BaseChatModel, LLMResult};
11use lc_core::token_counter::count_tokens;
12use lc_prompts::PromptTemplate;
13use lc_schema::Message;
14use std::collections::{HashMap, HashSet};
15
16/// Helper: format a relation using entity names instead of IDs.
17fn format_relation(r: &super::graph_store::Relation, store: &GraphStore) -> String {
18    let source_name = store
19        .get_entity(&r.source)
20        .map(|e| e.name.as_str())
21        .unwrap_or(&r.source);
22    let target_name = store
23        .get_entity(&r.target)
24        .map(|e| e.name.as_str())
25        .unwrap_or(&r.target);
26    format!(
27        "- {} --[{}]--> {}{}",
28        source_name,
29        r.relation_type,
30        target_name,
31        if r.description.is_empty() {
32            String::new()
33        } else {
34            format!(": {}", r.description)
35        }
36    )
37}
38
39/// Query mode selector.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum QueryMode {
42    Global,
43    Local,
44    Hybrid,
45}
46
47/// Result of a GraphRAG query.
48#[derive(Debug, Clone)]
49pub struct GraphRAGResult {
50    pub answer: String,
51    pub sources: Vec<String>,
52    pub mode: QueryMode,
53}
54
55const GLOBAL_QUERY_PROMPT: &str = r#"You are a helpful assistant answering questions based on community summaries from a knowledge graph.
56
57Community Summaries:
58{summaries}
59
60Question: {question}
61
62Provide a comprehensive answer based on the community summaries above. If the summaries do not contain enough information, say so.
63
64Answer:"#;
65
66const LOCAL_QUERY_PROMPT: &str = r#"You are a helpful assistant answering questions based on a local subgraph from a knowledge graph.
67
68Entities:
69{entities}
70
71Relations:
72{relations}
73
74Question: {question}
75
76Provide a detailed answer based on the local subgraph information above. If the subgraph does not contain enough information, say so.
77
78Answer:"#;
79
80const 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.
81
82Community Summaries:
83{summaries}
84
85Local Subgraph Entities:
86{entities}
87
88Local Subgraph Relations:
89{relations}
90
91Question: {question}
92
93Provide a comprehensive answer synthesizing both the community-level and local-level information. If there is not enough information, say so.
94
95Answer:"#;
96
97/// Executes a **global** query: aggregates community summaries and asks the LLM.
98pub async fn global_query<M: BaseChatModel>(
99    llm: &M,
100    store: &GraphStore,
101    question: &str,
102    max_context_tokens: Option<usize>,
103) -> Result<GraphRAGResult, super::GraphRAGError> {
104    let summaries = store.community_summaries();
105    if summaries.is_empty() {
106        return Err(super::GraphRAGError::QueryError(
107            "No community summaries available. Call build_communities() first.".into(),
108        ));
109    }
110
111    let summaries_text = truncate_summaries(summaries, max_context_tokens);
112    let question_str = question.to_string();
113    let prompt = format_template(
114        GLOBAL_QUERY_PROMPT,
115        &[("summaries", &summaries_text), ("question", &question_str)],
116    );
117
118    let messages = vec![Message::human(prompt)];
119    let response: LLMResult = llm
120        .chat(messages, None)
121        .await
122        .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
123
124    let sources: Vec<String> = store
125        .communities()
126        .iter()
127        .flat_map(|c| c.entities.iter().cloned())
128        .collect::<HashSet<_>>()
129        .into_iter()
130        .collect();
131
132    Ok(GraphRAGResult {
133        answer: response.content.trim().to_string(),
134        sources,
135        mode: QueryMode::Global,
136    })
137}
138
139/// Executes a **local** query: finds relevant entities by keyword match,
140/// retrieves their neighborhood subgraph, and asks the LLM.
141pub async fn local_query<M: BaseChatModel>(
142    llm: &M,
143    store: &GraphStore,
144    question: &str,
145    max_context_tokens: Option<usize>,
146    entity_matcher: Option<&dyn super::matcher::EntityMatcher>,
147) -> Result<GraphRAGResult, super::GraphRAGError> {
148    let seed_entities = match entity_matcher {
149        Some(matcher) => matcher.find_relevant(question, store, 10),
150        None => find_relevant_entities(store, question),
151    };
152    if seed_entities.is_empty() {
153        return Err(super::GraphRAGError::QueryError(
154            "No relevant entities found for the query.".into(),
155        ));
156    }
157
158    // Collect subgraph from all seed entities (depth 1).
159    let mut all_entity_ids: HashSet<String> = HashSet::new();
160    let mut all_entities = Vec::new();
161    let mut all_relations = Vec::new();
162    let mut seen_relations: HashSet<(String, String, String)> = HashSet::new();
163
164    for seed in &seed_entities {
165        let (ents, rels) = store.subgraph(seed, 1);
166        for e in ents {
167            if all_entity_ids.insert(e.id.clone()) {
168                all_entities.push(e);
169            }
170        }
171        for r in rels {
172            // M56: O(1) HashSet dedup instead of O(n^2) Vec::iter::any
173            let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
174            if seen_relations.insert(key) {
175                all_relations.push(r);
176            }
177        }
178    }
179
180    let entity_lines: Vec<String> = all_entities
181        .iter()
182        .map(|e| format!("- {} ({}): {}", e.name, e.entity_type, e.description))
183        .collect();
184
185    let relation_lines: Vec<String> = all_relations
186        .iter()
187        .map(|r| format_relation(r, store))
188        .collect();
189
190    let entities_str = entity_lines.join("\n");
191    let relations_str = relation_lines.join("\n");
192    let question_str = question.to_string();
193    let prompt = format_template(
194        LOCAL_QUERY_PROMPT,
195        &[
196            ("entities", &entities_str),
197            ("relations", &relations_str),
198            ("question", &question_str),
199        ],
200    );
201
202    let prompt = truncate_prompt(&prompt, max_context_tokens);
203
204    let messages = vec![Message::human(prompt)];
205    let response: LLMResult = llm
206        .chat(messages, None)
207        .await
208        .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
209
210    Ok(GraphRAGResult {
211        answer: response.content.trim().to_string(),
212        sources: seed_entities,
213        mode: QueryMode::Local,
214    })
215}
216
217/// Executes a **hybrid** query: combines global community summaries with
218/// local subgraph context.
219pub async fn hybrid_query<M: BaseChatModel>(
220    llm: &M,
221    store: &GraphStore,
222    question: &str,
223    max_context_tokens: Option<usize>,
224    entity_matcher: Option<&dyn super::matcher::EntityMatcher>,
225) -> Result<GraphRAGResult, super::GraphRAGError> {
226    let summaries = store.community_summaries();
227    let summaries_text = if summaries.is_empty() {
228        "No community summaries available.".to_string()
229    } else {
230        truncate_summaries(summaries, max_context_tokens)
231    };
232
233    let seed_entities = match entity_matcher {
234        Some(matcher) => matcher.find_relevant(question, store, 10),
235        None => find_relevant_entities(store, question),
236    };
237
238    let (entity_lines, relation_lines) = if seed_entities.is_empty() {
239        (String::from("No relevant entities found."), String::new())
240    } else {
241        let mut all_entity_ids: HashSet<String> = HashSet::new();
242        let mut all_entities = Vec::new();
243        let mut all_relations = Vec::new();
244        let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
245
246        for seed in &seed_entities {
247            let (ents, rels) = store.subgraph(seed, 1);
248            for e in ents {
249                if all_entity_ids.insert(e.id.clone()) {
250                    all_entities.push(e);
251                }
252            }
253            for r in rels {
254                let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
255                if seen_rel_keys.insert(key) {
256                    all_relations.push(r);
257                }
258            }
259        }
260
261        let el: Vec<String> = all_entities
262            .iter()
263            .map(|e| format!("- {} ({}): {}", e.name, e.entity_type, e.description))
264            .collect();
265        let rl: Vec<String> = all_relations
266            .iter()
267            .map(|r| format_relation(r, store))
268            .collect();
269        (el.join("\n"), rl.join("\n"))
270    };
271
272    let question_str = question.to_string();
273    let prompt = format_template(
274        HYBRID_QUERY_PROMPT,
275        &[
276            ("summaries", &summaries_text),
277            ("entities", &entity_lines),
278            ("relations", &relation_lines),
279            ("question", &question_str),
280        ],
281    );
282
283    let prompt = truncate_prompt(&prompt, max_context_tokens);
284
285    let messages = vec![Message::human(prompt)];
286    let response: LLMResult = llm
287        .chat(messages, None)
288        .await
289        .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
290
291    let mut sources: Vec<String> = seed_entities;
292    if !summaries.is_empty() {
293        sources.push(format!("{} community summaries", summaries.len()));
294    }
295
296    Ok(GraphRAGResult {
297        answer: response.content.trim().to_string(),
298        sources,
299        mode: QueryMode::Hybrid,
300    })
301}
302
303/// Helper: format a PromptTemplate with the given key-value pairs.
304fn format_template(template_str: &str, vars: &[(&str, &str)]) -> String {
305    let template = PromptTemplate::new(template_str);
306    let mut map = HashMap::new();
307    for (k, v) in vars {
308        map.insert(*k, *v);
309    }
310    template
311        .format(&map)
312        .unwrap_or_else(|_| template_str.to_string())
313}
314
315/// Truncates community summaries to fit within a token budget.
316///
317/// Keeps summaries from the beginning (highest-priority, largest communities)
318/// until the budget is exceeded, then drops the rest.
319fn truncate_summaries(summaries: &[String], max_tokens: Option<usize>) -> String {
320    let all_text = summaries.join("\n\n");
321
322    match max_tokens {
323        Some(budget) => {
324            let mut result = String::new();
325            let mut used_tokens = 0usize;
326
327            for summary in summaries {
328                let summary_tokens = count_tokens(summary);
329                if used_tokens + summary_tokens > budget {
330                    break;
331                }
332                if !result.is_empty() {
333                    result.push_str("\n\n");
334                }
335                result.push_str(summary);
336                used_tokens += summary_tokens;
337            }
338
339            if result.is_empty() {
340                // If even the first summary exceeds the budget, include it truncated
341                summaries.first().cloned().unwrap_or_default()
342            } else {
343                result
344            }
345        }
346        None => all_text,
347    }
348}
349
350/// Truncates a full prompt to fit within a token budget.
351///
352/// Keeps the prompt prefix (before the context) intact and truncates
353/// the context portion. If the prompt is already within budget, returns
354/// it unchanged.
355fn truncate_prompt(prompt: &str, max_tokens: Option<usize>) -> String {
356    match max_tokens {
357        Some(budget) => {
358            let current_tokens = count_tokens(prompt);
359            if current_tokens <= budget {
360                return prompt.to_string();
361            }
362
363            // Truncate from the end, keeping character boundaries
364            let ratio = budget as f64 / current_tokens as f64;
365            let target_chars = (prompt.len() as f64 * ratio) as usize;
366            // Find a safe char boundary
367            let truncated: String = prompt.chars().take(target_chars).collect();
368            format!("{}\n\n[Context truncated to fit token budget]", truncated)
369        }
370        None => prompt.to_string(),
371    }
372}
373
374/// Finds entity ids whose name or description contains query keywords.
375fn find_relevant_entities(store: &GraphStore, query: &str) -> Vec<String> {
376    let query_lower = query.to_lowercase();
377    // Split on whitespace and strip common punctuation so "Rust?" matches "Rust".
378    let keywords: Vec<&str> = query_lower
379        .split_whitespace()
380        .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
381        .filter(|w| !w.is_empty())
382        .collect();
383
384    let mut scored: Vec<(String, usize)> = Vec::new();
385
386    for (id, entity) in store.all_entities() {
387        let name_lower = entity.name.to_lowercase();
388        let desc_lower = entity.description.to_lowercase();
389        let type_lower = entity.entity_type.to_lowercase();
390
391        let mut score = 0usize;
392        for kw in &keywords {
393            if name_lower.contains(kw) {
394                score += 3; // name match is strongest
395            }
396            if type_lower.contains(kw) {
397                score += 2;
398            }
399            if desc_lower.contains(kw) {
400                score += 1;
401            }
402        }
403
404        if score > 0 {
405            scored.push((id.clone(), score));
406        }
407    }
408
409    scored.sort_by(|a, b| b.1.cmp(&a.1));
410    scored.into_iter().map(|(id, _)| id).collect()
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::graph_rag::graph_store::{Entity, Relation};
417
418    #[test]
419    fn test_find_relevant_entities() {
420        let mut store = GraphStore::new();
421        store.add_entity(Entity {
422            id: "e1".into(),
423            name: "Rust".into(),
424            entity_type: "Technology".into(),
425            description: "A systems programming language".into(),
426        });
427        store.add_entity(Entity {
428            id: "e2".into(),
429            name: "Python".into(),
430            entity_type: "Technology".into(),
431            description: "A scripting language".into(),
432        });
433        store.add_entity(Entity {
434            id: "e3".into(),
435            name: "Alice".into(),
436            entity_type: "Person".into(),
437            description: "A developer who uses Rust".into(),
438        });
439
440        let results = find_relevant_entities(&store, "Rust programming");
441        assert!(!results.is_empty());
442        // "Rust" entity should rank first (name match + description match)
443        assert_eq!(results[0], "e1");
444    }
445
446    #[test]
447    fn test_find_relevant_entities_no_match() {
448        let mut store = GraphStore::new();
449        store.add_entity(Entity {
450            id: "e1".into(),
451            name: "Rust".into(),
452            entity_type: "Technology".into(),
453            description: "A systems programming language".into(),
454        });
455
456        let results = find_relevant_entities(&store, "cooking recipe");
457        assert!(results.is_empty());
458    }
459
460    #[test]
461    fn test_truncate_summaries_no_limit() {
462        let summaries = vec!["Summary 1".to_string(), "Summary 2".to_string()];
463        let result = truncate_summaries(&summaries, None);
464        assert_eq!(result, "Summary 1\n\nSummary 2");
465    }
466
467    #[test]
468    fn test_truncate_summaries_within_budget() {
469        let summaries = vec!["Short summary".to_string()];
470        let result = truncate_summaries(&summaries, Some(100));
471        assert_eq!(result, "Short summary");
472    }
473
474    #[test]
475    fn test_truncate_summaries_exceeds_budget() {
476        let summaries = vec![
477            "First summary that is reasonably long".to_string(),
478            "Second summary that should be dropped".to_string(),
479        ];
480        // Budget of 5 tokens — only the first summary fits
481        let result = truncate_summaries(&summaries, Some(5));
482        assert!(result.contains("First summary"));
483        assert!(!result.contains("Second summary"));
484    }
485
486    #[test]
487    fn test_truncate_prompt_no_limit() {
488        let prompt = "This is a long prompt with lots of context".to_string();
489        let result = truncate_prompt(&prompt, None);
490        assert_eq!(result, prompt);
491    }
492
493    #[test]
494    fn test_truncate_prompt_within_budget() {
495        let prompt = "Short prompt".to_string();
496        let result = truncate_prompt(&prompt, Some(100));
497        assert_eq!(result, "Short prompt");
498    }
499
500    /// Verify that the HashSet-based dedup logic correctly removes duplicate relations.
501    /// This tests the pattern used in both local_query and hybrid_query.
502    #[test]
503    fn test_hybrid_query_relation_dedup_with_hashset() {
504        let mut store = GraphStore::new();
505        store.add_entity(Entity {
506            id: "e1".into(),
507            name: "Rust".into(),
508            entity_type: "Technology".into(),
509            description: "A systems programming language".into(),
510        });
511        store.add_entity(Entity {
512            id: "e2".into(),
513            name: "Mozilla".into(),
514            entity_type: "Organization".into(),
515            description: "Organization behind Rust".into(),
516        });
517        // Add the same relation twice — the HashSet dedup should keep only one
518        store.add_relation(Relation {
519            source: "e1".into(),
520            target: "e2".into(),
521            relation_type: "created_by".into(),
522            description: String::new(),
523            doc_id: None,
524        });
525        store.add_relation(Relation {
526            source: "e1".into(),
527            target: "e2".into(),
528            relation_type: "created_by".into(),
529            description: String::new(),
530            doc_id: None,
531        });
532
533        // Simulate the dedup logic from hybrid_query (HashSet outside the loop)
534        let seed_entities = vec!["e1".to_string()];
535        let mut all_relations = Vec::new();
536        let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
537        for seed in &seed_entities {
538            let (_, rels) = store.subgraph(seed, 1);
539            for r in rels {
540                let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
541                if seen_rel_keys.insert(key) {
542                    all_relations.push(r);
543                }
544            }
545        }
546
547        // Even though we added 2 identical relations, the HashSet dedup should keep only 1
548        // (Note: GraphStore internally deduplicates too, so we may get 1 or 2 from subgraph.
549        //  The key test is that the HashSet pattern works correctly.)
550        let unique_keys: HashSet<(String, String, String)> = all_relations
551            .iter()
552            .map(|r| (r.source.clone(), r.target.clone(), r.relation_type.clone()))
553            .collect();
554        assert_eq!(
555            unique_keys.len(),
556            1,
557            "should have exactly 1 unique relation after HashSet dedup"
558        );
559    }
560}