Skip to main content

lc_rag/graph_rag/
mod.rs

1// src/retrieval/graph_rag/mod.rs
2//! GraphRAG (Knowledge Graph RAG) module.
3//!
4//! Builds a knowledge graph from documents via LLM-based entity and relation
5//! extraction, detects communities, and supports Global / Local / Hybrid
6//! query modes.
7//!
8//! # Example
9//! ```ignore
10//! use langchainrust::retrieval::graph_rag::{GraphRAG, GraphRAGConfig, QueryMode};
11//! use langchainrust::OpenAIChat;
12//!
13//! let llm = OpenAIChat::new(config);
14//! let graph_rag = GraphRAG::new(llm).with_config(GraphRAGConfig::default());
15//!
16//! graph_rag.add_documents(&docs).await?;
17//! graph_rag.build_communities().await?;
18//! let result = graph_rag.query("What is Rust?", QueryMode::Local).await?;
19//! println!("{}", result.answer);
20//! ```
21
22pub 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/// GraphRAG error type.
38#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum GraphRAGError {
41    /// An error from the underlying LLM call.
42    #[error("LLM error: {0}")]
43    LLMError(String),
44
45    /// An error during entity/relation extraction.
46    #[error("Extraction error: {0}")]
47    ExtractionError(String),
48
49    /// An error during query execution.
50    #[error("Query error: {0}")]
51    QueryError(String),
52
53    /// An error during community detection or summarization.
54    #[error("Community error: {0}")]
55    CommunityError(String),
56}
57
58/// Configuration for GraphRAG.
59pub struct GraphRAGConfig {
60    /// Maximum number of entities to extract per document.
61    pub max_entities_per_doc: usize,
62    /// Maximum number of relations to extract per document.
63    pub max_relations_per_doc: usize,
64    /// Leiden modularity resolution γ for community detection
65    /// (see [`community::DEFAULT_RESOLUTION`]). Higher values produce more,
66    /// smaller communities; lower values merge more aggressively.
67    pub leiden_resolution: f64,
68    /// Deterministic RNG seed for the Leiden algorithm. Fixed by default so
69    /// rebuilding communities over the same graph yields the same partition.
70    pub leiden_seed: u64,
71    /// Maximum number of hierarchy levels, including level 0. Each higher
72    /// level aggregates communities of the level below (see [`Community`]).
73    pub max_community_levels: usize,
74    /// Maximum number of tokens for context in query prompts.
75    /// When set, community summaries or subgraph context is truncated to fit.
76    pub max_context_tokens: Option<usize>,
77    /// Custom entity matcher for local/hybrid queries.
78    /// When None, uses the default KeywordMatcher.
79    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    /// Creates a `GraphRAGConfig` with default values.
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Sets the maximum number of entities to extract per document.
103    pub fn with_max_entities_per_doc(mut self, n: usize) -> Self {
104        self.max_entities_per_doc = n;
105        self
106    }
107
108    /// Sets the maximum number of relations to extract per document.
109    pub fn with_max_relations_per_doc(mut self, n: usize) -> Self {
110        self.max_relations_per_doc = n;
111        self
112    }
113
114    /// Sets the Leiden modularity resolution γ.
115    pub fn with_leiden_resolution(mut self, resolution: f64) -> Self {
116        self.leiden_resolution = resolution;
117        self
118    }
119
120    /// Sets the deterministic RNG seed used by Leiden community detection.
121    pub fn with_leiden_seed(mut self, seed: u64) -> Self {
122        self.leiden_seed = seed;
123        self
124    }
125
126    /// Sets the maximum hierarchy depth (level 0 is the base partition).
127    pub fn with_max_community_levels(mut self, levels: usize) -> Self {
128        self.max_community_levels = levels;
129        self
130    }
131
132    /// Sets the maximum number of tokens for context in query prompts.
133    ///
134    /// When set, community summaries (Global/Hybrid) or subgraph context
135    /// (Local/Hybrid) are truncated from lowest-priority items to fit
136    /// within this budget.
137    pub fn with_max_context_tokens(mut self, tokens: usize) -> Self {
138        self.max_context_tokens = Some(tokens);
139        self
140    }
141
142    /// Sets a custom entity matcher for local/hybrid queries.
143    ///
144    /// When set, the matcher is used instead of the default keyword-based
145    /// matching to find relevant entities.
146    pub fn with_entity_matcher(mut self, matcher: Box<dyn EntityMatcher>) -> Self {
147        self.entity_matcher = Some(matcher);
148        self
149    }
150}
151
152/// GraphRAG: Knowledge Graph-based Retrieval Augmented Generation.
153///
154/// Wraps an LLM for entity/relation extraction and community summarization,
155/// and an in-memory [`GraphStore`] for graph operations.
156pub struct GraphRAG<M: BaseChatModel> {
157    llm: M,
158    store: RwLock<GraphStore>,
159    config: GraphRAGConfig,
160}
161
162impl<M: BaseChatModel> GraphRAG<M> {
163    /// Creates a new GraphRAG instance with the given LLM.
164    pub fn new(llm: M) -> Self {
165        Self {
166            llm,
167            store: RwLock::new(GraphStore::new()),
168            config: GraphRAGConfig::default(),
169        }
170    }
171
172    /// Sets a custom configuration.
173    pub fn with_config(mut self, config: GraphRAGConfig) -> Self {
174        self.config = config;
175        self
176    }
177
178    /// Adds documents to the knowledge graph by extracting entities and
179    /// relations from each document via the LLM.
180    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            // Build a name-to-id map for deduplication.
194            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            // Insert extracted entities (deduplicate by name).
201            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                    // Entity already exists; skip (M57: log instead of silent discard).
205                    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            // Insert extracted relations (resolve names to ids).
221            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    /// Runs hierarchical Leiden community detection and generates one
260    /// summary per community via the LLM.
261    ///
262    /// Level-0 communities are summarized from their entities and internal
263    /// relations; each higher level is rolled up from its child-community
264    /// summaries plus the relations crossing the children. Community ids
265    /// index the summary vector one-to-one.
266    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        // Generate summaries in id order; children always precede their
278        // parents because ids are assigned level by level.
279        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        // Write communities and summaries back.
306        let mut store = self.store.write().await;
307        store.set_communities(communities);
308        store.set_community_summaries(summaries);
309
310        Ok(())
311    }
312
313    /// Queries the knowledge graph using the specified mode.
314    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    /// Returns the number of entities in the graph.
353    pub async fn entity_count(&self) -> usize {
354        let store = self.store.read().await;
355        store.entity_count()
356    }
357
358    /// Returns the number of relations in the graph.
359    pub async fn relation_count(&self) -> usize {
360        let store = self.store.read().await;
361        store.relation_count()
362    }
363
364    /// Returns the number of communities across all hierarchy levels.
365    pub async fn community_count(&self) -> usize {
366        let store = self.store.read().await;
367        store.communities().len()
368    }
369
370    /// Returns a clone of the detected community hierarchy (empty until
371    /// [`GraphRAG::build_communities`] has run).
372    pub async fn communities(&self) -> Vec<Community> {
373        let store = self.store.read().await;
374        store.communities().to_vec()
375    }
376
377    /// Returns one summary per community, indexed by [`Community::id`].
378    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    // -- End-to-end hierarchy + level-aware query tests -------------------
431
432    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    /// Fake chat model that distinguishes base summaries, level-1 rollups and
444    /// query answers from the prompt text, and records every prompt sent.
445    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    /// Two triangles linked by one bridge, two 8-cliques linked the same way:
539    /// level 0 yields four communities; only the two triangles roll into a
540    /// level-1 group (the dense cliques stay unmerged).
541    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        // Hierarchy: four level-0 communities + one level-1 rollup.
607        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        // Summaries are parallel to ids: 4 base + 1 rollup.
618        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        // Global @ coarsest: two unparented clique communities + the rollup;
630        // every one of the 22 entities is covered exactly once.
631        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        // Global @ level 0: four base summaries, no rollup.
644        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        // Global @ all: four base + one rollup.
656        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        // Global @ missing level fails fast.
668        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        // Local query still answers from the subgraph.
675        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        // Hybrid @ coarsest: rollup summary plus local subgraph lines.
682        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}