构件图:使用Neo4j和LangChain实现“从本地到全局”的GraphRAG

2024年07月12日 由 alex 发表 397 0

我总是对在图形上实现检索增强生成(RAG)的新方法(通常称为 GraphRAG)感到好奇。不过,似乎每个人在听到 GraphRAG 这个术语时,脑海中都会浮现出不同的实现方式。在本文中,我们将深入探讨微软研究人员撰写的 "从局部到全局 GraphRAG "一文及其实现方法。我们将介绍知识图谱的构建和总结部分。


18


从高层次来看,GraphRAG 管道的输入是包含各种信息的源文件。使用 LLM 对文档进行处理,以提取论文中出现的实体及其关系的结构化信息。提取的结构化信息随后用于构建知识图谱。


使用知识图谱数据表示的优势在于,它可以快速、直接地将来自多个文档或数据源的有关特定实体的信息结合起来。如前所述,知识图谱并不是唯一的数据表示方式。在构建知识图谱后,他们结合使用图谱算法和 LLM 提示,生成知识图谱中实体社区的自然语言摘要。


这些摘要包含多个数据源和文档中关于特定实体和社区的浓缩信息。


19


以下是我们将使用 Neo4j 和 LangChain 重现其方法的管道的高级摘要。


索引--图生成

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


检索 - 解答

  • 从社区摘要到全局答案: 社区摘要通过生成中间答案来回答用户的查询,然后将中间答案汇总为最终的全局答案。


设置 Neo4j 环境

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


我们首先创建一个 Neo4jGraph 实例,这是我们添加到 LangChain 的便利封装器:


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,以便于重复使用:


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()


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


20


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


文本分块

文本分块步骤至关重要,会对下游结果产生重大影响。论文作者发现,使用较小的文本块可以提取更多的实体。


21


正如你所看到的,使用 2,400 个词组的文本块所提取的实体比使用 600 个词组时要少。此外,他们还发现 LLM 在第一次运行时可能无法提取所有实体。在这种情况下,他们引入了一种启发式方法来执行多次提取。


不过,这其中总会有取舍。使用较小的文本块可能会导致丢失遍布整个文档的特定实体的上下文和核心参照。例如,如果文档在不同的句子中分别提到了 "约翰 "和 "他",那么将文本分成更小的文本块可能会使人不清楚 "他 "指的是约翰。使用重叠文本分块策略可以解决部分核心参照问题,但并非所有问题都能解决。


让我们来看看文章文本的大小:


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


22


文章标记数的分布近似正态分布,峰值约为 400 个标记。文本块的频率在达到这个峰值后逐渐上升,然后对称下降,这表明大多数文本块都接近 400 个标记符。


基于这种分布,我们在此不对文本块进行任何处理,以避免核心参照问题。默认情况下,GraphRAG 项目使用的文本块大小为 300 个标记符,其中有 100 个标记符重叠。


提取节点和关系

下一步是从文本块中构建知识。在本例中,我们使用 LLM 从文本中提取节点和关系形式的结构化信息。你可以查看作者在论文中使用的 LLM 提示。如果需要,我们可以在 LLM 提示中预定义节点标签,但默认情况下,这是可选的。此外,原始文档中提取的关系并没有真正的类型,只有描述。我想这一选择背后的原因是允许 LLM 提取和保留更丰富、更细微的信息作为关系。但是,如果没有关系类型的说明,就很难有一个简洁的知识图谱(说明可以放在属性中)。


在我们的实现中,我们将使用 LangChain 库中的 LLMGraphTransformer。LLMGraphTransformer 并不像文章中的实现那样使用纯粹的提示工程,而是使用内置的函数调用支持来提取结构化信息(LangChain 中的结构化输出 LLM)。你可以查看系统提示:


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_properties 和 relationship_propertiesattributes 来指定希望 LLM 提取的节点或关系属性。


与 LLMGraphTransformer 实现不同的是,所有节点或关系属性都是可选的,因此并非所有节点都有 descriptionproperty。如果我们愿意,可以定义自定义提取,使其具有强制的 descriptionproperty,但在本实现中我们将跳过这一点。


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


MAX_WORKERS = 10
NUM_ARTICLES = 2000
graph_documents = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
    # Submitting all tasks and creating a list of future objects
    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 个关系。下面是在图中提取文档的示例。


23


在这一步骤中,作者引入了启发式方法来决定是否多次提取图信息。为简单起见,我们只提取一次。不过,如果我们想进行多次提取,可以将第一次提取的结果作为会话历史,然后像 GraphRAG 的作者那样,简单地指示 LLM 缺少很多实体,它应该提取更多的实体。


之前,我提到了文本块大小的重要性以及它对提取实体数量的影响。由于我们没有执行任何额外的文本块处理,因此我们可以根据文本块大小来评估提取实体的分布情况:


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"]
]
# Scatter plot with regression line
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()


24


散点图显示,虽然存在红线所示的正趋势,但这种关系是亚线性的。大多数数据点聚集在较低的实体数上,即使标记数增加也是如此。这表明,提取的实体数量与文本块的大小不成正比。虽然存在一些异常值,但总体模式表明,较高的标记数并不会持续导致较高的实体数。这验证了作者的发现,即较小的文本块大小可以提取更多的信息。


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


degree_dist = graph.query(
    """
MATCH (e:__Entity__)
RETURN count {(e)-[:!MENTIONS]-()} AS node_degree
"""
)
degree_dist_df = pd.DataFrame.from_records(degree_dist)
# Calculate mean and median
mean_degree = np.mean(degree_dist_df['node_degree'])
percentiles = np.percentile(degree_dist_df['node_degree'], [25, 50, 75, 90])
# Create a histogram with a logarithmic scale
plt.figure(figsize=(12, 6))
sns.histplot(degree_dist_df['node_degree'], bins=50, kde=False, color='blue')
# Use a logarithmic scale for the x-axis
plt.yscale('log')
# Adding labels and title
plt.xlabel('Node Degree')
plt.ylabel('Count (log scale)')
plt.title('Node Degree Distribution')
# Add mean, median, and percentile lines
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}')
# Add legend
plt.legend()
# Show the plot
plt.show()


25


连接很多。平均度数为 2.45,中位数为 1.00,表明一半以上的节点只有一个连接。大多数节点(75%)有两个或更少的连接,90% 的节点有五个或更少的连接。这种分布是现实世界中许多网络的典型特征,即少数枢纽有很多连接,而大多数节点只有很少的连接。


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


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
""")


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


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


实体解析

在构建知识图谱时,实体解析(去重复)至关重要,因为它能确保每个实体都得到唯一、准确的表达,防止重复和合并指向同一现实世界实体的记录。这一过程对于保持图中数据的完整性和一致性至关重要。如果没有实体解析,知识图谱就会受到数据零散和不一致的影响,从而导致错误和不可靠的见解。


26


这幅图展示了现实世界中的一个实体在不同文档中可能以略微不同的名称出现,因此在我们的图中也是如此。


此外,如果没有实体解析,数据稀疏也是一个重要问题。来自不同来源的不完整或部分数据会导致信息分散且互不关联,从而难以形成对实体的连贯而全面的理解。准确的实体解析可通过整合数据、填补空白和创建每个实体的统一视图来解决这一问题。


27


可视化左侧显示的是一个稀疏且无连接的图形。然而,如右侧所示,通过高效的实体解析,这样的图可以变得连接紧密。


总体而言,实体解析提高了数据检索和整合的效率,为不同来源的信息提供了一个连贯的视图。最终,它能在可靠、完整的知识图谱基础上提供更有效的问题解答。


遗憾的是,GraphRAG 论文的作者虽然在论文中提到了实体解析,但并没有在他们的 repo 中包含任何实体解析代码。不包含该代码的原因之一可能是,要为任何给定的领域实现健壮且性能良好的实体解析都很困难。在处理预定义的节点类型时,你可以针对不同的节点实施自定义启发式算法(当节点类型不是预定义时,它们就不够一致,如公司、组织、业务等)。但是,如果节点标签或类型事先不知道,就像我们的情况一样,这就成了一个更难的问题。尽管如此,我们还是会在这里的项目中实现一个版本的实体解析,将文本嵌入和图算法与词距和 LLMs 结合起来。


28


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

  1. 图中的实体 - 从图中的所有实体开始。
  2. K 最近图 - 构建 K 最近邻图,根据文本嵌入连接相似实体。
  3. 弱连接组件 - 在 K 最近图中识别弱连接组件,将可能相似的实体分组。在识别出这些组件后,添加词距过滤步骤。
  4. LLM 评估--使用 LLM 评估这些组件,并决定是否应合并每个组件中的实体,从而做出实体解析的最终决定(例如,合并 "硅谷银行 "和 "Silicon_Valley_Bank",而拒绝合并 "2023 年 9 月 16 日 "和 "2023 年 9 月 2 日 "等不同日期的实体)。


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


vector = Neo4jVector.from_existing_graph(
    OpenAIEmbeddings(),
    node_label='__Entity__','__Entity__',
    text_node_properties=['id', 'description'],
    embedding_node_property='embedding'
)


我们可以根据这些嵌入式的余弦距离,利用这些嵌入式找到相似的潜在候选者。我们将使用图形数据科学(GDS)库中的图形算法;因此,我们可以使用 GDS Python 客户端,以 Pythonic 的方式方便使用:


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


如果你对 GDS 库不熟悉,那么在执行任何图形算法之前,我们首先必须投影一个内存图形。


29


首先,Neo4j 存储图被投影到内存图中,以加快处理和分析速度。接着,在内存图上执行图算法。算法结果可以选择性地存储回 Neo4j 数据库。


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


G, result = gds.graph.project(
    "entities",                   # Graph name"entities",                   # Graph name
    "__Entity__",                 # Node projection
    "*",                          # Relationship projection
    nodeProperties=["embedding"]  # Configuration parameters
)


既然图已经投影到实体名称下,我们就可以执行图算法了。首先,我们将构建一个 k 最近图。影响 k 最近图稀疏或密集程度的两个最重要参数是相似性截止值(similarityCutoff)和 topK。topK 是为每个节点查找邻居的数量,最小值为 1。 相似性截止值会过滤掉相似性低于此阈值的关系。在这里,我们将使用默认的 topK 为 10,相似度截止值相对较高,为 0.95。使用高相似度截止值(如 0.95)可以确保只有高度相似的配对才会被视为匹配,从而最大限度地减少误报并提高准确率。


30


由于我们希望将结果存储回预测的内存图而不是知识图,因此我们将使用算法的突变模式:


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


下一步是识别与新推断出的相似性关系相连的实体组。识别连接节点群是网络分析中一个常见的过程,通常被称为群落检测或聚类,这涉及到寻找密集连接节点的子群。在本例中,我们将使用弱连接组件算法,该算法可帮助我们找到图中所有节点都相连的部分,即使我们忽略了连接的方向。


31


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


gds.wcc.write(
    G,
    writeProperty="wcc","wcc",
    relationshipTypes=["SIMILAR"]
)


文本嵌入比较有助于发现潜在的重复,但这只是实体解析过程的一部分。例如,谷歌和苹果在嵌入空间中非常接近(使用 ada-002 嵌入模型的余弦相似度为 0.96)。宝马和奔驰也是如此(余弦相似度为 0.97)。高文本嵌入相似度是一个良好的开端,但我们可以加以改进。因此,我们将增加一个额外的过滤器,只允许文本距离为 3 或更少的词对(这意味着只能更改字符):


word_edit_distance = 3
potential_duplicate_candidates = graph.query(
    """MATCH (e:`__Entity__`)
    WHERE size(e.id) > 3 // longer than 3 characters
    WITH e.wcc AS community, collect(e) AS nodes, count(*) AS count
    WHERE count > 1
    UNWIND nodes AS node
    // Add text distance
    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
    // combine groups together if they share elements
    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
    // extra filtering
    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来解释。


32


此外,单词距离的分界线可以是单词长度的函数,而不是一个单一的数字,而且其实现也可以更具可扩展性。


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


 {'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 提示,以便有效地指导和告知有关合并节点的最终决定:


system_prompt = """You are a data processing assistant. Your task is to identify duplicate entities in a list and decide which of them should be merged."""You are a data processing assistant. Your task is to identify duplicate entities in a list and decide which of them should be merged.
The entities might be slightly different in format or content, but essentially refer to the same thing. Use your analytical skills to determine duplicates.
Here are the rules for identifying duplicates:
1. Entities with minor typographical differences should be considered duplicates.
2. Entities with different formats but the same content should be considered duplicates.
3. Entities that refer to the same real-world object or concept, even if described differently, should be considered duplicates.
4. If it refers to different numbers, dates, or products, do not merge results
"""
user_template = """
Here is the list of entities to process:
{entities}
Please identify duplicates, merge them, and provide the merged list.
"""


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


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


class DuplicateEntities(BaseModel):
    entities: List[str] = Field(
        description="Entities that represent the same object or real-world entity and should be merged"
    )

class Disambiguate(BaseModel):
    merge_entities: Optional[List[DuplicateEntities]] = Field(
        description="Lists of entities that represent the same object or real-world entity and should be merged"
    )

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


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


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 调用进行并行化处理:


merged_entities = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
    # Submitting all tasks and creating a list of future objects
    futures = [
        executor.submit(entity_resolution, el['combinedResult'])
        for el in potential_duplicate_candidates
    ]
    for future in tqdm(
        as_completed(futures), total=len(futures), desc="Processing documents"
    ):
        to_merge = future.result()
        if to_merge:
            merged_entities.extend(to_merge)


实体解析的最后一步是提取实体解析LLM 的结果,并通过合并指定节点将结果写回数据库:


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,而每次调用将使用几百个标记,因此成本相当高,时间也很紧张。因此,我们在这里将避免这一步骤。我们仍然可以使用在初始文本处理过程中提取的描述属性。


构建和总结群落

图构建和索引过程的最后一步是识别图中的社群。在这种情况下,社群是指相互之间的连接比与图的其他部分连接更密集的一组节点,这表明了更高程度的交互或相似性。下面的可视化示例显示了社群检测结果。


33


一旦使用聚类算法确定了这些实体社区,LLM 就会生成每个社区的摘要,提供对其个体特征和关系的见解。


我们再次使用图形数据科学库。我们首先投射一个内存图。为了准确遵循原文,我们将把实体图投影为一个无向加权网络,其中网络表示两个实体之间的连接数:


G, result = gds.graph.project(
    "communities",  #  Graph name"communities",  #  Graph name
    "__Entity__",  #  Node projection
    {
        "_ALL_": {
            "type": "*",
            "orientation": "UNDIRECTED",
            "properties": {"weight": {"property": "*", "aggregation": "COUNT"}},
        }
    },
)


作者采用了莱顿算法(一种分层聚类方法)来识别图中的群落。使用分层群落检测算法的一个优势是能够在多个粒度级别上检查群落。作者建议总结每个层次上的所有群落,从而全面了解图的结构。


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


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 个不同的组件。值得注意的是,最大的组件由 9109 个节点组成,这在现实世界的网络中很常见,即一个超级组件与众多较小的孤立组件共存。最小的组件只有一个节点,平均组件大小约为 11.3 个节点。


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


gds.leiden.write(
    G,
    writeProperty="communities","communities",
    includeIntermediateCommunities=True,
    relationshipWeightProperty="weight",
)


该算法识别出五个层次的社区,其中最高层次(社区规模最大的最小粒度层次)有 1 188 个社区(相对于 1 119 个组件)。下面是使用 Gephi 对最后一级群落的可视化展示。


34


将 1000 多个社区可视化是很难的,甚至为每个社区挑选颜色都几乎是不可能的。不过,它们却能带来不错的艺术效果。


在此基础上,我们将为每个社区创建一个不同的节点,并将它们的层次结构表示为一个相互连接的图。稍后,我们还将以节点属性的形式存储社区摘要和其他属性。


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(*)
""")


作者还引入了社区等级,表示社区内实体出现在不同文本块中的数量:


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


现在,让我们来看看一个层次结构样本,其中有许多中间社区在更高层次上合并。这些社区是不重叠的,也就是说,每个实体在每个层级都只属于一个社区。


35


图片表示莱顿社群检测算法产生的分层结构。紫色节点代表单个实体,橙色节点代表分层社区。


层次结构显示了这些实体在不同社群中的组织结构,较小的社群在较高层次上合并成较大的社群。


现在让我们来看看较小的社群是如何在较高层次上合并的。


36


这张图片说明,连接较少的实体和较小的群落在各层次之间的变化极小。例如,这里的社区结构只在前两级发生变化,但在后三级保持不变。因此,对于这些实体来说,层级往往显得多余,因为不同层级的整体组织结构变化不大。


让我们来详细看看社区的数量、规模和不同层次:


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)
        ]
    )
# Create a DataFrame with the percentiles
percentiles_df = pd.DataFrame(
    percentiles_data,
    columns=[
        "Level",
        "Number of communities",
        "25th Percentile",
        "50th Percentile",
        "75th Percentile",
        "90th Percentile",
        "99th Percentile",
        "Max"
    ],
)
percentiles_df


37


在最初的实施过程中,每个级别的社区都进行了汇总。在我们的例子中,这将是 8,590 个社区,因此也是 8,590 个 LLM 调用。我认为,根据社区的层次结构,并不是每个层次都需要总结。例如,最后一级和倒数第二级之间只相差四个社区(1192 对 1188)。因此,我们将创建大量多余的摘要。解决方法之一是创建一种实现方式,可以为不同层级的社区制作一个不变的摘要;另一种方法是折叠不变的社区层级。


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


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
""")


目前,社区信息的结构如下:


{'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 提示,根据社区元素提供的信息生成自然语言摘要。我们可以从研究人员使用的提示中得到一些启发。


作者不仅总结了社区,还为每个社区生成了发现。发现可以定义为与特定事件或信息相关的简明信息。其中一个例子就是


"summary": "Abila City Park as the central location",
"explanation": "Abila City Park is the central entity in this community, serving as the location for the POK rally. This park is the common link between all other
entities, suggesting its significance in the community. The park's association with the rally could potentially lead to issues such as public disorder or conflict, depending on the
nature of the rally and the reactions it provokes. [records: Entities (5), Relationships (37, 38, 39, 40)]"


我的直觉告诉我,只通过一次检索就能提取的结果可能并不像我们所需要的那样全面,就像提取实体和关系一样。


此外,我还没有在本地或全局搜索检索器的代码中找到任何使用它们的参考或示例。因此,在这种情况下,我们将避免提取结果。或者,就像学者们经常说的那样: 这项工作留给读者去做。此外,我们还跳过了声称或协变量信息提取,这乍一看与研究结果类似。


我们用来制作社区摘要的提示相当简单明了:


community_template = """Based on the provided nodes and relationships that belong to the same graph community,
generate a natural language summary of the provided information:
{community_info}
Summary:"""  # noqa: E501
community_prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "Given an input triples, generate the information summary. No pre-amble.",
        ),
        ("human", community_template),
    ]
)
community_chain = community_prompt | llm | StrOutputParser()


剩下的工作就是将社区表示转换为字符串,以避免 JSON 标记的开销,从而减少标记的数量,并将链封装为一个函数:


def prepare_string(data):
    nodes_str = "Nodes are:\n"
    for node in data['nodes']:
        node_id = node['id']
        node_type = node['type']
        if 'description' in node and node['description']:
            node_description = f", description: {node['description']}"
        else:
            node_description = ""
        nodes_str += f"id: {node_id}, type: {node_type}{node_description}\n"
    rels_str = "Relationships are:\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", description: {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}


现在,我们可以为所选级别生成社区摘要。我们再次对调用进行并行化处理,以加快执行速度:


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="Processing communities"):
        summaries.append(future.result())


我没有提到的一个方面是,作者还解决了输入社区信息时超出上下文大小的潜在问题。随着图表的扩大,社区也会显著增长。在我们的案例中,最大的社区有 545 名成员。鉴于 GPT-4o 的上下文大小超过 100,000 个代币,我们决定跳过这一步。


最后,我们将把社区摘要存储回数据库:


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


最终的图形结构:


38


该图现在包含原始文件、提取的实体和关系,以及分层社区结构和摘要。


总结

从局部到全局 "论文的作者出色地展示了 GraphRAG 的新方法。他们展示了我们如何将来自不同文档的信息组合并总结成一个分层知识图谱结构。


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

文章来源:https://medium.com/neo4j/implementing-from-local-to-global-graphrag-with-neo4j-and-langchain-constructing-the-graph-73924cc5bab4
欢迎关注ATYUN官方公众号
商务合作及内容投稿请联系邮箱:bd@atyun.com
评论 登录
热门职位
Maluuba
20000~40000/月
Cisco
25000~30000/月 深圳市
PilotAILabs
30000~60000/年 深圳市
写评论取消
回复取消