设计实用的RAG处理流程:分块、过滤与流式处理
基于实际应用的RAG框架详解:结构感知的分块处理、安全的语素分割、三阶段检索过滤、片段重组、提示词设计以及流式处理。
通用语言模型在处理推理、写作和解释任务时表现极为出色,但一旦涉及它们从未见过的系统——比如你的内部文档、行业工作流程,或是团队每天使用的企业应用的具体运行方式——它们就力不从心了。这类知识并不存在于训练数据中,也不存在任何提示技巧能将其注入模型。更糟糕的是,模型很少承认自己的不足,反而会给出流畅且看似可靠的猜测。以下是基于现实数据的检索增强生成(RAG)系统的基础架构:包括文档的准备工作、信息块的构建方式、候选答案的筛选流程、提示语的设计方法,以及答案如何传递给用户,同时还会说明每项决策背后的逻辑,以便你将其应用到自己的系统中。
为何现实数据会改变模型的工作方式
RAG的原理很简单。不必依赖模型记住答案,而是将答案直接提供给模型阅读。真正的文档被拆分成可搜索的片段;当有问题出现时,系统会找出最有可能回答该问题的段落,并将其放入模型的上下文中。任务从“回忆这个事实”转变为“阅读这段文字并清晰解释它”,而这正是语言模型能够可靠完成的类型的工作。
这个概念很简单,但要让它良好运行却并非易事。获取正确的信息、保持信息的连贯性以及快速响应以避免用户一直盯着加载提示看,这些都需要做出一些容易被低估的决策。此处描述的系统仅处于基础阶段:先获取信息,再给出答案。更为复杂的扩展功能,比如知识图谱或能够使用工具的智能体,都是建立在这些基础要素之上的,只有当基础扎实时才能正常运行。
Markdown作为事实来源
系统所回答的所有知识都存储在 Markdown 文件中。这一选择是出于实用性而非美观考虑。Markdown 具备足够的结构来保留信息含义,包括标题、层级结构、列表和表格等,同时它仍然是纯文本,可以方便地被分割并嵌入到其他内容中,无需处理复杂的标记格式。
这些文档的编写方式符合人们撰写技术文档的自然习惯:每个主题一个标题,下级内容用子标题区分,结构复杂的部分使用表格,而当图片能比文字更快地解释问题时则使用截图。文档的编写风格并不会为了适应自动化流程而改变。这一点很重要,因为那些必须为检索系统编写的文档往往根本不会被撰写出来。
避免将截图纳入嵌入路径
截图需要单独的设计考量。在应用文档中,它们并非装饰元素:大量操作指南类问题实际上是在询问该使用哪个按钮或菜单,而仅靠文字很难有效解答这类问题。
文档文件中并不直接嵌入图片,而是将图片单独存储,每个 Markdown 文件通过 URL 引用这些图片。链接仅为文本形式,因此会像其他文本一样经历分块、嵌入和检索过程。当包含此类链接的分块被纳入答案中时,前端会在渲染时读取 URL 并加载图片。用户看到的文档与原文一致,包括其中的图片,而整个摄取和检索流程根本不会处理图片数据。
这一原则值得明确表述:在所有仅需文本处理的阶段都保持内容为文本形式,直到即将展示给用户之前才处理更复杂的媒体内容。
边生成边流式输出答案
在提供答案时也适用同样的“最后一步”思维方式。一旦开始生成内容,就没有理由让用户等待完整的回复。模型在生成内容的同时会通过持久连接将标记流回,因此答案会像有人逐字打字解释一样一点点形成。再加上图像会在其URL出现在数据流中时异步加载,整体体验就像一场对话,尽管实际上这只是先进行数据库查询再生成内容而已。
分层分块:尊重文档的结构
在能够进行搜索之前,必须先将文档划分成可以独立嵌入和检索的单元。许多RAG系统恰恰在这个环节偷工减料,而其后果往往容易被忽视。
为何固定大小的分割方式会悄无声息地失败
那种简单的方法很机械:选定一个词元数量,将文档切成大致相同大小的片段,然后继续处理。这种方法构建起来很快,但其错误方式却不会引发任何异常。固定大小的窗口根本无法识别句子边界,更不用说概念层面的边界了。它可能会把带编号的步骤一分为二,让表格与赋予其意义的标题分离,或者把定义放在一个片段中,而示例则放在另一个片段里。
这样的片段虽然长度合适,但结构有误,而检索质量恰恰取决于结构。如果检索到的文本不包含完整的思想,后续再怎么精心引导也无法弥补这一缺陷。模型只是在处理碎片化的信息,因此答案也会反映出这一点。
改用标题树进行读取
分层分块法基于这样一个认识:文档本身已具有结构,而这种结构正是宝贵的资源。编写良好的技术文档会形成一种标题树结构:顶层为主要章节,其下嵌套子章节,各层级还包含对应的正文内容。分块工具并非忽略这种树结构,而是沿着它进行处理:
- 每个主要章节都会被单独作为一个分块。
- 每个子章节也会被单独作为分块,但会记录其所属的父章节信息。
- 如果某个子章节的长度较短,与父章节放在一起阅读效果更好,则两者会合并为一个整体分块,而非生成内容过少且缺乏上下文的碎片。
- 那些不属于任何标题下的内容,如引言、零散的段落或注释,也会被单独作为一种分块类型保留下来,而不会被丢弃或强行塞进相邻的分块中。
让后续处理成为可能的元数据
每个数据块包含的不仅仅是文本:
- 路径指引:祖先标题的链式结构。即使单独获取,关于某个配置选项的数据块也仍能知晓自己属于更广泛的工作流程。
- 类型标签:用于区分顶层章节、嵌套细节、合并的父子数据块以及独立笔记。
- 稳定标识符:将数据块与其在源文件中的确切来源关联起来。
这些元数据并非装饰性内容,正是它们使得后续的重新排序、拆分部分的重组以及生成连贯且可追溯的答案成为可能。单纯由大小相同的文本块构成的列表无法实现这些功能,而清楚知晓自己在结构树中位置的数据块则可以。
其核心权衡在于遵循文档已有的结构,而非强行叠加某种任意的结构。这样做能很快见到成效:因为这些片段本身就是完整的思想,且其范围也是由文档编写者所设定的,所以阅读起来就像完整的思想一样。
针对嵌入令牌限制的适应性二次处理
分层分块方法虽能确定正确的结构,但却没有意识到一个根本性的限制:嵌入模型每次只能处理有限数量的令牌。而文档的结构并不受此限制影响。一篇较长的常见问题解答、一个庞大的参考表,或是内容过长的小节,虽然可以构成逻辑连贯的片段,却仍可能超出模型的处理能力。
这种故障的表现极为隐蔽且危险。根据客户端的不同,过大的数据块要么被直接拒绝,要么在嵌入前被悄悄截断,在这两种情况下,内容都在数据接入时悄无声息地消失了。截断带来的后果更为隐蔽,因为该数据块依然存在且仍会被获取;只是其向量已不再能代表你原本认为的文本内容。
解决办法是在分层处理流程之后再进行一次检查。每个数据块都会与设定在模型实际最大容量远低于此处的安全阈值进行比对,这样就能形成缓冲区而非断崖式边界。不同分词器的令牌计数存在差异,而预设的余量可以弥补这种不确定性。低于阈值的数据块会原样通过;高于阈值的数据块则会进一步拆分,每段拆分后的内容都会:
- 被明确标记为更大整体的一部分,以便后续处理阶段能够找到它的关联部分
究竟应在何处进行分割,以及为何即便在这种情况下简单的切割方式也不够理想,这都是一个重要的设计课题。从根本上说,关键在于分层处理能够确保文本结构,而自适应处理则能保证其适配性,这样良好的结构就不会以牺牲内容为代价。
存储向量及其元数据
嵌入式数据块需要一个专为某个问题设计的存储方案:即如何快速且大规模地找出这些向量中与新向量最相似的那些。关系型数据库并非为这类查询而设计,因此需要专门的向量存储系统来承担这项任务。
该数据库运行在容器中,而非直接安装在主机上。这样做的原因很实际:容器化的实例易于启动、销毁或转移到其他机器,且不会为主机系统带来任何依赖负担。
对于每个向量,该存储系统还会保存完整的元数据信息,包括对应片段的文本、路径指引、类型以及来源标识。因此,每次相似性匹配结果都会附带所有必要的上下文信息,而无需再作为匿名向量处理。将快速的相似性搜索与每个结果附带的丰富元数据相结合,才能实现过滤、重新排序以及相关内容重组等功能,而无需再次查询其他数据源。
请求生命周期:从问题到流式答案
这正是系统执行核心功能的地方,因此有必要进行足够精确的描述,以便该逻辑能够被重复使用。
分离提交与流式处理
该 API 提供了两个承担不同任务的端点。第一个端点接收问题并立即返回对话标识符;第二个则是流式处理端点,前端会使用该标识符与之连接。接收问题与提供答案是两项不同的职责,将它们分开处理可以让响应逐步返回,而无需保持单个请求处于开启状态直至生成完整的回复。此外,这也能让客户端更方便地重新连接或关联特定对话的日志。
在这两个端点之间,会按顺序执行以下步骤。
第一步:使用输入模型嵌入问题
用户文本的嵌入会使用与数据摄入时完全相同的模型。这是一个不可更改的细节。只有当查询向量与存储的向量处于同一个向量空间时,相似性比较才有意义。如果嵌入模型发生任何变化,所有存储的文本片段都必须重新进行嵌入,因为即使描述的是相同的内容,来自不同模型的向量也是无法比较的。
第二步:广泛搜索
查询向量会与存储的文本片段向量进行比较,最终选出大约十二个最相似的结果。所使用的度量标准是余弦相似度,它本质上衡量的是两个向量之间的角度:角度越小,含义的相似度就越高。这一阶段的设计较为宽松,其目的是确保在正式筛选开始之前不会遗漏任何相关内容。
第三步:通过三道筛选将候选项缩小范围
随后会依次应用三个过滤机制,将最初的十几个候选项缩减为真正有价值的少数几个:
- 相似度阈值。低于最低分值的候选项会被剔除。这类内容之所以能进入列表,只是因为没有更合适的选项,保留它们只会增加冗余信息。
- 重新排序阶段。通过一种与初次搜索不同的模型对剩余候选项进行重新评分。该模型不会分别将问题与每个片段嵌入后再比较向量值,而是将问题与某个片段作为整体输入,判断该片段是否真正能回答问题。这样可以排除一种特定的误判情况:即在向量空间中与查询内容相近,但实际阅读时却无法提供答案的片段。
每个筛选环节都有其特定功能:第一层用于防止噪声干扰,第二层提升精确度,第三层则对输入模型的上下文量设定严格限制。最终保留的只是最优质的材料,而不仅仅是长列表中的顶级条目。
采用这种排序方式的原因是成本考虑。配对阅读重排方法虽然比向量比较更准确,但成本也高得多,因为它需要同时处理每一对问题片段。如果在经过预筛选的十几条候选内容上运行该方法,而非整个语料库,就能在无需承担过高成本的情况下获得不错的精确度。
第4步:恢复分割后的部分
部分保留下来的片段仅是自适应处理过程中不得不拆分的某个章节的局部内容。如果模型在没有任何其他部分存在的提示的情况下仅收到三个部分中的第二部分,它的回答就会基于不完整的信息。因此,在组装提示语之前,会先检查每个保留下来的片段是否有对应的同源部分,即同一原始章节的其余部分。一旦找到这些对应部分,就会将它们按顺序提取并标注上各自的位置。这样模型就能读取整个章节而非仅其中的一部分。正是在这个环节,输入时附加的部分标签和稳定标识符发挥了作用。
第5步:组装提示语
所有检索到的片段及其对应的完整内容共同构成了上下文基础。用户的提问会与这些内容一同传递,而下一节将介绍的系统提示则会告知模型其应扮演的角色以及如何使用这些材料。
第6步:将答案流式返回
生成的文本会通过客户端最初连接的流式接口逐段返回,而非以一个完整的响应形式一次性送达。用户可以实时看到答案的生成过程。
总结来说:先将查询向量化,再进行广泛检索,通过三道过滤机制筛选内容,补充缺失的部分,向模型发出提示并接收其输出。这些步骤本身并无特别之处,质量取决于各阶段之间的顺畅衔接以及为每个阶段明确指定的唯一职责。
提示词设计:格式、模型与语气
当提示词被完整构建时,那些复杂的检索问题就已经得到解决:合适的文本片段已被找到、筛选并重新组合。剩下的部分同样容易出错——需要以恰当的方式呈现这些材料,让模型给出优质答案,而不仅仅是技术上正确的答案。
用 Markdown 编写提示词
提示语本身是用 Markdown 编写的。原因同样出于实用性考虑。模型在文档、README 文件以及技术写作中已经见过大量 Markdown 内容,因此以熟悉格式呈现的指令比需要模型自行解码的定制结构指令更容易被准确解析。标题用于分隔提示语的不同部分,检索到的上下文会与周围的指令清晰区分开,而从拆分后的各部分重新组合的内容会有明显的标记,这样模型就能将“由多个部分构成的连贯思路”与普通的检索文本区分开来。
选择更小、速度更快的模型进行合成
生成最终答案的模型是规模较小、速度较快的那种,而非现有的最大型模型。这看似违反常理,但只要了解它实际承担的任务就会明白原因。它并不需要从零开始推导任何内容,也不需记住罕见细节或弥补知识缺失——这些繁重的工作早已在信息检索和筛选阶段完成。它剩下的任务就是利用精心挑选、有序组织的上下文,以恰当的语气将其转化为清晰简洁的答案。
那是一项综合任务。当上下文已经具备足够信息时,一个较小的模型在回答质量上就能接近更大的模型,同时响应速度更快、成本也更低。当模型需要自行推导答案时,其更强的推理能力就值得投入成本;而当答案已经存在,任务只是要求对其进行清晰解释时,这种能力的价值就大大降低了。这种权衡取决于检索质量:上下文越薄弱,大型模型处理模糊信息的能力就越重要,这也是在过滤阶段投入资源的另一个理由。
精心设计的提示结构
系统提示遵循固定结构而非临时拼凑的形式:
- 角色定位。首先明确告知模型它属于何种类型的助手以及服务的领域,这样从第一行就能确定其语气和假设前提。
完全不依赖模型对“高效助手”应具备何种特性的默认认知。每一项行为都有详细规定,就如同在分配任务前先向新成员解释团队的沟通规范一样。
其优势在于:在有依据时能自信作答,没有依据时会坦诚说明,并且在每次对话中都能保持一致的语气。这种一致性并非来自模型本身,而是源于围绕它的提示词。
按意图路由:常规片段与屏幕片段
看似相似的问题其实可能要求完全不同的内容。像“此工作流中的定价是如何计算的?”这样的问题属于概念性疑问,需要的是解释说明;而“此界面上的哪个字段用于输入价格?”则是操作指引类问题,需要的是具体的字段、按钮或界面区域。尽管这两个问题都可能从文档的同一部分获取信息,但理想的回答却几乎毫无关联。若将它们一视同仁,对于只需要知道位置的人来说就会得到冗长的概念讲解;更糟糕的是,当用户需要确切的位置时,却会收到抽象的界面元素描述。
在信息收集阶段区分不同类型的知识
这种区分是在数据块层面进行的,而不仅仅在回答时才体现。除了常规的散文式文档数据块外,还有另一类数据块用于描述界面及其各个字段:每个字段的名称、功能以及它在同一界面中相对于其他字段的位置。这些内容并非在事后才添加标签,而是在数据被录入时就已经完成分类,因此当问题出现时,已有两套清晰分离的知识库,而非混杂在一起的一堆数据。
查询时选择回答方式
当有问题出现时,系统会判断它实际上需要的是哪种类型的数据块:概念性文档还是具体的界面及字段细节。这种分类会影响检索时优先选取哪些数据块,以及模型会收到怎样的回答提示:
- 针对界面元素的设计型问题,会得到注重精确度的提示:用词直白具体,明确指出位置与内容。
- 针对概念型问题,则会得到注重解释的提示:范围更广,更倾向于将不同想法联系起来。
两种情况下的检索流程与生成模型都是相同的;只有作答方式会随之改变,根据问题的需求来选择合适的作答方式,而非强制所有答案都遵循同一通用模板。
这比听起来更重要。即便检索能力再强,如果助手只采用单一的回答风格,其回应也会显得千篇一律。只有识别用户的意图而不仅仅是主题,才能让助手真正体现出对用户实际提问的重视。如果采用这种模式,还需为含糊不清的问题准备备用方案,例如默认采用概念性回应方式并纳入所有高度匹配的界面内容,这样即便分类出错也能妥善处理,而不会产生错误的回答风格。
控制记忆容量
如果服务逐渐耗尽内存,那么谨慎的检索和生成操作也将毫无意义。那些嵌入文本、保持数据块在内存中、反复组合提示词并输出结果的进程,有时甚至会同时进行这些操作,除非有机制主动管理,否则就会悄悄积累内存压力。那些仅用于单次请求的对象,在没有人为清理的情况下往往会比应有的时间更久地留在内存中。
因此,内存清理绝不能靠运气。在自然的边界点,比如请求结束或数据批量处理完成时,会主动回收内存,而非指望其自行释放。实际上,这就意味着要断开对大型中间对象的引用、清空每次请求的缓存,而对于本地运行的模型,则要释放运行时所占用的所有内存。
这是一项并不光鲜的工作,它从不会出现在演示中,只有在其缺失时才会被注意到:这类服务的性能会随着运行时间的延长而逐渐下降,无法像处理第1次查询那样快速响应第1000次查询。要做好这项工作,关键不在于巧妙的工程设计,而在于自律性——要将内存视为每个阶段都需要管理的事物,而非认为运行时自然会处理好。通过长时间的持续测试来观察内存使用情况,而不仅仅是短暂的基准测试,才是验证这种自律性是否有效的最简单方法。
核心要点
此处描述的基础架构是一个完整且实用的RAG流程:具备结构感知的文本分块、对标记安全的嵌入处理、多阶段过滤机制、拆分部分的重新组合、能够引导模型如实展现其认知水平的规范提示词,以及实时结果输出。其中最具决定性意义的选择包括:
- 将文档保存为结构化的纯文本格式,仅在渲染时处理图片及其他媒体文件。
- 按照标题结构对内容进行分块,并为每个分块添加导航路径、类型标签以及稳定标识符。
- 增加第二轮处理,通过预留安全余量、标注关键部分以及控制小范围重叠来确保符合嵌入模型的令牌限制。
- 始终使用数据摄入时所用的相同模型来嵌入查询,且当该模型发生变化时需重新进行嵌入处理。
- 先大量检索结果,再通过相似度阈值、配对重排算法以及严格的最终筛选标准来缩小范围。
- 在向模型提问之前先重新组合拆分后的各部分,避免模型对未加标注的片段进行推理。
- 在检索完成繁重工作后,让一个体积更小、速度更快的模型负责合成工作,并明确指定其角色、任务、缺失数据处理方式以及语调要求。
这些措施单独来看都较为简单,但结合起来就能构建出一个答案可靠、连贯且快速的系统,同时为日后采用更复杂的检索技术奠定稳定基础。
相关阅读
- 超越 Top-K:RAG 中的相关性阈值、混合搜索与重排序 — 了解为何仅靠向量数据库和大型语言模型无法构成成熟的 RAG 系统,以及如何通过分块、相似性阈值、混合搜索、重排序和评估来弥补这一缺陷。
- 为何生产环境中的RAG正在超越专用向量数据库 — 探讨了为什么独立向量数据库在生产环境中的RAG系统中逐渐失势,以及混合检索、过滤和统一数据层如何重塑这一技术架构。
- RAG架构解析:流程阶段、核心组件及不同版本类型 — 了解检索增强生成技术的端到端工作原理,RAG系统需要哪些组件,以及各类不同的RAG版本如何归类于同一框架之下。