1use 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
17fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum QueryMode {
43 Global,
44 Local,
45 Hybrid,
46}
47
48#[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
98pub 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
140pub 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 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 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
218pub 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
304fn 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
316fn 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 summaries.first().cloned().unwrap_or_default()
343 } else {
344 result
345 }
346 }
347 None => all_text,
348 }
349}
350
351fn 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 let ratio = budget as f64 / current_tokens as f64;
366 let target_chars = (prompt.len() as f64 * ratio) as usize;
367 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
375fn 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 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 #[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 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 #[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 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 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 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}