pub mod community;
pub mod extractor;
pub mod graph_store;
pub mod leiden;
pub mod matcher;
pub mod query;
pub use graph_store::{Community, Entity, GraphStore, Relation};
pub use matcher::{EmbeddingMatcher, EntityMatcher, KeywordMatcher};
pub use query::{GlobalLevel, GraphRAGResult, QueryMode};
use lc_core::language_models::BaseChatModel;
use lc_vector_stores::Document;
use tokio::sync::RwLock;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum GraphRAGError {
#[error("LLM error: {0}")]
LLMError(String),
#[error("Extraction error: {0}")]
ExtractionError(String),
#[error("Query error: {0}")]
QueryError(String),
#[error("Community error: {0}")]
CommunityError(String),
}
pub struct GraphRAGConfig {
pub max_entities_per_doc: usize,
pub max_relations_per_doc: usize,
pub leiden_resolution: f64,
pub leiden_seed: u64,
pub max_community_levels: usize,
pub max_context_tokens: Option<usize>,
pub entity_matcher: Option<Box<dyn EntityMatcher>>,
}
impl Default for GraphRAGConfig {
fn default() -> Self {
Self {
max_entities_per_doc: 10,
max_relations_per_doc: 10,
leiden_resolution: community::DEFAULT_RESOLUTION,
leiden_seed: community::DEFAULT_SEED,
max_community_levels: community::DEFAULT_MAX_LEVELS,
max_context_tokens: None,
entity_matcher: None,
}
}
}
impl GraphRAGConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_entities_per_doc(mut self, n: usize) -> Self {
self.max_entities_per_doc = n;
self
}
pub fn with_max_relations_per_doc(mut self, n: usize) -> Self {
self.max_relations_per_doc = n;
self
}
pub fn with_leiden_resolution(mut self, resolution: f64) -> Self {
self.leiden_resolution = resolution;
self
}
pub fn with_leiden_seed(mut self, seed: u64) -> Self {
self.leiden_seed = seed;
self
}
pub fn with_max_community_levels(mut self, levels: usize) -> Self {
self.max_community_levels = levels;
self
}
pub fn with_max_context_tokens(mut self, tokens: usize) -> Self {
self.max_context_tokens = Some(tokens);
self
}
pub fn with_entity_matcher(mut self, matcher: Box<dyn EntityMatcher>) -> Self {
self.entity_matcher = Some(matcher);
self
}
}
pub struct GraphRAG<M: BaseChatModel> {
llm: M,
store: RwLock<GraphStore>,
config: GraphRAGConfig,
}
impl<M: BaseChatModel> GraphRAG<M> {
pub fn new(llm: M) -> Self {
Self {
llm,
store: RwLock::new(GraphStore::new()),
config: GraphRAGConfig::default(),
}
}
pub fn with_config(mut self, config: GraphRAGConfig) -> Self {
self.config = config;
self
}
pub async fn add_documents(&self, docs: &[Document]) -> Result<(), GraphRAGError> {
for doc in docs {
let extraction = extractor::extract(
&self.llm,
&doc.content,
self.config.max_entities_per_doc,
self.config.max_relations_per_doc,
)
.await?;
let doc_id = doc.id.clone();
let mut store = self.store.write().await;
let mut name_to_id: std::collections::HashMap<String, String> = store
.all_entities()
.values()
.map(|e| (e.name.to_lowercase(), e.id.clone()))
.collect();
for ext_ent in &extraction.entities {
let key = ext_ent.name.to_lowercase();
if let Some(_existing_id) = name_to_id.get(&key) {
log::info!("GraphRAG: skipping duplicate entity '{}'", ext_ent.name);
continue;
}
let id = format!("e_{}", uuid::Uuid::new_v4().as_simple());
name_to_id.insert(key, id.clone());
store.add_entity(Entity {
id,
name: ext_ent.name.clone(),
entity_type: ext_ent.entity_type.clone(),
description: ext_ent.description.clone(),
});
}
for ext_rel in &extraction.relations {
let source_key = ext_rel.source.to_lowercase();
let target_key = ext_rel.target.to_lowercase();
let source_id = match name_to_id.get(&source_key) {
Some(id) => id.clone(),
None => {
log::info!(
"GraphRAG: skipping relation with unknown source entity '{}'",
ext_rel.source
);
continue;
}
};
let target_id = match name_to_id.get(&target_key) {
Some(id) => id.clone(),
None => {
log::info!(
"GraphRAG: skipping relation with unknown target entity '{}'",
ext_rel.target
);
continue;
}
};
store.add_relation(Relation {
source: source_id,
target: target_id,
relation_type: ext_rel.relation_type.clone(),
description: ext_rel.description.clone(),
doc_id: doc_id.clone(),
});
}
}
Ok(())
}
pub async fn build_communities(&self) -> Result<(), GraphRAGError> {
let communities = {
let store = self.store.read().await;
community::detect_hierarchy(
&store,
self.config.leiden_resolution,
self.config.max_community_levels,
self.config.leiden_seed,
)?
};
let mut summaries: Vec<String> = Vec::with_capacity(communities.len());
for comm in &communities {
let store_clone = {
let store = self.store.read().await;
store.clone()
};
let summary = if comm.level == 0 {
community::summarize_community(&self.llm, &store_clone, comm).await?
} else {
let child_summaries: Vec<String> = communities
.iter()
.filter(|child| child.parent == Some(comm.id))
.map(|child| summaries[child.id].clone())
.collect();
community::summarize_rollup(
&self.llm,
&store_clone,
comm,
&communities,
&child_summaries,
)
.await?
};
summaries.push(summary);
}
let mut store = self.store.write().await;
store.set_communities(communities);
store.set_community_summaries(summaries);
Ok(())
}
pub async fn query(&self, q: &str, mode: QueryMode) -> Result<GraphRAGResult, GraphRAGError> {
let store = {
let guard = self.store.read().await;
guard.clone()
};
let max_tokens = self.config.max_context_tokens;
match mode {
QueryMode::Global => {
query::global_query(&self.llm, &store, q, max_tokens, GlobalLevel::Coarsest).await
}
QueryMode::GlobalAt(level) => {
query::global_query(&self.llm, &store, q, max_tokens, level).await
}
QueryMode::Local => {
let matcher = self.config.entity_matcher.as_deref();
query::local_query(&self.llm, &store, q, max_tokens, matcher).await
}
QueryMode::Hybrid => {
let matcher = self.config.entity_matcher.as_deref();
query::hybrid_query(
&self.llm,
&store,
q,
max_tokens,
matcher,
GlobalLevel::Coarsest,
)
.await
}
QueryMode::HybridAt(level) => {
let matcher = self.config.entity_matcher.as_deref();
query::hybrid_query(&self.llm, &store, q, max_tokens, matcher, level).await
}
}
}
pub async fn entity_count(&self) -> usize {
let store = self.store.read().await;
store.entity_count()
}
pub async fn relation_count(&self) -> usize {
let store = self.store.read().await;
store.relation_count()
}
pub async fn community_count(&self) -> usize {
let store = self.store.read().await;
store.communities().len()
}
pub async fn communities(&self) -> Vec<Community> {
let store = self.store.read().await;
store.communities().to_vec()
}
pub async fn community_summaries(&self) -> Vec<String> {
let store = self.store.read().await;
store.community_summaries().to_vec()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_graph_rag_config_default() {
let config = GraphRAGConfig::default();
assert_eq!(config.max_entities_per_doc, 10);
assert_eq!(config.max_relations_per_doc, 10);
assert_eq!(config.leiden_resolution, community::DEFAULT_RESOLUTION);
assert_eq!(config.leiden_seed, community::DEFAULT_SEED);
assert_eq!(config.max_community_levels, community::DEFAULT_MAX_LEVELS);
assert!(config.max_context_tokens.is_none());
}
#[test]
fn test_graph_rag_config_builder() {
let config = GraphRAGConfig::new()
.with_max_entities_per_doc(5)
.with_max_relations_per_doc(8)
.with_leiden_resolution(0.5)
.with_leiden_seed(99)
.with_max_community_levels(2);
assert_eq!(config.max_entities_per_doc, 5);
assert_eq!(config.max_relations_per_doc, 8);
assert_eq!(config.leiden_resolution, 0.5);
assert_eq!(config.leiden_seed, 99);
assert_eq!(config.max_community_levels, 2);
}
#[test]
fn test_graph_error_display() {
let err = GraphRAGError::LLMError("timeout".into());
assert!(err.to_string().contains("timeout"));
let err = GraphRAGError::ExtractionError("bad json".into());
assert!(err.to_string().contains("bad json"));
let err = GraphRAGError::QueryError("no entities".into());
assert!(err.to_string().contains("no entities"));
let err = GraphRAGError::CommunityError("bad level".into());
assert!(err.to_string().contains("bad level"));
}
use async_trait::async_trait;
use futures_util::Stream;
use lc_core::language_models::{LLMResult, StreamChunk};
use lc_core::runnables::RunnableConfig;
use lc_core::{BaseLanguageModel, Runnable};
use lc_schema::Message;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
type PromptLog = Arc<Mutex<Vec<String>>>;
struct ScriptedChatModel {
prompts: PromptLog,
}
impl ScriptedChatModel {
fn new(prompts: PromptLog) -> Self {
Self { prompts }
}
}
fn last_prompt(prompts: &PromptLog) -> String {
prompts.lock().unwrap().last().unwrap().clone()
}
fn clear_prompts(prompts: &PromptLog) {
prompts.lock().unwrap().clear();
}
#[derive(Debug)]
struct ScriptedChatError;
impl std::fmt::Display for ScriptedChatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "scripted mock chat error")
}
}
impl std::error::Error for ScriptedChatError {}
#[async_trait]
impl Runnable<Vec<Message>, LLMResult> for ScriptedChatModel {
type Error = ScriptedChatError;
async fn invoke(
&self,
_input: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
Err(ScriptedChatError)
}
}
#[async_trait]
impl BaseLanguageModel<Vec<Message>, LLMResult> for ScriptedChatModel {
fn model_name(&self) -> &str {
"graphrag-e2e-mock"
}
fn get_num_tokens(&self, t: &str) -> usize {
t.len()
}
fn with_temperature(self, _: f32) -> Self {
self
}
fn with_max_tokens(self, _: usize) -> Self {
self
}
}
#[async_trait]
impl BaseChatModel for ScriptedChatModel {
async fn chat(
&self,
messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
let prompt = messages
.last()
.map(|m| m.content.clone())
.unwrap_or_default();
let reply = if prompt.contains("building a level-1 overview") {
"ROLLUP_SUMMARY"
} else if prompt.contains("You are a helpful assistant answering questions") {
"QUESTION_ANSWER"
} else {
"BASE_SUMMARY"
};
self.prompts.lock().unwrap().push(prompt);
Ok(LLMResult {
content: reply.to_string(),
model: "graphrag-e2e-mock".to_string(),
token_usage: None,
tool_calls: None,
thinking_content: None,
})
}
async fn stream_chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
{
Err(ScriptedChatError)
}
}
fn populate_hierarchical_fixture(store: &mut GraphStore) {
let groups: [Vec<usize>; 4] = [
(0..3).collect(),
(3..6).collect(),
(6..14).collect(),
(14..22).collect(),
];
for group in &groups {
for &i in group {
let name = format!("n{i}");
store.add_entity(Entity {
id: name.clone(),
name: name.to_uppercase(),
entity_type: "concept".to_string(),
description: format!("Entity {}", name.to_uppercase()),
});
}
for (ai, &a) in group.iter().enumerate() {
for &b in &group[ai + 1..] {
store.add_relation(Relation {
source: format!("n{a}"),
target: format!("n{b}"),
relation_type: "rel".to_string(),
description: String::new(),
doc_id: None,
});
}
}
}
store.add_relation(Relation {
source: "n2".into(),
target: "n3".into(),
relation_type: "bridge".into(),
description: String::new(),
doc_id: None,
});
store.add_relation(Relation {
source: "n13".into(),
target: "n14".into(),
relation_type: "bridge".into(),
description: String::new(),
doc_id: None,
});
}
fn count_occurrences(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
#[tokio::test]
async fn e2e_hierarchy_build_and_level_aware_queries() {
let prompts: PromptLog = Arc::new(Mutex::new(Vec::new()));
let rag = GraphRAG::new(ScriptedChatModel::new(prompts.clone())).with_config(
GraphRAGConfig::new()
.with_leiden_resolution(community::DEFAULT_RESOLUTION)
.with_max_community_levels(3),
);
{
let mut store = rag.store.write().await;
populate_hierarchical_fixture(&mut store);
}
rag.build_communities().await.unwrap();
clear_prompts(&prompts);
let communities = rag.communities().await;
assert_eq!(communities.len(), 5);
assert_eq!(communities.iter().filter(|c| c.level == 0).count(), 4);
let level1: Vec<&Community> = communities.iter().filter(|c| c.level == 1).collect();
assert_eq!(level1.len(), 1);
assert_eq!(level1[0].entities.len(), 6);
assert!(level1[0].parent.is_none());
let parents: Vec<usize> = communities.iter().filter_map(|c| c.parent).collect();
assert_eq!(parents, vec![level1[0].id, level1[0].id]);
let summaries = rag.community_summaries().await;
assert_eq!(summaries.len(), 5);
assert_eq!(
summaries
.iter()
.filter(|s| s.as_str() == "BASE_SUMMARY")
.count(),
4
);
assert_eq!(summaries[level1[0].id], "ROLLUP_SUMMARY");
let result = rag
.query("overview please", QueryMode::Global)
.await
.unwrap();
assert_eq!(result.answer, "QUESTION_ANSWER");
assert_eq!(result.mode, QueryMode::Global);
assert_eq!(result.sources.len(), 22);
let prompt = last_prompt(&prompts);
assert_eq!(count_occurrences(&prompt, "ROLLUP_SUMMARY"), 1);
assert_eq!(count_occurrences(&prompt, "BASE_SUMMARY"), 2);
clear_prompts(&prompts);
let result = rag
.query("fine detail", QueryMode::GlobalAt(GlobalLevel::Level(0)))
.await
.unwrap();
assert_eq!(result.mode, QueryMode::GlobalAt(GlobalLevel::Level(0)));
let prompt = last_prompt(&prompts);
assert_eq!(count_occurrences(&prompt, "BASE_SUMMARY"), 4);
assert_eq!(count_occurrences(&prompt, "ROLLUP_SUMMARY"), 0);
assert_eq!(result.sources.len(), 22);
clear_prompts(&prompts);
let result = rag
.query("everything", QueryMode::GlobalAt(GlobalLevel::All))
.await
.unwrap();
assert_eq!(result.answer, "QUESTION_ANSWER");
assert_eq!(result.mode, QueryMode::GlobalAt(GlobalLevel::All));
let prompt = last_prompt(&prompts);
assert_eq!(count_occurrences(&prompt, "BASE_SUMMARY"), 4);
assert_eq!(count_occurrences(&prompt, "ROLLUP_SUMMARY"), 1);
clear_prompts(&prompts);
let err = rag
.query("ghost", QueryMode::GlobalAt(GlobalLevel::Level(9)))
.await
.unwrap_err();
assert!(err.to_string().contains("level 9"), "{err}");
let result = rag.query("n0", QueryMode::Local).await.unwrap();
assert_eq!(result.mode, QueryMode::Local);
assert_eq!(result.answer, "QUESTION_ANSWER");
assert!(result.sources.contains(&"n0".to_string()));
clear_prompts(&prompts);
let result = rag.query("n0", QueryMode::Hybrid).await.unwrap();
assert_eq!(result.mode, QueryMode::Hybrid);
let prompt = last_prompt(&prompts);
assert!(prompt.contains("ROLLUP_SUMMARY"));
assert!(prompt.contains("N0 (concept)"));
}
#[tokio::test]
async fn global_query_without_communities_is_an_error() {
let prompts: PromptLog = Arc::new(Mutex::new(Vec::new()));
let rag = GraphRAG::new(ScriptedChatModel::new(prompts));
{
let mut store = rag.store.write().await;
store.add_entity(Entity {
id: "x".into(),
name: "X".into(),
entity_type: "concept".into(),
description: "Entity X".into(),
});
}
let err = rag.query("q", QueryMode::Global).await.unwrap_err();
assert!(err.to_string().contains("build_communities"));
}
}