Beyond RAG: Implementing Agent Search with LangGraph for Smarter Knowledge Retrieval

超越 RAG:使用 LangGraph 实现智能体搜索,以实现更智能的知识检索

阅读时长11分钟

编者按:这是一篇来自我们在 Onyx 的朋友的客座文章。随着 LangGraph 的成熟,我们看到越来越多的公司(KlarnaReplitAppFolio等等)开始将其用作其首选的智能体框架。我们认为这是一篇很棒的博客,详细描述了如何进行评估。您也可以在他们的博客上阅读这篇文章的版本:他们的博客版本

作者:Evan Lohn,Joachim Rahmfeld

Onyx,我们致力于扩展用户从其企业数据中获得的知识和见解,从而提高跨职能部门的生产力。

那么,Onyx 是什么? Onyx 是一款 AI 助手,企业可以将其部署在任何规模——笔记本电脑、本地部署或云端——以连接来自多个来源的文档化知识,包括 Slack、Google Drive 和 Confluence。 Onyx 利用 LLM 为团队创建主题专家,使用户不仅可以找到相关文档,还可以获得诸如“是否已支持功能 X?”或“功能 Y 的拉取请求在哪里?”等问题的答案。

去年,我们着手通过设定以下目标来增强我们的企业搜索和知识检索能力

  • 实现对复杂和模糊问题的可扩展答案
  • 当涉及多个实体时,提高答案质量
  • 围绕问题的关键方面提供更丰富的细节和背景

属于这些类别的问题通常对用户具有很高的价值,然而,传统的类 RAG 系统在这些情况下往往会遇到困难。

例如,考虑以下问题:“我们在耐克与彪马的定位中,哪些与产品相关的差异可能影响我们不同的销售结果?”。这个问题既涉及多个实体,又涉及歧义(与产品相关的销售结果可能意味着很多事情)。

除非语料库中恰好有文档几乎完全处理这个问题,否则 RAG 系统在这里很难找到好的答案。

这些是我们新的智能体搜索介入的问题类型。这里的想法是什么?

在高层次上,该方法是:1) 首先将问题分解为子问题,这些子问题可以侧重于更狭窄的背景以及消除潜在歧义的术语,2) 使用已回答的子问题和获取的文档来组成初始答案,然后 3) 基于初始答案和在初始过程中学到的各种事实,生成进一步完善的答案。

为了使上述示例更具体,一些有效的初始子问题可能是“我们与彪马讨论了哪些产品?”,“我们与耐克讨论了哪些产品?”,“彪马报告了哪些问题?”……
为了封装这种类型的逻辑过程,需要组织和协调许多步骤、计算和 LLM 调用。

本博客的目的是 i) 说明我们在功能层面上如何解决这个问题,ii) 讨论我们如何进行技术选择方法,以及 iii) 详细分享我们如何利用 LangGraph 作为骨干,以及我们具体学到了哪些经验教训。

我们希望这篇文章对那些对这个领域感兴趣和/或想要使用 LangGraph 构建智能体并分享我们的一些要求的读者有所帮助。

通用流程和技术要求

大致来说,我们目标逻辑流程在高层次上看起来像这样

Agent Search flow

此流程的关键方面和要求是

  • 除了直接搜索与原始问题相关的文档外,我们还将初始问题分解为更狭窄、定义明确的子问题。这有助于消除歧义并缩小搜索范围
  • 分解过程由初始搜索提供信息,为分解提供一些背景
  • 每个子问题的回答都有许多部分:查询扩展、搜索、文档验证、重排序、子答案生成、子答案验证
  • 初始答案基于搜索 + 子问题的答案。
  • 如果初始答案不足,我们将执行另一次分解以生成改进的答案,该答案旨在解决缺点和/或跟进子问题的答案。这种改进分解由以下因素提供信息
    • 问题和原始答案(以及它不足的事实)
    • 子问题及其答案(以及无法回答的子问题)
    • 基于初始搜索的单独的实体/关系/术语提取,以使分解更好地与文档集的内容对齐
  • 总的来说,并行性在许多层面上是必要的,包括
    • 每个子问题处理的检索文档验证
    • 并行处理多个子问题
    • 实体/关系/术语提取与子问题的处理并行
  • 同样,依赖管理至关重要。例如
    • 改进阶段的分解必须等到初始答案生成(并已确定需要改进),并且实体、关系和术语的提取完成
    • 未来的步骤由早期步骤的结果提供信息

因此,确实需要做很多事情才能实现我们的目标,即能够解决范围更广、更模糊的问题。

虽然此流程确实以工作流为中心,但它代表了迈向更广泛的智能体搜索流程的初步步骤。我们计划将各种工具连接到流程中,更新改进流程等等。我们稍后可能还会引入与用户的人工环路类型交互,例如在改进之前批准答案或使用一些手动更改重新运行部分流程。

为了满足我们的要求,并着眼于近期/中期未来,我们需要一个框架,该框架

  1. 是良好控制的,
  2. 易于扩展或(重新)配置,
  3. 具有成本效益,
  4. 允许高度并行化,
  5. 可以管理逻辑依赖关系(A 和 B 需要在 C 开始之前完成,E 可以与所有这些并行运行等),以及
  6. 支持令牌和其他对象的流式传输
  7. 未来允许更复杂的交互。

而且 - 哦,是的!- 答案还需要以符合用户期望的及时方式生成,并在规模上做到这一点。

因此,我们必须解决的关键问题是“我们如何最好地实现这一点?”

框架选项和评估方法

对于我们来说,选择本质上是,是通过扩展我们现有的流程从头开始自行实现此流程,还是利用现有的智能体框架 - 如果是,是哪一个。

鉴于我们上面概述的优先事项,我们最终选择了 LangGraph 作为我们实现框架的主要候选者,而从头开始实现可能是相对接近的第二选择。

最初支持 LangGraph 的驱动因素是

  • 一种自然的“他们的图片看起来像我们的图片”的情况,意思是:我们制定的流程非常符合 LangGraph 的节点、边和状态的概念
  • 具有强大社区的开源框架
  • “不是全新的”(相对于这个领域的创新时间尺度而言)
  • 高度控制
  • 原生流式传输支持
  • 对于潜在的未来 Onyx 功能(如人工环路或使用更改后的某些参数重新运行智能体流程的能力)的有趣功能

然而,我们当然也有一些担忧,这些担忧倾向于从头开始实现,包括

  • 依赖于第三方和降低的端到端控制
  • LangGraph 中的可变状态变量(“谁更改了这个值?”)
  • 调试时调用堆栈的可见性较低
  • 更改我们现有的流程

为了做出决定,就像对大多数此类项目所做的那样,我们从原型评估开始。具体来说,我们快速(约 1 周/1 FTE,包括学习)实现了我们目标流程的简化、独立的 LangGraph 实现,我们在其中尝试测试

  • 提供可接受运行时间的近似端到端功能
  • 扇出并行化
  • 子图并行化
  • 围绕状态管理的潜在问题
  • 流式传输

结果令人鼓舞,我们继续在我们的应用程序中用 LangGraph 实现了我们的实际流程。正如预期的那样,在此过程中,我们学到了许多额外的经验教训。下面我们记录了我们学到的东西,以及我们计划在未来遵循的约定,因为我们对智能体流程的使用将扩大。

LangGraph 学习 - 我们未来的最佳实践

随着项目迅速变得更加复杂,以下是我们观察到的一些现象以及我们坚持的做法。

代码组织

目录和文件结构

  • 在复杂的图中,节点的数量可能会变得相当大。我们决定使用每个节点一个文件的方法(针对不同节点的函数重用)。
  • 对于许多节点,建议使用清晰的目录结构和文件命名策略。我们为每个子图创建了一个目录,并通常采用 <action>_<object>.py 命名约定。添加步骤编号的数字也可能有所帮助,但这在添加或删除节点时需要额外的工作。
  • 我们广泛使用子图来实现并行化(见下文)和重用。每个子图目录都有自己的边、状态和模型文件以及其图构建器。
  • 为了可视化图,使用整体图的 mermaid png 被证明非常有用。

类型和状态管理

我们在整个代码库中使用 Pydantic,因此很高兴看到 LangGraph 除了 TypeDicts 外还支持 Pydantic 模型。因此,我们在整个 LangGraph 实现中也使用 Pydantic 模型。(不幸的是,对于图输出,Pydantic 尚不支持)。

由于我们在子图中有很多具有自己操作和“输出”(状态更新)的节点,我们通常将(子)图状态视为由节点更新驱动的。因此,我们没有直接在子图状态中定义键,而是为各种节点更新定义 Pydantic 状态模型,然后通过从各种节点更新(和其他)模型继承来构建图状态。

优点:

  • 键自然分组
  • 添加键时,只需更新节点状态模型(和节点)
  • 允许键的重叠
  • 允许默认值

挑战

  • 遵循这种方法当然会使查看整体图状态中的完整键集变得更加困难。
  • 必须选择一个好的结构以避免继承问题

不足为奇的是,对于状态键,深思熟虑地设置默认值(或不设置!)非常重要。如果处理不当,在不适当的情况下设置默认值可能会导致意外行为,而这些行为可能更难检测到。例如,考虑以下有问题的配置

Nested Subgraphs - Default for Key in Inner Subgraph that is Missing in Outer Subgraph

在这里,主图节点 A 将“my_key”设置为“my_data”。此值稍后将由内部子图节点使用。但是在此示例中,我们(有意地)错过了将该键添加到外部子图中。不足为奇的是,内部子图中此键的值将为空字符串。输出侧也会出现类似的情况:如果我们在内部子图节点 C 中更新了“my_key”,则这不会更新主图中的“my_key”状态。

如果我们改为小心谨慎,并且没有像这里所示那样在内部子图中为“my_key”设置默认值

Nested Subgraphs - No Default for Key in Inner Subgraph that is Missing in Outer Subgraph

那么会引发错误,因为“my_key”没有内部子图的输入值。然后,将添加外部子图中丢失的状态以达到正确的配置

Nested Subgraphs - With Key in Outer Subgraph

这当然与传统的嵌套函数没有真正的区别,但在我们的经验中,在 LangGraph 上下文中,这些问题更难识别。

我们的建议 - 毫不奇怪 - 是

  • 在图输入状态中定义所有键,不使用默认值,除非有文档记录的例外情况,通常在嵌套子图的上下文中
  • 将图中更新的所有键定义为 <type> | None = None,…,但当键是列表且我们希望从多个节点添加到列表时除外。

图组件和注意事项

并行性


我们对并行执行的流程有很多要求,并且有多种类型的并行性。

相同流程的并行性


Map-Reduce 分支我们流程中这种类型的并行性的一个示例是检索文档的验证,即,测试检索文档列表中的每个文档与问题的相关性。显然,人们希望并行执行这些测试,而 LangGraph 的 Map-Reduce 分支在这些情况下对我们来说非常有效

Fane-Out Parallelization for Document Verification

上面,粗体状态键是在扇出期间更新的键,斜体键是指扇出节点内部变量。

不同流程段的并行性


广泛使用子图!在以下情况下,假设 B_1 和 B_2 各自执行需要 5 秒,而 C 需要 8 秒。在左侧的场景中,D 实际上在 A 完成后 13 秒开始,因为 B_2 仅在 B1 和 C 完成后才开始。另一方面,在右侧的场景中,D 在 A 完成后 10 秒开始。将 B_1 和 B_2 包装到子图中可确保从父图的角度来看,左侧有一个节点,右侧有一个节点,并且 B_2 的执行不会等待 C 完成。(注意:我们始终将子图用作父图中的节点,而不是在父图的节点中调用子图。)

Parallel Subgraphs

可重用组件:再次是子图!


我们确实有很多由多个节点组成的重复流程段。一个例子是扩展搜索检索,其中检索给定(子)问题的文档。核心是,该过程包括搜索,然后是对每个检索到的文档相对于问题的相关性验证,最后是对验证后的文档进行重排序。为了使这个重复过程高效,我们将其包装到一个子图中,然后该子图由主图或其他子图使用。与往常一样,需要注意父图和子图之间键的定义和共享。

  • 节点结构
    • 我们通常采用“每个节点一个操作”的方法,尽管可以通过将更多连续操作放入一个节点来轻松减少节点蔓延。
    • 当子图在流程的多个步骤中使用时,有时我们发现方便在末尾引入一个“格式化节点”,其作用是将数据转换为所需的键更新。
  • 流式传输
    • 节点内自定义事件的流式传输
      为了提供良好的用户体验,我们需要在节点内流式传输大量事件。示例包括检索到的文档、生成的答案或子答案的 LLM 令牌等。其中许多是自定义事件,因为我们需要区分可能同时流式传输的各个子答案。我们的应用程序使用同步操作,并且遵循 LangGraph 的流式传输文档对我们来说非常有效,可以促进我们的自定义事件的流式传输。
  • LangGraph 版本
    • 由于这是一个快速发展的领域和一个快速发展的项目,因此保持对最新版本的关注非常重要

我们当前使用 LangGraph 的智能体搜索

最后,这是我们当前的图(X 射线级别设置为 1 以限制复杂性,因此许多节点实际上是可能包含更多子图的子图)

Onyx Agent Search - LangGraph Graph Structure (with xray of 1 level)

很明显,此流程与我们一开始制定的逻辑流程非常相似,并添加了一些内容以在未选择智能体搜索时方便基本搜索流程。

展望和行动号召

我们将此实现视为第一步,我们计划在不久的将来扩展流程,使其在本质上更像智能体。到目前为止,LangGraph 确实非常适合我们的需求。

我们邀请您在 GitHub 上查看 agent-search,预订演示免费试用我们的云版本,并加入 slack、 discord #agent-search 频道,更广泛地讨论我们的企业 AI 搜索以及智能体!