1use super::graph_store::GraphStore;
10use lc_core::language_models::{BaseChatModel, LLMResult};
11use lc_core::token_counter::count_tokens;
12use lc_prompts::PromptTemplate;
13use lc_schema::Message;
14use std::collections::{HashMap, HashSet};
15
16fn format_relation(r: &super::graph_store::Relation, store: &GraphStore) -> String {
18 let source_name = store
19 .get_entity(&r.source)
20 .map(|e| e.name.as_str())
21 .unwrap_or(&r.source);
22 let target_name = store
23 .get_entity(&r.target)
24 .map(|e| e.name.as_str())
25 .unwrap_or(&r.target);
26 format!(
27 "- {} --[{}]--> {}{}",
28 source_name,
29 r.relation_type,
30 target_name,
31 if r.description.is_empty() {
32 String::new()
33 } else {
34 format!(": {}", r.description)
35 }
36 )
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum QueryMode {
42 Global,
43 Local,
44 Hybrid,
45}
46
47#[derive(Debug, Clone)]
49pub struct GraphRAGResult {
50 pub answer: String,
51 pub sources: Vec<String>,
52 pub mode: QueryMode,
53}
54
55const GLOBAL_QUERY_PROMPT: &str = r#"You are a helpful assistant answering questions based on community summaries from a knowledge graph.
56
57Community Summaries:
58{summaries}
59
60Question: {question}
61
62Provide a comprehensive answer based on the community summaries above. If the summaries do not contain enough information, say so.
63
64Answer:"#;
65
66const LOCAL_QUERY_PROMPT: &str = r#"You are a helpful assistant answering questions based on a local subgraph from a knowledge graph.
67
68Entities:
69{entities}
70
71Relations:
72{relations}
73
74Question: {question}
75
76Provide a detailed answer based on the local subgraph information above. If the subgraph does not contain enough information, say so.
77
78Answer:"#;
79
80const 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.
81
82Community Summaries:
83{summaries}
84
85Local Subgraph Entities:
86{entities}
87
88Local Subgraph Relations:
89{relations}
90
91Question: {question}
92
93Provide a comprehensive answer synthesizing both the community-level and local-level information. If there is not enough information, say so.
94
95Answer:"#;
96
97pub async fn global_query<M: BaseChatModel>(
99 llm: &M,
100 store: &GraphStore,
101 question: &str,
102 max_context_tokens: Option<usize>,
103) -> Result<GraphRAGResult, super::GraphRAGError> {
104 let summaries = store.community_summaries();
105 if summaries.is_empty() {
106 return Err(super::GraphRAGError::QueryError(
107 "No community summaries available. Call build_communities() first.".into(),
108 ));
109 }
110
111 let summaries_text = truncate_summaries(summaries, max_context_tokens);
112 let question_str = question.to_string();
113 let prompt = format_template(
114 GLOBAL_QUERY_PROMPT,
115 &[("summaries", &summaries_text), ("question", &question_str)],
116 );
117
118 let messages = vec![Message::human(prompt)];
119 let response: LLMResult = llm
120 .chat(messages, None)
121 .await
122 .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
123
124 let sources: Vec<String> = store
125 .communities()
126 .iter()
127 .flat_map(|c| c.entities.iter().cloned())
128 .collect::<HashSet<_>>()
129 .into_iter()
130 .collect();
131
132 Ok(GraphRAGResult {
133 answer: response.content.trim().to_string(),
134 sources,
135 mode: QueryMode::Global,
136 })
137}
138
139pub async fn local_query<M: BaseChatModel>(
142 llm: &M,
143 store: &GraphStore,
144 question: &str,
145 max_context_tokens: Option<usize>,
146 entity_matcher: Option<&dyn super::matcher::EntityMatcher>,
147) -> Result<GraphRAGResult, super::GraphRAGError> {
148 let seed_entities = match entity_matcher {
149 Some(matcher) => matcher.find_relevant(question, store, 10),
150 None => find_relevant_entities(store, question),
151 };
152 if seed_entities.is_empty() {
153 return Err(super::GraphRAGError::QueryError(
154 "No relevant entities found for the query.".into(),
155 ));
156 }
157
158 let mut all_entity_ids: HashSet<String> = HashSet::new();
160 let mut all_entities = Vec::new();
161 let mut all_relations = Vec::new();
162 let mut seen_relations: HashSet<(String, String, String)> = HashSet::new();
163
164 for seed in &seed_entities {
165 let (ents, rels) = store.subgraph(seed, 1);
166 for e in ents {
167 if all_entity_ids.insert(e.id.clone()) {
168 all_entities.push(e);
169 }
170 }
171 for r in rels {
172 let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
174 if seen_relations.insert(key) {
175 all_relations.push(r);
176 }
177 }
178 }
179
180 let entity_lines: Vec<String> = all_entities
181 .iter()
182 .map(|e| format!("- {} ({}): {}", e.name, e.entity_type, e.description))
183 .collect();
184
185 let relation_lines: Vec<String> = all_relations
186 .iter()
187 .map(|r| format_relation(r, store))
188 .collect();
189
190 let entities_str = entity_lines.join("\n");
191 let relations_str = relation_lines.join("\n");
192 let question_str = question.to_string();
193 let prompt = format_template(
194 LOCAL_QUERY_PROMPT,
195 &[
196 ("entities", &entities_str),
197 ("relations", &relations_str),
198 ("question", &question_str),
199 ],
200 );
201
202 let prompt = truncate_prompt(&prompt, max_context_tokens);
203
204 let messages = vec![Message::human(prompt)];
205 let response: LLMResult = llm
206 .chat(messages, None)
207 .await
208 .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
209
210 Ok(GraphRAGResult {
211 answer: response.content.trim().to_string(),
212 sources: seed_entities,
213 mode: QueryMode::Local,
214 })
215}
216
217pub async fn hybrid_query<M: BaseChatModel>(
220 llm: &M,
221 store: &GraphStore,
222 question: &str,
223 max_context_tokens: Option<usize>,
224 entity_matcher: Option<&dyn super::matcher::EntityMatcher>,
225) -> Result<GraphRAGResult, super::GraphRAGError> {
226 let summaries = store.community_summaries();
227 let summaries_text = if summaries.is_empty() {
228 "No community summaries available.".to_string()
229 } else {
230 truncate_summaries(summaries, max_context_tokens)
231 };
232
233 let seed_entities = match entity_matcher {
234 Some(matcher) => matcher.find_relevant(question, store, 10),
235 None => find_relevant_entities(store, question),
236 };
237
238 let (entity_lines, relation_lines) = if seed_entities.is_empty() {
239 (String::from("No relevant entities found."), String::new())
240 } else {
241 let mut all_entity_ids: HashSet<String> = HashSet::new();
242 let mut all_entities = Vec::new();
243 let mut all_relations = Vec::new();
244 let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
245
246 for seed in &seed_entities {
247 let (ents, rels) = store.subgraph(seed, 1);
248 for e in ents {
249 if all_entity_ids.insert(e.id.clone()) {
250 all_entities.push(e);
251 }
252 }
253 for r in rels {
254 let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
255 if seen_rel_keys.insert(key) {
256 all_relations.push(r);
257 }
258 }
259 }
260
261 let el: Vec<String> = all_entities
262 .iter()
263 .map(|e| format!("- {} ({}): {}", e.name, e.entity_type, e.description))
264 .collect();
265 let rl: Vec<String> = all_relations
266 .iter()
267 .map(|r| format_relation(r, store))
268 .collect();
269 (el.join("\n"), rl.join("\n"))
270 };
271
272 let question_str = question.to_string();
273 let prompt = format_template(
274 HYBRID_QUERY_PROMPT,
275 &[
276 ("summaries", &summaries_text),
277 ("entities", &entity_lines),
278 ("relations", &relation_lines),
279 ("question", &question_str),
280 ],
281 );
282
283 let prompt = truncate_prompt(&prompt, max_context_tokens);
284
285 let messages = vec![Message::human(prompt)];
286 let response: LLMResult = llm
287 .chat(messages, None)
288 .await
289 .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
290
291 let mut sources: Vec<String> = seed_entities;
292 if !summaries.is_empty() {
293 sources.push(format!("{} community summaries", summaries.len()));
294 }
295
296 Ok(GraphRAGResult {
297 answer: response.content.trim().to_string(),
298 sources,
299 mode: QueryMode::Hybrid,
300 })
301}
302
303fn format_template(template_str: &str, vars: &[(&str, &str)]) -> String {
305 let template = PromptTemplate::new(template_str);
306 let mut map = HashMap::new();
307 for (k, v) in vars {
308 map.insert(*k, *v);
309 }
310 template
311 .format(&map)
312 .unwrap_or_else(|_| template_str.to_string())
313}
314
315fn truncate_summaries(summaries: &[String], max_tokens: Option<usize>) -> String {
320 let all_text = summaries.join("\n\n");
321
322 match max_tokens {
323 Some(budget) => {
324 let mut result = String::new();
325 let mut used_tokens = 0usize;
326
327 for summary in summaries {
328 let summary_tokens = count_tokens(summary);
329 if used_tokens + summary_tokens > budget {
330 break;
331 }
332 if !result.is_empty() {
333 result.push_str("\n\n");
334 }
335 result.push_str(summary);
336 used_tokens += summary_tokens;
337 }
338
339 if result.is_empty() {
340 summaries.first().cloned().unwrap_or_default()
342 } else {
343 result
344 }
345 }
346 None => all_text,
347 }
348}
349
350fn truncate_prompt(prompt: &str, max_tokens: Option<usize>) -> String {
356 match max_tokens {
357 Some(budget) => {
358 let current_tokens = count_tokens(prompt);
359 if current_tokens <= budget {
360 return prompt.to_string();
361 }
362
363 let ratio = budget as f64 / current_tokens as f64;
365 let target_chars = (prompt.len() as f64 * ratio) as usize;
366 let truncated: String = prompt.chars().take(target_chars).collect();
368 format!("{}\n\n[Context truncated to fit token budget]", truncated)
369 }
370 None => prompt.to_string(),
371 }
372}
373
374fn find_relevant_entities(store: &GraphStore, query: &str) -> Vec<String> {
376 let query_lower = query.to_lowercase();
377 let keywords: Vec<&str> = query_lower
379 .split_whitespace()
380 .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
381 .filter(|w| !w.is_empty())
382 .collect();
383
384 let mut scored: Vec<(String, usize)> = Vec::new();
385
386 for (id, entity) in store.all_entities() {
387 let name_lower = entity.name.to_lowercase();
388 let desc_lower = entity.description.to_lowercase();
389 let type_lower = entity.entity_type.to_lowercase();
390
391 let mut score = 0usize;
392 for kw in &keywords {
393 if name_lower.contains(kw) {
394 score += 3; }
396 if type_lower.contains(kw) {
397 score += 2;
398 }
399 if desc_lower.contains(kw) {
400 score += 1;
401 }
402 }
403
404 if score > 0 {
405 scored.push((id.clone(), score));
406 }
407 }
408
409 scored.sort_by(|a, b| b.1.cmp(&a.1));
410 scored.into_iter().map(|(id, _)| id).collect()
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::graph_rag::graph_store::{Entity, Relation};
417
418 #[test]
419 fn test_find_relevant_entities() {
420 let mut store = GraphStore::new();
421 store.add_entity(Entity {
422 id: "e1".into(),
423 name: "Rust".into(),
424 entity_type: "Technology".into(),
425 description: "A systems programming language".into(),
426 });
427 store.add_entity(Entity {
428 id: "e2".into(),
429 name: "Python".into(),
430 entity_type: "Technology".into(),
431 description: "A scripting language".into(),
432 });
433 store.add_entity(Entity {
434 id: "e3".into(),
435 name: "Alice".into(),
436 entity_type: "Person".into(),
437 description: "A developer who uses Rust".into(),
438 });
439
440 let results = find_relevant_entities(&store, "Rust programming");
441 assert!(!results.is_empty());
442 assert_eq!(results[0], "e1");
444 }
445
446 #[test]
447 fn test_find_relevant_entities_no_match() {
448 let mut store = GraphStore::new();
449 store.add_entity(Entity {
450 id: "e1".into(),
451 name: "Rust".into(),
452 entity_type: "Technology".into(),
453 description: "A systems programming language".into(),
454 });
455
456 let results = find_relevant_entities(&store, "cooking recipe");
457 assert!(results.is_empty());
458 }
459
460 #[test]
461 fn test_truncate_summaries_no_limit() {
462 let summaries = vec!["Summary 1".to_string(), "Summary 2".to_string()];
463 let result = truncate_summaries(&summaries, None);
464 assert_eq!(result, "Summary 1\n\nSummary 2");
465 }
466
467 #[test]
468 fn test_truncate_summaries_within_budget() {
469 let summaries = vec!["Short summary".to_string()];
470 let result = truncate_summaries(&summaries, Some(100));
471 assert_eq!(result, "Short summary");
472 }
473
474 #[test]
475 fn test_truncate_summaries_exceeds_budget() {
476 let summaries = vec![
477 "First summary that is reasonably long".to_string(),
478 "Second summary that should be dropped".to_string(),
479 ];
480 let result = truncate_summaries(&summaries, Some(5));
482 assert!(result.contains("First summary"));
483 assert!(!result.contains("Second summary"));
484 }
485
486 #[test]
487 fn test_truncate_prompt_no_limit() {
488 let prompt = "This is a long prompt with lots of context".to_string();
489 let result = truncate_prompt(&prompt, None);
490 assert_eq!(result, prompt);
491 }
492
493 #[test]
494 fn test_truncate_prompt_within_budget() {
495 let prompt = "Short prompt".to_string();
496 let result = truncate_prompt(&prompt, Some(100));
497 assert_eq!(result, "Short prompt");
498 }
499
500 #[test]
503 fn test_hybrid_query_relation_dedup_with_hashset() {
504 let mut store = GraphStore::new();
505 store.add_entity(Entity {
506 id: "e1".into(),
507 name: "Rust".into(),
508 entity_type: "Technology".into(),
509 description: "A systems programming language".into(),
510 });
511 store.add_entity(Entity {
512 id: "e2".into(),
513 name: "Mozilla".into(),
514 entity_type: "Organization".into(),
515 description: "Organization behind Rust".into(),
516 });
517 store.add_relation(Relation {
519 source: "e1".into(),
520 target: "e2".into(),
521 relation_type: "created_by".into(),
522 description: String::new(),
523 doc_id: None,
524 });
525 store.add_relation(Relation {
526 source: "e1".into(),
527 target: "e2".into(),
528 relation_type: "created_by".into(),
529 description: String::new(),
530 doc_id: None,
531 });
532
533 let seed_entities = vec!["e1".to_string()];
535 let mut all_relations = Vec::new();
536 let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
537 for seed in &seed_entities {
538 let (_, rels) = store.subgraph(seed, 1);
539 for r in rels {
540 let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
541 if seen_rel_keys.insert(key) {
542 all_relations.push(r);
543 }
544 }
545 }
546
547 let unique_keys: HashSet<(String, String, String)> = all_relations
551 .iter()
552 .map(|r| (r.source.clone(), r.target.clone(), r.relation_type.clone()))
553 .collect();
554 assert_eq!(
555 unique_keys.len(),
556 1,
557 "should have exactly 1 unique relation after HashSet dedup"
558 );
559 }
560}