使用OpenAI函数从文本构建知识图谱

使用LangChain和Neo4j无缝实现信息提取管道

从非结构化数据(如文本)中提取结构化信息已经存在一段时间,这并不新奇。然而,语言模型带来了信息提取领域的显著变革。在过去,你需要一个机器学习专家团队来整理数据集并训练定制模型,而现在你只需要访问一个LLM。进入门槛大大降低,使得之前仅针对领域专家的技术现在甚至对非技术人员更加易于接触。

The goal of information extraction pipeline is to extract structured information from unstructured text. Image by the author.

图片展示了无结构文本转化为结构化信息的过程。这一被称为信息提取流水线的过程,最终得到了信息的图表示。节点代表关键实体,而连接线表示这些实体之间的关系。知识图对于多跳问答、实时分析或者当你想要将结构化和无结构化数据合并到一个数据库中时非常有用。

尽管由于LLMs的出现,从文本中提取结构化信息变得更加容易,但这绝不是一个已解决的问题。在这篇博文中,我们将使用OpenAI函数与LangChain相结合,从一个样本维基百科页面构建一个知识图谱。在此过程中,我们将讨论最佳实践以及当前LLMs的一些局限性。

太长不看(TLDR);代码可在GitHub上找到。

Neo4j环境设置

您需要设置一个Neo4j来跟随本博文中的示例。最简单的方法是在Neo4j Aura上启动一个免费实例,它提供了Neo4j数据库的云实例。或者,您还可以通过下载Neo4j桌面应用程序并创建一个本地数据库实例来设置Neo4j数据库的本地实例。

以下代码将实例化一个LangChain包装器,以连接到Neo4j数据库。

from langchain.graphs import Neo4jGraph

url = "neo4j+s://databases.neo4j.io"
username ="neo4j"
password = ""
graph = Neo4jGraph(
url=url,
username=username,
password=password
)

信息提取流程线

一个典型的信息提取流程包含以下步骤。

Multiple steps of information extraction pipeline. Image by author.

在第一步中,我们通过一个指代消解模型来运行输入文本。指代消解是寻找与特定实体相关的所有表达式的任务。简单来说,它将所有代词链接到被引用的实体上。在流程的命名实体识别部分,我们试图提取所有提及的实体。上面的例子包含三个实体:Tomaz、Blog和Diagram。下一步是实体消歧步骤,这是信息提取流程中一个重要但经常被忽视的部分。实体消歧是准确识别和区分具有相似名称或引用的实体,以确保在给定的上下文中正确识别实体的过程。在最后一步中,模型试图识别实体之间的各种关系。例如,它可以找到Tomaz和Blog实体之间的LIKES关系。

使用OpenAI函数提取结构化信息

OpenAI功能非常适合从自然语言中提取结构化信息。 OpenAI功能的理念是使得一个语言模型生成一个预定义的JSON对象,并填充其中的值。这个预定义的JSON对象可以作为其他函数在所谓的RAG应用中的输入,也可以用于从文本中提取预定义的结构化信息。

在LangChain中,您可以将Pydantic类作为OpenAI函数功能所需JSON对象的描述进行传递。因此,我们将首先定义我们想要从文本中提取的信息的所需结构。LangChain已经有了节点和关系的定义,作为可以重用的Pydantic类。

class Node(Serializable):
"""Represents a node in a graph with associated properties.

Attributes:
id (Union[str, int]): A unique identifier for the node.
type (str): The type or label of the node, default is "Node".
properties (dict): Additional properties and metadata associated with the node.
"""

id: Union[str, int]
type: str = "Node"
properties: dict = Field(default_factory=dict)


class Relationship(Serializable):
"""Represents a directed relationship between two nodes in a graph.

Attributes:
source (Node): The source node of the relationship.
target (Node): The target node of the relationship.
type (str): The type of the relationship.
properties (dict): Additional properties associated with the relationship.
"""

source: Node
target: Node
type: str
properties: dict = Field(default_factory=dict)

非常遗憾,事实证明,OpenAI函数目前不支持将字典对象用作值。因此,我们必须重新定义属性以符合函数端点的限制。

from langchain.graphs.graph_document import (
Node as BaseNode,
Relationship as BaseRelationship
)
from typing import List, Dict, Any, Optional
from langchain.pydantic_v1 import Field, BaseModel

class Property(BaseModel):
"""A single property consisting of key and value"""
key: str = Field(..., description="key")
value: str = Field(..., description="value")

class Node(BaseNode):
properties: Optional[List[Property]] = Field(
None, description="List of node properties")

class Relationship(BaseRelationship):
properties: Optional[List[Property]] = Field(
None, description="List of relationship properties"
)

在这里,我们已经覆盖了属性值,将其设为Property类的列表,而不是字典,以克服API的限制。因为你只能向API传递一个对象,所以我们可以将节点和关系合并在一个称为KnowledgeGraph的单一类中。

class KnowledgeGraph(BaseModel):
"""Generate a knowledge graph with entities and relationships."""
nodes: List[Node] = Field(
..., description="List of nodes in the knowledge graph")
rels: List[Relationship] = Field(
..., description="List of relationships in the knowledge graph"
)

剩下的唯一事情就是做一些快速工程,然后我们就可以开始了。我通常进行快速工程的方式如下:

  • 迭代提示并使用自然语言改进结果
  • 如果某事不如预期地工作,请询问ChatGPT以使LLM更容易理解任务
  • 最后,当提示包含所有所需的指示时,请请求ChatGPT以markdown格式总结指示,以节省记号并可能获得更清晰的指示。

我特意选择了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"""# Knowledge Graph Instructions for GPT-4
## 1. Overview
You are a top-tier algorithm designed for extracting information in structured formats to build a knowledge graph.
- **Nodes** represent entities and concepts. They're akin to Wikipedia nodes.
- The aim is to achieve simplicity and clarity in the knowledge graph, making it accessible for a vast audience.
## 2. Labeling Nodes
- **Consistency**: Ensure you use basic or elementary types for node labels.
- For example, when you identify an entity representing a person, always label it as **"person"**. Avoid using more specific terms like "mathematician" or "scientist".
- **Node IDs**: Never utilize integers as node IDs. Node IDs should be names or human-readable identifiers found in the text.
{'- **Allowed Node Labels:**' + ", ".join(allowed_nodes) if allowed_nodes else ""}
{'- **Allowed Relationship Types**:' + ", ".join(allowed_rels) if allowed_rels else ""}
## 3. Handling Numerical Data and Dates
- Numerical data, like age or other related information, should be incorporated as attributes or properties of the respective nodes.
- **No Separate Nodes for Dates/Numbers**: Do not create separate nodes for dates or numerical values. Always attach them as attributes or properties of nodes.
- **Property Format**: Properties must be in a key-value format.
- **Quotation Marks**: Never use escaped single or double quotes within property values.
- **Naming Convention**: Use camelCase for property keys, e.g., `birthDate`.
## 4. Coreference Resolution
- **Maintain Entity Consistency**: When extracting entities, it's vital to ensure consistency.
If an entity, such as "John Doe", is mentioned multiple times in the text but is referred to by different names or pronouns (e.g., "Joe", "he"),
always use the most complete identifier for that entity throughout the knowledge graph. In this example, use "John Doe" as the entity ID.
Remember, the knowledge graph should be coherent and easily understandable, so maintaining consistency in entity references is crucial.
## 5. Strict Compliance
Adhere to the rules strictly. Non-compliance will result in termination."""),
("human", "Use the given format to extract information from the following input: {input}"),
("human", "Tip: Make sure to answer in the correct format"),
])
return create_structured_output_chain(KnowledgeGraph, llm, prompt, verbose=False)

您可以看到我们正在使用GPT-3.5模型的16k版本。主要原因是OpenAI功能的输出是一个结构化的JSON对象,而结构化的JSON语法会为结果增加许多令牌开销。从本质上讲,您为方便的结构化输出付费,会增加令牌空间的使用。

除了一般的说明之外,我还添加了限制从文本中提取哪些节点或关系类型的选项。通过示例,您会明白为什么这可能会很有用。

我们已经准备好Neo4j连接和LLM提示,这意味着我们可以将信息提取管道定义为一个单独的函数。

def extract_and_store_graph(
document: Document,
nodes:Optional[List[str]] = None,
rels:Optional[List[str]]=None) -> None:
# Extract graph data using OpenAI functions
extract_chain = get_extraction_chain(nodes, rels)
data = extract_chain.run(document.page_content)
# Construct a graph document
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
)
# Store information into a graph
graph.add_graph_documents([graph_document])

该函数接受一个LangChain文档,以及可选的节点和关系参数,用于限制我们希望LLM识别和提取的对象类型。大约一个月前,我们在Neo4j图对象中添加了add_graph_documents方法,我们可以在这里无缝导入图。

评估

我们将从华特·迪士尼维基百科页面中提取信息,并构建知识图谱来测试流程。在这里,我们将利用LangChain提供的维基百科加载器和文本切块模块。

from langchain.document_loaders import WikipediaLoader
from langchain.text_splitter import TokenTextSplitter

# Read the wikipedia article
raw_documents = WikipediaLoader(query="Walt Disney").load()
# Define chunking strategy
text_splitter = TokenTextSplitter(chunk_size=2048, chunk_overlap=24)

# Only take the first the raw_documents
documents = text_splitter.split_documents(raw_documents[:3])

您可能已经注意到,我们使用了相对较大的chunk_size值。原因是我们希望在核心引用解析部分能够提供尽可能多的上下文信息来使其效果最佳。请记住,核心引用步骤只有在实体及其引用出现在同一个块中时才起作用;否则,LLM就没有足够的信息将两者链接起来。

现在我们可以继续运行文档通过信息提取管道。

from tqdm import tqdm

for i, d in tqdm(enumerate(documents), total=len(documents)):
extract_and_store_graph(d)

该过程大约需要5分钟,速度相对较慢。因此,在生产环境中,您可能希望进行并行的 API 调用来解决这个问题并实现某种可扩展性。

让我们首先来看看LLM识别出的节点和关系的类型。

由于没有提供图表模式,LLM将动态决定使用哪种节点标签和关系类型。例如,我们可以观察到有公司和组织的节点标签。这两个事物可能在语义上相似或相同,所以我们希望只有一个节点标签来代表这两个内容。这个问题在关系类型上更为明显。例如,我们有合作创始人和合作创始于的关系,以及开发者和被开发者的关系。

对于任何更严肃的项目,您应该定义LLM应提取的节点标签和关系类型。幸运的是,我们已经添加了通过传递附加参数来限制提示中的类型的选项。

# Specify which node labels should be extracted by the 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浏览器和其他工具自己进行探索。

实体消歧

我要提到的一件事是,我们在实体消岐部分只是部分跳过了。我们使用了一个较大的块大小,并在系统提示中添加了一条特定的指示,用于指代消解和实体消岐。然而,由于每个块是单独处理的,无法确保不同文本块之间的实体一致性。例如,你可能会得到代表同一个人的两个节点。

Multiple nodes representing the same entity.

在这个例子中,Walt Disney 和 Walter Elias Disney 指的是同一个真实世界的人。实体消歧问题并不新鲜,并且已经提出了多种解决方案来解决它:

  • 使用实体链接或实体消歧 NLP 模型
  • 进行第二次通过LLM,并要求它执行实体消岐。
  • 基于图的方法

选择使用哪种解决方案取决于您的领域和用例。不过,请记住,实体消歧步骤不应被忽视,因为它可能对您的RAG应用程序的准确性和效果产生重大影响。

应用

我们将要做的最后一件事是向您展示如何通过构建Cypher语句在知识图谱中浏览信息。Cypher是一种用于处理图数据库的结构化查询语言,类似于SQL用于关系数据库的方式。LangChain拥有一个GraphCypherQAChain,它会读取图的架构并根据用户输入构建相应的Cypher语句。

# Query the knowledge graph in a RAG application
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, # Validate relationship directions
verbose=True
)
cypher_chain.run("When was Walter Elias Disney born?")

导致以下结果:

总结

知识图谱是一个很好的选择,当您需要结构化和非结构化数据的组合来支持您的RAG应用时。在本博客文章中,您已经学习了如何使用OpenAI函数在Neo4j中构建知识图谱,以处理任意文本。OpenAI函数提供了整洁结构化的输出,使它们成为提取结构化信息的理想选择。为了在构建LLMs图谱时获得更好的体验,请确保尽可能详细地定义图谱模式,并确保在提取后添加实体消歧步骤。

如果您渴望了解更多关于使用图构建人工智能应用的知识,请于2023年10月26日加入我们在Neo4j组织的NODES在线24小时会议。

代码可以在GitHub上找到。

2024-01-13 04:33:51 AI中文站翻译自原文