大家好,我是Peter,来自Unblocked团队。这位是我的同事Brandon。今天我想先简单介绍一下我们的工作。我们构建的是一个上下文引擎,公司名为Unblocked,旨在向人类以及越来越多的智能体(agents)提供组织层面的上下文信息。原本这个环节有两个小时,现在压缩成了一个小时,所以内容相当紧凑。这意味着我会快速过一遍工作坊的步骤。如果大家在过程中没能完全掌握所有细节,请不要担心,因为代码库已经上线,你们可以在之后从容地自行演练。

今天的核心观点是:RAG(检索增强生成)——在我们当前的语境中,指的是语义相似度搜索或向量检索——只是检索的一半。今天我们要构建另一半功能,即将自然语言转化为真正的数据库查询。这里是用于设置的二维码,如果还没设置的话请扫描。接下来我跳过这一步,直接展示接下来的内容……哦,看来渲染出了问题,请稍等一秒让我切换一下。好了,希望现在大家能看到了。这就是代码库的位置。

设置步骤相当简单,但有一些前置依赖项需要安装。在整个工作坊过程中,我们将逐步执行一系列拉取请求(Pull Requests)。目前网络似乎不太稳定或无法正常运行。让我们试试这个替代方案吧……好消息是,这次演示不需要网络连接。希望我们在开始前大家都已经准备好了依赖项,因为刚才那个链接显然无法使用。无论如何,我们将遵循一系列六个堆叠的拉取请求进行操作,我会随着讲解引用这些内容。

让我回到这里以便大家都能看到……好的,现在切回主题演讲。抱歉又要延长一点时间。好了,希望这次大家能看清了。很好。我们的智能体需要上下文信息,而这些信息分散在工程团队使用的各种工具中。你可以在拉取请求、代码审查评论、问题工单(如Linear和Jira)、文档以及Wiki平台(如Notion、Confluence)中找到它们,当然还有Slack和Teams等沟通渠道中的对话记录。

所有这些内容不仅具有语义可查询性,还具备结构化特征:包括作者、日期、状态、标签和关系等。大家可能还记得,哪怕是在两年前这个在AI领域看来已是“远古历史”的时期,当时的直觉做法是将所有数据倒入向量存储库中进行检索和重排序,希望能得到正确答案。但这种做法行不通。因此,我想退一步,将这一现象与更广泛的上下文讨论联系起来。

在一个服务于组织上下文的“上下文引擎”中,构成要素远不止代码本身。正如我所说,我们将所有事物连接在一起:左侧分散的上下文(包括你的代码、拉取请求、文档等)被输入系统,而输出的则是相互关联的上下文信息。这些信息针对手头的具体任务进行了专门定制。在这种模型中,我们不仅关心语义关系,当然也要关注连接所有数据的结构关系。

再次强调,RAG涉及嵌入查询、查找语义相似的文档,然后将这些内容填入提示词(prompt)中。这种方法在处理“寻找代码”、“找到修改它的拉取请求”或“查找讨论该问题的Slack线程”等内容型问题时表现出色。如果将其包裹在智能体循环中,你可以取得不错的进展;也可以将其连接到能够递归遍历文件系统的工具上,作为一个良好的起点。你获得了语义上的先发优势,随后智能体可以在GitHub的文件系统中递归操作,从而取得相当好的效果。

但在我们的经验中,这并非实践中的常态。工程师们经常提出诸如“我上周合并的拉取请求有哪些”或“我的团队上周合并了哪些”之类的问题。他们基本上是在寻找特定的内容,知道起点在哪里,但不知道终点在哪里,不过他们清楚其中的关系。以“我上周合并的拉取请求”为例,让我们从RAG的角度思考:你会嵌入这句话,找到最近的向量,进行重排序并取前K个结果。

你可能会得到提及“上周合并”的拉取请求,但无法获取实际的结构关系,如作者、合并状态以及是否真的上周才合并。这可能导致错误的答案:你提取了一个语义相关但实际是三年前合并的拉取请求。因此,这并没有真正回答问题,因为该问题包含结构性和时间维度的要素。检索在这里有两半:语义部分和结构化查询部分。

关键在于,在这种范式下,模型并不直接回答问题,而是编写访问数据库的查询语句。当然,说生成查询是我们唯一需要做的事情是天真了。为了可靠地生成查询并处理潜在的安全问题,我们需要关注以下四个要点:首先,语言模型在创建查询前必须了解数据库模式(schema);其次,它需要理解数据中的一些关系。

例如,如果你输入自然语言查询“显示Peter未处理的拉取请求”,模型可能不知道该如何处理,因为它可能会生成包含名字“Peter”的查询。但实际上GitHub登录名可能不同,因此模型需要能够进行映射转换。当然,从另一端出来的可能是不可信的查询语句——也许使用了函数操作符(在术语中),这可能会直接在服务器上执行JavaScript,而这通常是我们不希望发生的。

最后,如果第一次尝试出现错误,我们希望系统能够自我纠正。以下是这种机制在单次调用中的体现:我们需要类似这样的结构映射同事的名字(Rashine)及其用户登录名(Rashab)。同时,我们需要能够映射状态字段以及获取诸如“限制”和“排序”等操作符。这正是我们要构建的内容。

我们将首先构建一个完整的用户界面和一个临时的引擎,起初它并不具备完整功能。我们会逐步完善这个系统。到了第三步,整个系统将真正“活”过来。届时,我们实际上可以向它发出查询指令,并观察其运行效果。如果你之前没看过这部分内容,现在正是回顾和熟悉的好时机。

大家好,我是来提供帮助的。

是的,任何代码仓库都可以,具体选哪一个并不重要。如果你想走捷径,可以直接让智能体指向该引擎,让它自动完成所有设置工作,这也是可行的。好的,让我们继续推进。这就是我们最初的计划。到现在为止,你应该已经运行了数据摄入程序。即使还没运行也没关系,你可以稍后回头处理这部分内容。

我这就快速展示一下这个系统的基础界面是什么样子的。现在引擎正在运行,但如果你在这里输入任何内容,显然还无法正常工作。你应该会看到类似这样的提示界面。此外还有一个聊天标签页,这是一个完全实现的智能体,能够为你执行各种任务。我们稍后会回到这个话题进行详细讲解。

如果你有兴趣阅读代码,这是其中的契约定义部分。我们暂时跳过这部分细节不深究,但基本上这就是入口点,我们将围绕它进行构建。首先我们要讨论的是模式发现(Schema Discovery)。在数据摄入过程中,如果你回顾一下摄入脚本的工作方式,你会发现它实际上必须构建出一个模式结构,以便下载内容并进行存储。

但你可以想象到,你可能并不预先知道具体的模式结构。因此,数据摄入可能在另一个服务中进行,而你希望构建一个完全动态的模式发现引擎。由于不像 PostgreSQL 那样可以直接读取表定义来获取模式信息,这里没有现成的模式可供读取。所以我们将采取的方法是:对文档进行采样以推断出模式结构,这就是整个技巧所在。

具体来说,我们将重建这一过程:取出所有节点并将它们合并压缩成一个单一的树状结构。我们会采样300个文档,将其折叠成单棵树。空值(nulls)会被跳过,因为缺失的字段无法约束数据类型。接下来,我们会对枚举类型(enum types)做一些有趣的处理操作。

对于枚举类型,我们可以采用一种巧妙的过程式技巧来处理(这只是其中一种方法)。我建议在这种类型的任务中使用过程式代码,虽然你也可以通过大语言模型进行提取并让其工作,但这种方法相当不确定。因此,你始终需要某种结构化的框架来运行过程式代码,以验证提取出的内容或直接执行提取本身。

在这个示例项目中,正是由过程式代码负责进行提取操作。让我们去展示一下具体效果吧。我们将执行该步骤并再次运行程序。现在我们可以看到结果了:模式发现过程已经运行完毕,为我们创建了一个 schema,概述了在 GitHub 中拉取请求(Pull Request)的模式结构。

它甚至为我们创建了一些索引。这里特意标记为 Markdown 格式,是因为我们接下来要将这个模式传递给大语言模型,以便在合成实际查询时使用。

第二步是我之前几页幻灯片提到的身份解析(Identity Resolution)。这是因为像我的 GitHub 登录名“my lo”虽然相似,但并不完全相同。如果我问一个问题:“嘿,找出 Peter 所有的拉取请求”,如果不进行某种身份映射处理,系统只会简单地匹配名字“Peter”,从而找不到任何结果。因此我们必须解决这个问题。

乍一看,这似乎让我们与模式耦合得有点紧。但要知道,完全有可能动态地确定这一点。你可以让大语言模型为你合成这些映射关系。但在我们的案例中,我们只是采用一些简单的小技巧:非常简单的模糊匹配算法(如编辑距离)即可。你只需通过名字进行匹配,问题就解决了。

哦,实际上在继续之前我想给你展示一点东西。让我们再做一次同样的操作……哎呀,抱歉。绝对没问题。等你觉得画面够大时告诉我一声。好的,谢谢你的提醒。好吧,让我们再次运行我们的程序。现在……哦,实际上我必须等到下一步才能看到效果,很抱歉,这部分功能暂时还没有连接好。

所以我们将进入真正能带来回报的步骤,也就是第三步:实际的查询计划步骤。这 arguably 是最简单的部分,主要涉及大语言模型(LLM)的应用。在这里,我们传入模式信息并提供一个工具接口,强制它调用该工具进行名称提取和合成。随后它将执行生成的查询计划。

还有另一件重要的事情,那就是时间维度方面。如果你在大语言模型的提示词中传入当前的日期和时间,它在生成涉及时间的查询时会表现得相当出色。因此这也是在提示词中需要包含的重要内容。在这里,我们展示的是名称提取部分:在这种情况下,我们要强制触发该工具调用。

到了这一刻,我将向你展示这一切带来的成果回报。好了,看这里!现在我可以执行诸如“打开 Peter 的拉取请求”这样的操作了。瞧,它生成了匹配子句(match clause)。虽然从这里看不太清楚细节,但它成功地将“Peter”映射到了我的 GitHub 用户登录名(puery),并生成了相应的查询语句,这就是最终的结果。

这确实很酷,但我们必须继续推进到下一步。因为尽管我们现在能够查询数据了,但仍存在一些安全隐患。大语言模型可能会产生不可信的输出,例如使用 where 或函数子句在服务器上直接运行 JavaScript。这意味着这现在变成了一个提示注入(prompt injection)的风险点。

此外,你的模式中可能还隐藏着像 ID 或元数据字段这样的内部信息泄露风险。因此我们需要进行清理(sanitize)。我们不是采用黑名单机制,而是采用白名单机制:默认拒绝访问。你需要创建一个允许操作的列表,以及潜在地创建允许的字段列表。在我们的案例中,由于采用了动态模式发现技术,所以我们暂时不这样做。

不过,我们可以针对某些已知的字段(如元数据字段)进行限制。这对于租户隔离非常有用。例如,如果你有一个集合容纳了多个租户(虽然我不推荐这种做法,通常建议按租户分离集合),你可以拥有一个包含租户 ID 的元数据字段。在这种情况下,你需要隐藏该信息以防止泄露。

来自大语言模型,还要验证输出。如果它输出了那个元数据字段,你需要将其清除,然后在其下方注入你自己的元数据字段。

同样地,从技术上讲这并不难做到。你只需要编写代码即可,整个过程都是程序化的。我不建议在这一部分使用大语言模型。

因此,再次强调,采用白名单机制,不要黑名单或拒绝所有请求——我应该这么说。

最后一部分将自然地过渡到下一步。在验证结束时,我们显然会返回一个错误并将其丢弃。目前这个错误就在那里存在,暂时不会对其做任何处理。但我现在会展示一下它的样子。

哎呀。保持在这个页面并运行我们的服务。现在我准备再写一个查询,用于检查标题中的身份验证信息,而我们已禁止使用正则表达式。因此现在我们可以看到它拒绝了该查询。

最后,将所有内容串联起来的是重试循环机制。

计划可能会以几种不同的方式失败。例如,它可能无法通过验证:也许大语言模型实际上输出了根据我们的验证器来说是非法的内容。或者它可能在执行过程中失败:比如,假设它将包含和排除项目混淆了,这显然会导致失败。但我们可以自我纠正:将该错误传回给大语言模型并告诉它“嘿,这错了”,也许你能做得更好。当然,最多允许一定次数的尝试次数。

现在我将在项目中展示一下这看起来是什么样子的:让我们退回到上一级层级。哎呀,抱歉。请原谅我,我必须看着屏幕读这些内容。

好的,现在如果我们运行相同的查询……完美。我们可以看到它经过了几次尝试才正确。不过在第三次尝试时出现了一点问题,因为我们说过不使用正则表达式和全文搜索(FTS)。于是它说我们将只使用匹配功能,但现在是精确匹配。因此它试图查找“authentication fails”(身份验证失败),这意味着我们还可以做一件事:添加全文搜索。

这相当基础。显然我们需要创建一个索引,并将其与作者和状态过滤器结合在同一个匹配操作中。现在我们应该能够回答像这样的问题了:让我们看看会发生什么,看它是否真的会按照我所说的去做。

成功了!就是这样。

现在最终的回报是,如果我们进入聊天标签页,我们可以提出一些更复杂的问题——代理可以在后台进行推理。你可以说类似“我们上周有没有处理过与身份验证相关的工作?”这样的话。

在这里我们可以看到它生成了查询,现在正在拉取数据并进行推理。好了,就是这样。

简单总结一下我们构建的内容:基本上我们已经接近尾声了,接下来会有大量时间用于提问。回顾一下,有些问题是传统 RAG 无法处理的:诸如状态或时序性等结构性问题。当你用传统的 RAG 询问这类问题时,你得不到正确的数据结果。因此支撑查询游乐场和聊天代理的引擎是相同的:如果你想稍后玩一下,尽管去试吧。它们背后的机制是一样的。唯一的区别当然是聊天代理实际上进行了推理,而查询界面仅用于执行查询。

以下是可复用的思想:模式感知提示、动态模式发现、身份解析、验证和错误重试。其中,错误循环实际上是最重要的部分之一。

因此我们构建了一个能够进行过滤、提供聚合功能、具有时间范围限制并支持全文搜索的系统。它经过验证,具备自我纠正能力,并为完整的聊天代理提供动力。你可以将其引入你的组织并直接部署。我建议在内部部署之前,再对验证框架进行一些完善工作。是的,它可以回答你关于所有 PR(拉取请求)的问题。就这样了:我们留出了大量时间用于提问,现在大家笑了。

每个人都成功设置了吗?我只是好奇一下。“没有。”好的。如果你想的话,可以在会后来找 Brandon 和我,我们会在这里稍作停留。哦,在 P16 展位也可以找到我们。“是的,但那是今天下午四点才开始的活动。”“没错,我们会在那里待整整一周。酷!有问题吗?”

“是的,我有一个问题。”我认为你很好地分解了基础知识。“对。我的想法是:在大规模情况下,例如你是如何生成模式并将其放入系统提示中的?这在规模上是如何运作的?”这取决于公司规模,你是如何容纳所有这些数据的?你如何选择数据以及处理这些数据需要多长时间?成本是多少?这是我的疑问所在。

“这是一个好问题。”所以,我要重复一下这个问题以便大家都能听到。“演示显然展示了一个相当人为构造的例子,你只是专注于拉取请求。问题是:如何扩展这种机制?例如如何在多个不同的集合、多张表等之间进行此操作?”

你可以在顶部添加另一个大语言模型步骤:让模式集合拥有微小的摘要和描述,然后根据查询内容加载正确的模式。大语言模型在这方面表现得令人惊讶地好:一年前它们还不行,但现在非常擅长于此。这就是缩小上下文范围的方法,也是回答你关于扩展的问题的方式。“还有没有其他方面是你感兴趣的?”

“获取所有数据以及成本问题。”“是的,还包括延迟等因素。处理这些数据需要多长时间?我应该选择哪些数据放入其中?”

这是一个好问题。“在 Unblocked,我们持续不断地摄取内容。我们会收到所有事件的网络钩子回调,例如拉取请求中的变更。”因此每次发生变更时,我们都会收到该网络钩子事件,摄取内容并将其存储在数据库中。这实际上只是一个关于如何从摄取层面扩展的分布式系统问题。

存储,同样的情况。所以也许这会让它更具体一些。那么如果你说这些是前三大数据源,比如有人说是 GitHub 之类的……嗯,肯定是 Slack。很多关于“为什么”的问题和决策都是在 Slack 中进行的。在这种结构化查询的语境下,人们通常会想到过去进行过的对话……他们能够向 Unblocked 提问说:“哦,是的,你知道的,大约三周前,我和某某进行了一次对话。”这正是结构化查询的完美应用场景。嗯,是的,我会说 Slack 对于回答“为什么”类问题是最重要的。很多关于决策背后的原因也嵌入在 PR(拉取请求)中。PR 的评论中包含所有的组织历史记录,那基本上就是一个黑洞,对吧?

所以在提供服务之前,你可以对数据进行扫描并挖掘有趣的见解。例如在 Unblocked 中,我们创建了称为“最佳实践记忆”的东西,这些是从过去的 PR 评论中提取提炼出来的。这说得通吗?>> 是的。没错。还有别的吗?有。>> 问题直接涉及到这个构建过程。[喷鼻息声] >> 是的,我的意思是你可以这样做。如果你有结构化查询表,你可以通过 MCP(模型上下文协议)来表达这一点。即使后端架构发生变化,你也可以通过每次更新工具来动态实现这一点。嗯,这种类型的事情展示了 MCP 调用另一端发生的情况。所以你可以拥有一个只接受自然语言查询的 MCP 接口,然后在后端进行翻译;或者你也可以提供对数据库的直接访问界面。这取决于客户界面的样子,但确实如此。是的。>> 是的。>> 是的。

第二部分更好。关于…… >> 嗯,如何结合它的意思?或者让我确认一下我是否理解你最后一个问题。>> 是的。感觉这就像……不,完全不是这样。不,不,它们绝对是互补的。是的。我的意思是,你可以进行全文搜索来做这类事情……你知道的,那就是全文搜索,对吧?它会识别关键词。你仍然需要某种机制来进行实际的语义相似度搜索,因为那里有你所有的想法和句子被编码起来,并不一定是文本上的匹配。所以你想要那种概念性的匹配,对吧?正如我一开始所说……相似度搜索对于跳跃点来说实际上非常强大。所以你可以例如从向量搜索开始,然后在那里给它访问像这样的工具,因为它实际上会有一些有趣的起始数据,并帮助它形成查询以去获取数据。所以是的,这就是我们做的事情……抱歉你问题的第一部分被截断了。

所以你采取……你知道的,无论如何这些都是文档,它们有点像形成 JSON,所以你可以合并这些东西并查找特定的令牌……然后你知道的,提取这些令牌,这就是你提炼类型的方式。所以你可以查看源代码,你会发现至少这一版本的算法相当 hacky(临时拼凑),对吧?它会做一些事情,比如确保字符计数在二到八之间用于类型信息……然后对于枚举值,它也在查找特定的模式。>> 是的。>> 是的。

是的。>> 是的。

现在,这是一个非常重载的术语,所以我将确保我完全理解你在说什么。你是指混合搜索吗?即一个既做语义相似度又做词汇搜索,然后进行排名融合的提供商。

是的。哦,是的。是的。不……所以我们这样做。是的。答案是肯定的。>> 是的。

是的。

嗯,只是经验性的试错。我的意思是这里没有简单的解决方案。嗯……在幕后比如在 Unblocks,我们使用大量的静态架构,因为我们希望绝对确保正确性。这只是一个我想展示的技术,因为你知道的……对于数据探索或实验性的东西,或者为了连接任意数据库,这是一种很好的技术。所以这是我们通过与客户的一些试错和迭代获得的……你知道的,我们从文档存储中摄取大量内容,然后从客户那里得到回复说:“哦,你错过了我正在搜索的东西。”我们深入研究并找出原因……最后达到了最有意义的样本量。

是的,>> 这绝对是真的。是的, definitely(当然)。你总是会错过一些东西的。总是会有些文档有一些边缘的情况。是的。还有其他人吗?我以为我看到那边有个问题。哦,对。

抱歉,你能再说一遍第一部分吗?>> 提到你理解架构(schema)。 >> 嗯……是的,我们做的是强制调用工具而不是用于模式发现,因为那是在开始时发生的。它是用于名称提取的……所以我们有一个验证器来观察代理在做什么。所以如果它没有调用第一部分,也就是名称提取……嗯……这里实际上有两种技术。一种是你创建一个执行查询的工具,并将用户名作为参数接受给该工具。所以如果用户名不正确或格式错误,你可以直接失败处理。嗯……如果你想通过工具调用来进行名称提取,那么你必须观察代理在做什么。然后如果它没有调用,你基本上插入一条用户消息说:“你忘了调用提取工具。现在去做。”是的。>> 是的。

你们如何处理变化?

是的。

是的。所以记住……对于这个特定的例子,架构是完全未知的,所以当文档被添加时……那个架构可能会更新。所以你的选择要么是你知道的在采样间隔上重新运行模式生成……在我们的案例中,Unblock 的模式是静态的并与我们的代码库绑定,我们实际上控制着模式。所以如果 GitHub 决定改变……那个架构的话……当然这很少发生,因为那会对他们的客户产生严重影响。但如果他们真的改变了,那么我们就会静态地采用所做的任何更改……然后通过 CI 部署它。是的。

还有其他人吗?>> 太棒了。我想在这种情况下我们可以收回一些时间。谢谢。[掌声]

[音乐]