1pub mod community;
23pub mod extractor;
24pub mod graph_store;
25pub mod leiden;
26pub mod matcher;
27pub mod query;
28
29pub use graph_store::{Community, Entity, GraphStore, Relation};
30pub use matcher::{EmbeddingMatcher, EntityMatcher, KeywordMatcher};
31pub use query::{GlobalLevel, GraphRAGResult, QueryMode};
32
33use lc_core::language_models::BaseChatModel;
34use lc_vector_stores::Document;
35use tokio::sync::RwLock;
36
37#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum GraphRAGError {
41 #[error("LLM error: {0}")]
43 LLMError(String),
44
45 #[error("Extraction error: {0}")]
47 ExtractionError(String),
48
49 #[error("Query error: {0}")]
51 QueryError(String),
52
53 #[error("Community error: {0}")]
55 CommunityError(String),
56}
57
58pub struct GraphRAGConfig {
60 pub max_entities_per_doc: usize,
62 pub max_relations_per_doc: usize,
64 pub leiden_resolution: f64,
68 pub leiden_seed: u64,
71 pub max_community_levels: usize,
74 pub max_context_tokens: Option<usize>,
77 pub entity_matcher: Option<Box<dyn EntityMatcher>>,
80}
81
82impl Default for GraphRAGConfig {
83 fn default() -> Self {
84 Self {
85 max_entities_per_doc: 10,
86 max_relations_per_doc: 10,
87 leiden_resolution: community::DEFAULT_RESOLUTION,
88 leiden_seed: community::DEFAULT_SEED,
89 max_community_levels: community::DEFAULT_MAX_LEVELS,
90 max_context_tokens: None,
91 entity_matcher: None,
92 }
93 }
94}
95
96impl GraphRAGConfig {
97 pub fn new() -> Self {
99 Self::default()
100 }
101
102 pub fn with_max_entities_per_doc(mut self, n: usize) -> Self {
104 self.max_entities_per_doc = n;
105 self
106 }
107
108 pub fn with_max_relations_per_doc(mut self, n: usize) -> Self {
110 self.max_relations_per_doc = n;
111 self
112 }
113
114 pub fn with_leiden_resolution(mut self, resolution: f64) -> Self {
116 self.leiden_resolution = resolution;
117 self
118 }
119
120 pub fn with_leiden_seed(mut self, seed: u64) -> Self {
122 self.leiden_seed = seed;
123 self
124 }
125
126 pub fn with_max_community_levels(mut self, levels: usize) -> Self {
128 self.max_community_levels = levels;
129 self
130 }
131
132 pub fn with_max_context_tokens(mut self, tokens: usize) -> Self {
138 self.max_context_tokens = Some(tokens);
139 self
140 }
141
142 pub fn with_entity_matcher(mut self, matcher: Box<dyn EntityMatcher>) -> Self {
147 self.entity_matcher = Some(matcher);
148 self
149 }
150}
151
152pub struct GraphRAG<M: BaseChatModel> {
157 llm: M,
158 store: RwLock<GraphStore>,
159 config: GraphRAGConfig,
160}
161
162impl<M: BaseChatModel> GraphRAG<M> {
163 pub fn new(llm: M) -> Self {
165 Self {
166 llm,
167 store: RwLock::new(GraphStore::new()),
168 config: GraphRAGConfig::default(),
169 }
170 }
171
172 pub fn with_config(mut self, config: GraphRAGConfig) -> Self {
174 self.config = config;
175 self
176 }
177
178 pub async fn add_documents(&self, docs: &[Document]) -> Result<(), GraphRAGError> {
181 for doc in docs {
182 let extraction = extractor::extract(
183 &self.llm,
184 &doc.content,
185 self.config.max_entities_per_doc,
186 self.config.max_relations_per_doc,
187 )
188 .await?;
189
190 let doc_id = doc.id.clone();
191 let mut store = self.store.write().await;
192
193 let mut name_to_id: std::collections::HashMap<String, String> = store
195 .all_entities()
196 .values()
197 .map(|e| (e.name.to_lowercase(), e.id.clone()))
198 .collect();
199
200 for ext_ent in &extraction.entities {
202 let key = ext_ent.name.to_lowercase();
203 if let Some(_existing_id) = name_to_id.get(&key) {
204 log::info!("GraphRAG: skipping duplicate entity '{}'", ext_ent.name);
206 continue;
207 }
208
209 let id = format!("e_{}", uuid::Uuid::new_v4().as_simple());
210 name_to_id.insert(key, id.clone());
211
212 store.add_entity(Entity {
213 id,
214 name: ext_ent.name.clone(),
215 entity_type: ext_ent.entity_type.clone(),
216 description: ext_ent.description.clone(),
217 });
218 }
219
220 for ext_rel in &extraction.relations {
222 let source_key = ext_rel.source.to_lowercase();
223 let target_key = ext_rel.target.to_lowercase();
224
225 let source_id = match name_to_id.get(&source_key) {
226 Some(id) => id.clone(),
227 None => {
228 log::info!(
229 "GraphRAG: skipping relation with unknown source entity '{}'",
230 ext_rel.source
231 );
232 continue;
233 }
234 };
235 let target_id = match name_to_id.get(&target_key) {
236 Some(id) => id.clone(),
237 None => {
238 log::info!(
239 "GraphRAG: skipping relation with unknown target entity '{}'",
240 ext_rel.target
241 );
242 continue;
243 }
244 };
245
246 store.add_relation(Relation {
247 source: source_id,
248 target: target_id,
249 relation_type: ext_rel.relation_type.clone(),
250 description: ext_rel.description.clone(),
251 doc_id: doc_id.clone(),
252 });
253 }
254 }
255
256 Ok(())
257 }
258
259 pub async fn build_communities(&self) -> Result<(), GraphRAGError> {
267 let communities = {
268 let store = self.store.read().await;
269 community::detect_hierarchy(
270 &store,
271 self.config.leiden_resolution,
272 self.config.max_community_levels,
273 self.config.leiden_seed,
274 )?
275 };
276
277 let mut summaries: Vec<String> = Vec::with_capacity(communities.len());
280 for comm in &communities {
281 let store_clone = {
282 let store = self.store.read().await;
283 store.clone()
284 };
285 let summary = if comm.level == 0 {
286 community::summarize_community(&self.llm, &store_clone, comm).await?
287 } else {
288 let child_summaries: Vec<String> = communities
289 .iter()
290 .filter(|child| child.parent == Some(comm.id))
291 .map(|child| summaries[child.id].clone())
292 .collect();
293 community::summarize_rollup(
294 &self.llm,
295 &store_clone,
296 comm,
297 &communities,
298 &child_summaries,
299 )
300 .await?
301 };
302 summaries.push(summary);
303 }
304
305 let mut store = self.store.write().await;
307 store.set_communities(communities);
308 store.set_community_summaries(summaries);
309
310 Ok(())
311 }
312
313 pub async fn query(&self, q: &str, mode: QueryMode) -> Result<GraphRAGResult, GraphRAGError> {
315 let store = {
316 let guard = self.store.read().await;
317 guard.clone()
318 };
319
320 let max_tokens = self.config.max_context_tokens;
321
322 match mode {
323 QueryMode::Global => {
324 query::global_query(&self.llm, &store, q, max_tokens, GlobalLevel::Coarsest).await
325 }
326 QueryMode::GlobalAt(level) => {
327 query::global_query(&self.llm, &store, q, max_tokens, level).await
328 }
329 QueryMode::Local => {
330 let matcher = self.config.entity_matcher.as_deref();
331 query::local_query(&self.llm, &store, q, max_tokens, matcher).await
332 }
333 QueryMode::Hybrid => {
334 let matcher = self.config.entity_matcher.as_deref();
335 query::hybrid_query(
336 &self.llm,
337 &store,
338 q,
339 max_tokens,
340 matcher,
341 GlobalLevel::Coarsest,
342 )
343 .await
344 }
345 QueryMode::HybridAt(level) => {
346 let matcher = self.config.entity_matcher.as_deref();
347 query::hybrid_query(&self.llm, &store, q, max_tokens, matcher, level).await
348 }
349 }
350 }
351
352 pub async fn entity_count(&self) -> usize {
354 let store = self.store.read().await;
355 store.entity_count()
356 }
357
358 pub async fn relation_count(&self) -> usize {
360 let store = self.store.read().await;
361 store.relation_count()
362 }
363
364 pub async fn community_count(&self) -> usize {
366 let store = self.store.read().await;
367 store.communities().len()
368 }
369
370 pub async fn communities(&self) -> Vec<Community> {
373 let store = self.store.read().await;
374 store.communities().to_vec()
375 }
376
377 pub async fn community_summaries(&self) -> Vec<String> {
379 let store = self.store.read().await;
380 store.community_summaries().to_vec()
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_graph_rag_config_default() {
390 let config = GraphRAGConfig::default();
391 assert_eq!(config.max_entities_per_doc, 10);
392 assert_eq!(config.max_relations_per_doc, 10);
393 assert_eq!(config.leiden_resolution, community::DEFAULT_RESOLUTION);
394 assert_eq!(config.leiden_seed, community::DEFAULT_SEED);
395 assert_eq!(config.max_community_levels, community::DEFAULT_MAX_LEVELS);
396 assert!(config.max_context_tokens.is_none());
397 }
398
399 #[test]
400 fn test_graph_rag_config_builder() {
401 let config = GraphRAGConfig::new()
402 .with_max_entities_per_doc(5)
403 .with_max_relations_per_doc(8)
404 .with_leiden_resolution(0.5)
405 .with_leiden_seed(99)
406 .with_max_community_levels(2);
407
408 assert_eq!(config.max_entities_per_doc, 5);
409 assert_eq!(config.max_relations_per_doc, 8);
410 assert_eq!(config.leiden_resolution, 0.5);
411 assert_eq!(config.leiden_seed, 99);
412 assert_eq!(config.max_community_levels, 2);
413 }
414
415 #[test]
416 fn test_graph_error_display() {
417 let err = GraphRAGError::LLMError("timeout".into());
418 assert!(err.to_string().contains("timeout"));
419
420 let err = GraphRAGError::ExtractionError("bad json".into());
421 assert!(err.to_string().contains("bad json"));
422
423 let err = GraphRAGError::QueryError("no entities".into());
424 assert!(err.to_string().contains("no entities"));
425
426 let err = GraphRAGError::CommunityError("bad level".into());
427 assert!(err.to_string().contains("bad level"));
428 }
429
430 use async_trait::async_trait;
433 use futures_util::Stream;
434 use lc_core::language_models::{LLMResult, StreamChunk};
435 use lc_core::runnables::RunnableConfig;
436 use lc_core::{BaseLanguageModel, Runnable};
437 use lc_schema::Message;
438 use std::pin::Pin;
439 use std::sync::{Arc, Mutex};
440
441 type PromptLog = Arc<Mutex<Vec<String>>>;
442
443 struct ScriptedChatModel {
446 prompts: PromptLog,
447 }
448
449 impl ScriptedChatModel {
450 fn new(prompts: PromptLog) -> Self {
451 Self { prompts }
452 }
453 }
454
455 fn last_prompt(prompts: &PromptLog) -> String {
456 prompts.lock().unwrap().last().unwrap().clone()
457 }
458
459 fn clear_prompts(prompts: &PromptLog) {
460 prompts.lock().unwrap().clear();
461 }
462
463 #[derive(Debug)]
464 struct ScriptedChatError;
465 impl std::fmt::Display for ScriptedChatError {
466 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467 write!(f, "scripted mock chat error")
468 }
469 }
470 impl std::error::Error for ScriptedChatError {}
471
472 #[async_trait]
473 impl Runnable<Vec<Message>, LLMResult> for ScriptedChatModel {
474 type Error = ScriptedChatError;
475 async fn invoke(
476 &self,
477 _input: Vec<Message>,
478 _config: Option<RunnableConfig>,
479 ) -> Result<LLMResult, Self::Error> {
480 Err(ScriptedChatError)
481 }
482 }
483
484 #[async_trait]
485 impl BaseLanguageModel<Vec<Message>, LLMResult> for ScriptedChatModel {
486 fn model_name(&self) -> &str {
487 "graphrag-e2e-mock"
488 }
489 fn get_num_tokens(&self, t: &str) -> usize {
490 t.len()
491 }
492 fn with_temperature(self, _: f32) -> Self {
493 self
494 }
495 fn with_max_tokens(self, _: usize) -> Self {
496 self
497 }
498 }
499
500 #[async_trait]
501 impl BaseChatModel for ScriptedChatModel {
502 async fn chat(
503 &self,
504 messages: Vec<Message>,
505 _config: Option<RunnableConfig>,
506 ) -> Result<LLMResult, Self::Error> {
507 let prompt = messages
508 .last()
509 .map(|m| m.content.clone())
510 .unwrap_or_default();
511 let reply = if prompt.contains("building a level-1 overview") {
512 "ROLLUP_SUMMARY"
513 } else if prompt.contains("You are a helpful assistant answering questions") {
514 "QUESTION_ANSWER"
515 } else {
516 "BASE_SUMMARY"
517 };
518 self.prompts.lock().unwrap().push(prompt);
519 Ok(LLMResult {
520 content: reply.to_string(),
521 model: "graphrag-e2e-mock".to_string(),
522 token_usage: None,
523 tool_calls: None,
524 thinking_content: None,
525 })
526 }
527
528 async fn stream_chat(
529 &self,
530 _messages: Vec<Message>,
531 _config: Option<RunnableConfig>,
532 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
533 {
534 Err(ScriptedChatError)
535 }
536 }
537
538 fn populate_hierarchical_fixture(store: &mut GraphStore) {
542 let groups: [Vec<usize>; 4] = [
543 (0..3).collect(),
544 (3..6).collect(),
545 (6..14).collect(),
546 (14..22).collect(),
547 ];
548 for group in &groups {
549 for &i in group {
550 let name = format!("n{i}");
551 store.add_entity(Entity {
552 id: name.clone(),
553 name: name.to_uppercase(),
554 entity_type: "concept".to_string(),
555 description: format!("Entity {}", name.to_uppercase()),
556 });
557 }
558 for (ai, &a) in group.iter().enumerate() {
559 for &b in &group[ai + 1..] {
560 store.add_relation(Relation {
561 source: format!("n{a}"),
562 target: format!("n{b}"),
563 relation_type: "rel".to_string(),
564 description: String::new(),
565 doc_id: None,
566 });
567 }
568 }
569 }
570 store.add_relation(Relation {
571 source: "n2".into(),
572 target: "n3".into(),
573 relation_type: "bridge".into(),
574 description: String::new(),
575 doc_id: None,
576 });
577 store.add_relation(Relation {
578 source: "n13".into(),
579 target: "n14".into(),
580 relation_type: "bridge".into(),
581 description: String::new(),
582 doc_id: None,
583 });
584 }
585
586 fn count_occurrences(haystack: &str, needle: &str) -> usize {
587 haystack.matches(needle).count()
588 }
589
590 #[tokio::test]
591 async fn e2e_hierarchy_build_and_level_aware_queries() {
592 let prompts: PromptLog = Arc::new(Mutex::new(Vec::new()));
593 let rag = GraphRAG::new(ScriptedChatModel::new(prompts.clone())).with_config(
594 GraphRAGConfig::new()
595 .with_leiden_resolution(community::DEFAULT_RESOLUTION)
596 .with_max_community_levels(3),
597 );
598 {
599 let mut store = rag.store.write().await;
600 populate_hierarchical_fixture(&mut store);
601 }
602
603 rag.build_communities().await.unwrap();
604 clear_prompts(&prompts);
605
606 let communities = rag.communities().await;
608 assert_eq!(communities.len(), 5);
609 assert_eq!(communities.iter().filter(|c| c.level == 0).count(), 4);
610 let level1: Vec<&Community> = communities.iter().filter(|c| c.level == 1).collect();
611 assert_eq!(level1.len(), 1);
612 assert_eq!(level1[0].entities.len(), 6);
613 assert!(level1[0].parent.is_none());
614 let parents: Vec<usize> = communities.iter().filter_map(|c| c.parent).collect();
615 assert_eq!(parents, vec![level1[0].id, level1[0].id]);
616
617 let summaries = rag.community_summaries().await;
619 assert_eq!(summaries.len(), 5);
620 assert_eq!(
621 summaries
622 .iter()
623 .filter(|s| s.as_str() == "BASE_SUMMARY")
624 .count(),
625 4
626 );
627 assert_eq!(summaries[level1[0].id], "ROLLUP_SUMMARY");
628
629 let result = rag
632 .query("overview please", QueryMode::Global)
633 .await
634 .unwrap();
635 assert_eq!(result.answer, "QUESTION_ANSWER");
636 assert_eq!(result.mode, QueryMode::Global);
637 assert_eq!(result.sources.len(), 22);
638 let prompt = last_prompt(&prompts);
639 assert_eq!(count_occurrences(&prompt, "ROLLUP_SUMMARY"), 1);
640 assert_eq!(count_occurrences(&prompt, "BASE_SUMMARY"), 2);
641 clear_prompts(&prompts);
642
643 let result = rag
645 .query("fine detail", QueryMode::GlobalAt(GlobalLevel::Level(0)))
646 .await
647 .unwrap();
648 assert_eq!(result.mode, QueryMode::GlobalAt(GlobalLevel::Level(0)));
649 let prompt = last_prompt(&prompts);
650 assert_eq!(count_occurrences(&prompt, "BASE_SUMMARY"), 4);
651 assert_eq!(count_occurrences(&prompt, "ROLLUP_SUMMARY"), 0);
652 assert_eq!(result.sources.len(), 22);
653 clear_prompts(&prompts);
654
655 let result = rag
657 .query("everything", QueryMode::GlobalAt(GlobalLevel::All))
658 .await
659 .unwrap();
660 assert_eq!(result.answer, "QUESTION_ANSWER");
661 assert_eq!(result.mode, QueryMode::GlobalAt(GlobalLevel::All));
662 let prompt = last_prompt(&prompts);
663 assert_eq!(count_occurrences(&prompt, "BASE_SUMMARY"), 4);
664 assert_eq!(count_occurrences(&prompt, "ROLLUP_SUMMARY"), 1);
665 clear_prompts(&prompts);
666
667 let err = rag
669 .query("ghost", QueryMode::GlobalAt(GlobalLevel::Level(9)))
670 .await
671 .unwrap_err();
672 assert!(err.to_string().contains("level 9"), "{err}");
673
674 let result = rag.query("n0", QueryMode::Local).await.unwrap();
676 assert_eq!(result.mode, QueryMode::Local);
677 assert_eq!(result.answer, "QUESTION_ANSWER");
678 assert!(result.sources.contains(&"n0".to_string()));
679 clear_prompts(&prompts);
680
681 let result = rag.query("n0", QueryMode::Hybrid).await.unwrap();
683 assert_eq!(result.mode, QueryMode::Hybrid);
684 let prompt = last_prompt(&prompts);
685 assert!(prompt.contains("ROLLUP_SUMMARY"));
686 assert!(prompt.contains("N0 (concept)"));
687 }
688
689 #[tokio::test]
690 async fn global_query_without_communities_is_an_error() {
691 let prompts: PromptLog = Arc::new(Mutex::new(Vec::new()));
692 let rag = GraphRAG::new(ScriptedChatModel::new(prompts));
693 {
694 let mut store = rag.store.write().await;
695 store.add_entity(Entity {
696 id: "x".into(),
697 name: "X".into(),
698 entity_type: "concept".into(),
699 description: "Entity X".into(),
700 });
701 }
702 let err = rag.query("q", QueryMode::Global).await.unwrap_err();
703 assert!(err.to_string().contains("build_communities"));
704 }
705}