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 {
324 count_tokens(text).unwrap_or_else(|e| {
325 log::warn!("token counting failed, falling back to byte-length estimate: {e}");
326 text.len()
327 })
328}
329
330fn truncate_summaries(summaries: &[String], max_tokens: Option<usize>) -> String {
335 let all_text = summaries.join("\n\n");
336
337 match max_tokens {
338 Some(budget) => {
339 let mut result = String::new();
340 let mut used_tokens = 0usize;
341
342 for summary in summaries {
343 let summary_tokens = count_tokens_estimate(summary);
344 if used_tokens + summary_tokens > budget {
345 break;
346 }
347 if !result.is_empty() {
348 result.push_str("\n\n");
349 }
350 result.push_str(summary);
351 used_tokens += summary_tokens;
352 }
353
354 if result.is_empty() {
355 summaries.first().cloned().unwrap_or_default()
357 } else {
358 result
359 }
360 }
361 None => all_text,
362 }
363}
364
365fn truncate_prompt(prompt: &str, max_tokens: Option<usize>) -> String {
371 match max_tokens {
372 Some(budget) => {
373 let current_tokens = count_tokens_estimate(prompt);
374 if current_tokens <= budget {
375 return prompt.to_string();
376 }
377
378 let ratio = budget as f64 / current_tokens as f64;
380 let target_chars = (prompt.len() as f64 * ratio) as usize;
381 let truncated: String = prompt.chars().take(target_chars).collect();
383 format!("{}\n\n[Context truncated to fit token budget]", truncated)
384 }
385 None => prompt.to_string(),
386 }
387}
388
389fn find_relevant_entities(store: &GraphStore, query: &str) -> Vec<String> {
397 let matcher = KeywordMatcher::new();
398 matcher.find_relevant(query, store, usize::MAX)
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::graph_rag::graph_store::{Entity, Relation};
405
406 #[test]
407 fn test_find_relevant_entities() {
408 let mut store = GraphStore::new();
409 store.add_entity(Entity {
410 id: "e1".into(),
411 name: "Rust".into(),
412 entity_type: "Technology".into(),
413 description: "A systems programming language".into(),
414 });
415 store.add_entity(Entity {
416 id: "e2".into(),
417 name: "Python".into(),
418 entity_type: "Technology".into(),
419 description: "A scripting language".into(),
420 });
421 store.add_entity(Entity {
422 id: "e3".into(),
423 name: "Alice".into(),
424 entity_type: "Person".into(),
425 description: "A developer who uses Rust".into(),
426 });
427
428 let results = find_relevant_entities(&store, "Rust programming");
429 assert!(!results.is_empty());
430 assert_eq!(results[0], "e1");
432 }
433
434 #[test]
435 fn test_find_relevant_entities_no_match() {
436 let mut store = GraphStore::new();
437 store.add_entity(Entity {
438 id: "e1".into(),
439 name: "Rust".into(),
440 entity_type: "Technology".into(),
441 description: "A systems programming language".into(),
442 });
443
444 let results = find_relevant_entities(&store, "cooking recipe");
445 assert!(results.is_empty());
446 }
447
448 #[test]
450 fn test_find_relevant_entities_matches_keyword_matcher() {
451 let mut store = GraphStore::new();
452 store.add_entity(Entity {
453 id: "e1".into(),
454 name: "Rust".into(),
455 entity_type: "Technology".into(),
456 description: "A systems programming language".into(),
457 });
458 store.add_entity(Entity {
459 id: "e2".into(),
460 name: "Python".into(),
461 entity_type: "Technology".into(),
462 description: "A scripting language".into(),
463 });
464 store.add_entity(Entity {
465 id: "e3".into(),
466 name: "Alice".into(),
467 entity_type: "Person".into(),
468 description: "A developer who uses Rust".into(),
469 });
470
471 let via_delegate = find_relevant_entities(&store, "Rust programming");
472 let via_matcher =
473 KeywordMatcher::new().find_relevant("Rust programming", &store, usize::MAX);
474 assert_eq!(via_delegate, via_matcher);
475 }
476
477 #[test]
478 fn test_truncate_summaries_no_limit() {
479 let summaries = vec!["Summary 1".to_string(), "Summary 2".to_string()];
480 let result = truncate_summaries(&summaries, None);
481 assert_eq!(result, "Summary 1\n\nSummary 2");
482 }
483
484 #[test]
485 fn test_truncate_summaries_within_budget() {
486 let summaries = vec!["Short summary".to_string()];
487 let result = truncate_summaries(&summaries, Some(100));
488 assert_eq!(result, "Short summary");
489 }
490
491 #[test]
492 fn test_truncate_summaries_exceeds_budget() {
493 let summaries = vec![
494 "First summary that is reasonably long".to_string(),
495 "Second summary that should be dropped".to_string(),
496 ];
497 let result = truncate_summaries(&summaries, Some(5));
499 assert!(result.contains("First summary"));
500 assert!(!result.contains("Second summary"));
501 }
502
503 #[test]
504 fn test_truncate_prompt_no_limit() {
505 let prompt = "This is a long prompt with lots of context".to_string();
506 let result = truncate_prompt(&prompt, None);
507 assert_eq!(result, prompt);
508 }
509
510 #[test]
511 fn test_truncate_prompt_within_budget() {
512 let prompt = "Short prompt".to_string();
513 let result = truncate_prompt(&prompt, Some(100));
514 assert_eq!(result, "Short prompt");
515 }
516
517 #[test]
520 fn test_hybrid_query_relation_dedup_with_hashset() {
521 let mut store = GraphStore::new();
522 store.add_entity(Entity {
523 id: "e1".into(),
524 name: "Rust".into(),
525 entity_type: "Technology".into(),
526 description: "A systems programming language".into(),
527 });
528 store.add_entity(Entity {
529 id: "e2".into(),
530 name: "Mozilla".into(),
531 entity_type: "Organization".into(),
532 description: "Organization behind Rust".into(),
533 });
534 store.add_relation(Relation {
536 source: "e1".into(),
537 target: "e2".into(),
538 relation_type: "created_by".into(),
539 description: String::new(),
540 doc_id: None,
541 });
542 store.add_relation(Relation {
543 source: "e1".into(),
544 target: "e2".into(),
545 relation_type: "created_by".into(),
546 description: String::new(),
547 doc_id: None,
548 });
549
550 let seed_entities = vec!["e1".to_string()];
552 let mut all_relations = Vec::new();
553 let mut seen_rel_keys: HashSet<(String, String, String)> = HashSet::new();
554 for seed in &seed_entities {
555 let (_, rels) = store.subgraph(seed, 1);
556 for r in rels {
557 let key = (r.source.clone(), r.target.clone(), r.relation_type.clone());
558 if seen_rel_keys.insert(key) {
559 all_relations.push(r);
560 }
561 }
562 }
563
564 let unique_keys: HashSet<(String, String, String)> = all_relations
568 .iter()
569 .map(|r| (r.source.clone(), r.target.clone(), r.relation_type.clone()))
570 .collect();
571 assert_eq!(
572 unique_keys.len(),
573 1,
574 "should have exactly 1 unique relation after HashSet dedup"
575 );
576 }
577}