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/// Counts tokens; when the encoder fails to load, overestimates with the byte length
323/// (better to slightly exceed the budget than to silently count 0 and break truncation).
324fn count_tokens_estimate(text: &str) -> usize {
325    count_tokens(text).unwrap_or_else(|e| {
326        log::warn!("token counting failed, falling back to byte-length estimate: {e}");
327        text.len()
328    })
329}
330
331/// Truncates community summaries to fit within a token budget.
332///
333/// Keeps summaries from the beginning (highest-priority, largest communities)
334/// until the budget is exceeded, then drops the rest.
335fn truncate_summaries(summaries: &[String], max_tokens: Option<usize>) -> String {
336    let all_text = summaries.join("\n\n");
337
338    match max_tokens {
339        Some(budget) => {
340            let mut result = String::new();
341            let mut used_tokens = 0usize;
342
343            for summary in summaries {
344                let summary_tokens = count_tokens_estimate(summary);
345                if used_tokens + summary_tokens > budget {
346                    break;
347                }
348                if !result.is_empty() {
349                    result.push_str("\n\n");
350                }
351                result.push_str(summary);
352                used_tokens += summary_tokens;
353            }
354
355            if result.is_empty() {
356                // If even the first summary exceeds the budget, include it truncated
357                summaries.first().cloned().unwrap_or_default()
358            } else {
359                result
360            }
361        }
362        None => all_text,
363    }
364}
365
366/// Truncates a full prompt to fit within a token budget.
367///
368/// Keeps the prompt prefix (before the context) intact and truncates
369/// the context portion. If the prompt is already within budget, returns
370/// it unchanged.
371fn truncate_prompt(prompt: &str, max_tokens: Option<usize>) -> String {
372    match max_tokens {
373        Some(budget) => {
374            let current_tokens = count_tokens_estimate(prompt);
375            if current_tokens <= budget {
376                return prompt.to_string();
377            }
378
379            // Truncate from the end, keeping character boundaries
380            let ratio = budget as f64 / current_tokens as f64;
381            let target_chars = (prompt.len() as f64 * ratio) as usize;
382            // Find a safe char boundary
383            let truncated: String = prompt.chars().take(target_chars).collect();
384            format!("{}\n\n[Context truncated to fit token budget]", truncated)
385        }
386        None => prompt.to_string(),
387    }
388}
389
390/// Finds entity ids whose name or description contains query keywords.
391///
392/// P1-6: delegates to [`KeywordMatcher`](super::matcher::KeywordMatcher), eliminating the
393/// duplicated name+3/type+2/desc+1 keyword-weighting implementation that used to live in
394/// both query.rs and matcher.rs. The old function returned all hits (no top_k limit), so a
395/// sufficiently large k is passed to keep the behavior unchanged.
396/// P2-4: improvements such as synonyms / Chinese-English normalization / CJK bigrams /
397/// TF-IDF weighting all land on `KeywordMatcher` through the delegation; nothing to change here.
398fn find_relevant_entities(store: &GraphStore, query: &str) -> Vec<String> {
399    let matcher = KeywordMatcher::new();
400    matcher.find_relevant(query, store, usize::MAX)
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::graph_rag::graph_store::{Entity, Relation};
407
408    #[test]
409    fn test_find_relevant_entities() {
410        let mut store = GraphStore::new();
411        store.add_entity(Entity {
412            id: "e1".into(),
413            name: "Rust".into(),
414            entity_type: "Technology".into(),
415            description: "A systems programming language".into(),
416        });
417        store.add_entity(Entity {
418            id: "e2".into(),
419            name: "Python".into(),
420            entity_type: "Technology".into(),
421            description: "A scripting language".into(),
422        });
423        store.add_entity(Entity {
424            id: "e3".into(),
425            name: "Alice".into(),
426            entity_type: "Person".into(),
427            description: "A developer who uses Rust".into(),
428        });
429
430        let results = find_relevant_entities(&store, "Rust programming");
431        assert!(!results.is_empty());
432        // "Rust" entity should rank first (name match + description match)
433        assert_eq!(results[0], "e1");
434    }
435
436    #[test]
437    fn test_find_relevant_entities_no_match() {
438        let mut store = GraphStore::new();
439        store.add_entity(Entity {
440            id: "e1".into(),
441            name: "Rust".into(),
442            entity_type: "Technology".into(),
443            description: "A systems programming language".into(),
444        });
445
446        let results = find_relevant_entities(&store, "cooking recipe");
447        assert!(results.is_empty());
448    }
449
450    /// P1-6: `find_relevant_entities` delegates to `KeywordMatcher`; the results must match.
451    #[test]
452    fn test_find_relevant_entities_matches_keyword_matcher() {
453        let mut store = GraphStore::new();
454        store.add_entity(Entity {
455            id: "e1".into(),
456            name: "Rust".into(),
457            entity_type: "Technology".into(),
458            description: "A systems programming language".into(),
459        });
460        store.add_entity(Entity {
461            id: "e2".into(),
462            name: "Python".into(),
463            entity_type: "Technology".into(),
464            description: "A scripting language".into(),
465        });
466        store.add_entity(Entity {
467            id: "e3".into(),
468            name: "Alice".into(),
469            entity_type: "Person".into(),
470            description: "A developer who uses Rust".into(),
471        });
472
473        let via_delegate = find_relevant_entities(&store, "Rust programming");
474        let via_matcher =
475            KeywordMatcher::new().find_relevant("Rust programming", &store, usize::MAX);
476        assert_eq!(via_delegate, via_matcher);
477    }
478
479    #[test]
480    fn test_truncate_summaries_no_limit() {
481        let summaries = vec!["Summary 1".to_string(), "Summary 2".to_string()];
482        let result = truncate_summaries(&summaries, None);
483        assert_eq!(result, "Summary 1\n\nSummary 2");
484    }
485
486    #[test]
487    fn test_truncate_summaries_within_budget() {
488        let summaries = vec!["Short summary".to_string()];
489        let result = truncate_summaries(&summaries, Some(100));
490        assert_eq!(result, "Short summary");
491    }
492
493    #[test]
494    fn test_truncate_summaries_exceeds_budget() {
495        let summaries = vec![
496            "First summary that is reasonably long".to_string(),
497            "Second summary that should be dropped".to_string(),
498        ];
499        // Budget of 5 tokens — only the first summary fits
500        let result = truncate_summaries(&summaries, Some(5));
501        assert!(result.contains("First summary"));
502        assert!(!result.contains("Second summary"));
503    }
504
505    #[test]
506    fn test_truncate_prompt_no_limit() {
507        let prompt = "This is a long prompt with lots of context".to_string();
508        let result = truncate_prompt(&prompt, None);
509        assert_eq!(result, prompt);
510    }
511
512    #[test]
513    fn test_truncate_prompt_within_budget() {
514        let prompt = "Short prompt".to_string();
515        let result = truncate_prompt(&prompt, Some(100));
516        assert_eq!(result, "Short prompt");
517    }
518
519    /// Verify that the HashSet-based dedup logic correctly removes duplicate relations.
520    /// This tests the pattern used in both local_query and hybrid_query.
521    #[test]
522    fn test_hybrid_query_relation_dedup_with_hashset() {
523        let mut store = GraphStore::new();
524        store.add_entity(Entity {
525            id: "e1".into(),
526            name: "Rust".into(),
527            entity_type: "Technology".into(),
528            description: "A systems programming language".into(),
529        });
530        store.add_entity(Entity {
531            id: "e2".into(),
532            name: "Mozilla".into(),
533            entity_type: "Organization".into(),
534            description: "Organization behind Rust".into(),
535        });
536        // Add the same relation twice — the HashSet dedup should keep only one
537        store.add_relation(Relation {
538            source: "e1".into(),
539            target: "e2".into(),
540            relation_type: "created_by".into(),
541            description: String::new(),
542            doc_id: None,
543        });
544        store.add_relation(Relation {
545            source: "e1".into(),
546            target: "e2".into(),
547            relation_type: "created_by".into(),
548            description: String::new(),
549            doc_id: None,
550        });
551
552        // Simulate the dedup logic from hybrid_query (HashSet outside the loop)
553        let seed_entities = vec!["e1".to_string()];
554        let mut all_relations = Vec::new();
555        let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
556        for seed in &seed_entities {
557            let (_, rels) = store.subgraph(seed, 1);
558            for r in rels {
559                let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
560                if seen_rel_keys.insert(key) {
561                    all_relations.push(r);
562                }
563            }
564        }
565
566        // Even though we added 2 identical relations, the HashSet dedup should keep only 1
567        // (Note: GraphStore internally deduplicates too, so we may get 1 or 2 from subgraph.
568        //  The key test is that the HashSet pattern works correctly.)
569        let unique_keys: HashSet<(String, String, String)> = all_relations
570            .iter()
571            .map(|r| (r.source.clone(), r.target.clone(), r.relation_type.clone()))
572            .collect();
573        assert_eq!(
574            unique_keys.len(),
575            1,
576            "should have exactly 1 unique relation after HashSet dedup"
577        );
578    }
579}