一个由 ChatGPT 想象的在图中游走的 AI 代理
大型语言模型(LLM)在传统的自然语言处理任务如摘要和情感分析方面表现出色,而更强的模型还展示了令人期待的推理能力。LLM的推理通常被理解为通过制定计划、执行计划并在每一步评估进度来解决复杂问题的能力。根据这一评估,它们可以通过修订计划或采取替代行动来适应。在RAG应用中,代理的兴起正成为回答复杂问题的一种越来越有说服力的方法。
在这篇博客文章中,我们将探讨GraphReader代理的实现。该代理设计用于从遵循预定义模式的结构化知识图中检索信息。与你在演示中可能看到的典型图形不同,这个图更像是一个文档或词汇图,包含文档、文档片段以及以原子事实形式的相关元数据。
生成的知识图谱遵循 GraphReader 实现。图片由作者提供。
上面的图片展示了一个知识图谱,从顶部的文档节点 Joan of Arc 开始。该文档被分解成文本片段,由编号的圆形节点(0、1、2、3)表示,这些节点通过 NEXT 关系顺序连接,表示这些片段在文档中的出现顺序。在文本片段之下,图谱进一步分解为原子事实,其中具体的内容陈述被表示出来。最后,在图谱的底部层次,我们看到关键元素,以带有诸如 历史人物 、 丹麦人 、 法兰西民族 和 法国 等主题的圆形节点表示。这些元素充当元数据,将事实与文档相关的更广泛的主题和概念联系起来。
一旦我们构建了知识图谱,我们将按照 GraphReader论文 中提供的实现进行操作。
GraphReader 代理实现。图片来自论文 paper,经作者许可。
代理的探索过程包括使用合理的计划初始化代理,并选择初始节点开始在图中的搜索。代理通过首先收集原子事实,然后阅读相关文本片段,并更新其笔记本来进行这些节点的探索。代理可以根据收集到的信息决定继续探索更多片段、邻近节点,或者终止探索。当代理决定终止时,将执行答案推理步骤以生成最终答案。
在这篇博客文章中,我们将使用Neo4j作为存储层,并结合LangChain和LangGraph来定义代理及其流程,实现GraphReader论文。
代码可在 GitHub 上获取。
环境设置你需要设置一个 Neo4j 实例来跟随本博客文章中的示例。最简单的方法是通过 Neo4j Aura 启动一个免费实例,它提供 Neo4j 数据库的云实例。或者,你也可以通过下载 Neo4j Desktop 应用程序并创建一个本地数据库实例来设置本地实例。
以下代码将实例化一个 LangChain 包装器以连接到 Neo4j 数据库。
os.environ["NEO4J_URI"] = "bolt://localhost:7687"
os.environ["NEO4J_USERNAME"] = "neo4j"
os.environ["NEO4J_PASSWORD"] = "password"
graph = Neo4jGraph(refresh_schema=False)
graph.query("CREATE CONSTRAINT IF NOT EXISTS FOR (c:Chunk) REQUIRE c.id IS UNIQUE")
graph.query("CREATE CONSTRAINT IF NOT EXISTS FOR (c:AtomicFact) REQUIRE c.id IS UNIQUE")
graph.query("CREATE CONSTRAINT IF NOT EXISTS FOR (c:KeyElement) REQUIRE c.id IS UNIQUE")
此外,我们还为将要使用的节点类型添加了约束。这些约束确保了更快的导入和检索性能。
此外,你还需要一个 OpenAI API 密钥,并将其传递到以下代码中:
os.environ["OPENAI_API_KEY"] = getpass.getpass("OpenAI API Key:")
图构建
我们将在这个示例中使用圣女贞德的维基百科页面。我们将使用LangChain内置的工具来检索文本。
wikipedia = WikipediaQueryRun(
api_wrapper=WikipediaAPIWrapper(doc_content_chars_max=10000)
)
text = wikipedia.run("Joan of Arc")
如前所述,GraphReader 代理期望包含片段、相关原子事实和关键元素的知识图谱。
GraphReader 知识图谱构建。图片来自 论文,经作者许可。
首先,文档会被分割成块。在论文中,他们在分割时保持了段落结构。然而,这种方式在通用情况下很难实现。因此,我们将在这里使用简单的分割方法。
接下来,每个片段由LLM处理以识别 原子事实 ,这些是最小的、不可分割的信息单位,捕捉核心细节。例如,从句子“Neo4j的首席执行官Emil Eifrem在瑞典”可以分解出“Neo4j的首席执行官是Emil Eifrem”和“Neo4j在瑞典”。每个原子事实都专注于一个清晰独立的信息片段。
从这些原子事实中,关键元素被识别出来。对于第一个事实,“Neo4j 的 CEO 是 Emil Eifrem”,关键元素是“CEO”、“Neo4j”和“Emil Eifrem”。对于第二个事实,“Neo4j 位于瑞典”,关键元素是“Neo4j”和“瑞典”。这些关键元素是每个原子事实的核心含义中必不可少的名词和专有名词。
论文附录中提供了用于提取图的提示。
从论文 论文 中获取的关键元素和原子事实提取提示,已获得作者许可。
作者使用了基于提示的提取方法,即你指示LLM应该输出什么内容,然后实现一个能够以结构化方式解析信息的函数。我个人倾向于使用LangChain中的with_structured_output
方法来提取结构化信息,这种方法利用了工具功能来提取结构化信息,这样我们就可以跳过定义自定义解析函数的步骤。
这里是我们可以用来提取信息的提示。
construction_system = """
你现在是一个智能助手,任务是仔细地从长文中提取出关键元素和原子事实。
1. 关键元素:文本叙述中至关重要的名词(例如,人物、时间、事件、地点、数字)、动词(例如,动作)和形容词(例如,状态、情感)。
2. 原子事实:最细小、不可分割的事实,以简洁的句子形式呈现。这些包括命题、理论、存在、概念以及逻辑、因果关系、事件序列、人际关系、时间线等隐含元素。
要求:
#####
1. 确保所有识别出的关键元素都能反映在相应的原子事实中。
2. 你应该全面地提取关键元素和原子事实,特别是那些重要且可能具有查询价值的部分,不要遗漏细节。
3. 在适用的情况下,用具体的名词替换代词(例如,将I、He、She替换为实际的名字)。
4. 确保你提取的关键元素和原子事实与原文本的语言相同(例如,英语或中文)。
"""
construction_human = """使用给定的格式从以下输入中提取信息:{input}"""
construction_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
construction_system,
),
(
"human",
(
"使用给定的格式从以下输入中提取信息:{input}"
),
),
]
)
我们将指令放入系统提示中,然后在用户消息中提供需要处理的相关文本片段。
为了定义期望的输出,我们可以使用 Pydantic 对象定义。
class AtomicFact(BaseModel):
key_elements: List[str] = Field(description="""原子事实叙述中至关重要的名词(例如,人物、时间、事件、地点、数字)、动词(例如,动作)和形容词(例如,状态、感受)。""")
atomic_fact: str = Field(description="""最小且不可分割的事实,以简洁的句子形式呈现。这些包括命题、理论、存在、概念以及逻辑、因果关系、事件序列、人际关系、时间线等隐含元素。""")
class Extraction(BaseModel):
atomic_facts: List[AtomicFact] = Field(description="原子事实列表")
我们希望提取一个原子事实列表,每个原子事实包含一个包含事实的字符串字段,以及一个当前关键元素列表。为了获得最佳结果,重要的是为每个元素添加描述。
现在我们可以将所有内容串联起来。
model = ChatOpenAI(model="gpt-4o-2024-08-08", temperature=0.1)
structured_llm = model.with_structured_output(Extraction)
construction_chain = construction_prompt | structured_llm
为了将所有内容整合在一起,我们将创建一个函数,该函数接收一个文档,将其切分成块,提取原子事实和关键元素,并将结果存储到Neo4j中。
async def process_document(text, document_name, chunk_size=2000, chunk_overlap=200):
start = datetime.now()
print(f"开始提取时间:{start}")
text_splitter = TokenTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
texts = text_splitter.split_text(text)
print(f"总文本块数:{len(texts)}")
tasks = [
asyncio.create_task(construction_chain.ainvoke({"input":chunk_text}))
for index, chunk_text in enumerate(texts)
]
results = await asyncio.gather(*tasks)
print(f"完成LLM提取时间:{datetime.now() - start}")
docs = [el.dict() for el in results]
for index, doc in enumerate(docs):
doc['chunk_id'] = encode_md5(texts[index])
doc['chunk_text'] = texts[index]
doc['index'] = index
for af in doc["atomic_facts"]:
af["id"] = encode_md5(af["atomic_fact"])
# 导入块/原子事实/关键元素
graph.query(import_query,
params={"data": docs, "document_name": document_name})
# 创建块之间的下一个关系
graph.query("""MATCH (c:Chunk) WHERE c.document_name = $document_name
WITH c ORDER BY c.index WITH collect(c) AS nodes
UNWIND range(0, size(nodes) -2) AS index
WITH nodes[index] AS start, nodes[index + 1] AS end
MERGE (start)-[:NEXT]->(end)
""",
params={"document_name":document_name})
print(f"完成导入时间:{datetime.now() - start}")
从高层次上看,这段代码通过将文档拆分成多个片段,使用AI模型从每个片段中提取信息,并将结果存储在图数据库中。以下是概要:
- 它将文档文本分割成指定大小的块,允许有一定的重叠。作者在论文中使用了2000个token的块大小。
- 对于每个块,它异步地将文本发送到LLM以提取原子事实和关键元素。
- 每个块和事实都使用_md5_编码函数生成一个唯一的标识符。
- 处理后的数据被导入到图数据库中,并在连续的块之间建立了关系。
我们现在可以在这个 Joan of Arc 的文本上运行这个函数。
await process_document(text, "Joan of Arc", chunk_size=500, chunk_overlap=100)
我们使用了较小的分块大小,因为这是一个小文档,并且我们希望为了演示目的生成几个分块。如果你在 Neo4j Browser 中探索该图,你应该能看到类似的可视化效果。
生成的图的可视化。图片由作者提供。
在结构的中心是文档节点(蓝色),它分支到段落节点(粉红色)。这些段落节点又链接到原子事实(橙色),每个原子事实又连接到关键元素(绿色)。
让我们稍微检查一下构建的图。我们将从检查原子事实的 token 数量分布开始。
def num_tokens_from_string(string: str) -> int:
"""返回文本字符串中的 token 数量."""
encoding = tiktoken.encoding_for_model("gpt-4")
num_tokens = len(encoding.encode(string))
return num_tokens
atomic_facts = graph.query("MATCH (a:AtomicFact) RETURN a.text AS text")
df = pd.DataFrame.from_records(
[{"tokens": num_tokens_from_string(el["text"])} for el in atomic_facts]
)
sns.histplot(df["tokens"])
结果
原子事实的 token 数量分布。作者制作的图片。
原子事实相对较短,最长的也只有大约50个token。让我们来看几个例子,以便更好地理解。
graph.query("""MATCH (a:AtomicFact)
RETURN a.text AS text
ORDER BY size(text) ASC LIMIT 3
UNION ALL
MATCH (a:AtomicFact)
RETURN a.text AS text
ORDER BY size(text) DESC LIMIT 3""")
结果
原子事实
一些最短的事实缺乏上下文。例如,原始的评分和剧本并没有直接提到这一点。因此,如果我们处理多个文档,这些原子事实可能不太有用。这种缺乏上下文的问题可以通过额外的提示工程来解决。
让我们也来检查一下最常见的关键词。
data = graph.query("""
MATCH (a:KeyElement)
RETURN a.id AS key,
count{(a)<-[:HAS_KEY_ELEMENT]-()} AS connections
ORDER BY connections DESC LIMIT 5""")
df = pd.DataFrame.from_records(data)
sns.barplot(df, x='key', y='connections')
结果
最常提到的五个关键元素。作者图片。
不出所料,圣女贞德是最常被提及的关键字或元素。接下来是一些宽泛的关键字,如电影、英语和法国。我怀疑如果我们解析了许多文档,宽泛的关键字最终会有很多连接,这可能会导致一些在原始实现中未解决的下游问题。另一个较小的问题是提取的非确定性,因为每次运行的结果都会略有不同。
此外,作者们采用了如 Lu et al. (2023) 所述的关键元素规范化,具体使用了频率过滤、规则、语义和关联聚合。在这个实现中,我们跳过了这一步。
GraphReader 代理我们准备实现GraphReader,一个基于图的代理系统。代理从几个预定义的步骤开始,然后是它可以自主遍历图的步骤,这意味着代理决定接下来的步骤以及如何遍历图。
这里是我们将实现的代理的LangGraph可视化。
LangGraph 中的 Agent 工作流实现。作者提供。
该过程始于理性的规划阶段,在此之后,代理选择初始的工作节点(关键元素)。接下来,代理检查与选定的关键元素相关的原子事实。由于所有这些步骤都是预定义的,因此它们用实线表示。
根据原子事实核查的结果,流程将转向读取相关文本片段或探索初始关键元素的邻居,以寻找更多相关的信息。在这里,下一步是基于LLM的结果而定的,因此用虚线表示。
在片段检查阶段,LLM 读取并评估从当前文本片段中收集的信息是否足够。根据这一评估,LLM 有几种选择。如果信息似乎不完整或不清楚,LLM 可以决定读取额外的文本片段。或者,LLM 可以选择探索邻近的关键元素,寻找更多上下文或初始选择可能未捕捉到的相关信息。然而,如果 LLM 确定已经收集了足够的相关信息,它将直接进入答案推理步骤,在此步骤中,LLM 根据收集到的信息生成最终答案。
在整个过程中,代理根据条件检查的结果动态地导航流程,根据具体情况决定是否重复步骤或继续前进。这为处理不同输入提供了灵活性,同时保持了步骤的结构化进展。
现在,我们将逐步介绍并使用 LangGraph 抽象来实现这些步骤。您可以通过 LangChain 的学院课程 更多地了解 LangGraph。
LangGraph 状态为了构建一个LangGraph实现,我们首先定义一个在流程各步骤中传递的状态。
class InputState(TypedDict):
question: str
class OutputState(TypedDict):
answer: str
analysis: str
previous_actions: List[str]
class OverallState(TypedDict):
question: str
rational_plan: str
notebook: str
previous_actions: Annotated[List[str], add]
check_atomic_facts_queue: List[str]
check_chunks_queue: List[str]
neighbor_check_queue: List[str]
chosen_action: str
对于更复杂的使用场景,可以使用多个独立的状态。在我们的实现中,我们分别使用了输入和输出状态来定义 LangGraph 的输入和输出,以及一个独立的整体状态,该状态在各个步骤之间传递。
默认情况下,当从节点返回时,状态会被覆盖。但是,你可以定义其他操作。例如,通过定义 previous_actions
,我们可以让状态被追加或添加,而不是被覆盖。
代理从维护一个笔记本开始,记录支持的事实,这些事实最终将用于推导出最终答案。其他状态将在后续逐步解释。
让我们继续定义 LangGraph 中的节点。
理性规划在理性规划步骤中,代理将问题分解为更小的步骤,识别所需的关键信息,并创建一个逻辑计划。逻辑计划使代理能够处理复杂的多步骤问题。
虽然代码不可用,但所有提示都在附录中,所以我们可以轻松地复制它们。
合理的计划提示。来自论文 论文,已获得作者许可。
作者并没有明确说明提示是在系统消息还是用户消息中提供的。大多数情况下,我决定将指令作为系统消息提供。
以下代码展示了如何使用上述理性计划作为系统消息来构建一个链。
rational_plan_system = """作为一个智能助手,你的主要目标是通过从给定的文章中收集支持性事实来回答问题。为了实现这一目标,第一步是根据问题制定一个理性计划。该计划应概述逐步解决问题的过程,并明确回答问题所需的关键信息。
示例:
#####
用户:Danny 和 Alice 谁的网球职业生涯更长?
助手:为了回答这个问题,我们首先需要找到 Danny 和 Alice 的网球职业生涯长度,例如他们的职业生涯开始和结束时间,然后进行比较。
#####
请严格遵循上述格式。我们开始吧。"""
rational_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
rational_plan_system,
),
(
"human",
(
"{question}"
),
),
]
)
rational_chain = rational_prompt | model | StrOutputParser()
现在,我们可以使用这个链来定义一个理性计划节点。在LangGraph中,一个节点是一个函数,它以状态作为输入,并输出更新后的状态。
def 理性计划节点(state: InputState) -> OverallState:
理性计划 = rational_chain.invoke({"question": state.get("question")})
print("-" * 20)
print(f"步骤: 理性计划")
print(f"理性计划: {理性计划}")
return {
"rational_plan": 理性计划,
"previous_actions": ["rational_plan"],
}
该函数首先调用 LLM 链,生成理性计划。我们进行一些打印以进行调试,然后将函数的输出更新为状态。我喜欢这种做法的简洁性。
初始节点选择在下一步中,我们根据问题和理性计划选择初始节点。提示如下:
初始节点选择的提示。经作者许可,摘自论文论文。
提示从给LLM一些关于整个代理系统的背景信息开始,然后是任务说明。想法是让LLM选择最相关的前10个节点并对它们进行评分。作者只是将数据库中的所有关键元素放在提示中供LLM选择。然而,我认为这种方法并不真正具有可扩展性。因此,我们将创建并使用一个向量索引来检索提示的输入节点列表。
neo4j_vector = Neo4jVector.from_existing_graph(
embedding=embeddings,
index_name="keyelements",
node_label="KeyElement",
text_node_properties=["id"],
embedding_node_property="embedding",
retrieval_query="RETURN node.id AS text, score, {} AS metadata"
)
def get_potential_nodes(question: str) -> List[str]:
data = neo4j_vector.similarity_search(question, k=50)
return [el.page_content for el in data]
from_existing_graph
方法从图中提取定义的 text_node_properties
并计算缺失的嵌入。在这里,我们只是对 KeyElement 节点的 id
属性进行嵌入。
现在让我们定义这个链。我们首先复制提示。
initial_node_system = """
作为智能助手,你的主要目标是根据文本中的信息回答问题。为了实现这一目标,已经从文本中创建了一个图,包含以下元素:
1. 文本片段:原始文本的片段。
2. 原子事实:从文本片段中提取的最小、不可分割的事实。
3. 节点:文本中的关键元素(名词、动词或形容词),这些元素与从不同文本片段中提取的多个原子事实相关联。
你的当前任务是检查一个节点列表,目标是从图中选择最相关的初始节点,以高效地回答问题。你将获得问题、合理的计划和一个节点关键元素列表。这些初始节点至关重要,因为它们是搜索相关信息的起点。
要求:
#####
1. 一旦你选择了起始节点,评估其与潜在答案的相关性,给出0到100之间的评分。100分表示与答案高度相关,而0分表示相关性较低。
2. 在单独的一行中呈现每个选定的起始节点及其相关性评分。每行格式如下:节点:[节点的关键元素],评分:[相关性评分]。
3. 请至少选择10个起始节点,确保它们不重复且多样化。
4. 在用户的输入中,每一行构成一个节点。在选择起始节点时,请从提供的节点中进行选择,不要自行创造。你输出的节点必须与用户提供的节点完全一致,用词相同。
最后,我再次强调,你需要从给定的节点中选择起始节点,并且必须与你选择的节点的用词一致。请严格遵循上述格式。让我们开始吧。
"""
initial_node_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
initial_node_system,
),
(
"human",
(
"""问题:{question}
计划:{rational_plan}
节点:{nodes}"""
),
),
]
)
再次,我们将大部分指令作为系统消息。由于我们有多个输入,可以在人类消息中定义它们。然而,这次我们需要一个更结构化的输出。我们不需要编写一个解析函数来将文本转换为JSON,而是可以简单地使用use_structured_output
方法来定义所需的输出结构。
class Node(BaseModel):
key_element: str = Field(description="""相关节点的关键元素或名称""")
score: int = Field(description="""通过分配0到100之间的分数来表示与潜在答案的相关性。100分表示高度相关,而0分表示相关性较低。""")
class InitialNodes(BaseModel):
initial_nodes: List[Node] = Field(description="与问题和计划相关的节点列表")
initial_nodes_chain = initial_node_prompt | model.with_structured_output(InitialNodes)
我们希望输出一个包含关键元素和分数的节点列表。我们可以轻松地使用 Pydantic 模型来定义输出。此外,为了尽可能地引导 LLM,我们需要为每个字段添加描述。
这一步的最后一项是将节点定义为一个函数。
def initial_node_selection(state: OverallState) -> OverallState:
potential_nodes = get_potential_nodes(state.get("question"))
initial_nodes = initial_nodes_chain.invoke(
{
"question": state.get("question"),
"rational_plan": state.get("rational_plan"),
"nodes": potential_nodes,
}
)
# 论文使用了5个初始节点
check_atomic_facts_queue = [
el.key_element
for el in sorted(
initial_nodes.initial_nodes,
key=lambda node: node.score,
reverse=True,
)
][:5]
return {
"check_atomic_facts_queue": check_atomic_facts_queue,
"previous_actions": ["initial_node_selection"],
}
在初始节点选择中,我们首先通过基于输入的向量相似性搜索获取潜在节点的列表。另一种选择是使用理性计划。LLM 被提示输出最相关的10个节点。然而,作者建议我们只使用5个初始节点。因此,我们只需按节点的得分排序并选取前5个节点。然后,我们用选定的初始关键元素更新 check_atomic_facts_queue
。
在这一步中,我们检查初始关键元素和链接的原子事实。提示为:
探索原子事实的提示。经作者许可,摘自论文论文。
所有提示都从给 LLM 提供一些背景信息开始,然后是任务指令。LLM 被指示阅读原子事实,并决定是否需要阅读链接的文本片段,或者如果原子事实无关,则通过探索邻居来查找更多信息。提示的最后一部分是输出指令。我们将再次使用结构化输出方法,以避免手动解析和结构化输出。
由于链的实现非常相似,仅通过提示不同,因此在这篇博客文章中我们不会展示每个定义。然而,我们将查看 LangGraph 节点定义以更好地理解流程。
def atomic_fact_check(state: OverallState) -> OverallState:
atomic_facts = get_atomic_facts(state.get("check_atomic_facts_queue"))
print("-" * 20)
print(f"步骤:atomic_fact_check")
print(
f"正在读取关于 {state.get('check_atomic_facts_queue')} 的原子事实"
)
atomic_facts_results = atomic_fact_chain.invoke(
{
"question": state.get("question"),
"rational_plan": state.get("rational_plan"),
"notebook": state.get("notebook"),
"previous_actions": state.get("previous_actions"),
"atomic_facts": atomic_facts,
}
)
notebook = atomic_facts_results.updated_notebook
print(
f"原子检查后的下一步理性:{atomic_facts_results.rational_next_action}"
)
chosen_action = parse_function(atomic_facts_results.chosen_action)
print(f"选择的操作:{chosen_action}")
response = {
"notebook": notebook,
"chosen_action": chosen_action.get("function_name"),
"check_atomic_facts_queue": [],
"previous_actions": [
f"atomic_fact_check({state.get('check_atomic_facts_queue')})"
],
}
if chosen_action.get("function_name") == "stop_and_read_neighbor":
neighbors = get_neighbors_by_key_element(
state.get("check_atomic_facts_queue")
)
response["neighbor_check_queue"] = neighbors
elif chosen_action.get("function_name") == "read_chunk":
response["check_chunks_queue"] = chosen_action.get("arguments")[0]
return response
原子事实核查节点开始时通过调用LLM来评估选定节点的原子事实。由于我们使用了 use_structured_output
,我们可以直接解析更新后的笔记本和选定操作的输出。如果选定的操作是通过检查邻居来获取更多信息,我们会使用一个函数来找到这些邻居,并将它们添加到 check_atomic_facts_queue
中。否则,我们将选定的片段添加到 check_chunks_queue
中。我们通过更新笔记本、队列和选定操作来更新整体状态。
如你从 LangGraph 节点的名字中所想象的,在这一步中,LLM 读取选定的文本片段,并根据提供的信息决定最佳的下一步操作。提示如下:
探索片段的提示。经作者许可,摘自论文论文。
LLM 被指示阅读文本片段并决定最佳方法。我的直觉是,有时相关的信息可能出现在文本片段的开头或结尾,而且由于分块过程,部分信息可能会丢失。因此,作者决定给 LLM 一个选项,让它可以选择阅读前一个或后一个片段。如果 LLM 认为已经获得了足够的信息,它可以进入最终步骤。否则,它可以选择使用 search_more
函数来查找更多细节。
再次,我们只看LangGraph节点函数。
def chunk_check(state: OverallState) -> OverallState:
check_chunks_queue = state.get("check_chunks_queue")
chunk_id = check_chunks_queue.pop()
print("-" * 20)
print(f"步骤:读取片段({chunk_id})")
chunks_text = get_chunk(chunk_id)
read_chunk_results = chunk_read_chain.invoke(
{
"question": state.get("question"),
"rational_plan": state.get("rational_plan"),
"notebook": state.get("notebook"),
"previous_actions": state.get("previous_actions"),
"chunk": chunks_text,
}
)
notebook = read_chunk_results.updated_notebook
print(
f"读取片段后的下一步理性:{read_chunk_results.rational_next_move}"
)
chosen_action = parse_function(read_chunk_results.chosen_action)
print(f"选择的操作:{chosen_action}")
response = {
"notebook": notebook,
"chosen_action": chosen_action.get("function_name"),
"previous_actions": [f"读取片段({chunk_id})"],
}
if chosen_action.get("function_name") == "read_subsequent_chunk":
subsequent_id = get_subsequent_chunk_id(chunk_id)
check_chunks_queue.append(subsequent_id)
elif chosen_action.get("function_name") == "read_previous_chunk":
previous_id = get_previous_chunk_id(chunk_id)
check_chunks_queue.append(previous_id)
elif chosen_action.get("function_name") == "search_more":
# 转到下一个片段
# 否则探索邻居
if not check_chunks_queue:
response["chosen_action"] = "search_neighbor"
# 获取邻居/使用向量相似度
print(f"邻居理性:{read_chunk_results.rational_next_move}")
neighbors = get_potential_nodes(
read_chunk_results.rational_next_move
)
response["neighbor_check_queue"] = neighbors
response["check_chunks_queue"] = check_chunks_queue
return response
我们首先从队列中弹出一个块ID,并从图中获取其文本。使用获取的文本和来自LangGraph系统整体状态的额外信息,我们调用LLM链。如果LLM决定它想读取之前的或后续的块,我们将它们的ID添加到队列中。另一方面,如果LLM选择搜索更多信息,我们有两个选项。如果队列中还有其他块可以读取,我们就继续读取它们。否则,我们可以使用向量搜索获取更多相关的关键元素,并通过读取它们的原子事实等重复此过程。
这篇论文对 search_more
函数有些怀疑。一方面,它指出 search_more
函数只能读取队列中的其他块。另一方面,在附录中的示例中,该函数显然在探索邻居。
示例操作历史。来自论文 paper,已获得作者许可。
为了澄清,我邮件联系了作者,他们确认了search_more
函数首先尝试处理队列中的额外块。如果队列中没有额外的块,它会继续探索邻居节点。由于没有明确定义如何探索邻居节点,我们再次使用向量相似性搜索来查找潜在的节点。
当LLM决定探索邻居时,我们有一些辅助函数来寻找潜在的关键元素进行探索。然而,我们并不会探索所有这些元素。相反,LLM会决定其中哪些元素值得探索,如果有值得探索的元素的话。提示如下:
探索邻居的提示。来自论文 论文,已获得作者许可。
基于提供的潜在邻居,LLM 可以决定探索哪些邻居。如果没有任何值得探索的邻居,它可以决定终止流程并进入答案推理步骤。
代码是:
def 邻居选择(state: OverallState) -> OverallState:
print("-" * 20)
print(f"步骤:邻居选择")
print(f"可能的候选者:{state.get('neighbor_check_queue')}")
邻居选择结果 = 邻居选择链.invoke(
{
"问题": state.get("question"),
"理性计划": state.get("rational_plan"),
"笔记本": state.get("notebook"),
"节点": state.get("neighbor_check_queue"),
"先前动作": state.get("previous_actions"),
}
)
print(
f"选择邻居后的下一步行动理由:{邻居选择结果.rational_next_move}"
)
选择的动作 = 解析函数(邻居选择结果.chosen_action)
print(f"选择的动作:{选择的动作}")
# 清空邻居选择队列
响应 = {
"选择的动作": 选择的动作.get("function_name"),
"neighbor_check_queue": [],
"previous_actions": [
f"neighbor_select({选择的动作.get('arguments', [''])[0] if 选择的动作.get('arguments', ['']) else ''})"
],
}
if 选择的动作.get("function_name") == "read_neighbor_node":
响应["check_atomic_facts_queue"] = [
选择的动作.get("arguments")[0]
]
return 响应
在这里,我们执行LLM链并解析结果。如果选择的操作是探索任何邻居,我们将它们添加到check_atomic_facts_queue
中。
我们流程的最后一步是要求 LLM 根据笔记本中收集的信息构建最终答案。提示为:
用于回答推理的提示。经作者许可,摘自论文论文。
这个节点的实现相当简单,如代码所示:
def answer_reasoning(state: OverallState) -> OutputState:
print("-" * 20)
print("步骤:回答推理")
final_answer = answer_reasoning_chain.invoke(
{"question": state.get("question"), "notebook": state.get("notebook")}
)
return {
"answer": final_answer.final_answer,
"analysis": final_answer.analyze,
"previous_actions": ["answer_reasoning"],
}
我们只需将原始问题和包含收集信息的笔记本输入到链中,要求它形成最终答案并在分析部分提供解释。
LangGraph 流定义剩下的就是定义LangGraph的流程以及它应该如何在节点之间遍历。我对LangChain团队选择的简单方法非常喜欢。
langgraph = StateGraph(OverallState, input=InputState, output=OutputState)
langgraph.add_node(rational_plan_node)
langgraph.add_node(initial_node_selection)
langgraph.add_node(atomic_fact_check)
langgraph.add_node(chunk_check)
langgraph.add_node(answer_reasoning)
langgraph.add_node(neighbor_select)
langgraph.add_edge(START, "rational_plan_node")
langgraph.add_edge("rational_plan_node", "initial_node_selection")
langgraph.add_edge("initial_node_selection", "atomic_fact_check")
langgraph.add_conditional_edges(
"atomic_fact_check",
atomic_fact_condition,
)
langgraph.add_conditional_edges(
"chunk_check",
chunk_condition,
)
langgraph.add_conditional_edges(
"neighbor_select",
neighbor_condition,
)
langgraph.add_edge("answer_reasoning", END)
langgraph = langgraph.compile()
我们首先定义状态图对象,在其中可以定义在 LangGraph 中传递的信息。每个节点都可以通过 add_node
方法添加。普通的边,其中一步总是跟随另一步,可以通过 add_edge
方法添加。另一方面,如果遍历依赖于之前的动作,我们可以使用 add_conditional_edge
并传入选择下一个节点的函数。例如,atomic_fact_condition
函数如下所示:
def atomic_fact_condition(
state: OverallState,
) -> Literal["neighbor_select", "chunk_check"]:
if state.get("chosen_action") == "stop_and_read_neighbor":
return "neighbor_select"
elif state.get("chosen_action") == "read_chunk":
return "chunk_check"
正如你所见,定义条件边非常简单。
评估最后我们可以用几个问题来测试我们的实现。我们先从一个简单的问题开始。
langgraph.invoke({"question":"圣女贞德有没有输过任何战役?"})
结果
图片由作者提供。
代理人首先制定了一个合理的计划,以识别贞德在其军事生涯中参与的战役,并确定是否有任何战役失败。在制定了这个计划后,它转向对关键战役的事实核查,例如奥尔良的围攻、巴黎的围攻以及拉夏蒂永的战役。代理人没有扩展图谱,而是直接确认了它需要的事实。它阅读了提供更多关于贞德未能成功的战役细节的文本片段,特别是巴黎的围攻和拉夏蒂永的失败。由于这些信息回答了关于贞德是否失去任何战役的问题,代理人在此停止了进一步的探索。整个过程以最终答案结束,确认了根据收集到的证据,贞德确实输了一些战役,特别是在巴黎和拉夏蒂永。
现在让我们来个出其不意。
langgraph.invoke({"question":"西班牙的天气如何?"})
结果
图片由作者提供。
在制定了合理的计划后,代理选择了初始的关键元素进行探索。然而,问题在于这些关键元素在数据库中并不存在,LLM只是凭空想象出来的。也许一些提示工程可以解决这种凭空想象的问题,但我还没有尝试过。需要注意的一点是,这并不是特别糟糕,因为这些关键元素在数据库中不存在,所以我们无法获取任何相关信息。由于代理没有得到任何相关数据,它继续搜索更多信息。然而,这些邻居也没有任何相关性,因此过程被停止,让用户知道这些信息是不可用的。
现在让我们尝试一个需要多步推理的问题。
langgraph.invoke(
{"question":"圣女贞德在年轻时访问过哪些城市,后来她在这些城市中赢得过战役吗?"})
结果
图片由作者提供。
这整个流程有点太长了,所以我只复制了答案部分。这个问题的流程相当非确定性,而且非常依赖于所使用的模型。这有点好笑,但当我测试新模型时,它的表现反而更差。所以,GPT-4 表现最好(也是在这个例子中使用的模型),其次是 GPT-4-turbo,最后是 GPT-4o。
概要我对GraphReader及其类似方法感到非常兴奋,特别是因为我认为这种(图)RAG方法可以非常通用,适用于任何领域。此外,由于图模式是静态的,你可以避免整个图建模的部分,让图代理使用预定义的函数来遍历图。
我们在实现过程中讨论了一些问题。例如,许多文档的图构建可能会导致一些主要元素最终成为超级节点,有时原子事实中并不包含完整上下文。
检索部分非常依赖于提取和选择的关键元素。在原来的实现中,他们将所有关键元素都放在提示中供选择。然而,我怀疑这种方法在扩展性上表现不佳。也许我们也需要一个额外的功能,让代理能够通过其他方式(而不仅仅是探索邻居关键元素)来搜索更多信息。
最后,代理系统高度依赖于LLM的性能。根据我的测试,来自OpenAI的最佳模型是原始的GPT-4,这很有趣,因为它是最老的版本。我还没有测试过o1。
总而言之,我很兴奋能够进一步探索这些文档图的实现,其中从文本片段中提取元数据并用于更好地导航信息。如果你有任何改进这个实现的想法或有其他你喜欢的方法,请告诉我。
如往常一样,代码可在 GitHub 上获取。
共同学习,写下你的评论
评论加载中...
作者其他优质文章