定制 RAG 解决方案实战示例

真实案例展示了定制的检索增强生成系统如何解决复杂的企业知识难题。

如今,RAG 有时名声不佳:有人认为它已经简单到不值一提(入门确实简单,但扩展并非如此);也有人认为它已被“智能体系统”取代(但很多时候,稍加探究就会发现,所谓智能体系统很快便显得与 RAG 十分相似……)。

本文将通过一两个完整示例,说明我们如何应对以下常见挑战:

  • 处理文本与数值混合的数据,以及它为何会让简单的 RAG 失效:关键词相互冲突,数字本身又不承载语义。

  • 为何采用“摘要优先”的嵌入设计:先为每个分块生成简短的描述性摘要,再基于摘要进行嵌入和查询。

  • 如何生成上下文摘要:加入父文档的上下文,以便区分形式相似的统计数据。

  • 何时应依靠代码和 Pydantic 模型:如果逐字保留内容至关重要,可将自定义代码和/或 Pydantic 模型与 LLM 调用结合起来,以确保可靠性。

构建定制 RAG 解决方案

基础知识

从客服机器人到内部知识助手,各类应用都由 RAG 系统提供支持。

其底层流程通常如下:

  1. 对源文档进行分块

  2. 将每个分块嵌入向量空间

  3. 查询时检索排名前 K 的分块

  4. 根据这些分块生成答案

LangChain、LlamaIndex 和 OpenAI 的 Filestore 等热门工具包,让这些步骤变得近乎易如反掌。然而在实际管线中,数据往往不只是密集文本,基础 RAG 可能难以应对。接下来,我们将展示数据挑战的具体示例,并随着复杂度逐步增加,一步步构建解决方案。

情况变得更棘手时

  1. 当数据不只是文本时(其实很常见)

来看游戏场景中的以下数据分块:

JSON

{ "Attack": { "Range": { "default": 5, "with_Draconic_Ascension": 5.5, }, "Speed": { "default": "2 seconds", "with_Draconic_Ascension": "2.2 seconds", }, "Damage": { "default": 10, "with_Draconic_Ascension": 12, } }} 

嵌入之所以有效,是因为模型通过语义和语法学习到了词语之间的关系。上述数据混合了文本和数字;一旦脱离这个特定上下文,数字与词语之间便毫无关联。因此可以说,这个数据分块基本上就是一些略具描述性的词语,后面跟着若干随机数字。

如果只有这一类数据,这其实不成问题,因为我们仍可利用少量描述性词语的嵌入进行检索(或者直接使用 text-to-SQL)。但如果这个分块淹没在大量同样出现这些词语的密集文本分块中呢?例如:

JSON

{"Draconic_Ascension": { "description": "Transform into dragon form. Attack range and speed are increased by 10%, damage is boosted by 20%.", "details": { "activation_conditions": "Can only be activated when HP is below 50%or Fury meter is full.", "visual_effects": "Wings unfurl, scales shimmer with embers, voice lines change to echoing growls.", "lore": "An ancient bloodline awakens. The bearer of the mark channels the soul of the last Flamewing Wyrm, becoming a living storm of fire and fury." } }}

现在假设我们要检索“使用龙族飞升时的攻击范围是多少?”我们很可能无法检索到所需的相关分块,因为它深埋在其他含有相同关键词的分块噪声中。

根本问题在于,我们无法有效区分这些数据分块,尽管它们针对同一主题提供了不同类型的信息。我们能否以某种方式丰富或增强这些数据?当然可以:smile:

  1. 用摘要丰富数据——没错,你没看错

与其直接嵌入分块本身,不如先生成一段摘要来描述数据内容,再基于摘要进行嵌入和检索。到了生成阶段,我们仍会使用与摘要关联的原始数据。

因此,对于上面的两个分块示例,我们会生成类似这样的摘要:

  1. 攻击范围、速度和伤害的统计数据(默认状态及龙族飞升状态)。

  2. 龙族飞升的说明与详情,包括激活条件、视觉效果和背景故事。

随后,我们还会增强查询,使其与摘要“对齐”。例如,我们会把“使用龙族飞升时的攻击范围是多少?”改成“使用龙族飞升时,攻击范围的统计数据是多少?”当检索查询来自非技术领域的用户、并以~~“随心所欲”~~普通自然语言提问时,这一点尤为重要。毕竟,他们既不了解也不关心 RAG 如何运作才能最大限度提高查准率和查全率。

说明情况何时会变得更棘手的示意图。

  1. 不要脱离上下文(这条通常也适用于生活)

接下来,再看一种情况:需要处理大量如下所示、外观相似的数据分块:

Plain Text

# Chunk one{ "Attack": { "Range": { "default": 9, }, ... }}# Chunk two{ "Attack": { "Range": { "default": 6, }, ... }}# Chunk three{ "Attack": { "Range": { "default": 7, }, ... }}

如果继续采用同样的方法,假设用户问:“角色 X 的攻击范围是多少?”面对刚刚生成的这些摘要,我们无异于凭运气猜答案,因为它们看起来也非常相似。那么,怎样才能区分它们呢?

答案很简单:提供上下文。我们可以直接在数据分块中加入对其父文档的引用,例如本例中的 {”角色”: “X”}。这样,即使角色 Y 和 Z 也有相同类型的数据,我们仍能准确检索到角色 X 的正确数据。

不过,更好且更具通用性的方法,是为分块生成上下文摘要。也就是说,不再只为数据分块本身生成摘要,而是同时传入其父文档和分块,生成整体上下文摘要,并在摘要中说明该分块与父文档的关系,例如:

  1. 此分块提供了角色 X 的……详细统计数据。该分块通过展示 X 在攻击速度方面的优势,说明其在完整文档中的作用……

  2. 此分块提供了角色 Y 的……详细统计数据。该分块通过展示 Y 使用特殊能力后提升的属性,说明其在完整文档中的作用……

  3. 此分块提供了角色 Z 的……详细统计数据。该分块通过展示 Z 非常适合在团队对战中担任坦克的属性,说明其在完整文档中的作用……

这种方法(部分灵感来自 Anthropic)对于上述示例可能显得有些小题大做,但对于脱离上下文后容易被误解的分块却非常有效。此外,它还能提供适用于所有分块的统一方法,让工程管线保持简洁。

说明情况何时会变得更棘手的示意图。

  1. 当你需要~~控制一切~~严谨行事时

通常,我们会先获得完整数据,再将其拆分成适用于 RAG 系统的分块。这个示例略有不同:数据虽已拆成分块,但分块质量很差。它们只是从一个逻辑分块中随机截取的片段,实际上需要重新组合起来。逻辑分块是指本应自然归在一起的一段内容,例如文档的一个小节或一段语义连贯的文字。

说明情况何时会变得更棘手的示意图。

我们最初尝试把所有数据都交给一次 LLM 调用,让它自行判断如何分组,然后返回分组后的内容。LLM 应该很擅长这个,对吧?这个嘛,既是也不是。

我们也曾在其他几种场景中发现,当要求返回完整、准确的内容时,LLM 往往会“偷懒”,表现并不可靠,尤其是在上下文很长的情况下。这其实完全可以理解。但这对当前用例来说是无法接受的,因为我们确实需要一字不差的准确内容——不能摘要,也不能遗漏原始内容的任何部分。任何细节都不能遗漏。

当然,“是”的一面在于,它确实非常擅长理解这些残缺分块的语义和结构。前提是它肯逐字返回原文。真让人恼火:/

那么,我们怎样才能利用 LLM 的长处,同时避开它不可靠的方面呢?我们请出了老朋友——代码(也就是自定义 Python 函数)。 再加上一个“简单得不能再简单”的 Pydantic 模型。解决方案如下:

  • 遍历各个片段,同时维护当前逻辑分块

  • 处理每个片段时询问 LLM:该片段是否属于当前逻辑分块?按照 Pydantic 模型回答“是”或“否”。

  • 如果是,则将该片段附加到分块中;如果否,则输出已经完整的当前逻辑分块,再以该片段开始一个新分块。

说明情况何时会变得更棘手的示意图。

当然,与一次性处理全部内容相比,这种方法会多使用一些 token。但对于必须优先确保原文完整保留的特定用例而言,这点额外成本完全值得。

这个解决方案非常简单,但遵循了一项重要原则:需要严谨性时,不能完全依赖 LLM,毕竟它们具有概率性。

借助自定义代码或函数以及 Pydantic 模型,我们既能充分发挥 LLM 的能力,又能获得可预测且可靠的结果。

总结

构建生成式 AI 解决方案,既是工程挑战,也是 AI 挑战。希望这些示例能给你带来启发,帮助你应对自己的独特挑战。如需进一步了解工程优先的生成式 AI 解决方案,请阅读我们的博文:基于路由器的智能体系统设计。

作者

Cynthia Yu