使用Neo4j和LangChain实现从本地到全球的GraphRAG构建图谱

使用Neo4j和LangChain实现从本地到全球的GraphRAG构建图谱

Barry Lv6

结合文本提取、网络分析与LLM提示及摘要技术,提升RAG准确性

我对在图上实施检索增强生成(Retrieval-Augmented Generation, RAG)的新方法,即常被称为GraphRAG的技术,始终充满好奇。然而,每当人们听到GraphRAG 这个词时,似乎每个人心中都有不同的实现构想。在这篇博文中,我们将深入探讨微软研究人员撰写的“从局部到全局GraphRAG ”文章及其具体实现。我们将重点介绍知识图谱构建与摘要部分,而检索器内容则留待下一篇博文详述。研究者们非常友好地为我们提供了代码仓库,并且他们还设有项目页面

上述文章中采用的方法颇具趣味性。据我理解,该方法利用知识图谱作为整合多源信息流程中的一环。从文本中提取实体及其关系并非新鲜事,但作者们提出了一种新颖(至少对我而言)的理念:将浓缩的图结构与信息重新概括为自然语言文本。流程始于来自文档的输入文本,经过处理生成图谱,随后图谱又被转换回自然语言文本,生成的文本包含了特定实体或图谱社区的浓缩信息,这些信息原本分散在多个文档中。

从宏观层面看,GraphRAG流程的输入是包含各类信息的源文档。这些文档通过LLM处理,提取出论文中出现的实体及其关系的结构化信息,进而构建知识图谱。

采用知识图谱数据表示的优势在于,它能迅速且直接地整合来自多个文档或数据源的特定实体信息。如前所述,知识图谱并非唯一的数据表示形式。在构建知识图谱后,他们结合图算法与LLM提示技术,生成知识图谱中发现的实体社区的自然语言摘要。

这些摘要随后包含了特定实体和社区跨越多个数据源和文档的浓缩信息。

若要更深入理解该流程,我们可以参考原文中提供的分步骤描述。

以下是我们将使用Neo4j和LangChain重现其方法的高层次流程概述。

索引 — 图生成

  • 源文档到文本块:源文档被分割成较小的文本块进行处理。
  • 文本块到元素实例:每个文本块经过分析,提取实体和关系,生成代表这些元素的元组列表。
  • 元素实例到元素摘要:提取的实体和关系由LLM总结为每个元素的描述性文本块。
  • 元素摘要到图社区:这些实体摘要形成一个图,然后使用Leiden 等算法将其划分为具有层次结构的社区。
  • 图社区到社区摘要:每个社区的摘要由LLM生成,以理解数据集的全局主题结构和语义。

检索 — 回答

  • 社区摘要到全局答案:社区摘要用于通过生成中间答案来回答用户查询,这些中间答案随后被汇总成最终的全局答案。

请注意,我的实现是在他们的代码可用之前完成的,因此底层方法或使用的LLM提示可能存在细微差异。我会尽量在我们进行的过程中解释这些差异。

代码可在GitHub 上获取。

设置 Neo4j 环境

我们将使用 Neo4j 作为底层图存储。最简单的入门方法是使用免费的 Neo4j Sandbox 实例,它提供了安装了图数据科学插件的 Neo4j 数据库云实例。或者,您可以通过下载 Neo4j Desktop 应用程序并创建本地数据库实例来设置本地 Neo4j 数据库。如果您使用的是本地版本,请确保安装 APOC 和 GDS 插件。对于生产环境,您可以使用付费的托管 AuraDS(数据科学)实例,该实例提供了 GDS 插件。

我们首先创建一个 Neo4jGraph 实例,这是我们在 LangChain 中添加的便捷包装器:

1
2
3
4
5
6
7
from langchain_community.graphs import Neo4jGraph

os.environ["NEO4J_URI"] = "bolt://44.202.208.177:7687"
os.environ["NEO4J_USERNAME"] = "neo4j"
os.environ["NEO4J_PASSWORD"] = "mast-codes-trails"

graph = Neo4jGraph(refresh_schema=False)

数据集

我们将使用我之前通过Diffbot的API 创建的一个新闻文章数据集。为了便于复用,我已将其上传至我的GitHub:

1
2
3
4
5
6
7
8
news = pd.read_csv(
"https://raw.githubusercontent.com/tomasonjo/blog-datasets/main/news_articles.csv"
)
news["tokens"] = [
num_tokens_from_string(f"{row['title']} {row['text']}")
for i, row in news.iterrows()
]
news.head()

让我们查看数据集的前几行。

我们拥有文章的标题和正文,以及它们的发布日期和使用tiktoken库计算的标记数量。

文本分块

文本分块步骤至关重要,对下游结果有显著影响。论文作者发现,使用较小的文本块总体上能提取出更多的实体。

如您所见,使用2,400个令牌的文本块提取的实体数量少于使用600个令牌时的数量。此外,他们还发现大型语言模型(LLMs)可能无法在首次运行时提取所有实体。在这种情况下,他们引入了一种启发式方法,多次执行提取操作。我们将在下一节详细讨论这一点。

然而,总是存在权衡。使用较小的文本块可能会丢失文档中分散的特定实体的上下文和指代关系。例如,如果一个文档在不同句子中提到“John”和“他”,将文本分割成较小的块可能会导致“他”指代John变得不明确。一些指代问题可以通过重叠文本分块策略解决,但并非所有问题都能解决。

让我们检查一下文章文本的大小:

1
2
3
4
5
sns.histplot(news["tokens"], kde=False)
plt.title('Distribution of chunk sizes')
plt.xlabel('Token count')
plt.ylabel('Frequency')
plt.show()

文章令牌数量的分布大致呈正态分布,峰值约为400个令牌。块的频率逐渐增加至这一峰值,然后对称下降,表明大多数文本块接近400个令牌。

由于这种分布,我们在这里不会进行任何文本分块,以避免指代问题。默认情况下,GraphRAG项目使用300个令牌的块大小 ,并带有100个令牌的重叠。

提取节点和关系

下一步是从文本块中构建知识。为此,我们使用LLM从文本中提取以节点和关系形式存在的结构化信息。你可以查看论文作者在文章中使用的LLM提示 。他们提供了LLM提示,如果需要,我们可以预定义节点标签,但默认情况下这是可选的。此外,原始文档中提取的关系实际上没有类型,只有描述。我认为这样做的目的是为了让LLM能够提取并保留更丰富、更细致的关系信息。但如果没有关系类型规范(描述可以作为属性),很难构建一个清晰的知识图谱。

在我们的实现中,我们将使用LangChain库中的LLMGraphTransformer 。与文章中的实现不同,LLMGraphTransformer利用内置的函数调用支持来提取结构化信息(LangChain中的结构化输出LLMs)。你可以检查系统提示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from langchain_experimental.graph_transformers import LLMGraphTransformer
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(temperature=0, model_name="gpt-4o")

llm_transformer = LLMGraphTransformer(
llm=llm,
node_properties=["description"],
relationship_properties=["description"]
)

def process_text(text: str) -> List[GraphDocument]:
doc = Document(page_content=text)
return llm_transformer.convert_to_graph_documents([doc])

在这个例子中,我们使用GPT-4o进行图谱提取。作者特别指示LLM提取实体及其关系和描述 。通过LangChain实现,你可以使用node_propertiesrelationship_properties属性来指定你希望LLM提取哪些节点或关系属性。

与LLMGraphTransformer实现的不同之处在于,所有节点或关系属性都是可选的,因此并非所有节点都会有description属性。如果我们愿意,可以定义一个自定义提取,强制要求description属性,但在这个实现中我们跳过了这一点。

我们将并行化请求以加快图谱提取速度,并将结果存储到Neo4j中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
MAX_WORKERS = 10
NUM_ARTICLES = 2000
graph_documents = []

with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
# 提交所有任务并创建一个future对象列表
futures = [
executor.submit(process_text, f"{row['title']} {row['text']}")
for i, row in news.head(NUM_ARTICLES).iterrows()
]

for future in tqdm(
as_completed(futures), total=len(futures), desc="Processing documents"
):
graph_document = future.result()
graph_documents.extend(graph_document)

graph.add_graph_documents(
graph_documents,
baseEntityLabel=True,
include_source=True
)

在这个例子中,我们从2,000篇文章中提取图谱信息并存储结果到Neo4j。我们提取了大约13,000个实体和16,000个关系。以下是提取文档在图谱中的一个示例。

完成提取大约需要35(±5)分钟,使用GPT-4o的成本约为30美元。

在这一步中,作者引入了启发式方法来决定是否进行多遍提取图谱信息。为了简化,我们只进行一遍。然而,如果我们想进行多遍,可以将第一次提取的结果作为对话历史,并简单地指示LLM有许多实体缺失 ,它应该提取更多,就像GraphRAG作者所做的那样。

之前我提到过文本块大小对提取实体数量的重要性。由于我们没有进行额外的文本分块,我们可以评估基于文本块大小的提取实体分布:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
entity_dist = graph.query(
"""
MATCH (d:Document)
RETURN d.text AS text,
count {(d)-[:MENTIONS]->()} AS entity_count
"""
)
entity_dist_df = pd.DataFrame.from_records(entity_dist)
entity_dist_df["token_count"] = [
num_tokens_from_string(str(el)) for el in entity_dist_df["text"]
]
# 散点图与回归线
sns.lmplot(
x="token_count",
y="entity_count",
data=entity_dist_df,
line_kws={"color": "red"}
)
plt.title("Entity Count vs Token Count Distribution")
plt.xlabel("Token Count")
plt.ylabel("Entity Count")
plt.show()

散点图显示,尽管存在正向趋势(由红线表示),但关系是次线性的。大多数数据点聚集在较低的实体计数上,即使令牌计数增加。这表明提取的实体数量并不与文本块大小成比例增长。尽管存在一些异常值,但总体模式显示较高的令牌计数并不一定导致更高的实体计数。这验证了作者的发现,即较小的文本块大小将提取更多信息。

我还认为检查构建图谱的节点度分布会很有趣。以下代码检索并可视化节点度分布:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
degree_dist = graph.query(
"""
MATCH (e:__Entity__)
RETURN count {(e)-[:!MENTIONS]-()} AS node_degree
"""
)
degree_dist_df = pd.DataFrame.from_records(degree_dist)

# 计算均值和中位数
mean_degree = np.mean(degree_dist_df['node_degree'])
percentiles = np.percentile(degree_dist_df['node_degree'], [25, 50, 75, 90])
# 创建一个带有对数刻度的直方图
plt.figure(figsize=(12, 6))
sns.histplot(degree_dist_df['node_degree'], bins=50, kde=False, color='blue')
# 使用对数刻度作为x轴
plt.yscale('log')
# 添加标签和标题
plt.xlabel('Node Degree')
plt.ylabel('Count (log scale)')
plt.title('Node Degree Distribution')
# 添加均值、中位数和百分位线
plt.axvline(mean_degree, color='red', linestyle='dashed', linewidth=1, label=f'Mean: {mean_degree:.2f}')
plt.axvline(percentiles[0], color='purple', linestyle='dashed', linewidth=1, label=f'25th Percentile: {percentiles[0]:.2f}')
plt.axvline(percentiles[1], color='orange', linestyle='dashed', linewidth=1, label=f'50th Percentile: {percentiles[1]:.2f}')
plt.axvline(percentiles[2], color='yellow', linestyle='dashed', linewidth=1, label=f'75th Percentile: {percentiles[2]:.2f}')
plt.axvline(percentiles[3], color='brown', linestyle='dashed', linewidth=1, label=f'90th Percentile: {percentiles[3]:.2f}')
# 添加图例
plt.legend()
# 显示图表
plt.show()

节点度分布遵循幂律模式,表明大多数节点只有很少的连接,而少数节点高度连接。均值度为2.45,中位数为1.00,显示超过一半的节点只有一个连接。大多数节点(75%)有两个或更少的连接,90%有五个或更少。这种分布在许多现实世界网络中很常见,其中少数枢纽节点有很多连接,而大多数节点只有很少的连接。

由于节点和关系描述都不是强制属性,我们还将检查提取了多少:

1
2
3
4
5
6
7
8
9
10
11
graph.query("""
MATCH (n:`__Entity__`)
RETURN "node" AS type,
count(*) AS total_count,
count(n.description) AS non_null_descriptions
UNION ALL
MATCH (n)-[r:!MENTIONS]->()
RETURN "relationship" AS type,
count(*) AS total_count,
count(r.description) AS non_null_descriptions
""")

结果显示,12,994个节点中有5,926个(45.6%)具有描述属性。另一方面,15,921个关系中只有5,569个(35%)具有这样的属性。

请注意,由于LLMs的概率性质,不同运行和不同源数据、LLMs和提示下的数字可能会有所不同。

实体解析

实体解析(去重)在构建知识图谱时至关重要,因为它确保每个实体被唯一且准确地表示,防止重复并合并指向同一现实世界实体的记录。这一过程对于维护图谱中的数据完整性和一致性至关重要。没有实体解析,知识图谱将遭受数据碎片化和不一致的问题,导致错误和不可靠的洞察。

此图展示了单一现实世界实体可能在不同文档中以略有不同的名称出现,从而在我们的图谱中产生差异。

此外,在没有实体解析的情况下,稀疏数据成为一个重大问题。来自不同来源的不完整或部分数据可能导致信息分散和断开,难以形成对实体的连贯和全面的理解。准确的实体解析通过整合数据、填补空白并创建每个实体的统一视图来解决这一问题。

可视化的左侧展示了一个稀疏且不连通的图谱。然而,如右侧所示,通过高效的实体解析,这样的图谱可以变得紧密相连。

总的来说,实体解析提高了数据检索和集成的效率,提供了跨不同来源信息的连贯视图。它最终基于可靠且完整的知识图谱,实现了更有效的问答。

遗憾的是,GraphRAG论文的作者在其代码库中并未包含任何实体解析代码,尽管他们在论文中提到了这一点。可能的原因之一是,对于任何给定领域,实现一个强大且性能良好的实体解析是困难的。在处理预定义类型的节点时,可以为不同节点实现自定义启发式方法(当它们未预定义时,它们不够一致,如公司、组织、企业等)。然而,如果节点标签或类型不是预先已知的,就像我们这种情况,问题变得更加复杂。尽管如此,我们将在项目中实现一个版本的实体解析,结合文本嵌入和图算法与词距离和LLMs。

我们的实体解析过程包括以下步骤:

  1. 图谱中的实体 — 从图谱中的所有实体开始。
  2. K-最近邻图 — 构建一个基于文本嵌入连接相似实体的K-最近邻图。
  3. 弱连通分量 — 在K-最近邻图中识别弱连通分量,将可能相似的实体分组。在这些分量被识别后,添加一个词距离过滤步骤。
  4. LLM评估 — 使用LLM评估这些分量,并决定每个分量内的实体是否应合并,从而得出实体解析的最终决策(例如,合并‘Silicon Valley Bank’和‘Silicon_Valley_Bank’,而拒绝合并不同日期如‘2023年9月16日’和‘2023年9月2日’)。

我们首先计算实体名称和描述属性的文本嵌入。我们可以使用LangChain中的Neo4jVector集成中的from_existing_graph方法来实现这一点:

1
2
3
4
5
6
vector = Neo4jVector.from_existing_graph(
OpenAIEmbeddings(),
node_label='__Entity__',
text_node_properties=['id', 'description'],
embedding_node_property='embedding'
)

我们可以使用这些嵌入基于这些嵌入的余弦距离找到相似的潜在候选。我们将使用Graph Data Science (GDS) library 中可用的图算法;因此,我们可以使用GDS Python client 以Pythonic方式轻松使用:

1
2
3
4
5
6
from graphdatascience import GraphDataScience

gds = GraphDataScience(
os.environ["NEO4J_URI"],
auth=(os.environ["NEO4J_USERNAME"], os.environ["NEO4J_PASSWORD"])
)

如果您不熟悉GDS库,我们首先必须投影一个内存中的图谱,然后才能执行任何图算法。

首先,Neo4j存储的图谱被投影到内存中的图谱中,以便更快地处理和分析。接下来,在内存中的图谱上执行图算法。可选地,算法的结果可以存储回Neo4j数据库。更多信息请参阅文档

为了创建K-最近邻图,我们将投影所有实体及其文本嵌入:

1
2
3
4
5
6
G, result = gds.graph.project(
"entities", # 图谱名称
"__Entity__", # 节点投影
"*", # 关系投影
nodeProperties=["embedding"] # 配置参数
)

现在图谱在entities名称下投影,我们可以执行图算法。我们将从构建K-最近邻图 开始。影响K-最近邻图稀疏或密集的两个最重要参数是similarityCutofftopKtopK是每个节点要找到的邻居数量,最小值为1。相似度截止值过滤掉相似度低于此阈值的关系。在这里,我们将使用默认的topK值10和相对较高的相似度截止值0.95。使用高相似度截止值(如0.95)确保只考虑高度相似的配对,最小化假阳性并提高准确性。

由于我们希望将结果存储回投影的内存中的图谱而不是知识图谱,我们将使用算法的mutate模式:

1
2
3
4
5
6
7
8
9
similarity_threshold = 0.95

gds.knn.mutate(
G,
nodeProperties=['embedding'],
mutateRelationshipType= 'SIMILAR',
mutateProperty= 'score',
similarityCutoff=similarity_threshold
)

下一步是识别通过新推断的相似关系连接的实体组。识别连接节点的组是网络分析中的常见过程,通常称为社区检测聚类,涉及找到密集连接节点的子组。在这个例子中,我们将使用弱连通分量算法 ,它帮助我们找到所有节点都连接的部分,即使我们忽略连接的方向。

我们使用算法的write模式将结果存储回数据库(存储的图谱):

1
2
3
4
5
gds.wcc.write(
G,
writeProperty="wcc",
relationshipTypes=["SIMILAR"]
)

文本嵌入比较有助于找到潜在的重复项,但它只是实体解析过程的一部分。例如,Google和Apple在嵌入空间中非常接近(使用ada-002嵌入模型,余弦相似度为0.96)。同样,BMW和Mercedes Benz也是如此(余弦相似度为0.97)。高文本嵌入相似度是一个好的开始,但我们可以改进它。因此,我们将添加一个额外的过滤器,只允许文本距离为三个或更少的词对(意味着只能改变字符):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
word_edit_distance = 3
potential_duplicate_candidates = graph.query(
"""MATCH (e:`__Entity__`)
WHERE size(e.id) > 3 // 长度超过三个字符
WITH e.wcc AS community, collect(e) AS nodes, count(*) AS count
WHERE count > 1
UNWIND nodes AS node
// 添加文本距离
WITH distinct
[n IN nodes WHERE apoc.text.distance(toLower(node.id), toLower(n.id)) < $distance
OR node.id CONTAINS n.id | n.id] AS intermediate_results
WHERE size(intermediate_results) > 1
WITH collect(intermediate_results) AS results
// 合并共享元素的组
UNWIND range(0, size(results)-1, 1) as index
WITH results, index, results[index] as result
WITH apoc.coll.sort(reduce(acc = result, index2 IN range(0, size(results)-1, 1) |
CASE WHEN index <> index2 AND
size(apoc.coll.intersection(acc, results[index2])) > 0
THEN apoc.coll.union(acc, results[index2])
ELSE acc
END
)) as combinedResult
WITH distinct(combinedResult) as combinedResult
// 额外过滤
WITH collect(combinedResult) as allCombinedResults
UNWIND range(0, size(allCombinedResults)-1, 1) as combinedResultIndex
WITH allCombinedResults[combinedResultIndex] as combinedResult, combinedResultIndex, allCombinedResults
WHERE NOT any(x IN range(0,size(allCombinedResults)-1,1)
WHERE x <> combinedResultIndex
AND apoc.coll.containsAll(allCombinedResults[x], combinedResult)
)
RETURN combinedResult
""", params={'distance': word_edit_distance})

这个Cypher语句稍微复杂一些,其解释超出了本博客文章的范围。您总是可以请LLM来解释它。

此外,词距离截止值可以是词长度的函数,而不是单一数字,实现可以更具可扩展性。

重要的是,它输出我们可能想要合并的潜在实体组。以下是潜在节点的列表:

1
2
3
4
5
6
7
8
9
10
11
12
{'combinedResult': ['Sinn Fein', 'Sinn Féin']},
{'combinedResult': ['Government', 'Governments']},
{'combinedResult': ['Unreal Engine', 'Unreal_Engine']},
{'combinedResult': ['March 2016', 'March 2020', 'March 2022', 'March_2023']},
{'combinedResult': ['Humana Inc', 'Humana Inc.']},
{'combinedResult': ['New York Jets', 'New York Mets']},
{'combinedResult': ['Asia Pacific', 'Asia-Pacific', 'Asia_Pacific']},
{'combinedResult': ['Bengaluru', 'Mangaluru']},
{'combinedResult': ['U.S. Securities And Exchange Commission',
'Us Securities And Exchange Commission']},
{'combinedResult': ['Jp Morgan', 'Jpmorgan']},
{'combinedResult': ['Brighton', 'Brixton']},

如你所见,我们的解析方法对某些节点类型比其他类型更有效。根据初步检查,它似乎对人和组织更有效,而对日期的处理则相当糟糕。如果我们使用预定义的节点类型,我们可以为不同的节点类型准备不同的启发式方法。在这个例子中,我们没有预定义的节点标签,因此我们将求助于LLM来做出关于实体是否应该合并的最终决定。

首先,我们需要制定LLM提示,以有效指导和通知关于节点合并的最终决策:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
system_prompt = """你是一个数据处理助手。你的任务是识别列表中的重复实体,并决定哪些应该合并。
实体可能在格式或内容上略有不同,但基本上指的是同一件事。使用你的分析技能来确定重复项。

以下是识别重复项的规则:
1. 具有轻微排版差异的实体应被视为重复项。
2. 具有不同格式但内容相同的实体应被视为重复项。
3. 即使描述方式不同,但指代同一现实世界对象或概念的实体应被视为重复项。
4. 如果指代不同的数字、日期或产品,请不要合并结果
"""
user_template = """
以下是需要处理的实体列表:
{entities}

请识别重复项,合并它们,并提供合并后的列表。
"""

我总是喜欢在期望结构化数据输出时使用LangChain中的with_structured_output方法,以避免手动解析输出。

在这里,我们将输出定义为一个列表的列表,其中每个内部列表包含应该合并的实体。这种结构用于处理例如输入可能是[Sony, Sony Inc, Google, Google Inc]的情况。在这种情况下,你会希望将“Sony”和“Sony Inc”与“Google”和“Google Inc”分开合并。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class DuplicateEntities(BaseModel):
entities: List[str] = Field(
description="代表同一对象或现实世界实体并应合并的实体"
)


class Disambiguate(BaseModel):
merge_entities: Optional[List[DuplicateEntities]] = Field(
description="代表同一对象或现实世界实体并应合并的实体列表"
)


extraction_llm = ChatOpenAI(model_name="gpt-4o").with_structured_output(
Disambiguate
)

接下来,我们将LLM提示与结构化输出集成,使用LangChain表达式语言(LCEL)语法创建一个链,并将其封装在一个disambiguate函数中。

1
2
3
4
5
6
7
8
extraction_chain = extraction_prompt | extraction_llm


def entity_resolution(entities: List[str]) -> Optional[List[List[str]]]:
return [
el.entities
for el in extraction_chain.invoke({"entities": entities}).merge_entities
]

我们需要通过entity_resolution函数运行所有潜在的候选节点,以决定它们是否应该合并。为了加快速度,我们将再次并行化LLM调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
merged_entities = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
# 提交所有任务并创建一个未来对象列表
futures = [
executor.submit(entity_resolution, el['combinedResult'])
for el in potential_duplicate_candidates
]

for future in tqdm(
as_completed(futures), total=len(futures), desc="处理文档"
):
to_merge = future.result()
if to_merge:
merged_entities.extend(to_merge)

实体解析的最后一步涉及从entity_resolutionLLM获取结果,并通过合并指定的节点将它们写回数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
graph.query("""
UNWIND $data AS candidates
CALL {
WITH candidates
MATCH (e:__Entity__) WHERE e.id IN candidates
RETURN collect(e) AS nodes
}
CALL apoc.refactor.mergeNodes(nodes, {properties: {
description:'combine',
`.*`: 'discard'
}})
YIELD node
RETURN count(*)
""", params={"data": merged_entities})

这种实体解析并不完美,但它为我们提供了一个改进的起点。此外,我们可以改进确定哪些实体应保留的逻辑。

元素摘要

在下一步中,作者执行了一个元素摘要步骤。本质上,每个节点和关系都会通过一个实体摘要提示 。作者指出了他们方法的新颖性和兴趣:

“总的来说,我们在可能嘈杂的图结构中为同质节点使用丰富的描述性文本,这与LLM的能力和全局、查询聚焦摘要的需求相一致。这些特性也使我们的图索引与典型的知识图谱区分开来,后者依赖于简洁一致的知识三元组(主体,谓词,客体)进行下游推理任务。”

这个想法令人兴奋。我们仍然从文本中提取主体和客体的ID或名称,这使我们能够将关系链接到正确的实体,即使实体出现在多个文本块中。然而,关系并没有简化为单一类型。相反,关系类型实际上是自由形式的文本,这使我们能够保留更丰富和细致的信息。

此外,实体信息通过LLM进行摘要,使我们能够更有效地嵌入和索引这些信息和实体,以便更准确地检索。

有人可能会认为,通过添加额外的、可能是任意的节点和关系属性,也可以保留这种更丰富和细致的信息。任意节点和关系属性的一个问题在于,可能难以一致地提取信息,因为LLM可能在每次执行时使用不同的属性名称或关注不同的细节。

其中一些问题可以通过使用带有额外类型和描述信息的预定义属性名称来解决。在这种情况下,你需要一个领域专家来帮助定义这些属性,留给LLM在预定义描述之外提取任何关键信息的空间很小。

这是一种在知识图谱中表示更丰富信息的有趣方法。

元素摘要步骤的一个潜在问题是它不具备良好的扩展性,因为它需要对图中的每个实体和关系进行LLM调用。我们的图相对较小,有13,000个节点和16,000个关系。即使是这样一个小图,我们也需要进行29,000次LLM调用,每次调用会使用几百个令牌,这相当昂贵且耗时。因此,我们将避免在这里进行这一步骤。我们仍然可以使用在初始文本处理期间提取的描述属性。

构建和总结社区

图构建和索引过程的最后一步是识别图中的社区。在此上下文中,社区是指一组节点,它们彼此之间的连接比与图中其他部分的连接更为密集,表明它们之间具有更高级别的交互或相似性。以下可视化展示了社区检测结果的示例。

一旦通过聚类算法识别出这些实体社区,LLM 会为每个社区生成一个总结,提供对其个体特征和关系的洞察。

我们再次使用 Graph Data Science 库。首先,我们将一个内存中的图进行投影。为了精确遵循原文,我们将实体图投影为一个无向加权网络,其中网络表示两个实体之间的连接数量:

1
2
3
4
5
6
7
8
9
10
11
G, result = gds.graph.project(
"communities", # 图名称
"__Entity__", # 节点投影
{
"_ALL_": {
"type": "*",
"orientation": "UNDIRECTED",
"properties": {"weight": {"property": "*", "aggregation": "COUNT"}},
}
},
)

作者采用了Leiden 算法 ,一种层次聚类方法,来识别图中的社区。使用层次社区检测算法的一个优势是能够在多个粒度级别上检查社区。作者建议在每个级别上总结所有社区,以全面理解图的结构。

首先,我们将使用弱连通分量(WCC)算法来评估图的连通性。该算法识别图中的孤立部分,即检测相互连接但不与其他部分连接的节点子集或组件。这些组件帮助我们理解网络内部的碎片化情况,并识别与其他节点独立的节点组。WCC 对于分析图的整体结构和连通性至关重要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
wcc = gds.wcc.stats(G)
print(f"Component count: {wcc['componentCount']}")
print(f"Component distribution: {wcc['componentDistribution']}")
# Component count: 1119
# Component distribution: {
# "min":1,
# "p5":1,
# "max":9109,
# "p999":43,
# "p99":19,
# "p1":1,
# "p10":1,
# "p90":7,
# "p50":2,
# "p25":1,
# "p75":4,
# "p95":10,
# "mean":11.3 }

WCC 算法结果识别出 1,119 个不同的组件。值得注意的是,最大的组件包含 9,109 个节点,这在现实世界的网络中很常见,即一个超级组件与许多较小的孤立组件共存。最小的组件只有一个节点,平均组件大小约为 11.3 个节点。

接下来,我们将运行 Leiden 算法,该算法也可在 GDS 库中使用,并启用 includeIntermediateCommunities 参数以返回并存储所有级别的社区。我们还包含了一个 relationshipWeightProperty 参数来运行 Leiden 算法的加权变体。使用算法的 write 模式将结果存储为节点属性。

1
2
3
4
5
6
gds.leiden.write(
G,
writeProperty="communities",
includeIntermediateCommunities=True,
relationshipWeightProperty="weight",
)

该算法识别出五个级别的社区,最高级别(社区最大,粒度最小)有 1,188 个社区(而非 1,119 个组件)。以下是使用 Gephi 在最后一级社区的可视化。

可视化超过 1,000 个社区很困难;即使是为每个社区选择颜色也几乎不可能。然而,它们构成了不错的艺术渲染。

在此基础上,我们将为每个社区创建一个独立的节点,并将它们的层次结构表示为一个互联图。稍后,我们还将社区总结和其他属性存储为节点属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
graph.query("""
MATCH (e:`__Entity__`)
UNWIND range(0, size(e.communities) - 1 , 1) AS index
CALL {
WITH e, index
WITH e, index
WHERE index = 0
MERGE (c:`__Community__` {id: toString(index) + '-' + toString(e.communities[index])})
ON CREATE SET c.level = index
MERGE (e)-[:IN_COMMUNITY]->(c)
RETURN count(*) AS count_0
}
CALL {
WITH e, index
WITH e, index
WHERE index > 0
MERGE (current:`__Community__` {id: toString(index) + '-' + toString(e.communities[index])})
ON CREATE SET current.level = index
MERGE (previous:`__Community__` {id: toString(index - 1) + '-' + toString(e.communities[index - 1])})
ON CREATE SET previous.level = index - 1
MERGE (previous)-[:IN_COMMUNITY]->(current)
RETURN count(*) AS count_1
}
RETURN count(*)
""")

作者还引入了一个 community rank,表示社区内的实体在不同文本块中出现的次数:

1
2
3
4
5
graph.query("""
MATCH (c:__Community__)<-[:IN_COMMUNITY*]-(:__Entity__)<-[:MENTIONS]-(d:Document)
WITH c, count(distinct d) AS rank
SET c.community_rank = rank;
""")

现在让我们检查一个包含许多中间社区在更高级别合并的样本层次结构。社区是不重叠的,意味着每个实体在每个级别上恰好属于一个社区。

该图像展示了 Leiden 社区检测算法产生的层次结构。紫色节点代表单个实体,橙色节点代表层次社区。

层次结构展示了这些实体被组织成不同社区的方式,较小的社区在更高级别合并成较大的社区。

现在让我们检查较小的社区如何在更高级别合并。

该图像表明,连接较少的实体和相应较小的社区在不同级别上变化最小。例如,这里的社区结构仅在前两个级别发生变化,但在最后三个级别保持不变。因此,对于这些实体,层次级别往往显得冗余,因为整体组织在不同层级上没有显著变化。

让我们更详细地检查不同级别上的社区数量及其大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
community_size = graph.query(
"""
MATCH (c:__Community__)<-[:IN_COMMUNITY*]-(e:__Entity__)
WITH c, count(distinct e) AS entities
RETURN split(c.id, '-')[0] AS level, entities
"""
)
community_size_df = pd.DataFrame.from_records(community_size)
percentiles_data = []
for level in community_size_df["level"].unique():
subset = community_size_df[community_size_df["level"] == level]["entities"]
num_communities = len(subset)
percentiles = np.percentile(subset, [25, 50, 75, 90, 99])
percentiles_data.append(
[
level,
num_communities,
percentiles[0],
percentiles[1],
percentiles[2],
percentiles[3],
percentiles[4],
max(subset)
]
)

# 创建一个包含百分位数的 DataFrame
percentiles_df = pd.DataFrame(
percentiles_data,
columns=[
"Level",
"Number of communities",
"25th Percentile",
"50th Percentile",
"75th Percentile",
"90th Percentile",
"99th Percentile",
"Max"
],
)
percentiles_df

在原始实现中,每个级别的社区都被总结了。在我们的案例中,这将是 8,590 个社区,因此是 8,590 次 LLM 调用。我认为,根据层次社区结构,并非每个级别都需要总结。例如,最后一个级别和倒数第二个级别之间的差异仅为四个社区(1,192 对 1,188)。因此,我们将创建许多冗余的总结。一种解决方案是为不同级别上没有变化的社区创建一个总结;另一种解决方案是折叠那些没有变化的社区层次结构。

此外,我不确定是否要总结只有一个成员的社区,因为它们可能不会提供太多价值或信息。在这里,我们将总结第 0、1 和 4 级的社区。首先,我们需要从数据库中检索它们的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
community_info = graph.query("""
MATCH (c:`__Community__`)<-[:IN_COMMUNITY*]-(e:__Entity__)
WHERE c.level IN [0,1,4]
WITH c, collect(e ) AS nodes
WHERE size(nodes) > 1
CALL apoc.path.subgraphAll(nodes[0], {
whitelistNodes:nodes
})
YIELD relationships
RETURN c.id AS communityId,
[n in nodes | {id: n.id, description: n.description, type: [el in labels(n) WHERE el <> '__Entity__'][0]}] AS nodes,
[r in relationships | {start: startNode(r).id, type: type(r), end: endNode(r).id, description: r.description}] AS rels
""")

目前,社区信息具有以下结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{'communityId': '0-6014',
'nodes': [{'id': 'Darrell Hughes', 'description': None, type:"Person"},
{'id': 'Chief Pilot', 'description': None, type: "Person"},
...
}],
'rels': [{'start': 'Ryanair Dac',
'description': 'Informed of the change in chief pilot',
'type': 'INFORMED',
'end': 'Irish Aviation Authority'},
{'start': 'Ryanair Dac',
'description': 'Dismissed after internal investigation found unacceptable behaviour',
'type': 'DISMISSED',
'end': 'Aidan Murray'},
...
]}

现在,我们需要准备一个 LLM 提示,根据我们社区提供的元素信息生成自然语言总结。我们可以从研究人员使用的提示 中获得一些灵感。

作者不仅总结了各个社区,还为每个社区生成了发现结果。发现可以定义为关于特定事件或信息的简明信息。例如:

1
2
"summary": "阿比拉城市公园作为中心地点",
"explanation": "阿比拉城市公园是该社区的核心实体,作为POK集会的地点。这个公园是所有其他实体的共同联系点,表明其在社区中的重要性。公园与集会的关联可能会导致公共秩序或冲突等问题,具体取决于集会的性质及其引发的反应。[记录:实体(5个),关系(37、38、39、40)]"

我的直觉认为,仅通过一次遍历提取发现可能不够全面,就像提取实体和关系一样。

此外,我在本地或全局搜索检索器中没有找到任何关于它们在代码中使用的参考或示例。因此,我们在此情况下不会提取发现。或者,正如学者们常说的:这一练习留给读者。此外,我们还跳过了声明或协变信息提取 ,乍看之下与发现相似。

我们将使用的生成社区总结的提示相当直接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
community_template = """基于属于同一图社区的提供节点和关系,
生成所提供信息的自然语言总结:
{community_info}

总结:""" # noqa: E501

community_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"给定输入三元组,生成信息总结。无需前言。",
),
("human", community_template),
]
)

community_chain = community_prompt | llm | StrOutputParser()

剩下的唯一任务是将社区表示转换为字符串,以减少令牌数量,避免JSON令牌开销,并将链包装为一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def prepare_string(data):
nodes_str = "节点是:\n"
for node in data['nodes']:
node_id = node['id']
node_type = node['type']
if 'description' in node and node['description']:
node_description = f", 描述:{node['description']}"
else:
node_description = ""
nodes_str += f"id: {node_id}, 类型: {node_type}{node_description}\n"

rels_str = "关系是:\n"
for rel in data['rels']:
start = rel['start']
end = rel['end']
rel_type = rel['type']
if 'description' in rel and rel['description']:
description = f", 描述:{rel['description']}"
else:
description = ""
rels_str += f"({start})-[:{rel_type}]->({end}){description}\n"

return nodes_str + "\n" + rels_str

def process_community(community):
stringify_info = prepare_string(community)
summary = community_chain.invoke({'community_info': stringify_info})
return {"community": community['communityId'], "summary": summary}

现在我们可以为选定的层级生成社区总结。同样,我们并行调用以加快执行速度:

1
2
3
4
5
6
summaries = []
with ThreadPoolExecutor() as executor:
futures = {executor.submit(process_community, community): community for community in community_info}

for future in tqdm(as_completed(futures), total=len(futures), desc="处理社区"):
summaries.append(future.result())

我未提及的一个方面是,作者还解决了输入社区信息时可能超出上下文大小的问题。随着图的扩展,社区也可能显著增长。在我们的案例中,最大的社区包含545个成员。鉴于GPT-4o的上下文大小超过100,000个令牌,我们决定跳过这一步骤。

作为最后一步,我们将社区总结存储回数据库:

1
2
3
4
5
graph.query("""
UNWIND $data AS row
MERGE (c:__Community__ {id:row.community})
SET c.summary = row.summary
""", params={"data": summaries})

最终的图结构:

图现在包含了原始文档、提取的实体和关系,以及分层社区结构和总结。

概述

“From Local to Global”论文的作者 在展示GraphRAG的新方法方面做得非常出色。他们展示了如何将来自各种文档的信息结合起来,并总结成一个层次化的知识图谱结构。

有一点没有明确提到的是,我们还可以在图中整合结构化数据源;输入不仅限于非结构化文本。

我特别欣赏他们的提取方法的一点是,他们为节点和关系都捕捉了描述。描述使得LLM能够保留比仅将所有内容简化为节点ID和关系类型更多的信息。

此外,他们还展示了单次文本提取可能无法捕捉所有相关信息,并引入了必要时进行多次提取的逻辑。作者们还提出了一个有趣的想法,即在图社区上进行总结,使我们能够在多个数据源上嵌入和索引浓缩的主题信息。

在下一篇博客文章中,我们将详细介绍本地和全局搜索检索器的实现,并讨论基于给定图结构我们可以实施的其他方法。

一如既往,代码可以在GitHub 上找到。

这次,我还上传了数据库转储 ,以便您可以探索结果并尝试不同的检索器选项。

您还可以将此转储导入永久免费的Neo4j AuraDB实例 ,我们可以用它来进行检索探索,因为我们不需要图数据科学算法,只需要图模式匹配、向量和全文索引。

了解更多关于Neo4j与所有GenAI框架的集成 以及我的书《数据科学中的图算法》 中的实用图算法。

  • 标题: 使用Neo4j和LangChain实现从本地到全球的GraphRAG构建图谱
  • 作者: Barry
  • 创建于 : 2024-07-10 00:52:30
  • 更新于 : 2024-08-31 06:59:45
  • 链接: https://wx.role.fun/2024/07/10/c3d5e951d3eb4fb7921f4bb58e58ce26/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。