从文本等非结构化数据中抽取结构化信息的技术已经存在一段时间,这并非什么新技术。但是,大语言模型(LLMs)为信息提取技术带来了翻天覆地的变化。过去,你可能需要一个由机器学习专家组成的团队来整理数据集和训练专用模型,而现在,你只需要使用一个大语言模型就可以了。这大大降低了进入门槛,让前几年还只属于领域专家的技术,现在连非技术人员也能轻松掌握。
信息提取流程的目的是将非结构化文本转换为结构化信息。图片来源于作者。
图片展现了从非结构化文本到结构化信息的转换过程。这个被称作信息提取流程的过程,最终形成了信息的图谱表示。图中的节点代表关键实体,连线则表明这些实体之间的相互关系。知识图谱在进行多步骤问答、实时数据分析或者将结构化与非结构化数据合并到同一个数据库中时非常有用。
虽然利用大语言模型使得从文本中提取结构化信息变得更加容易,但这个问题远未完全解决。在这篇博客中,我们将展示如何利用 OpenAI 函数和 LangChain构建一个从维基百科样本页面提取出的知识图谱,并讨论在此过程中的一些最佳实践以及当前大语言模型所面临的一些限制。
简而言之,相关代码已经发布在GitHub上。
Neo4j 环境搭建
要跟随本博客的示例,你需要搭建一个 Neo4j 环境。最简单的方式是在 Neo4j Aura上启动一个免费的云实例。你也可以选择下载 Neo4j Desktop 并创建一个本地数据库实例。
以下代码段将演示如何使用 LangChain 包来连接 Neo4j 数据库。
ini复制代码from langchain.graphs import Neo4jGraph
url = "neo4j+s://databases.neo4j.io"
username ="neo4j"
password = ""
graph = Neo4jGraph(
url=url,
username=username,
password=password
)
信息提取流程
一个标准的信息提取流程通常包括以下几个步骤。
信息提取流程的各个阶段。 在信息提取的第一步,我们会使用共指消解模型处理输入文本。共指消解是指识别所有指向特定实体的表述,简而言之,就是将代词与其所指的实体相连接。在命名实体识别阶段,我们的目标是识别文本中提到的所有实体。以前面的例子为例,识别出的实体有三个:Tomaz、Blog 和 Diagram。接下来是实体消歧环节,这是信息提取中非常关键但常被忽略的一个环节。实体消歧是指在不同实体名称或引用相似的情况下,准确区分并识别出给定上下文中正确的实体。在最后一个步骤中,模型会尝试识别实体之间的不同关系,比如识别出 Tomaz 和 Blog 之间的 喜欢(LIKES) 关系。
利用 OpenAI 函数提取结构化信息
OpenAI 函数非常适合从自然语言文本中提取结构化信息。OpenAI 函数的核心思想是让大语言模型输出一个预定义的、已填充好数据的 JSON 对象。这个预定义的 JSON 对象既可以作为所谓的 RAG 应用程序中其他函数的输入,也可以用来直接从文本中提取出预设的结构化信息。
在 LangChain 中,你可以传入一个 Pydantic 类作为描述,以定义你希望 OpenAI 函数输出的 JSON 对象的结构。因此,我们将从定义我们希望从文本中提取的信息结构开始。LangChain 已经提供了可以直接使用的节点和关系定义,这些都是以 Pydantic 类的形式预先定义好的。
python复制代码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 函数还不支持使用字典对象作为值。因此,我们需要重新定义 properties,以确保它符合函数接口的现有限制。
python复制代码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"
)
在此,我们将 properties 的值改写为 Property 类的列表,而非原本的字典形式,这样做是为了解决 API 的使用限制。由于 API 仅允许传递单一对象,我们将节点和关系整合到一个名为 KnowledgeGraph 的类中来实现这一目标。
python复制代码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 进行澄清,使其更易于被大语言模型理解
- 最终,当提示包含了所有必要的说明后,让 ChatGPT 用 markdown 格式总结这些说明,这不仅可以节省 Token,还能让指令更加明了
我之所以选择 markdown 格式,是因为我注意到 OpenAI 模型似乎更善于响应 markdown 语法的提示,至少根据我的体验,这种做法是合理的。
经过一系列的提示工程迭代,我设计出了以下用于信息提取流程的系统提示。
less复制代码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 语法会显著增加结果的 token 消耗。换句话说,你需要用更多的 token 来换取输出的结构化便捷性。
除了基本的操作指南,我还增加了一个选项,允许限制从文本中提取特定的节点或关系类型。通过实例,你会明白这个功能为何实用。
现在,我们已经搭建好了 Neo4j 数据库的连接和大语言模型的提示框架,这意味着我们可以把信息提取流程简化为一个单独的函数。
ini复制代码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 文档,并可以选择性地输入节点和关系参数,这些参数的作用是限定大语言模型应识别和抽取的对象类型。就在大约一个月前,我们向 Neo4j 图数据库对象中增加了 add_graph_documents
方法,现在我们可以借助这个方法来轻松整合图数据。
评估
我们计划从沃尔特·迪士尼的维基百科页面抽取信息,并以此来构建知识图谱,用以检验整个流程的效果。在这一过程中,我们会使用 LangChain 提供的维基百科内容加载和文本分块功能模块。
ini复制代码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 值。这么做的理由是我们希望为每个句子提供尽可能多的上下文信息,这样可以最大化共指消解步骤的效果。要注意的是,共指消解只有在实体及其指代同时出现在同一文本块中时才能正常工作;如果不是这样,大语言模型就缺乏将它们关联起来的足够信息。
现在,我们可以开始将文档通过信息提取流程进行处理。
scss复制代码from tqdm import tqdm
for i, d in tqdm(enumerate(documents), total=len(documents)):
extract_and_store_graph(d)
整个处理过程大约需要5分钟,相对来说速度不快。因此,在实际应用中,你可能需要并行执行 API 调用以解决这个问题,并且实现可扩展性。
我们先来看看大语言模型识别出的节点和关系类型。
因为没有预设的图谱模式,大语言模型需要即时决定使用哪些节点标签和关系类型。比如说,我们发现它标记了 Company 和 Organization 两种节点。这两者在语义上可能很接近或者完全相同,因此我们只需要一个节点标签来统一表示它们。关系类型的问题更加明显。例如,我们同时发现了 CO-FOUNDER 和 COFOUNDEROF,以及 DEVELOPER 和 DEVELOPEDBY 这样的关系类型。
对于任何更正式的项目,你应该预先定义好大语言模型需要提取的节点标签和关系类型。幸运的是,我们提供了一个选项,可以通过传递额外的参数来限制这些类型。
ini复制代码# 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 浏览器或其他工具自行探索。
实体消歧
有一点我需要特别说明,我们在一定程度上忽略了实体消歧的步骤。我们处理时选用了较大的数据块,并在系统提示中加入了特别的指令来执行共指消解和实体消歧。但是,因为每个数据块是独立处理的,我们无法保证不同文本块之间实体的一致性。举个例子,你可能会遇到两个代表同一人的节点。
出现多个节点代表同一实体的情况。
以这个例子为例,Walt Disney 和 Walter Elias Disney 都指向同一个真实世界中的人物。实体消歧这个问题并非新鲜事,业界已经提出了多种解决方案:
你应该选择哪种方案取决于你的业务领域和具体场景。但是,请记住,不要忽视实体消歧这一步,因为它对于提高你的 RAG(关系和图)应用程序的准确性和效果至关重要。
RAG 应用
我们接下来要做的是演示如何通过构建 Cypher 查询语句来在知识图谱中检索信息。Cypher 是图数据库的查询语言,和 SQL 对于关系数据库的作用类似。LangChain 提供了一个 GraphCypherQAChain,它能根据图的结构和用户的输入来生成相应的 Cypher 查询语句。
ini复制代码# 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 函数以其整洁的结构化输出而便利,非常适合用于抽取结构化信息。为了确保在利用大语言模型构建图谱时有一个愉快的体验,请务必详尽地定义图谱的架构,并且在信息抽取之后加入实体消歧的步骤。
如果你想进一步了解如何使用图技术构建 AI 应用,请参加 Neo4j 在 2023 年 10 月 26 日举办的 NODES 在线 24 小时会议。
相关代码已经发布在 GitHub 上。