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,
46 Local,
48 Hybrid,
50 GlobalAt(GlobalLevel),
52 HybridAt(GlobalLevel),
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum GlobalLevel {
60 #[default]
64 Coarsest,
65 Level(usize),
67 All,
70}
71
72fn select_summary_ids(store: &GraphStore, level: GlobalLevel) -> Vec<usize> {
75 store
76 .communities()
77 .iter()
78 .filter(|c| match level {
79 GlobalLevel::Coarsest => c.parent.is_none(),
80 GlobalLevel::Level(l) => c.level == l,
81 GlobalLevel::All => true,
82 })
83 .map(|c| c.id)
84 .collect()
85}
86
87fn level_name(level: GlobalLevel) -> String {
89 match level {
90 GlobalLevel::Coarsest => "coarsest".to_string(),
91 GlobalLevel::Level(l) => format!("level {l}"),
92 GlobalLevel::All => "any level".to_string(),
93 }
94}
95
96#[derive(Debug, Clone)]
98pub struct GraphRAGResult {
99 pub answer: String,
101 pub sources: Vec<String>,
103 pub mode: QueryMode,
105}
106
107const GLOBAL_QUERY_PROMPT: &str = r#"You are a helpful assistant answering questions based on community summaries from a knowledge graph.
108
109Community Summaries:
110{summaries}
111
112Question: {question}
113
114Provide a comprehensive answer based on the community summaries above. If the summaries do not contain enough information, say so.
115
116Answer:"#;
117
118const LOCAL_QUERY_PROMPT: &str = r#"You are a helpful assistant answering questions based on a local subgraph from a knowledge graph.
119
120Entities:
121{entities}
122
123Relations:
124{relations}
125
126Question: {question}
127
128Provide a detailed answer based on the local subgraph information above. If the subgraph does not contain enough information, say so.
129
130Answer:"#;
131
132const 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.
133
134Community Summaries:
135{summaries}
136
137Local Subgraph Entities:
138{entities}
139
140Local Subgraph Relations:
141{relations}
142
143Question: {question}
144
145Provide a comprehensive answer synthesizing both the community-level and local-level information. If there is not enough information, say so.
146
147Answer:"#;
148
149pub async fn global_query<M: BaseChatModel>(
152 llm: &M,
153 store: &GraphStore,
154 question: &str,
155 max_context_tokens: Option<usize>,
156 level: GlobalLevel,
157) -> Result<GraphRAGResult, super::GraphRAGError> {
158 let all_summaries = store.community_summaries();
159 if all_summaries.is_empty() {
160 return Err(super::GraphRAGError::QueryError(
161 "No community summaries available. Call build_communities() first.".into(),
162 ));
163 }
164
165 let selected_ids = select_summary_ids(store, level);
166 if selected_ids.is_empty() {
167 return Err(super::GraphRAGError::QueryError(format!(
168 "No communities at the {} hierarchy level.",
169 level_name(level)
170 )));
171 }
172
173 let selected: Vec<String> = selected_ids
174 .iter()
175 .filter_map(|id| all_summaries.get(*id).cloned())
176 .collect();
177 let summaries_text = truncate_summaries(&selected, max_context_tokens);
178 let question_str = question.to_string();
179 let prompt = format_template(
180 GLOBAL_QUERY_PROMPT,
181 &[("summaries", &summaries_text), ("question", &question_str)],
182 );
183
184 let messages = vec![Message::human(prompt)];
185 let response: LLMResult = llm
186 .chat(messages, None)
187 .await
188 .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
189
190 let sources: Vec<String> = selected_ids
191 .iter()
192 .flat_map(|id| {
193 store
194 .communities()
195 .iter()
196 .find(|c| c.id == *id)
197 .into_iter()
198 .flat_map(|c| c.entities.iter().cloned())
199 })
200 .collect::<HashSet<_>>()
201 .into_iter()
202 .collect();
203
204 Ok(GraphRAGResult {
205 answer: response.content.trim().to_string(),
206 sources,
207 mode: if level == GlobalLevel::Coarsest {
208 QueryMode::Global
209 } else {
210 QueryMode::GlobalAt(level)
211 },
212 })
213}
214
215pub async fn local_query<M: BaseChatModel>(
218 llm: &M,
219 store: &GraphStore,
220 question: &str,
221 max_context_tokens: Option<usize>,
222 entity_matcher: Option<&dyn super::matcher::EntityMatcher>,
223) -> Result<GraphRAGResult, super::GraphRAGError> {
224 let seed_entities = match entity_matcher {
225 Some(matcher) => matcher.find_relevant(question, store, 10),
226 None => find_relevant_entities(store, question),
227 };
228 if seed_entities.is_empty() {
229 return Err(super::GraphRAGError::QueryError(
230 "No relevant entities found for the query.".into(),
231 ));
232 }
233
234 let mut all_entity_ids: HashSet<String> = HashSet::new();
236 let mut all_entities = Vec::new();
237 let mut all_relations = Vec::new();
238 let mut seen_relations: HashSet<(String, String, String)> = HashSet::new();
239
240 for seed in &seed_entities {
241 let (ents, rels) = store.subgraph(seed, 1);
242 for e in ents {
243 if all_entity_ids.insert(e.id.clone()) {
244 all_entities.push(e);
245 }
246 }
247 for r in rels {
248 let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
250 if seen_relations.insert(key) {
251 all_relations.push(r);
252 }
253 }
254 }
255
256 let entity_lines: Vec<String> = all_entities
257 .iter()
258 .map(|e| format!("- {} ({}): {}", e.name, e.entity_type, e.description))
259 .collect();
260
261 let relation_lines: Vec<String> = all_relations
262 .iter()
263 .map(|r| format_relation(r, store))
264 .collect();
265
266 let entities_str = entity_lines.join("\n");
267 let relations_str = relation_lines.join("\n");
268 let question_str = question.to_string();
269 let prompt = format_template(
270 LOCAL_QUERY_PROMPT,
271 &[
272 ("entities", &entities_str),
273 ("relations", &relations_str),
274 ("question", &question_str),
275 ],
276 );
277
278 let prompt = truncate_prompt(&prompt, max_context_tokens);
279
280 let messages = vec![Message::human(prompt)];
281 let response: LLMResult = llm
282 .chat(messages, None)
283 .await
284 .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
285
286 Ok(GraphRAGResult {
287 answer: response.content.trim().to_string(),
288 sources: seed_entities,
289 mode: QueryMode::Local,
290 })
291}
292
293pub async fn hybrid_query<M: BaseChatModel>(
296 llm: &M,
297 store: &GraphStore,
298 question: &str,
299 max_context_tokens: Option<usize>,
300 entity_matcher: Option<&dyn super::matcher::EntityMatcher>,
301 level: GlobalLevel,
302) -> Result<GraphRAGResult, super::GraphRAGError> {
303 let all_summaries = store.community_summaries();
304 let selected_ids = if all_summaries.is_empty() {
305 Vec::new()
306 } else {
307 select_summary_ids(store, level)
308 };
309 let summaries_text = if all_summaries.is_empty() {
310 "No community summaries available.".to_string()
311 } else if selected_ids.is_empty() {
312 format!("No community summaries at the {} level.", level_name(level))
315 } else {
316 let selected: Vec<String> = selected_ids
317 .iter()
318 .filter_map(|id| all_summaries.get(*id).cloned())
319 .collect();
320 truncate_summaries(&selected, max_context_tokens)
321 };
322
323 let seed_entities = match entity_matcher {
324 Some(matcher) => matcher.find_relevant(question, store, 10),
325 None => find_relevant_entities(store, question),
326 };
327
328 let (entity_lines, relation_lines) = if seed_entities.is_empty() {
329 (String::from("No relevant entities found."), String::new())
330 } else {
331 let mut all_entity_ids: HashSet<String> = HashSet::new();
332 let mut all_entities = Vec::new();
333 let mut all_relations = Vec::new();
334 let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
335
336 for seed in &seed_entities {
337 let (ents, rels) = store.subgraph(seed, 1);
338 for e in ents {
339 if all_entity_ids.insert(e.id.clone()) {
340 all_entities.push(e);
341 }
342 }
343 for r in rels {
344 let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
345 if seen_rel_keys.insert(key) {
346 all_relations.push(r);
347 }
348 }
349 }
350
351 let el: Vec<String> = all_entities
352 .iter()
353 .map(|e| format!("- {} ({}): {}", e.name, e.entity_type, e.description))
354 .collect();
355 let rl: Vec<String> = all_relations
356 .iter()
357 .map(|r| format_relation(r, store))
358 .collect();
359 (el.join("\n"), rl.join("\n"))
360 };
361
362 let question_str = question.to_string();
363 let prompt = format_template(
364 HYBRID_QUERY_PROMPT,
365 &[
366 ("summaries", &summaries_text),
367 ("entities", &entity_lines),
368 ("relations", &relation_lines),
369 ("question", &question_str),
370 ],
371 );
372
373 let prompt = truncate_prompt(&prompt, max_context_tokens);
374
375 let messages = vec![Message::human(prompt)];
376 let response: LLMResult = llm
377 .chat(messages, None)
378 .await
379 .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
380
381 let mut sources: Vec<String> = seed_entities;
382 if !selected_ids.is_empty() {
383 sources.push(format!(
384 "{} community summaries ({})",
385 selected_ids.len(),
386 level_name(level)
387 ));
388 }
389
390 Ok(GraphRAGResult {
391 answer: response.content.trim().to_string(),
392 sources,
393 mode: if level == GlobalLevel::Coarsest {
394 QueryMode::Hybrid
395 } else {
396 QueryMode::HybridAt(level)
397 },
398 })
399}
400
401fn format_template(template_str: &str, vars: &[(&str, &str)]) -> String {
403 let template = PromptTemplate::new(template_str);
404 let mut map = HashMap::new();
405 for (k, v) in vars {
406 map.insert(*k, *v);
407 }
408 template
409 .format(&map)
410 .unwrap_or_else(|_| template_str.to_string())
411}
412
413fn count_tokens_estimate(text: &str) -> usize {
416 count_tokens(text).unwrap_or_else(|e| {
417 log::warn!("token counting failed, falling back to byte-length estimate: {e}");
418 text.len()
419 })
420}
421
422fn truncate_summaries(summaries: &[String], max_tokens: Option<usize>) -> String {
427 let all_text = summaries.join("\n\n");
428
429 match max_tokens {
430 Some(budget) => {
431 let mut result = String::new();
432 let mut used_tokens = 0usize;
433
434 for summary in summaries {
435 let summary_tokens = count_tokens_estimate(summary);
436 if used_tokens + summary_tokens > budget {
437 break;
438 }
439 if !result.is_empty() {
440 result.push_str("\n\n");
441 }
442 result.push_str(summary);
443 used_tokens += summary_tokens;
444 }
445
446 if result.is_empty() {
447 summaries.first().cloned().unwrap_or_default()
449 } else {
450 result
451 }
452 }
453 None => all_text,
454 }
455}
456
457fn truncate_prompt(prompt: &str, max_tokens: Option<usize>) -> String {
463 match max_tokens {
464 Some(budget) => {
465 let current_tokens = count_tokens_estimate(prompt);
466 if current_tokens <= budget {
467 return prompt.to_string();
468 }
469
470 let ratio = budget as f64 / current_tokens as f64;
472 let target_chars = (prompt.len() as f64 * ratio) as usize;
473 let truncated: String = prompt.chars().take(target_chars).collect();
475 format!("{}\n\n[Context truncated to fit token budget]", truncated)
476 }
477 None => prompt.to_string(),
478 }
479}
480
481fn find_relevant_entities(store: &GraphStore, query: &str) -> Vec<String> {
490 let matcher = KeywordMatcher::new();
491 matcher.find_relevant(query, store, usize::MAX)
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::graph_rag::graph_store::{Entity, Relation};
498
499 #[test]
500 fn test_find_relevant_entities() {
501 let mut store = GraphStore::new();
502 store.add_entity(Entity {
503 id: "e1".into(),
504 name: "Rust".into(),
505 entity_type: "Technology".into(),
506 description: "A systems programming language".into(),
507 });
508 store.add_entity(Entity {
509 id: "e2".into(),
510 name: "Python".into(),
511 entity_type: "Technology".into(),
512 description: "A scripting language".into(),
513 });
514 store.add_entity(Entity {
515 id: "e3".into(),
516 name: "Alice".into(),
517 entity_type: "Person".into(),
518 description: "A developer who uses Rust".into(),
519 });
520
521 let results = find_relevant_entities(&store, "Rust programming");
522 assert!(!results.is_empty());
523 assert_eq!(results[0], "e1");
525 }
526
527 #[test]
528 fn test_find_relevant_entities_no_match() {
529 let mut store = GraphStore::new();
530 store.add_entity(Entity {
531 id: "e1".into(),
532 name: "Rust".into(),
533 entity_type: "Technology".into(),
534 description: "A systems programming language".into(),
535 });
536
537 let results = find_relevant_entities(&store, "cooking recipe");
538 assert!(results.is_empty());
539 }
540
541 #[test]
543 fn test_find_relevant_entities_matches_keyword_matcher() {
544 let mut store = GraphStore::new();
545 store.add_entity(Entity {
546 id: "e1".into(),
547 name: "Rust".into(),
548 entity_type: "Technology".into(),
549 description: "A systems programming language".into(),
550 });
551 store.add_entity(Entity {
552 id: "e2".into(),
553 name: "Python".into(),
554 entity_type: "Technology".into(),
555 description: "A scripting language".into(),
556 });
557 store.add_entity(Entity {
558 id: "e3".into(),
559 name: "Alice".into(),
560 entity_type: "Person".into(),
561 description: "A developer who uses Rust".into(),
562 });
563
564 let via_delegate = find_relevant_entities(&store, "Rust programming");
565 let via_matcher =
566 KeywordMatcher::new().find_relevant("Rust programming", &store, usize::MAX);
567 assert_eq!(via_delegate, via_matcher);
568 }
569
570 fn hierarchy_store() -> GraphStore {
571 let mut store = GraphStore::new();
572 store.set_communities(vec![
574 super::super::graph_store::Community {
575 id: 0,
576 entities: vec!["a".into(), "b".into()],
577 level: 0,
578 parent: Some(3),
579 },
580 super::super::graph_store::Community {
581 id: 1,
582 entities: vec!["c".into(), "d".into()],
583 level: 0,
584 parent: Some(3),
585 },
586 super::super::graph_store::Community {
587 id: 2,
588 entities: vec!["e".into(), "f".into()],
589 level: 0,
590 parent: None,
591 },
592 super::super::graph_store::Community {
593 id: 3,
594 entities: vec!["a".into(), "b".into(), "c".into(), "d".into()],
595 level: 1,
596 parent: None,
597 },
598 ]);
599 store.set_community_summaries(vec![
600 "base-0".into(),
601 "base-1".into(),
602 "base-2".into(),
603 "rollup-3".into(),
604 ]);
605 store
606 }
607
608 #[test]
609 fn select_coarsest_picks_every_subtree_root() {
610 let store = hierarchy_store();
611 assert_eq!(
614 select_summary_ids(&store, GlobalLevel::Coarsest),
615 vec![2, 3]
616 );
617 }
618
619 #[test]
620 fn select_exact_level_filters_by_level() {
621 let store = hierarchy_store();
622 assert_eq!(
623 select_summary_ids(&store, GlobalLevel::Level(0)),
624 vec![0, 1, 2]
625 );
626 assert_eq!(select_summary_ids(&store, GlobalLevel::Level(1)), vec![3]);
627 assert!(select_summary_ids(&store, GlobalLevel::Level(9)).is_empty());
628 }
629
630 #[test]
631 fn select_all_returns_every_community() {
632 let store = hierarchy_store();
633 assert_eq!(
634 select_summary_ids(&store, GlobalLevel::All),
635 vec![0, 1, 2, 3]
636 );
637 }
638
639 #[test]
640 fn test_truncate_summaries_no_limit() {
641 let summaries = vec!["Summary 1".to_string(), "Summary 2".to_string()];
642 let result = truncate_summaries(&summaries, None);
643 assert_eq!(result, "Summary 1\n\nSummary 2");
644 }
645
646 #[test]
647 fn test_truncate_summaries_within_budget() {
648 let summaries = vec!["Short summary".to_string()];
649 let result = truncate_summaries(&summaries, Some(100));
650 assert_eq!(result, "Short summary");
651 }
652
653 #[test]
654 fn test_truncate_summaries_exceeds_budget() {
655 let summaries = vec![
656 "First summary that is reasonably long".to_string(),
657 "Second summary that should be dropped".to_string(),
658 ];
659 let result = truncate_summaries(&summaries, Some(5));
661 assert!(result.contains("First summary"));
662 assert!(!result.contains("Second summary"));
663 }
664
665 #[test]
666 fn test_truncate_prompt_no_limit() {
667 let prompt = "This is a long prompt with lots of context".to_string();
668 let result = truncate_prompt(&prompt, None);
669 assert_eq!(result, prompt);
670 }
671
672 #[test]
673 fn test_truncate_prompt_within_budget() {
674 let prompt = "Short prompt".to_string();
675 let result = truncate_prompt(&prompt, Some(100));
676 assert_eq!(result, "Short prompt");
677 }
678
679 #[test]
682 fn test_hybrid_query_relation_dedup_with_hashset() {
683 let mut store = GraphStore::new();
684 store.add_entity(Entity {
685 id: "e1".into(),
686 name: "Rust".into(),
687 entity_type: "Technology".into(),
688 description: "A systems programming language".into(),
689 });
690 store.add_entity(Entity {
691 id: "e2".into(),
692 name: "Mozilla".into(),
693 entity_type: "Organization".into(),
694 description: "Organization behind Rust".into(),
695 });
696 store.add_relation(Relation {
698 source: "e1".into(),
699 target: "e2".into(),
700 relation_type: "created_by".into(),
701 description: String::new(),
702 doc_id: None,
703 });
704 store.add_relation(Relation {
705 source: "e1".into(),
706 target: "e2".into(),
707 relation_type: "created_by".into(),
708 description: String::new(),
709 doc_id: None,
710 });
711
712 let seed_entities = vec!["e1".to_string()];
714 let mut all_relations = Vec::new();
715 let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
716 for seed in &seed_entities {
717 let (_, rels) = store.subgraph(seed, 1);
718 for r in rels {
719 let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
720 if seen_rel_keys.insert(key) {
721 all_relations.push(r);
722 }
723 }
724 }
725
726 let unique_keys: HashSet<(String, String, String)> = all_relations
730 .iter()
731 .map(|r| (r.source.clone(), r.target.clone(), r.relation_type.clone()))
732 .collect();
733 assert_eq!(
734 unique_keys.len(),
735 1,
736 "should have exactly 1 unique relation after HashSet dedup"
737 );
738 }
739}