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