如今,RAG 有时名声不佳:有人认为它已经简单到不值一提(入门确实简单,但扩展并非如此);也有人认为它已被“智能体系统”取代(但很多时候,稍加探究就会发现,所谓智能体系统很快便显得与 RAG 十分相似……)。
本文将通过一两个完整示例,说明我们如何应对以下常见挑战:
处理文本与数值混合的数据,以及它为何会让简单的 RAG 失效:关键词相互冲突,数字本身又不承载语义。
为何采用“摘要优先”的嵌入设计:先为每个分块生成简短的描述性摘要,再基于摘要进行嵌入和查询。
如何生成上下文摘要:加入父文档的上下文,以便区分形式相似的统计数据。
何时应依靠代码和 Pydantic 模型:如果逐字保留内容至关重要,可将自定义代码和/或 Pydantic 模型与 LLM 调用结合起来,以确保可靠性。
基础知识
从客服机器人到内部知识助手,各类应用都由 RAG 系统提供支持。
其底层流程通常如下:
对源文档进行分块
将每个分块嵌入向量空间
查询时检索排名前 K 的分块
根据这些分块生成答案
LangChain、LlamaIndex 和 OpenAI 的 Filestore 等热门工具包,让这些步骤变得近乎易如反掌。然而在实际管线中,数据往往不只是密集文本,基础 RAG 可能难以应对。接下来,我们将展示数据挑战的具体示例,并随着复杂度逐步增加,一步步构建解决方案。
当数据不只是文本时(其实很常见)
来看游戏场景中的以下数据分块:
JSON
嵌入之所以有效,是因为模型通过语义和语法学习到了词语之间的关系。上述数据混合了文本和数字;一旦脱离这个特定上下文,数字与词语之间便毫无关联。因此可以说,这个数据分块基本上就是一些略具描述性的词语,后面跟着若干随机数字。
如果只有这一类数据,这其实不成问题,因为我们仍可利用少量描述性词语的嵌入进行检索(或者直接使用 text-to-SQL)。但如果这个分块淹没在大量同样出现这些词语的密集文本分块中呢?例如:
JSON
现在假设我们要检索“使用龙族飞升时的攻击范围是多少?”我们很可能无法检索到所需的相关分块,因为它深埋在其他含有相同关键词的分块噪声中。
根本问题在于,我们无法有效区分这些数据分块,尽管它们针对同一主题提供了不同类型的信息。我们能否以某种方式丰富或增强这些数据?当然可以:smile:
用摘要丰富数据——没错,你没看错
与其直接嵌入分块本身,不如先生成一段摘要来描述数据内容,再基于摘要进行嵌入和检索。到了生成阶段,我们仍会使用与摘要关联的原始数据。
因此,对于上面的两个分块示例,我们会生成类似这样的摘要:
攻击范围、速度和伤害的统计数据(默认状态及龙族飞升状态)。
龙族飞升的说明与详情,包括激活条件、视觉效果和背景故事。
随后,我们还会增强查询,使其与摘要“对齐”。例如,我们会把“使用龙族飞升时的攻击范围是多少?”改成“使用龙族飞升时,攻击范围的统计数据是多少?”当检索查询来自非技术领域的用户、并以~~“随心所欲”~~普通自然语言提问时,这一点尤为重要。毕竟,他们既不了解也不关心 RAG 如何运作才能最大限度提高查准率和查全率。


不要脱离上下文(这条通常也适用于生活)
接下来,再看一种情况:需要处理大量如下所示、外观相似的数据分块:
Plain Text
如果继续采用同样的方法,假设用户问:“角色 X 的攻击范围是多少?”面对刚刚生成的这些摘要,我们无异于凭运气猜答案,因为它们看起来也非常相似。那么,怎样才能区分它们呢?
答案很简单:提供上下文。我们可以直接在数据分块中加入对其父文档的引用,例如本例中的 {”角色”: “X”}。这样,即使角色 Y 和 Z 也有相同类型的数据,我们仍能准确检索到角色 X 的正确数据。
不过,更好且更具通用性的方法,是为分块生成上下文摘要。也就是说,不再只为数据分块本身生成摘要,而是同时传入其父文档和分块,生成整体上下文摘要,并在摘要中说明该分块与父文档的关系,例如:
此分块提供了角色 X 的……详细统计数据。该分块通过展示 X 在攻击速度方面的优势,说明其在完整文档中的作用……
此分块提供了角色 Y 的……详细统计数据。该分块通过展示 Y 使用特殊能力后提升的属性,说明其在完整文档中的作用……
此分块提供了角色 Z 的……详细统计数据。该分块通过展示 Z 非常适合在团队对战中担任坦克的属性,说明其在完整文档中的作用……
这种方法(部分灵感来自 Anthropic)对于上述示例可能显得有些小题大做,但对于脱离上下文后容易被误解的分块却非常有效。此外,它还能提供适用于所有分块的统一方法,让工程管线保持简洁。


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


我们最初尝试把所有数据都交给一次 LLM 调用,让它自行判断如何分组,然后返回分组后的内容。LLM 应该很擅长这个,对吧?这个嘛,既是也不是。
我们也曾在其他几种场景中发现,当要求返回完整、准确的内容时,LLM 往往会“偷懒”,表现并不可靠,尤其是在上下文很长的情况下。这其实完全可以理解。但这对当前用例来说是无法接受的,因为我们确实需要一字不差的准确内容——不能摘要,也不能遗漏原始内容的任何部分。任何细节都不能遗漏。
当然,“是”的一面在于,它确实非常擅长理解这些残缺分块的语义和结构。前提是它肯逐字返回原文。真让人恼火:/
那么,我们怎样才能利用 LLM 的长处,同时避开它不可靠的方面呢?我们请出了老朋友——代码(也就是自定义 Python 函数)。 再加上一个“简单得不能再简单”的 Pydantic 模型。解决方案如下:
遍历各个片段,同时维护当前逻辑分块
处理每个片段时询问 LLM:该片段是否属于当前逻辑分块?按照 Pydantic 模型回答“是”或“否”。
如果是,则将该片段附加到分块中;如果否,则输出已经完整的当前逻辑分块,再以该片段开始一个新分块。


当然,与一次性处理全部内容相比,这种方法会多使用一些 token。但对于必须优先确保原文完整保留的特定用例而言,这点额外成本完全值得。
这个解决方案非常简单,但遵循了一项重要原则:需要严谨性时,不能完全依赖 LLM,毕竟它们具有概率性。
借助自定义代码或函数以及 Pydantic 模型,我们既能充分发挥 LLM 的能力,又能获得可预测且可靠的结果。
构建生成式 AI 解决方案,既是工程挑战,也是 AI 挑战。希望这些示例能给你带来启发,帮助你应对自己的独特挑战。如需进一步了解工程优先的生成式 AI 解决方案,请阅读我们的博文:基于路由器的智能体系统设计。