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