文件明明写着“签收三十天内,经核实属于质量问题,运费由商家承担”,系统回答却只剩“商家承担”。在检查模型之前,先把入库片段打印出来。如果片段本来就只有最后半句,生成器甚至没有机会读取条件。
RAG 的知识库不是文件夹的原样复制,而是一次有损转换的结果:文件变成结构块,结构块变成片段,片段再进入检索。每个阶段都需要说明保留了什么、丢掉了什么。本篇围绕虚构售后政策,走完这个过程。
PDF 里的字为什么不一定按阅读顺序排列
文本 PDF 可能保存的是文字和页面坐标,提取工具需要恢复阅读顺序。双栏页面如果按横向坐标拼接,可能把左边的退款条件与右边的发票说明交错起来。文本能被复制,只说明有文本层,不说明提取结果已经适合阅读。
扫描 PDF 则可能只有图像,需要 OCR。识别文字以后仍要处理段落、列、页眉页脚和表格。OCR 将“三十天”识别为“三天”,或漏掉“不予报销”的“不”,会直接改变业务结论。全篇字符准确率很高也可能掩盖这几个关键错误。
因此保存页面号、原图区域和文本块位置很重要。关键条款出现争议时,能回到原页核验;只存一段清洗后的纯文本就失去这条路径。用多模态模型读图也不消除这个要求,它仍可能漏字或解释错误。
网页还可能有导航、隐藏菜单和重复版权说明。清洗目标是移除不参与任务的模板内容,不能把“仅限活动期间”误当广告删掉。代码文档里的缩进和列表编号也可能表达结构,不宜先全局去空白再考虑含义。
表格要保留行与列的关系
设想表格列为“产品、质量退货期限、运费承担方”,P-204 行为“三十天、商家”,P-205 行为“十五天、商家”。只取出“十五天,商家”既不知道产品,也不知道期限属于哪类退货。
一种适合文本检索的表示是将每一行展开为“产品 P-205;质量退货期限十五天;运费承担方商家”,同时保留表名与原页。但若标题里还有“以下规则仅适用于已核实质量问题”,每一行也需要继承这个条件。
合并单元格与脚注尤其容易丢信息。脚注可能只适用于带星号的产品,不能复制到全表。对于数值汇总任务,表格应保留结构化数据,由程序计算;文本片段可以解释业务含义,不应承担全部聚合运算。
图片说明同样要与所属图、章节关联。单独检索“红灯表示异常”不够,读者还需要知道是哪台设备、哪个指示灯。若图片本身承载无法文字化的信息,可以保留原图引用与文字说明,后续由支持图像的流程读取。
切分真正要平衡什么
小片段更容易匹配具体问题,却可能丢掉定义和条件;大片段保留上下文,却可能把多个主题揉成一个向量,并占用更多生成窗口。没有一个固定长度同时解决这两个问题。
固定长度切分便于实现,但不认识句子边界。递归切分通常依次尝试段落、句子等分隔,超长时再细分;它尊重表面结构,却不保证业务条款完整。结构切分依据标题、列表、表格和完整条款,更贴近文档组织。
语义切分可以根据相邻句表示的变化寻找边界,但语义相近不一定属于同一规则。两个产品的相似条款可能需要分开,同一条款的例外段却需要一起读取。它是候选策略,仍应通过真实问题和片段审阅来选择。
重叠用于让边界附近的信息出现在相邻片段。设块长 500 Token、重叠 100,每次向前推进约 400;它增加冗余,可能改善跨边界召回,也可能让重复证据占据候选名额。重叠不能保证跨页脚注或远处定义被带回来。
检索小片段,阅读完整条款
父子片段把检索单位与阅读单位分开。子片段突出“质量问题退货运费”,便于匹配;通过 parent_id 回取包含期限、例外与定义的完整父条款。父条款仍有上限,否则每次命中都回到整本手册。
句子窗口是相近思路:先定位一句,再取邻近几句。但窗口可能跨进下一产品的规则,所以最好结合章节边界。检索命中哪句、实际回填哪些内容,都应记入日志,才能解释模型最后看到什么。
假设命中“商家承担运费”,回填后恢复“三十天内,经核实属于质量问题”。这时正确回答得到了必要前提。若回填又混入“普通退货七天”,应保留两个条款各自标题,避免生成阶段把条件交叉组合。
片段身份与文本内容要分别管理
document_id 标识文档,version 标识某次内容版本,chunk_id 标识该版本下的片段;page、section、offset 或区域坐标用于回到源文件。内容哈希适合检测内容变化,却不应单独承担权限、版本和来源身份。
两个店铺可能发布完全相同的文字,但访问范围不同;两个版本的同一句话也可能有不同有效日期。如果只按文本哈希全局合并,会让来源和权限丢失。去重可以共享底层内容存储,但仍要保留每个来源的独立身份与授权。
下面使用已整理的结构块,展示片段怎样带上来源。它没有实现 OCR,也没有把人工块伪装成解析器输出。chunk_provenance.py下载可独立下载。
"""对人工整理的文档块构造带来源的片段;不执行 PDF 解析或 OCR。"""
from hashlib import sha256
# 假设解析器已按阅读顺序输出这些块;page 和 heading 来自源文件。
blocks = [
{"page": 1, "heading": "质量退货", "text": "签收三十天内,经核实属于质量问题的退货,运费由商家承担。"},
{"page": 1, "heading": "普通退货", "text": "签收七天内且商品未使用、包装完好,运费由买家承担。"},
]
document_id, version = "after-sales", "v1"
chunks = []
for position, item in enumerate(blocks):
# 用文档身份、版本与块序号建立来源身份;内容哈希另外用于变化检测。
chunk_id = f"{document_id}:{version}:{position}"
chunks.append({
"id": chunk_id,
"document_id": document_id,
"version": version,
"page": item["page"],
"heading": item["heading"],
"text": item["heading"] + ":" + item["text"],
"content_hash": sha256(item["text"].encode("utf-8")).hexdigest(),
})
# 展示同一原文的错误拆法;后半段无法说明承担费用的适用条件。
text = blocks[0]["text"]
condition, conclusion = text.split("运费", 1)
print("残缺候选:", "运费" + conclusion)
print("完整候选与来源:", chunks[0])
# 真正入库还应按目标 Tokenizer 检查长度,而不是用汉字数冒充 Token 数。
索引中的文本往往与源文件不完全相同:补了标题、展开了表格、删除了页脚。记录转换版本和原文定位,才知道一句生成答案应该回到哪个原始区域。字符偏移基于哪份文本也要明确,清洗前后的偏移不能混用。
用问题检查切分,而不是只看平均块长
拿三条问题审查候选:十天质量退货、四十天质量退货、P-205 二十天质量退货。每个片段都应该支持判断期限与产品范围;只统计块长均值无法发现条件丢失。
抽样还应覆盖扫描件、长表格、跨页条款和政策例外。检查关键数字、否定词、产品号与来源是否保留。对无法可靠解析的文件,记录失败状态并转人工处理,不要把空字符串标成成功入库。
经过这一层,知识库得到的是可追溯、能独立阅读的材料。下一篇解释这些材料如何变成文本向量与索引,以及为什么向量能比较语义,却不能替代业务条件判断。