本文中的代码有些过时了,可以在https://python.langchain.com/docs/how_to/graph_constructing/找到最新版本。
从非结构化数据(如文本)中提取结构化信息已经有一段时间了,但这并不是什么新鲜事。然而,大型语言模型(LLM)为信息提取领域带来了显著的转变。以前你需要一个机器学习专家团队来整理数据集并训练定制模型,但现在你只需访问一个大型语言模型就可以了。这使得几年前仅限于领域专家的工作现在也变得更容易,即使是非技术人士也能轻松应对。
信息抽取管道的目的是从非结构化文本中提取结构化信息。这张图片是我自己制作的。
该图展示了将非结构化文本转化为结构化信息的过程。这一过程被标记为信息提取流程,最终形成信息的图。图中的节点表示关键实体,连接线表示这些实体间的关系。知识图谱在多跳问答、实时分析或在单一数据库中结合结构化和非结构化数据时非常有用。
虽然从文本中提取结构化信息变得更容易,这要归功于大型语言模型(LLM),但这远非一个已解决的问题。在这篇文章中,我们将使用OpenAI功能结合LangChain从维基百科的一个样本页面构建知识图谱。在此过程中,我们将讨论最佳实践以及当前LLM的一些局限性。
简而言之,代码可在 GitHub 上找到。
你需要设置一个Neo4j才能跟上这篇博客文章中的示例。最简单的方法是在Neo4j Aura上启动一个免费实例。或者,你也可以通过下载Neo4j Desktop并创建本地数据库实例来设置本地的Neo4j实例。
以下代码将创建一个LangChain封装器,并连接到Neo4j数据库。
从langchain.graphs导入Neo4jGraph模块 url = "neo4j+s://databases.neo4j.io" username = "neo4j" graph = Neo4jGraph( url=url, username=username, password="" )
一个典型的信息抽取流程包括以下步骤。
信息提取的多个步骤。作者提供。
在第一步中,我们将输入文本通过一个共指消解模型。共指消解的任务是找到所有指代特定实体的表达形式。简单来说,它会将所有代词与所指实体关联起来。在命名实体识别阶段,我们的目标是提取所有提到的实体。上述示例包含三个实体:Tomaz、Blog和Diagram(图)。接下来是实体消歧的步骤,这对于信息提取流程来说非常重要,但往往被忽视。实体消歧的过程旨在精确识别和区分具有相似名称或引用的实体,确保它们在给定的上下文中被正确识别。最后一步中,模型会尝试识别实体之间的各种关系。例如,它可能会发现Tomaz和Blog实体之间存在LIKE(喜欢)的关系。
OpenAI函数非常适合从自然语言中提取结构化的信息。其背后的想法是让LLM输出一个填充了相应值的预定义JSON对象。这个预定义的JSON对象可以作为其他函数的输入,在所谓的RAG应用程序中作为输入,或者用于从文本中提取预定义的结构化信息。
在 LangChain 中,你可以将一个 Pydantic 类作为描述传递给 OpenAI 函数功能的 JSON 对象。因此,我们将首先定义我们希望从文本中提取的信息结构。LangChain 已提供了可用于复用的 Pydantic 类定义,涵盖了节点和关系。
class Node(Serializable): """表示图中的一个节点及其相关属性。 属性: id (Union[str, int]): 节点的唯一ID。 type (str): 节点的类型或标签,默认为 "Node"。 properties (dict): 节点的额外属性和元数据。 """ id: Union[str, int] type: str = "Node" properties: dict = Field(default_factory=dict) class Relationship(Serializable): """表示图中两个节点之间的方向关系。 属性: source (Node): 关系的起始节点。 target (Node): 关系的结束节点。 type (str): 关系的类型。 properties (dict): 关系的额外属性。 """ source: Node target: Node type: str properties: dict = Field(default_factory=dict)
不幸的是,目前 OpenAI 的功能不支持将字典对象作为值。因此,我们必须修改 定义 以适应功能端点的限制。
从langchain.graphs.graph_document导入( Node作为BaseNode, Relationship作为BaseRelationship ) 从typing导入List, Dict, Any, Optional 从langchain.pydantic_v1导入Field, BaseModel 类 Property(BaseModel): """一个由键和值组成的单一属性""" key: str = Field(..., description="键") value: str = Field(..., description="值") 类 Node(BaseNode): properties: Optional[List[Property]] = Field( None, description="节点的属性列表") 类 Relationship(BaseRelationship): properties: Optional[List[Property]] = Field( None, description="关系的属性列表" )
在这里,我们将属性值更改为Property类的列表,而不是字典,以克服API的限制性。因为API只能接受一个对象,我们可以将它们合并到一个名为知识图谱的类中。
class KnowledgeGraph(BaseModel): """生成包含实体和关系的知识图谱.""" nodes: List[Node] = Field( ..., description="节点列表") rels: List[Relationship] = Field( ..., description="关系列表" )
剩下的就是做一些提示工程的活儿了,就可以出发了。我通常做提示工程的方法是这样的:
我特意选择了Markdown格式,因为我曾看到过某个地方提到OpenAI的模型似乎更喜欢Markdown语法的提示,据我个人的经验,至少在我个人的经验中是可信的。
经过多次提示工程的迭代后,我设计了以下信息提取流程。
llm = ChatOpenAI(model="gpt-3.5-turbo-16k", temperature=0) def get_extraction_chain( allowed_nodes: Optional[List[str]] = None, allowed_rels: Optional[List[str]] = None ): prompt = ChatPromptTemplate.from_messages( [( "system", f"""# 知识图谱指令 - GPT-4 ## 1. 概览 你是一个顶级算法,用于提取结构化格式的信息来构建知识图谱。 - **节点**代表实体和概念,类似于维基百科条目。 - 目标是使知识图谱简洁明了,便于广大受众理解。 ## 2. 标记节点 - **一致性**:确保使用基本或基础类型作为节点标签。 - 例如,当你识别出表示人的实体时,始终标记为“person”。避免使用更具体的术语,如“数学家”或“科学家”。 - **节点ID**:不要使用整数作为节点ID。节点ID应该是文本中的名称或人类可读的标识符。 {'- **允许的节点标签:**' + ", ".join(allowed_nodes) if allowed_nodes else ""} {'- **允许的关系类型:**' + ", ".join(allowed_rels) if allowed_rels else ""} ## 3. 处理数值数据和日期 - 数值数据(如年龄等)应作为相应节点的属性进行整合。 - **不要为日期或数值创建单独的节点**:不要为日期或数值创建单独的节点。始终将它们作为节点的属性或属性附加。 - **属性格式**:属性必须采用键值格式。 - **引号**:在属性值中不要使用转义的单引号或双引号。 - **命名约定**:使用驼峰命名法(如 `birthDate`)进行属性键的命名。 ## 4. 共指消解 - **保持实体一致性**:提取实体时,保持一致性至关重要。 如果实体如“John Doe”在文本中多次提及,但用不同的名称或代词(例如“Joe”,“他”)指代, 在整个知识图谱中始终使用该实体的最完整标识符。在此示例中,使用“John Doe”作为实体ID。 记住,知识图谱需要连贯且易于理解,因此保持实体引用的一致性非常重要。 ## 5. 严格遵守 请严格遵守规则,否则将被终止。""" ), ("human", "请使用给定的格式从以下输入中提取信息:{input}"), ("human", "提示:确保以正确的格式回答"), ]) return create_structured_output_chain(KnowledgeGraph, llm, prompt, verbose=False)
我们正在使用的是GPT-3.5的16k版本。主要原因是OpenAI的函数输出是一个结构化的JSON对象,结构化的JSON语法会为结果增加许多token的开销。也就是说,你实际上为获得结构化输出的便利性而增加了token的使用量。
除了通用说明之外,我还添加了限制从文本中提取哪些节点或关系类型的选项。通过示例你会看到为什么这可能会很有用。
我们已经准备好Neo4j的连接和LLM提示,这意味着我们可以将信息提取管道定义为一个简单的函数。
def 提取并存储图数据( document: Document, nodes: Optional[List[str]] = None, rels: Optional[List[str]] = None) -> None: # 使用OpenAI函数如下提取图数据 extract_chain = get_extraction_chain(nodes, rels) data = extract_chain.run(document.page_content) # 构建图文档 graph_document = GraphDocument( nodes = [map_to_base_node(node) for node in data.nodes], relationships = [map_to_base_relationship(rel) for rel in data.rels], source = document ) # 存储图数据 graph.add_graph_documents([graph_document])
该函数接受一个LangChain文档以及可选的节点和关系参数,这些参数用于限制对象类型。我们希望LLM识别和提取的对象类型。大约一个月之前,我们在Neo4j图对象中添加了add_graph_documents
方法,我们可以利用该方法无缝导入图。
我们将从Walt Disney的维基百科页面提取信息,构建知识图来测试这一流程。这里,我们将使用LangChain提供的维基百科加载工具和文本分段工具。
从 langchain.document_loaders 导入 WikipediaLoader 从 langchain.text_splitter 导入 TokenTextSplitter # 读取维基百科文章 raw_documents = WikipediaLoader(query="Walt Disney").load() # 设置分块策略 text_splitter = TokenTextSplitter(chunk_size=2048, chunk_overlap=24) # 只用前三个文档 documents = text_splitter.split_documents(raw_documents[:3])
你可能已经注意到我们使用的chunk_size
值相对较大。原因是我们希望在一个句子周围提供尽可能多的背景信息,以便指代解析部分能够尽可能好地工作。记住,只有当实体及其指称出现在同一个chunk中时,指代解析步骤才能顺利进行;否则,大模型就没有足够的信息来链接这两者。
现在我们可以直接让这些文档通过信息提取流程。
from tqdm import tqdm for i, d in tqdm(enumerate(documents), total=len(documents)): extract_and_store_graph(d)
这个过程大约需要5分钟,速度相对慢一些。因此,你可能希望在实际部署中采用并行的API调用来解决这个问题,从而实现某种程度的扩展能力。
我们先来看看LLM找到的节点和关系类型。
由于图谱模式未提供,LLM 在运行时决定使用哪些节点标签和关系类型。例如,我们可以观察到存在 Company 和 Organization 节点标签。这两者在语义上可能非常相似或完全相同,因此我们希望只保留一个节点标签来代表它们。这种问题在关系类型中更加突出。例如,我们有 CO-FOUNDER 和 COFOUNDEROF 两种关系,以及 DEVELOPER 和 DEVELOPEDBY 这两种关系。
对于任何更正式的项目,你应该定义LLM需要提取的节点标签和关系类型。这样,幸运的是,我们已经在提示中增加了通过传递额外参数来限制这些标签和关系类型的选项。
# 允许的节点包括 allowed_nodes = ["Person", "Company", "Location", "Event", "Movie", "Service", "Award"] for i, d in tqdm(enumerate(documents), total=len(documents)): # 遍历文档并提取图形 extract_and_store_graph(d, allowed_nodes)
在这个例子中,我只限制了节点的标签,但你可以很容易地通过向 extract_and_store_graph
函数传递另一个参数来轻松实现限制关系的类型。
提取出来的子图的可视化结果长这个样子。
图形比预期的要好(经过五次迭代 :) )。在可视化中没能完整展示整个图形,但你可以在 Neo4j 浏览器或其他工具里自己探索一下。
我要提到的一点是我们部分跳过了实体消解的步骤。我们使用了较大的块大小,并在系统提示中加入了具体指令,用于共指消解和实体消歧。因为每个块是独立处理的,所以无法确保不同文本块间实体的一致性。比如说,可能会出现两个都代表同一个人的节点。
多个节点代表同一个实体。
在这一示例中,Walt Disney 和 Walter Elias Disney 指的是同一个人。实体消歧问题并不是一个新的挑战,并且已经提出了许多方案来解决。
你应该使用哪种解决方案取决于你的领域和使用场景。不过,实体消歧步骤非常重要,不应被忽视,因为这一步骤可能对RAG应用的准确性和效果产生很大影响。
我们最后要展示的是如何通过构建Cypher语句在知识图谱中浏览信息。Cypher是一种用于图数据库的结构化查询语言,类似于SQL在关系数据库中的使用。LangChain有一个GraphCypherQAChain,它可以读取图的模式,并根据用户输入生成合适的Cypher语句。
# 在RAG应用中查询知识图谱 from langchain.chains import GraphCypherQAChain graph.refresh_schema() # 刷新图谱模式 cypher_chain = GraphCypherQAChain.from_llm( graph=graph, cypher_llm=ChatOpenAI(temperature=0, model="gpt-4"), qa_llm=ChatOpenAI(temperature=0, model="gpt-3.5-turbo"), validate_cypher=True, # 验证关系的方向 verbose=True ) cypher_chain.run("华特·迪士尼出生于何时?")
结果如下:
知识图谱非常适合用于需要结合结构化和非结构化数据来驱动RAG应用程序的情境。在这篇博客文章中,您已经了解了如何使用OpenAI的功能在Neo4j中构建任意文本的知识图谱。OpenAI的功能提供整洁的结构化输出,使其成为提取结构化信息的理想工具。用LLM构建图谱时,确保有良好的体验,请确保尽可能详细地定义图谱模式和结构,并在提取之后添加实体消歧的步骤。
如果你对使用图来构建AI应用感兴趣并想了解更多,欢迎在2023年10月26日加入我们由Neo4j组织的NODES,在线大会。
代码可在 GitHub 上找到。