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/// 计 token;编码器加载失败时按字节数高估(宁可略超预算,不静默按 0 算导致截断失效)。
317fn count_tokens_estimate(text: &str) -> usize {
318    count_tokens(text).unwrap_or_else(|e| {
319        log::warn!("token 计数失败,回退为按字节数估算: {e}");
320        text.len()
321    })
322}
323
324/// Truncates community summaries to fit within a token budget.
325///
326/// Keeps summaries from the beginning (highest-priority, largest communities)
327/// until the budget is exceeded, then drops the rest.
328fn truncate_summaries(summaries: &[String], max_tokens: Option<usize>) -> String {
329    let all_text = summaries.join("\n\n");
330
331    match max_tokens {
332        Some(budget) => {
333            let mut result = String::new();
334            let mut used_tokens = 0usize;
335
336            for summary in summaries {
337                let summary_tokens = count_tokens_estimate(summary);
338                if used_tokens + summary_tokens > budget {
339                    break;
340                }
341                if !result.is_empty() {
342                    result.push_str("\n\n");
343                }
344                result.push_str(summary);
345                used_tokens += summary_tokens;
346            }
347
348            if result.is_empty() {
349                // If even the first summary exceeds the budget, include it truncated
350                summaries.first().cloned().unwrap_or_default()
351            } else {
352                result
353            }
354        }
355        None => all_text,
356    }
357}
358
359/// Truncates a full prompt to fit within a token budget.
360///
361/// Keeps the prompt prefix (before the context) intact and truncates
362/// the context portion. If the prompt is already within budget, returns
363/// it unchanged.
364fn truncate_prompt(prompt: &str, max_tokens: Option<usize>) -> String {
365    match max_tokens {
366        Some(budget) => {
367            let current_tokens = count_tokens_estimate(prompt);
368            if current_tokens <= budget {
369                return prompt.to_string();
370            }
371
372            // Truncate from the end, keeping character boundaries
373            let ratio = budget as f64 / current_tokens as f64;
374            let target_chars = (prompt.len() as f64 * ratio) as usize;
375            // Find a safe char boundary
376            let truncated: String = prompt.chars().take(target_chars).collect();
377            format!("{}\n\n[Context truncated to fit token budget]", truncated)
378        }
379        None => prompt.to_string(),
380    }
381}
382
383/// Finds entity ids whose name or description contains query keywords.
384///
385/// P1-6: 委托给 [`KeywordMatcher`](super::matcher::KeywordMatcher),消除
386/// 原先 query.rs 与 matcher.rs 两处重复的 name+3/type+2/desc+1 关键词权重实现。
387/// 旧函数返回全部命中(无 top_k 限制),故传一个足够大的 k 保持行为不变。
388/// P2-4: 同义词/中英归一化/CJK 二元组/TF-IDF 加权等改进都随委托落在
389/// `KeywordMatcher` 上,这里无需改动。
390fn find_relevant_entities(store: &GraphStore, query: &str) -> Vec<String> {
391    let matcher = KeywordMatcher::new();
392    matcher.find_relevant(query, store, usize::MAX)
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::graph_rag::graph_store::{Entity, Relation};
399
400    #[test]
401    fn test_find_relevant_entities() {
402        let mut store = GraphStore::new();
403        store.add_entity(Entity {
404            id: "e1".into(),
405            name: "Rust".into(),
406            entity_type: "Technology".into(),
407            description: "A systems programming language".into(),
408        });
409        store.add_entity(Entity {
410            id: "e2".into(),
411            name: "Python".into(),
412            entity_type: "Technology".into(),
413            description: "A scripting language".into(),
414        });
415        store.add_entity(Entity {
416            id: "e3".into(),
417            name: "Alice".into(),
418            entity_type: "Person".into(),
419            description: "A developer who uses Rust".into(),
420        });
421
422        let results = find_relevant_entities(&store, "Rust programming");
423        assert!(!results.is_empty());
424        // "Rust" entity should rank first (name match + description match)
425        assert_eq!(results[0], "e1");
426    }
427
428    #[test]
429    fn test_find_relevant_entities_no_match() {
430        let mut store = GraphStore::new();
431        store.add_entity(Entity {
432            id: "e1".into(),
433            name: "Rust".into(),
434            entity_type: "Technology".into(),
435            description: "A systems programming language".into(),
436        });
437
438        let results = find_relevant_entities(&store, "cooking recipe");
439        assert!(results.is_empty());
440    }
441
442    /// P1-6: `find_relevant_entities` 委托 `KeywordMatcher`,结果必须一致。
443    #[test]
444    fn test_find_relevant_entities_matches_keyword_matcher() {
445        let mut store = GraphStore::new();
446        store.add_entity(Entity {
447            id: "e1".into(),
448            name: "Rust".into(),
449            entity_type: "Technology".into(),
450            description: "A systems programming language".into(),
451        });
452        store.add_entity(Entity {
453            id: "e2".into(),
454            name: "Python".into(),
455            entity_type: "Technology".into(),
456            description: "A scripting language".into(),
457        });
458        store.add_entity(Entity {
459            id: "e3".into(),
460            name: "Alice".into(),
461            entity_type: "Person".into(),
462            description: "A developer who uses Rust".into(),
463        });
464
465        let via_delegate = find_relevant_entities(&store, "Rust programming");
466        let via_matcher =
467            KeywordMatcher::new().find_relevant("Rust programming", &store, usize::MAX);
468        assert_eq!(via_delegate, via_matcher);
469    }
470
471    #[test]
472    fn test_truncate_summaries_no_limit() {
473        let summaries = vec!["Summary 1".to_string(), "Summary 2".to_string()];
474        let result = truncate_summaries(&summaries, None);
475        assert_eq!(result, "Summary 1\n\nSummary 2");
476    }
477
478    #[test]
479    fn test_truncate_summaries_within_budget() {
480        let summaries = vec!["Short summary".to_string()];
481        let result = truncate_summaries(&summaries, Some(100));
482        assert_eq!(result, "Short summary");
483    }
484
485    #[test]
486    fn test_truncate_summaries_exceeds_budget() {
487        let summaries = vec![
488            "First summary that is reasonably long".to_string(),
489            "Second summary that should be dropped".to_string(),
490        ];
491        // Budget of 5 tokens — only the first summary fits
492        let result = truncate_summaries(&summaries, Some(5));
493        assert!(result.contains("First summary"));
494        assert!(!result.contains("Second summary"));
495    }
496
497    #[test]
498    fn test_truncate_prompt_no_limit() {
499        let prompt = "This is a long prompt with lots of context".to_string();
500        let result = truncate_prompt(&prompt, None);
501        assert_eq!(result, prompt);
502    }
503
504    #[test]
505    fn test_truncate_prompt_within_budget() {
506        let prompt = "Short prompt".to_string();
507        let result = truncate_prompt(&prompt, Some(100));
508        assert_eq!(result, "Short prompt");
509    }
510
511    /// Verify that the HashSet-based dedup logic correctly removes duplicate relations.
512    /// This tests the pattern used in both local_query and hybrid_query.
513    #[test]
514    fn test_hybrid_query_relation_dedup_with_hashset() {
515        let mut store = GraphStore::new();
516        store.add_entity(Entity {
517            id: "e1".into(),
518            name: "Rust".into(),
519            entity_type: "Technology".into(),
520            description: "A systems programming language".into(),
521        });
522        store.add_entity(Entity {
523            id: "e2".into(),
524            name: "Mozilla".into(),
525            entity_type: "Organization".into(),
526            description: "Organization behind Rust".into(),
527        });
528        // Add the same relation twice — the HashSet dedup should keep only one
529        store.add_relation(Relation {
530            source: "e1".into(),
531            target: "e2".into(),
532            relation_type: "created_by".into(),
533            description: String::new(),
534            doc_id: None,
535        });
536        store.add_relation(Relation {
537            source: "e1".into(),
538            target: "e2".into(),
539            relation_type: "created_by".into(),
540            description: String::new(),
541            doc_id: None,
542        });
543
544        // Simulate the dedup logic from hybrid_query (HashSet outside the loop)
545        let seed_entities = vec!["e1".to_string()];
546        let mut all_relations = Vec::new();
547        let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
548        for seed in &seed_entities {
549            let (_, rels) = store.subgraph(seed, 1);
550            for r in rels {
551                let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
552                if seen_rel_keys.insert(key) {
553                    all_relations.push(r);
554                }
555            }
556        }
557
558        // Even though we added 2 identical relations, the HashSet dedup should keep only 1
559        // (Note: GraphStore internally deduplicates too, so we may get 1 or 2 from subgraph.
560        //  The key test is that the HashSet pattern works correctly.)
561        let unique_keys: HashSet<(String, String, String)> = all_relations
562            .iter()
563            .map(|r| (r.source.clone(), r.target.clone(), r.relation_type.clone()))
564            .collect();
565        assert_eq!(
566            unique_keys.len(),
567            1,
568            "should have exactly 1 unique relation after HashSet dedup"
569        );
570    }
571}