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