当RAG机器人引用错误的保险免赔额时
固定大小的分块器将金额拆分到多行中;在采信结果之前,需重新构建解析结构并经过四层分块处理——从递归基准到父文档检索。
以下内容均为用于教学的虚构叙事。公司名称、人物及金额均为编造;所描述的故障模式则是受监管的保险业及其他高风险领域中生产型RAG系统实际会遇到的问题。
一个星期二的深夜,理赔主管联系了工程团队:有客服向客户称其洪水险的免赔额为500美元,而保单上明确写的是5,000美元。这款名为“Atlas”的检索增强型机器人已运行三周,配备了向量存储、强大的嵌入模型、功能完备的LLM以及精美的用户界面,但它缺乏合理的文本分块策略。
系统检索到的片段如下:
...applies to all covered perils. Deductible: $5
00 for flood events in Zone A, $500 for wind events, and $1,0
PDF提取功能导致数字被拆分到不同行中,随后固定大小的文本分块工具又在这些断点处每隔500个字符进行切割。嵌入模块为包含“$5”和“$500”字样的文本片段建立了索引。模型据此给出了答案——但却是错误的。
以下是数据摄取流程的重建方式:首先是一个设定上限的解析阶段,随后是四个分层处理步骤,这些步骤以逐渐增加的成本向该上限靠拢。
这种设计方式能确保成本核算的准确性:几乎所有的分块处理成本都是一次性的索引计算费用。该操作在离线环境下进行,即在任何用户查询之前完成。在查询的p95指标中出现的唯一与分块处理相关的成本就是嵌入模型本身的费用。较高层级所谓的“昂贵”指的是每份文档需支付一次高额费用,而非每次请求都收费。
第0阶段:你无法对在解析时已破坏的结构进行分块处理
最初的解决方案是采用更智能的分块工具。更好的第一个问题是:展示正在被分块的文本,而非PDF文件。早期Atlas的文本简直就是一堵字符墙,标题与正文混在了一起,免赔额表格也变得支离破碎,不同行的数值直接挨在一起。页面标题(“Policy Form HO-3 — Page 14 of 62”)每隔几千个字符就会重复出现。
没有任何分块工具能够恢复已被解析器破坏的边界。如果标题消失了,Markdown标题分割器就失去了分割依据;如果表格被拆解了,也就无法保持行的完整性。
应将每种输入类型与能够生成分块工具可用结构的解析器相匹配:
原生 PDF 和 DOCX 文件(保单表格、批注、指南等)。建议使用具备布局识别的解析工具——如 LlamaParse、Unstructured.io、Docling、Azure Document Intelligence——而非简单的文本提取方式。需要包含元素类型标识的 Markdown 格式(标题、表格、列表),而非纯文本。
扫描版 PDF、图片和传真。需进行 OCR 处理(Tesseract 效果最差,PaddleOCR、Textract、Document AI、Azure 的效果更好)。不仅要获取文本,还需包含布局信息以及每个区域的识别置信度。后续可通过置信度数值判断“该数值存在不确定性,不可据此计算免赔额”。
HTML 和内部维基页面。需去除多余样式,输出保留标题结构的纯净 Markdown 文本。
演示文稿和电子表格。要保留幻灯片或工作表的边界,避免将无关内容混在同一块数据中。
音频与视频(包括通话记录、网络研讨会内容)。Whisper、Deepgram或AssemblyAI应能生成标注出发言者及发言时间的文字记录。如果无法区分不同发言者,理赔员与客户之间的矛盾陈述可能会混为一谈,形成具有误导性的段落。
经过考虑布局结构的重新解析后,免赔额相关部分呈现为如下形式:
## Section 4 — Deductibles
### 4.2 Peril-specific deductibles
| Peril | Zone A | Zone B |
|----------------|---------|---------|
| Flood | $5,000 | $2,500 |
| Wind / hail | $500 | $500 |
| Named storm | $1,000 | $1,000 |
表格已返回,标题结构也已恢复。“$5,000”再次被视为一个单独的标记单元。分块代码尚未更改,因此信息检索的结果更为可靠。
第一层级:句法分块——成本低、速度快,是最佳起点
固定大小分块:意外被采用的原型方案
每N个字符或标记进行分割(CharacterTextSplitter、tiktoken)。索引开销几乎为零,适合处理突发情况,但在实际生产环境中会带来严重问题。这种分割方式会在句子中间、表格中间或数字中间切断内容,正是导致故障的典型情形。从那次事件后制定的规则是:绝不能使用固定长度的分割方式。
各团队不断发现同样的规律:用简短博客文章构成的演示语料库还能承受字符分割,但一旦出现多列结构的PDF文件或经过OCR处理的推荐内容,答案质量就会在一夜之间急剧下降。应将固定窗口视为本地实验的临时方案,并明确要求在任何外部用户访问之前替换掉它们。
重叠:一种调节手段,而非策略
chunk_overlap会将第N块的尾部内容重复到第N+1块的开头。通常设置为10%到15%(例如在长度为500个标记的块中设置为50)。对于1级和2级任务而言,这种重叠是一种低成本的保障措施;但它并不能解决边界不合理的问题——只是将其重复而已。如果匹配内容位于重叠区域,top-k结果中的向量数量和重复匹配项会增加约10%。
当相似内容在邻居列表中占主导地位时,重排算法和大型语言模型会浪费上下文窗口,对同一句话进行处理两次。如果出于其他原因必须保持较高的重叠比例,可以通过父节点ID或近乎相同的文本哈希值来进行去重,这是一种成本低廉的缓解方法。
句子/段落模式:遵循语法规则,忽略文档整体结构
可将整句话打包到预算中(NLTK、spaCy、LlamaIndex的SentenceSplitter功能)。不会在句子中间断开——这样就不会导致换行后的数字被分割——但它无法识别标题、表格或排除项列表。在电话录音转录方面表现优异,而在结构化的政策表格处理上则效果一般。
递归字符分割:首先使用的默认方式
RecursiveCharacterTextSplitter会按优先级尝试不同的分隔符——空行、换行符、句子、单词——只有当某部分仍过大时才会另寻方法,随后将较小的部分合并至接近chunk_size的大小。常见的基准设置为500个标记,重叠率为50%:
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(policy_markdown)
在重新解析后的免赔额相关部分,系统保留了全部4.2的内容——包括表格在内——因为空行自然形成了一个低于500个标记的单位。该漏洞由此消失。应将recursive-500/50作为衡量标准,而非最终设计成果来使用。
把这一基准分数写在白板上,拒绝那些在相同测试题上无法超越该分数的“更智能”的分块算法。许多看似出色的复杂方案,在与简单的递归分割器在标准Markdown格式下对比后就会显得平平无奇。
第二层级:具备结构意识的分块——在文档已有分隔处进行切割
使用约80道真实问题的测试集时,recursive-500/50的准确率约为71%。错误主要出现在内容较长的段落以及模型无法判断答案源自哪项政策或哪个部分的情形中。
Markdown/HTML标题的分割
MarkdownHeaderTextSplitter / HTMLHeaderTextSplitter 会依据标题层级进行分割,并将标题路径纳入元数据中:
from langchain_text_splitters import MarkdownHeaderTextSplitter
header_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
)
sections = header_splitter.split_text(policy_markdown)
# then apply the recursive splitter inside each section
从4.2小节拆分出的每个片段都应包含类似“表单HO-3 → 扣除项 → 风险特定表格”这样的导航路径。在嵌入文本之前将这些导航路径拼接上去,这样向量就能同时编码位置信息与数字信息。采用“哪个章节?”类的查询方式会失败;引用时可以写明“参见4.2节”。成本几乎为零——但前提是阶段0已生成真正的markdown格式。扁平化的文本会悄无声息地变成一个巨大的整体。这种方法最适合用于维基、技术文档以及带有标题的结构化政策文件。
元数据字段也应随向量一同传递:源文档编号、章节路径、生效日期,以及如果政策因州而异时的管辖区域信息。这些字段能够支持纯相似度搜索无法实现的过滤功能(例如“仅显示2024-01-01之后生效的HO-3”)。
考虑布局结构/按标题分割
非结构化的chunk_by_title(以及LlamaParse/Docling中的类似工具)会尊重解析器识别的元素边界:表格保持完整,标题作为分块起点,列表则保留其开头句。这类工具非常适合处理合同和复杂的PDF文件。解析API在索引时需一次性付费,而廉价的上游解析器则能大幅降低成本——无需为没有引擎的汽车购买昂贵的方向盘。
考虑代码结构(AST)
对于政策文本集而言无关紧要,但对于Terraform/Python RAG应用则极为重要。按行分割可将签名与正文分开。tree-sitter或具备语言识别功能的递归分割工具则会依据函数/类节点进行切割。当文本集为代码仓库或基础设施即代码文件时,此模式为默认选择。
若将普通文本策略与源代码混放在同一个索引中且不使用分隔器,通常会陷入两难境地:函数内容会被截断,而策略表则因基于大括号的规则而被破坏。
第三级:基于模型的分块——仅在结构缺失时才进行人工判断
在黄金数据集上的准确率约为84%时,出现了一种新的错误类型:没有标题的无结构通话记录。递归生成的500词长度的块会跨越主题变化点——比如先涉及保险索赔内容,接着又转到邮寄地址——因此嵌入向量平均包含两个主题,无法准确对应任一主题。
语义分块
逐句嵌入向量;当连续句子的余弦相似度降至阈值以下时进行分割(可使用SemanticChunker或任何可靠的嵌入模型)。在索引阶段仅进行一次嵌入处理。该方法适用于语音数据,且阈值因语料库而异。电话通话中的高阈值会将冗长的政策文本拆分成极小的片段——因此仅在对非结构化语音数据使用语义分块,而政策文本则应采用二级处理方式。
根据文档类型——语音、表格或维基——进行路由处理,比寻找通用的分块工具更有效。其实现成本仅为在数据导入时添加一个分类器或简单的文件类型转换机制,但能减少混乱的嵌入结果。
命题/原子事实分块
小型大型语言模型可将段落重写为独立的陈述句(“HO-3保险中A区的洪水免赔额为5,000美元”)。由于每个单元都是包含上下文的事实,因此准确性更高。处理每段文本只需调用一次大型语言模型,对于像40页长的常见问题解答这类规模较小但精度要求较高的文档而言,成本十分合理。不过存在一个隐患:这些陈述句可能会偏离原文表述。当客户对答案提出异议时,直接引用原文比意译更为有效。应将这些陈述句作为检索辅助工具,同时提供原始段落以便引用。
合规与理赔机构最终会要求查看答案对应的页面图片或PDF高亮内容。应尽早设计引用标识,这样这些陈述句索引才能起到加速检索的作用,而不会成为唯一的记录系统。
智能分块
将整份文档交给旗舰模型来选择分割边界。这种方法可行但扩展性差:成本会随语料库规模呈线性增长,而价值却不会。适用于几百份核心文档,但不适合数百万份。
第4级:检索时分割——先搜索小单元,再返回大单元
第1–3级假设索引单元即为返回单元。这会导致一种错误的权衡:小分割单元虽然能生成清晰的嵌入,但会限制大语言模型获取上下文;大分割单元虽能提供上下文,却会使嵌入变得模糊。不断调整chunk_size也永远无法找到通用的最佳值。
第4级则区分了不同角色:一个为嵌入器设计的搜索单元,以及一个为大语言模型设计的交付单元。简易判断标准:如果需要额外的存储结构(如文档存储、父节点映射表或树结构),那就属于第4级。
父文档/从小到大
为子项建立索引时使用150–200个词条。匹配成功后返回层级较高的父项(整个4.2节或约1,500词条的子部分):
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1500)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=InMemoryStore(), # the "second store"
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
retriever.add_documents(policy_docs)
LlamaIndex的AutoMergingRetriever也提供类似的层级结构。额外的索引成本几乎为零——只需将相同文本拆分成更小的片段即可。在测试集中,仅这一改进就让Atlas的准确率从约84%提升到约91%:“我的洪水免赔额是多少”这一查询能直接匹配到对应的表格行,而大型语言模型仍能看到周围的注释(包括A区的定义)。运营成本方面:需要通过父项ID对文档库进行索引,并在内容更新时保持同步。
此处删除操作和版本控制非常重要:当4.2节被修改时,子项和父项必须同时更新,否则系统检索到的精确子项对应的父项可能会过时。应将数据导入视为对父项、子项及嵌入向量的整体事务性发布,而非三个独立的操作。
上下文检索
在嵌入之前,可以先让小型大语言模型生成一两句背景说明语句并放在开头;再与BM25结合使用。像“A区:5,000美元 / B区:2,500美元”这样的信息就能被检索到。成本为每个数据块调用一次大语言模型——如果不进行提示词缓存的话成本会非常高;而通过缓存,文档开头部分的信息可以保持活跃状态,从而降低每个数据块的调用成本。
后期分块
使用长上下文嵌入模型对整个文档进行嵌入,然后在每个数据块范围内对词向量取平均值。这样无需为每个数据块单独调用大语言模型,数据块向量就能承载文档上下文。该方法与基于上下文的检索方式具有竞争力,但会限制用户只能使用长上下文嵌入模型。
分层/RAPTOR
将数据块聚类、汇总,再将这些汇总结果重新聚类为树形结构;对每一层都建立索引。宏观问题对应汇总节点,具体细节则对应最底层的叶子节点。由于层级最多为4级,当文档内容发生变化时需要重新构建结构,成本高昂且十分麻烦。由于需每季度更新政策,RAPTOR成为Atlas的首选方案。
静态知识库——即每年更新一次的内部百科全书——能够承受树形结构的重新构建,而动态的保险表格则无法做到。只有在明确变更频率和重建预算的情况下,才应选择分层方法。
重新构建后的方案说明
在Slack演练结束六周后,Atlas在约300个问题的测试集中取得了近93%的准确率。以下为简化的设计评审版本:
首先采用递归字符分割方法,并在大约500个标记和50%的重叠率处进行基于结构的切割——这几乎不耗费成本,却能提供一个可测量的基准。接下来先升级到父文档检索功能,这样匹配结果和输出规模就可以有所不同。只有在此之后,如果评估结果显示召回率仍有问题,才需要考虑使用上下文检索。
以上方法都无法挽救糟糕的解析结果。在调整分割器之前,应先修复数据提取问题。
单页版本
第0阶段——解析。将相应的解析器与输入内容匹配:原生文档使用结构化Markdown;扫描件使用文本+布局+OCR置信度信息;HTML文档使用格式规范的Markdown;演示文稿使用幻灯片/页面边界信息;音频文件则使用按时间分段的转录文本。这为后续处理设定了上限。
第一级——句法层面。仅适用于原型,大小固定。重叠度通过旋钮调节(10–15%),过高会导致重复的top-k结果出现。句子分割遵循语法规则,忽略文档逻辑。递归式500/50是默认基准,并非最终标准。
第二级——结构感知型。标题分割会保留章节路径,效果取决于解析器的质量。by_title布局方式能保持表格完整,但低质量的解析器会破坏这一特性。代码仓库则采用抽象语法树进行分割。
第三级——基于模型。通过相似度进行语义分割,需根据具体语料库进行调整。命题式方法在小型语料库中能提升精确度,但容易受文本表述影响。代理式分割在大规模应用中很少具有可行性。
第4级——检索时间。针对小单元进行匹配,传递大单元内容。父文档是投资回报率最高的升级方案。上下文检索需要缓存支持。延迟分块成本更低,但受嵌入器限制。RAPTOR在更新后会过时。如果需要第二个存储系统,则属于第4级。
那次值得反思的事件其实并非关于选择LangChain还是LlamaIndex,而是要在做数学运算之前先尊重文档结构,用真实客户问题来测试分块工具,并且拒绝使用那些根本无法包含完整数值信息的模型片段。
应使用那些已经让产品出问题的数据项来构建黄金数据集——比如错误的免赔额、缺失的除外条款、混乱的附加说明——而非凭空编造的琐碎信息。需分别评估答案的正确性及引用信息的准确性,这样即便出现错误数值也不应被视为正确结果。当有新的模型版本推出时,必须确保其在两项指标上都优于现有基准,才能投入实际使用。
最后,要设置一个紧急停止机制:如果对某段数字内容的OCR识别置信度较低,或者父版本与子版本之间存在差异,助手应拒绝回答相关免赔额问题并上报问题,而非随意编造置信度数值。相比一个随意给出的错误五百美元答案,用户更能接受“无法从提交的表格中验证该信息”的回应。这类拒绝处理流程应纳入产品需求文档,而非等到问题发生后才放在事后分析的幻灯片中。