一、一份一百二十八页的材料问不出正确答案
上个月一个做建材的客户把他的企业资料打包发过来,三百一十二个文件,格式很杂,一百六十多个 Word,八十多个 PDF,剩下的散着 Markdown 和纯文本。其中最长的一份是产品手册,一百二十八页,翻页导出之后还带着页眉页脚和水印。客户提了三个问题,其中一个是「三号线用的密封胶型号和固化温度分别是多少」,这条信息确实写在手册第十二页的表格里,可我们的问答接口给出的答案牛头不对马嘴,把一段讲包装运输的文字抄了回去。
我们把整条链路拆开看了一遍,从文件上传到答案输出,一共经过解析、切分、入库、召回、拼装五个环节。模型用的是同一个,参数也没动过,检索日志里那一句的问法甚至已经改成了和原文几乎一致的措辞,可命中的块里就是没有那行表格。问题最后落在切分上,那一页的表格在入库时被切成了三块,型号留在第二块,固化温度掉进了第三块,模型拿到的上下文里两个信息再也没碰过面。
这类问题的规模不小。我们扫了一遍这三百多个文件,抽取出来的纯文本合计两千一百多万个汉字,最长的单篇十一万字,最短的两百来字。按最早那版切法,总共切出了四万七千多个块,平均每块四百五十字,可其中有一万一千多个块的正文是从一句话中间断开的。召回接口一次只能塞进去六块,这意味着大量有价值的块根本没有机会被送进提示词,链路后面的功夫全都白做。
二、问题出在切分而不是模型
很多人遇到答不准,第一反应是换模型或者调提示词。我们也试过,把提示词从八百字加到一千八百字,把温度从零点三降到零点一,答案的措辞变得规整了一些,但那句密封胶的固化温度依然答不出来。原因很简单,提示词里从来没有出现过那个数字,模型没有能力凭空把它编出来,除非它愿意瞎猜,而这比答错更危险。
真正的根因是切分破坏了语义单元。企业在写文档时,一个完整的说明往往跨着标题、正文和表格三样东西,标题给出主题,正文给出条件,表格给出数据,三者落在同一页上才成立。按固定字数硬切,切点落在哪里完全由字符数决定,它不认识标题也不认识表格,于是把一页完整的说明打散成互不相干的碎片,块与块之间那点上下文联系在入库时就丢掉了。
另一个被忽略的细节是块的大小分布。太小的块信息量不足,召回时容易命中一堆只能说半句话的碎片;太大的块又会让提示词被单个来源占满,其他来源进不来。我们统计过那四万七千个块,短的只有十二个字,长的到三千四百字,中位数在四百字左右,但这种分布是硬切出来的,长的那些并不是因为内容真的那么多,只是因为恰好没碰到句子边界。
三、三种切分策略的取舍
第一种是最省事的做法,整篇文档直接塞进提示词,不做切分。这条路实现成本几乎为零,只要文档不超过模型上下文就行。代价也很明显,一份一百二十八页的材料折合二十多万个汉字,远超任何常见模型能吃下的窗口,就算截断到前面几万字,命中的信息也大概率落在被截掉的部分里,等于把检索交给运气。
第二种是按固定长度硬切,外加一点重叠。常见参数是每块五百字,前后重叠五十字,用滑动窗口顺着往下走。这条路稳定、可预测,重叠也确实能缓解切断语义的问题,代价是重叠会把存储放大将近一成,而且切点依然不认识标题,一页里的表格和它的说明文字仍然可能被劈开,需要靠重叠的五十个字去赌那点运气,赌不中的时候就回到原点。
第三种是按文档自身的结构来切。先解析出标题层级,把正文挂到最近的上级标题下,再在段落边界处按目标字数收口,块与块之间天然带着标题路径作为上下文。这条路要多写不少解析代码,还得处理格式不统一带来的各种边角情况,但切出来的块语义完整度明显更高,同一个来源的相邻块共享同一条标题路径,模型看到的不是碎片,而是带位置的一段说明。
四、先统一成中间结构再切块
我们把解析拆成了两步,第一步只做一件事,把 Word、PDF、Markdown、纯文本四种输入抽成同一种中间结构,每条记录带三个字段,层级 level、文本 text 和页码 page。docx 走 python-docx,按段落的样式名判断层级,Heading 1 记一级,Heading 2 记二级,正文段落记零级;Markdown 直接数行首井号的个数;纯文本没有显式层级,就用空行和缩进去推断,推断不出来的一律按零级处理。
PDF 是最麻烦的一种。pdfplumber 能按字符坐标抽文本,也能把表格还原成二维数组,我们先用它把页面里的文字和表格分别取出来,再按纵坐标给所有元素排一次序,让阅读顺序尽量贴近人眼看的样子。扫描出来的 PDF 没有文本层,抽取结果是一串空字符串,这一种我们单独标记出来,不参与切分。
这套解析与切分是 AI智能媒体助理里让知识库真正起作用的那一步,切错了后面全白搭,我们把切分参数反复调了三轮才定下来。目标块大小四百个汉字,允许上下浮动一百二十字,切点一律找最近的段落边界,遇到单个段落超过六百字的情况才强制在句子之间切开。同一份文档里,块的顺序严格按原文顺序排,编号从零开始连续递增。
五、块表的字段与注入上限
块表叫 kb_chunk,字段一共八个,chunk_id、doc_id、seq、level、title_path、content、char_len 和 page_no。title_path 存的是从一级标题拼到当前层级的路径,用斜杠分隔,比如「产品手册/技术参数/密封材料」,它同时充当召回结果的展示标题和注入提示词时的上下文前缀,一个字段两用,省掉了一次额外的关联查询。
注入策略也做了限制。一次问答最多注入六块,按相似度从高到低取,六块的正文加前缀合计不超过两千四百个汉字,超出的部分按相似度从尾部裁掉。这个上限是压着模型上下文的两成定的,剩下八成留给系统提示、对话历史和用户问题,避免因为检索结果太长把模型的注意力挤没了。
入库时还对块做了一次去重。同一份文档里正文完全相同的块只保留 seq 值靠前的那个,标题路径相同的相邻小块在总长度没超过六百字的前提下合并成一块。这一步做完,四万七千多个块收敛到三万九千多,去掉的多数是页眉页脚和重复的表头,它们本身没有信息量,却会在召回时占据本该属于正文的名额。
六、踩过的三个坑
第一个坑是 PDF 里的表格被抽成一行乱码。现象是某份设备清单里的参数表在抽取结果里变成了一长串没有分隔的汉字和数字,读起来像「型号A-120.5MPa-30℃」,切块时被当成正文切成了两半。根因是表格的单元格在 PDF 里本来就是独立定位的文字对象,按纵坐标排序之后它们被顺序串了起来,行列关系全丢了。改法是先用坐标还原成二维数组,列数不超过六列的表格转成「字段名 冒号 字段值」的多行文本,超过六列的按行展开写成条目,让每一行自身都是可读的。
第二个坑是扫描件根本没有文本层。现象是客户上传的一批盖章文件,问答时怎么问都答不出来,检索日志里连一个候选块都没有。根因是这类 PDF 是图片扫描的,抽取出来只有空白字符,解析这一步没报错,块表里也没有它的数据,链路一路安静地走完了。改法是在解析结束时做一次字符数校验,单页抽取字符数低于五十且整篇低于两百,就把这份文档标记为疑似扫描件,在前端给出待识别的提示,同时不阻塞其他文档入库。
第三个坑是超大文档切出上千块带来召回噪声。现象是那份一百二十八页的手册切出了一千零四十个块,问任何一个参数问题,返回的六块里有三块来自这份手册,而且经常命中目录页和修订记录。根因是块越多,相似度分数挤在同一区间的概率越高,目录页因为它覆盖了所有章节名,反而成了高分常客。改法有三个,一是把目录页识别出来单独标记并在召回时降权,二是同一文档在一次问答里最多贡献三块,三是给命中块按标题路径做一次聚类,同一路径下只留分数靠前的一块。
七、这层解决不了什么
切分解决不了信息本身没写清楚的问题。文档里如果只写了「按标准执行」却没写是哪个标准,切得再漂亮也答不出来,这种情况我们只能靠召回结果里的原文片段提醒用户去补资料,而不能指望模型推导。同义词和口语化提问也不在这层,用户问「封口胶」而文档里写的是「密封胶」,靠字符串相似度匹配不上,得在检索侧补同义词典或者用向量召回兜底。
跨文档的推理同样超出这层的能力。一个问题的答案如果需要把采购合同里的报价条款和产品手册里的技术参数放在一起才能得到,我们的召回能分别捞到两块,但让模型把两个来源自行拼起来,结果并不稳定,偶尔会把两家供应商的数据混着用。这类场景我们目前的处理方式是让用户在提问时带上文档名,把召回范围先收窄,再让模型在有限来源里作答。
解析的准确率也有代价。四种输入格式的抽取逻辑各自维护,每接一种新格式就要补一套分支,Word 里嵌的文本框、PDF 里的分栏排版、Markdown 里的代码围栏,都会影响层级推断的结果。我们现在的做法是解析完让用户在页面上预览前二十块,确认无误再入库,这一步看着多余,但它把返工的成本从重新切一遍整份文档,降到了重新上传一次文件。
八、小结
回头看这件事,我们改过的东西其实只有一处,就是把切分从按字符数硬切,换成了按文档结构切。参数层面老老实实定了三个数,目标块四百字、浮动一百二十字、单次注入六块两千四百字,剩下的精力都花在了四种格式的解析和三个坑的修补上。切换之后同一批问题的答准率从六成上下提到了九成出头,用的还是同一个模型。
AI智能媒体助理的知识库现在按这套方式切块,一份几十页材料也只注入命中的那几块,提示词长度从最早的一万四千字压到了两千七百字上下,单次问答的响应时间跟着从十一秒降到四秒以内。这些数字里没有一项是靠换模型换来的,全部来自解析和切分这两步,它们藏在链路最前面,出错的时候却总是最后由答案来背锅。