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,
45 Local,
47 Hybrid,
49}
50
51#[derive(Debug, Clone)]
53pub struct GraphRAGResult {
54 pub answer: String,
56 pub sources: Vec<String>,
58 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
104pub 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
146pub 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 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 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
224pub 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
310fn 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
322fn 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
331fn 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 summaries.first().cloned().unwrap_or_default()
358 } else {
359 result
360 }
361 }
362 None => all_text,
363 }
364}
365
366fn 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 let ratio = budget as f64 / current_tokens as f64;
381 let target_chars = (prompt.len() as f64 * ratio) as usize;
382 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
390fn 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 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 #[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 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 #[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 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 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 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}