☰
本地AI文档问答预处理实战:L0规则分片与上下文窗口优化
2026/9/30 18:39:54 网站建设 项目流程

直连 AI 之前,我低估了文档预处理这道工序,直到被本地模型反复折腾到没脾气。

先说背景。我在做一套完全跑在本地的文档问答系统,模型用的开源大模型(7B 级别),负责读企业内部的 Word、PDF、Excel,然后回答"某某制度第三版改了啥""这张表去年Q2的数据是多少"这类问题。最初图省事,直接把文档提取成纯文本,往模型上下文里一塞就完事。结果第一天就被打脸:一份 80 页的制度文件,提取出的纯文本超过 15 万字符,本地模型上下文窗口根本接不住,直接报错;就算硬塞进去,模型回答也是前言不搭后语,完全抓不住"第几章第几条"这种结构信息。

于是我做了一个 Node.js 写的"前置预处理模块",专门负责把办公文档切成适合模型消费的块,并加了一层所谓的 L0 自然语言硬规则调度,决定每块文本怎么切、切完怎么送、哪些块根本不送。这篇文章就是把整个过程的思路和踩过的坑完整复盘一遍。适合谁看?如果你的本地 AI 应用要处理真实办公文档,而你现在还在"提取文本 -> 直接拼接 -> 丢给模型"这条路上,那这篇基本就是帮你在下一个坑前面踩刹车。

1. 直连 AI 的效果惨案:办公文档预处理为什么躲不掉

1.1 Token 超限不是配置问题,是数学问题

很多人以为上下文窗口超了,调大参数就行。本地部署过模型就明白,窗口大小受显存和推理引擎限制,7B 模型开 32K 上下文,KV Cache 就能吃掉几个 GB 显存,再往上加,生成速度肉眼可见地变慢。而一份真实办公文档有多夸张?我统计过手头 200 份文档,纯文本平均长度是 6.2 万字符,最大的是一份带附件的项目验收报告,24 万字符。哪怕 32K 窗口硬撑,也就够塞正文的零头。

更隐蔽的问题在 token 换算。纯英文文档一个 token 大概对应 4 个字符,但中文文档一个汉字在多数 tokenizer 里接近一个 token,三个 GB 级文本就是几十万 token。所以在预处理阶段,分词器、字符数、token 上限之间必须做一次统一换算,否则你设的"每片 2000 字符"在模型侧可能已经超窗。

1.2 结构信息丢失:模型读到的是一堆没有骨架的字符

直连的第二个问题是对文档结构的破坏。直接用正则或者简单 split 去分段,标题层级、表格关系、列表嵌套、页眉页脚全部拍平。模型拿到这种没有标题层级、没有表格行列关系的扁平文本,只能靠语感猜"这一段是在说制度条款,那一段是在说执行流程"。如果文档里有一张 5 列 20 行的对比表,转成纯文本后关系完全丢失,模型甚至会把列头当成句子去理解。

我后来做了一个实验:同一份 20 页的合同,A 版本是纯文本直连,B 版本是经过结构化分片后再送模型。问"违约责任在第几条、具体怎么约定",A 版本从第 17 页找了一段相关文字但没给条款号,B 版本直接定位到"第十一条 违约责任",连子项都列全了。差别就在结构保留。

1.3 大对象混入上下文:一个表格毁掉整轮问答

还有一种情况比超限更恶心,就是文档里嵌了一张超宽表格。比如 Excel 导出的 PDF 里有一张 30 列的数据表,文本提取出来后单行就有几千字符。直连模型时,这一行就会把上下文里其他有效信息的相对权重全部稀释,模型注意力全都扑在那一堆数字上,问什么都答不上来。

预处理模块在这里承担的是一个"路由角色":普通段落等价于给模型看,超大表格走单独通道(抽摘要、按列拆分、或者干脆不送),图片/扫描页走 OCR 分支或标记跳过。这些决策不能靠模型来完成,成本太高,速度太慢,就用规则来定。这就引出了 L0 层的定位。

2. L0 硬规则调度系统:划分边界、定义规则、确定优先级

2.1 L0/L1/L2 分层的思路:别让大模型干小模型的活

在我这套设计里,文档处理分成三层。

L0 层是纯代码规则,不加载任何模型,只做确定性操作:解析文档、分片、标记类型、判断是否超长、判断是否为表格/代码块。特点是快、可复现、能审计。L1 层用小型模型(比如 embedding 或文本分类模型)做语义粗筛,决定某一块文本和当前问题是否相关,这一层是"半智能"。L2 层才是大模型深度推理,做实际问答或者总结。

L0 的定位就是"守门员":在上模型之前把决策面缩小。所有能用规则给定的边界绝不劳烦模型。比如"标题下第一段属于正文""表格列数和行数超过阈值就拆分""句子不完整就不进入切片",这些逻辑写成硬规则,比任何提示词都稳定。

2.2 我的 L0 规则集和优先级仲裁

这里把我实际沉淀下来的规则清单列一份,分成四类:结构规则、切片规则、保护规则、路由规则。

规则类别规则名具体逻辑优先级
结构规则标题锚点识别 h1-h6 和 Word 标题样式,作为块的天然边界最高
结构规则表格整体块表格无论大小,默认不拆分,整表为一个块最高
结构规则列表聚拢同一层级连续列表项合并为一个逻辑块高
切片规则章节切片两个相邻标题之间的内容合并成一个候选块高
切片规则字符上限单块字符数不超过 maxChars(默认 1800)中
切片规则句子保护切分点必须落在句号/分号/换行符之后,绝不在句中切中
保护规则代码块保护代码片段、脚本、命令行内容禁止切片最高
保护规则术语表保护连续"名词+解释"的对句不拆分中
路由规则超大对象超过 maxChars 2 倍的对象单独走摘要通道最高
路由规则图片页标记无文本页记为 placeholder,不进入切片队列中

为什么要设优先级?因为冲突是常态。一个表格 20 行 * 8 列、撑满 A4 纸,按"不拆分"规则它是一个整体块,但它又远超 maxChars。这时候必须有一个仲裁顺序:先看路由规则是不是超大对象,是就直接走摘要通道,不再纠结切不切;如果不走摘要,再看结构规则是"表格整体块",此时允许打破字符上限但记录一个 warning;最后才轮到字符上限规则。仲裁顺序写死,不会今天切明天不切。

2.3 为什么规则必须"硬":确定性优先于聪明

有人问我,规则这么写死,遇到没见过的文档结构怎么办?我的回答是:没见过的结构宁可切得粗糙一点,也不要让预处理行为变得不可预测。硬规则的价值有三个:

第一,可调试。某天模型回答效果差了,你能一条一条回放规则,精确到哪一行代码把文本切成这个样子的;如果上了模型智能分片,答案无法复现,排查就变成了玄学。

第二,可压测。规则确定后,我能拿 1000 份文档离线跑分片,统计分片失败率、切割点命中率、超长块比例,每个指标都能量化。

第三,成本为零。L0 层不加载模型,100 份文档分片只要几百毫秒,而用大模型做一次分片决策动辄几秒。预处理是高频操作,每条文档进来都要跑,用规则是唯一经济上成立的方案。

3. Node.js 流水线实现:从原始文件到结构化分片的代码路径

3.1 解析选型:mammoth、pdf-parse、exceljs 的组合方案

整个流水线第一步是把 office 二进制解析成结构化中间格式。我在 Node.js 生态里对比过几个库,最终组合是:

  • docx:mammoth。它能把 .docx 转成 HTML,保留 h1-h6、table、p、li 这些标签。比 extractRawText 直接给纯文本强太多,因为标题层级还在。
  • pdf:pdf-parse。轻量好集成,但要注意它按页输出文本,页与页之间不会自动加分隔标记,需要自己补。扫描版 PDF 它基本无能为力,我这边另用 OCR 服务兜底。
  • xlsx:exceljs。用于读 .xlsx,按工作表 + 行 + 列组织成二维数组,转成统一的"表格块"。
  • pptx:直接放弃了文本级解析,用 pptx-parser 提取文本但只做标题和正文分离。

解析完统一输出为中间格式,结构大概是这样:

{ type: "doc", title: "2024年度项目管理规范", blocks: [ { type: "heading", level: 1, text: "第一章 总则" }, { type: "paragraph", text: "为进一步规范项目管理流程……" }, { type: "heading", level: 2, text: "1.1 项目立项" }, { type: "table", rows: [["字段", "说明"], [...] ] }, { type: "list", items: ["项一", "项二"] }, ] }

3.2 分片器核心实现:标题锚点 + 句子保护 + 上限收敛

拿到中间格式后进入分片器。我的实现思路是三步走:先按标题生成候选块,再对超长块做二次拆分,最后校验句子完整性和上限。

第一步,按标题锚点切块。遍历 blocks,遇到 heading 就开一个新块,把后续内容塞进去,直到遇到下一个 heading。

function cutByHeadings(blocks) { const chunks = []; let current = { heading: null, body: [] }; for (const block of blocks) { if (block.type === "heading") { if (current.body.length) { chunks.push(current); } current = { heading: block, body: [] }; } else { current.body.push(block); } } if (current.body.length) { chunks.push(current); } return chunks; }

第二步,处理超长块。单个候选块连续长度超过 maxChars,并且内部有多个段落时,按段落边界切;只有一段超长,按标点位置(句号、分号、换行)找最近切点。

function splitLongBody(bodyText, maxChars) { const sentences = bodyText.split(/(?<=[。;\n])/); const result = []; let buf = ""; for (const sent of sentences) { if ((buf + sent).length > maxChars && buf.length > 0) { result.push(buf); buf = sent; } else { buf += sent; } } if (buf.length) result.push(buf); return result; }

第三步,校验规则。这里有个细节,split 用的正则里用到了 JS 的 lookbehind,Node.js 8 以上支持没问题,但注意正则表达式别写复杂到影响超大文本性能。我当时在 20MB 的日志型文档上跑,lookbehind 加中文标点,V8 卡了近半秒,后来换成先按 \n 分段再按标点补判断,速度快一个量级。

3.3 规则引擎仲裁:一个函数把规则变成决策矩阵

分片过程中最麻烦的其实是规则的仲裁顺序。我把决策逻辑收拢成一个函数,输入候选块,输出处理策略:normal / split / summary / skip。

function routeCandidate(candidate, ctx) { const charLen = candidate.text.length; // 1. 路由规则优先 if (candidate.type === "img-placeholder") return "skip"; if (charLen > ctx.maxChars * 2) return "summary"; // 2. 保护规则 if (candidate.type === "code" || candidate.type === "table") { if (charLen > ctx.maxChars) { ctx.warnings.push(`oversize-${candidate.type}:${candidate.heading}`); } return "normal"; } // 3. 切片规则 if (charLen > ctx.maxChars) return "split"; return "normal"; }

实际生产代码比这个长不少,但核心思想就是这个:决策必须线性、可预测。每一份文档的每个块最终落在四种策略中的一种,全部记录下来,方便后面验证。

4. 我被文档烦得最多的六个坑:逐案复盘排查链路

这一章价值可能比前面所有设计都大。因为我踩过的这些坑,全部是"看起来没问题但模型就是答不对"的隐形杀手。

4.1 坑一:mammoth 提取的层级样式丢失,标题全变成正文

第一版我直接调 mammoth 的 extractRawText,代码简单,输出是纯文本。跑下来发现,Word 里用"标题1/标题2"样式的标题提取后和其他段落没有区别,我的分片逻辑完全找不到锚点。换 convertToHtml 之后,HTML 里的 h1-h6 标签把层级带出来了。但新问题跟着来:有些同事的文档标题是用超大字号手动排的,没有用 Word 样式。mammoth 对这种情况不会输出 h 标签,标题又丢了。

排查链路:先抽查 10 份问题文档的 HTML 输出,发现"样式标题"能识别,"手动大字号标题"识别不了。最终两个兜底一起上:一是正则匹配"第[一二三四五六七八九十百千]+章""第[0-9]+条"这类模式,强制提升为 heading;二是对字体大小超过正文 1.5 倍的段落,在 HTML 解析里做启发式判断,提升为 heading。启发式会引入误判,但宁可多切几块,也不能让标题混在正文里。

4.2 坑二:PDF 页面边界把表格劈成两半

扫描版 PDF 是 OCR 的问题,这还好发现。真正阴险的是表格恰好跨页的 PDF。比如某个采购清单,表头在第 3 页,表体一部分在第 4 页。pdf-parse 按页返回文本,我的分片逻辑把第 3 页末尾和第 4 页开头各当成独立段落,结果表格被劈成两半,列头信息和数据行完全分离。

修复办法是给 pdf-parse 的原始输出加一层"边界页粘连"逻辑:检测当前页末尾和下一页开头是否构成完整表格行。表格行特征很简单,用"以回车结尾但中间有多个连续空格或制表符"和"下一页开头仍是多列形态"来判断。具体实现是维护一个 pendingLine,如果上一页的最后一行以"|"或连续 2 个以上空格结尾,就与下一页首行拼接。这个规则让表格碎片恢复率从 61% 提升到 93%。剩下的 7% 是极端情况,我直接标记为"table-fragment",不进模型。

4.3 坑三:并发分片时内存暴涨,Node 进程被 OOM

预处理模块上线初期,我图性能,用 Promise.all 一次性把整个目录 100 份文档全部并发解析。结果 Node 进程内存直接飙到 2GB+,小服务器直接被 OOM killer 干掉。

排查链路很短:内存 profile 一开就发现,pdf-parse 对单份大型 PDF 的文本对象持有时间是解析期的十几倍,mammoth 的 HTML 字符串也没及时释放,加上并发 100 份,堆内存直接爆。解决没有用复杂方案,改成异步并发限流,一次最多 6 个文件,用 p-limit 或者自己写个计数器都行。核心是每处理完一份文档,把大字符串引用置空,主动请求 GC。实测内存峰值从 2GB 降到 400MB,处理总耗时反而没怎么增加,因为瓶颈本来就在磁盘 IO。

4.4 坑四:固定 maxChars 切中文,把句子腰斩

这个坑很典型。一开始我把 maxChars 设成 1500,代码按字符数硬切。模型问答的效果非常差,去翻分片日志才发现大量切片落在句子中间,比如"根据《合同-法》第三条的规定"被切成了"根据《合同" + "法》第三条规定"。模型拿到的单块内容语义残缺,回答质量全废。

修复有两个点。第一,切分位置必须落在标点后,标点集合配置成可扩展:中文句号、逗号、分号、换行、分号加右括号。第二,对"《"这类特殊字符做保护,切点之前如果存在未闭合的《,就继续往前找句号。这个规则很朴素,但让"句子断裂率"从 12% 降到 0.3%。

4.5 坑五:规则优先级写反,超大表格走错了通道

仲裁顺序我在第二版调过一次,就是因为一个 300 行的大表。最初路由规则排在切片规则后面,结果大表被判断为"超长 -> split",进入拆分逻辑。但表格一旦拆行,后面的问答就彻底乱套,比如"第三季度合计"这一行被拆没了,模型回答"查不到第三季度数据"。把路由规则(超大对象进摘要通道)提到最前面之后,大表不再拆分,单独交给摘要生成,问题消失。这件事给我的教训是:规则的优先级必须写在文档里,每次改规则先看仲裁序列表,不要凭感觉加新规则。

4.6 坑六:Windows 路径和中文文件名的编码问题

最后一个是纯 Node.js 的环境坑。公司内部很多人用 Windows 分享文档,文件路径带中文 + 空格,比如"D:\项目文件\2024年 制度 汇总.docx"。Node 18 在 Windows 下一般没大问题,但我在 Linux 部署机上收到这些文件时,文件名乱码、路径解析失败。排查时发现是上传环节把文件名用成了 UTF-8 之外的编码。统一在接收文件入口处做一次名称规范化,转成 UTF-8 NFC 形式,并把路径里的空格和特殊字符做安全替换。虽然不算文档解析的坑,但在真实办公环境里,这种问题比技术问题更频繁。

5. 分片质量的验证方式与一次真实的调参过程

5.1 不走模型评分,我用四个量化指标验收

分片质量很难主观判断,我没调大模型来打分,而是定了四个硬指标,离线验收时直接跑:

指标定义及格线
超限块占比单块字符数超过 maxChars 的块数 / 总块数< 2%
句子断裂率切断位置在句中的块数 / 总块数< 1%
结构保留率分片中含 heading 的块数 / 总块数> 80%
路由准确率系统判定路由策略与人工判定一致的比例> 95%

这套指标统计下来,整体稳定在:超限块 1.2%,句子断裂率 0.3%,结构保留率 92%,路由准确率 96%。

5.2 一次带着数据说话的调参过程

上线后第三周,不断有用户反馈"问制度文档时,模型经常答出过时内容"。我去查分片日志,发现一个规律:最新版本制度文档里,同一个章节会被切成两块,第一块是新版内容,第二块是附录里粘贴的旧版条款。模型拿到两个块,分不清谁新谁旧。

排查到根因是分片时没有任何"版本元信息"被标记。我在 L0 规则里加了一条:如果文档内容中出现"修订""版本号""生效日期""废止"等关键词,自动填充一个 version_meta 字段,并把块头部注入"本文档版本号:2024V3"这样的上下文前缀。这样模型每块都能看到版本信息。改完一周后,同一个问题的误答率降了一半以上。这次让我意识到,L0 规则要不停从业务反馈里倒推迭代,而不是上线就完事。

5.3 我对这套方案的整体评价

从第一次被本地模型上下文窗口打脸,到 L0 硬规则调度跑通,前后大概三周。现在这套模块每天处理几十份真实办公文档,稳定运行,没有一次因为预处理问题导致模型报错。Node.js 在这个场景下的表现超出我预期,异步 IO 处理文档解析很顺手,mammoth 和 pdf-parse 生态够成熟,唯一的短板是深度办公格式(比如复杂嵌套表格、宏文档)需要自己二次处理,但这是任何语言都要面对的事。

最后分享一个建议:如果你也在做本地 AI + 办公文档相关的项目,先别急着调 prompt 或者换模型,把输入侧的分片和规则做好,收益往往比你想象的更大。而 L0 规则迭代最好的素材,就是每一次模型答错的案例——把它们攒下来,反推是哪条规则漏了,比再调十个提示词都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询