本地部署了大模型之后,最尴尬的不是模型跑不起来,而是文档喂不进去。我最早拿了份四十多页的 docx 合同去问 qwen2.5:7b,接口直接甩了个context_length_exceeded,后面又试过截断标题、压缩摘要,一晚上折腾下来,最后发现病根不在模型,而在投喂方式。于是我在 Node.js 侧写了一个前置预处理模块,核心就两件事:办公文档分片,以及 L0 自然语言硬规则调度。前者把 docx、pdf、txt 这类乱糟糟的原始文件拆成干净、语义相对完整、长度可控的文本片段;后者在完全不跑模型的前提下,用自然语言配置的硬规则把文档提前分流、打标、做路由,省掉大量上层工作。这套流程跑通之后,办公室里的文档问答、合同快速分类、自动摘要都能稳定吃上本地模型,而不是每次都在上下文窗口边缘试探。如果你也在折腾本地文档知识库、本地模型 RAG,或者单纯想用 Node.js 把文档处理链路做扎实,这篇实战记录应该对你有用。下面所有代码基于 Node.js 22.12+,ESM 模块写法。
1. 整体思路与选型陷阱
1.1 前置预处理模块到底解决什么问题
先说我踩的第一个认知坑:绝大多数人以为本地模型部署好了,把文档丢过去就能用。实际上本地模型的上下文窗口就那么点,qwen2.5:7b 的默认上下文也就是几 K token,一次装不下整份合同。更麻烦的是,docx 本质是个 zip 包,pdf 有自己的对象结构,模型看到的不是文本,是一堆二进制。你要是直接把文件内容塞进 prompt,模型要么吐乱码,要么假装自己看到了内容。所谓前置预处理,就是在文档进模型之前,把“文件格式”降维成“干净文本”,再把“长文本”裁剪成“上下文窗口吃得下的片段”,然后按规则决定哪些片段优先喂、哪些需要打标、哪些干脆不喂。
这个模块还承担一个容易被忽略的责任:它决定了后续所有环节的稳定性。RAG 里的切片质量、向量检索的召回率、摘要生成的连贯性,都跟前置分片强相关。与其在模型侧反复调 prompt,不如把文档解析和分片做扎实一点。而且这些工作放在前端 Node 服务里跑,不占用 GPU 推理资源,逻辑也完全可控。
1.2 模块架构与数据流设计
我最终落地的架构不是那种大而全的框架,而是一条能随时插拔的管道。文档源进来后,按扩展名分发到不同解析器,解析成纯文本后先做编码和噪音清洗,然后进入分片器,分片器产出的每一片都会附带元信息,包括文件名、页信息、字符范围、块序号。分片之后立刻进入 L0 规则调度层,这一层根据配置好的自然语言规则对每个分片做打标、过滤、排序或直接路由到特定业务逻辑。最后,管道输出一份 JSONL 或直接写入向量库,等上层 Agent、RAG 或聊天接口来消费。
我把调度放在分片之后是有意的。分片先保证每块内容长度可控,调度再对每一块做规则判断,这样能避免“一条规则想匹配全文,结果文档太长直接超时报错”的尴尬。举个例子,一条规则说“正文出现‘甲方乙方’就标记为合同相关”,如果先分片,匹配范围是每片几百字,速度非常快;如果不分片,拿全文去跑正则,大文件会被反复扫描,最终性能也会拖垮整个服务。
1.3 技术选型:为什么固定在 Node.js 22.12+
选 Node.js 而不是 Python,不是因为 Python 不好,而是因为我希望整个预处理服务能和上层 Web 接口共用一套代码,部署成一个进程。Node.js 22.12+ 对 ESM、原生 fetch 的支持都足够舒服,做文件批处理时异步 I/O 又能充分发挥。相比 Python 多进程方案,Node 的 stream 和异步并发在处理几十个文档时更顺手。
这个版本还有一个实用价值:默认情况下require(esm)已经比较友好,这意味着老代码和新模块混用不会太痛。实际开发中我用createRequire接了不少老包,比如pdf-parse这种 CommonJS 库,完全没有额外负担。之前有人问我为什么不干脆用 shell 脚本做分片,我只能说,一旦要接入规则调度和后续向量库逻辑,脚本方式根本维护不了。选型上不是追求技术新,而是让整个管道对团队里其他 Node 开发者来说足够亲切。
2. 办公文档分片:从“能读”到“能喂”
2.1 分片原则与参数预估方法
分片不是拿一把大刀按固定长度切,那样会把句子、表格、条款拦腰截断。我自己总结了两条原则:第一,优先保语义完整性,不到万不得已不切开一句话、一个表格、一个列表项;第二,必须控制 token 消耗,每一片大小要结合模型窗口和下游用途来定。这两者冲突时,我的做法是先用段落和句子边界来切,如果某一个段落特别长,比如是一个大表格,就整块独立成片,宁可它超过常规长度,也不要拆成两块导致语义断裂。
token 预估是个很务实的活。不同本地模型的 tokenizer 对中文的编码效率不一样,有些中文模型下 1 个汉字约等于 0.6 到 1 个 token,英文一个词约 1.3 到 1.5 个 token。为了不让分片超限,我采用的是“保守对齐法”:中文场景按 1 个汉字约等于 1 个 token 来估算,这样算出来的maxChars会略小于真实安全值,宁可多切几片,也不要触发上下文超限。假设模型窗口是 2048 token,我希望每条分片最多占用窗口的 60%,也就是约 1200 token,那maxChars就设成 1200 字;overlap 按 10% 到 15% 计算,取 120 到 180 字。如果窗口是 4096 token,分片可以放宽到 2400 字左右,但我不建议一上来就拉满,因为分片过大会拖累检索精度,也容易让摘要类任务“跑偏”。
2.2 文档解析层的踩坑实现
解析层是最容易被低估的部分。我最初以为读文件就是fs.readFileSync,结果 docx 抽出来一堆 XML 标签,pdf 抽出来乱码。最后我的解析函数长这样:
import fs from 'node:fs/promises'; import path from 'node:path'; import { createRequire } from 'node:module'; import mammoth from 'mammoth'; import iconv from 'iconv-lite'; const require = createRequire(import.meta.url); async function extractDocument(filePath) { const ext = path.extname(filePath).toLowerCase(); const rawBuffer = await fs.readFile(filePath); if (ext === '.txt' || ext === '.md' || ext === '.csv') { // txt 文件最阴,可能是 utf8、gbk、gb18030 混着来 const utf8Text = rawBuffer.toString('utf8'); if (utf8Text.includes('\uFFFD')) { const decoded = iconv.decode(rawBuffer, 'gbk'); return normalizeText(decoded); } return normalizeText(utf8Text); } if (ext === '.docx') { const result = await mammoth.extractRawText({ buffer: rawBuffer }); return normalizeText(result.value); } if (ext === '.pdf') { const pdfParse = require('pdf-parse/lib/pdf-parse.js'); // 重点:Node 22 里直接传 buffer 可能触发类型报错,转成 Uint8Array 更稳 const data = await pdfParse(new Uint8Array(rawBuffer)); return normalizeText(data.text); } throw new Error(`unsupported file type: ${ext}`); }这里有几个细节值得展开。pdf-parse在 Node 22 下如果直接传Buffer,有时会报buffer参数类型错误,转成Uint8Array就能跳过这个坑;txt 文件如果出现\uFFFD,大概率是 UTF-8 解码碰上了 GBK 内容,我会用iconv-lite按 GBK 再解一遍。真正处理老式.doc文件我倒没有在这里硬啃,办公室场景里老.doc文件我习惯先用 LibreOffice 一键转成.docx或.pdf再进管道,命令行一行搞定,省掉一堆 COM 或者拼格式解析的麻烦。解析之后的normalizeText我会顺手做三件事:去掉连续空白符、修正全角符号、把多个换行压缩成段落标记。这一步对后续分片判断边界非常重要。
2.3 滑窗分片完整实现
分片器是整条链路里最容易出现“看起来合理但细看全是问题”的部分。我实现的逻辑不是简单的slice,而是先找段落边界,按段落合并,再处理超长段落,最后用 overlap 拼接。核心代码如下:
function estimateTokenNums(text) { // 保守估算法:中文按 1 汉字 ≈ 1 token,英文按单词数 * 1.4 const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const englishWords = (text.match(/[a-zA-Z0-9]+/g) || []).length; return Math.ceil(chineseChars + englishWords * 1.4); } function splitSentences(text) { // 按句子标记切分,句尾保留分隔符 return text.match(/[^。!?!?\n]+[。!?!?\n]*/g) || [text]; } function makeChunksWithOverlap(text, maxChars = 1200, overlapChars = 150) { const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(Boolean); const chunks = []; let buffer = ''; for (const paragraph of paragraphs) { const isTable = /^\s*\|/.test(paragraph) || paragraph.includes('\t'); // 表格和超长段落不允许跨片拆分 if (isTable || paragraph.length > maxChars * 0.8) { if (buffer) { chunks.push(buffer); buffer = ''; } chunks.push(paragraph); continue; } if ((buffer + '\n\n' + paragraph).length <= maxChars) { buffer = buffer ? buffer + '\n\n' + paragraph : paragraph; } else { if (buffer) chunks.push(buffer); buffer = paragraph; } } if (buffer) chunks.push(buffer); // 相邻 chunk 做 overlap,避免句间衔接信息丢失 for (let i = 1; i < chunks.length; i++) { const prevTail = chunks[i - 1].slice(-overlapChars); const nextHead = chunks[i].slice(0, overlapChars); if (!nextHead.startsWith(prevTail.slice(-20))) { chunks[i] = prevTail + chunks[i]; } } return chunks.map((chunk, index) => ({ chunkIndex: index, text: chunk, chars: chunk.length, estimatedTokens: estimateTokenNums(chunk), })); }这里有一个很关键的设计:表格和超长段落直接独立成片,不和前后内容硬凑。因为表格一旦切开,列对齐和表格语义全丢,下游模型根本没法理解;超长段落同理,常见于合同里的“连续条款”或 PDF 里被错误解析成一行的大段文字。overlap 拼接时我只取前一片末尾一小段拼到下一片头部,同时用startsWith简单判断避免重复叠加。不过这样也会带来一个新问题——overlap 拼接后可能导致分片开头出现上一次的残句,后面我会专门讲怎么处理。
3. L0自然语言硬规则调度:先用零成本规则卡住确定性
3.1 L0 调度是什么,为什么不用大模型做路由
在讲 L0 之前,我想先澄清一个概念。L0 是 Level 0,也就是整个调度体系里最底层的规则层。它不代表“低端”,而是代表“零推理、纯确定性、强制优先”。和我配合的上层还有 L1、L2 这类概念:L1 可能是一个轻量分类模型,L2 才是大模型路由。但在实际业务里我发现,很多事情根本不需要上升到模型,一条自然语言表达的硬规则就能解决。
举个例子。合同类文档要单独走机密通道,不进公共知识库;发票类文档不需要全文嵌入向量库,只需要提取关键字段;PDF 扫描件必须先做 OCR 再进入后续流程。这些判断如果用大模型来做,每次都要启动一次推理,既慢又贵,而且模型还可能出现随机错误。用 L0 硬规则,就是在文档进入任何 AI 模型之前,先用正则、关键词、长度阈值等确定性的手段把路分好。如果规则命中,就立刻执行预设动作,比如打标、分流、忽略、转 OCR。这既节省了算力,又保证业务逻辑不出“灵异事件”。
3.2 规则配置与自然语言解析实现
自然语言硬规则,我的实现方式是用 JSON 配置文件表达规则,让非技术同事也能看懂规则意图,同时保持程序可解析。典型配置长这样:
{ "rules": [ { "id": "R-001", "priority": 10, "when": { "title_contains": ["合同", "协议", "agreement"], "body_contains": ["甲方", "乙方", "违约金"], "min_chars": 200 }, "then": { "action": "tag", "tag": "legal_contract", "skip_embedding": false, "context_prompt": "你是一名合同审查助手,请重点关注责任条款和违约条款。" } }, { "id": "R-002", "priority": 20, "when": { "file_ext": [".pdf"], "body_contains": "扫描", "max_chars": 50 }, "then": { "action": "redirect_ocr", "reason": "疑似图片型 PDF,需要 OCR 后才能继续" } } ] }规则字段本身是半结构化的自然语言,title_contains、body_contains、min_chars这些都是人能读懂的词,不是一堆关系代数。解析这些规则时,我只需要做一个字段映射表,把规则里的自然语言条件翻译成具体的断言函数。这里不建议写一个“强大到能理解任意自然语言”的解析器,那又变成另一个大模型问题了。我的做法是定义好一组固定操作符,比如title_contains对应“标题里包含任意一个关键词”,matches_regex对应“正文满足某个正则”,然后让用户写规则时从这组操作符里选。这在实用性和可控性之间是平衡点。
3.3 调度执行、优先级与并发控制
调度执行的代码不算复杂,但细节很多。我会先给每个分片提取“标题”,通常取文档第一段或文件名去掉扩展名的部分;然后按 priority 从小到大排序规则,命中优先级高的规则后,是否继续执行低优先级规则由then.stop字段决定。默认情况下命中最高优先级就直接结束,避免同一个分片被打上十几个互相矛盾的标签。
并发控制这块我用了一个自定义的小池子,没有直接引第三方并发库。原因是这个环节的文件读取和分片计算都有 I/O 和 CPU 开销,需要限制同时打开的文档数,否则跑批处理时文件描述符会爆、内存也会被顶穿。限制并发数到 4 到 6 个,配合 Node 的异步模型,已经足够把多核 CPU 吃满,同时还留有余量给上层接口服务。跑大目录时我还会按目录分层串行,避免一次性扫描出几千个文件把队列压垮。
4. 完整链路串联与本地AI接入效果
4.1 把分片和调度串成一条预处理管道
把前面所有组件串起来的是preprocessPipeline函数。它接收一个目录路径,递归找文件,按扩展名过滤,每个文件走 解析 -> 清洗 -> 分片 -> L0调度,最后统一写回结果。这个函数用runWithConcurrency控制并发,避免一次把所有文件读进内存。核心代码如下:
async function preprocessPipeline(dirPath, rules = []) { const files = await listFiles(dirPath, ['.docx', '.pdf', '.txt', '.md']); const tasks = files.map((filePath) => async () => { const start = Date.now(); const rawText = await extractDocument(filePath); const title = path.basename(filePath).replace(/\.[^.]+$/, ''); const chunks = makeChunksWithOverlap(rawText, 1200, 150); const processedChunks = chunks.map((chunk) => ({ ...chunk, sourceFile: filePath, title, })); const ruleHits = applyL0Rules(processedChunks, rules); return { filePath, meta: { pages: rawText.length ? undefined : 0, chars: rawText.length, chunks: processedChunks.length, ruleHits, costMs: Date.now() - start, }, chunks: processedChunks, }; }); return runWithConcurrency(tasks, 5); }这里我特意把pages暂时留空,因为pdf-parse能返回页数,但 docx 的页数需要额外渲染引擎才能拿,为了保持接口统一,就没有强求。管道跑完后,我会把结果里的ruleHits单独汇总,给上层一个全局视角,比如“这批 40 个文档里有 6 个合同、3 个发票、1 个需要 OCR”。这不是为了好看,而是让后续人工抽查时有明确索引。
4.2 运行效果日志与上下文统计
我第一次跑通完整管道时,输出大概长这样:
[preprocess] Q4_合作协议.docx -> chars=8214, chunks=7, rules=[legal_contract, confidential] [preprocess] meeting-202501-notes.pdf -> chars=1302, chunks=2, rules=[] [preprocess] 发票扫描件.pdf -> chars=18, chunks=1, rules=[redirect_ocr] [preprocess] 项目复盘.txt -> chars=4530, chunks=4, rules=[internal_summary]从这些日志就能看出问题。合同类文档因为规则命中legal_contract,会带着专属 prompt 进入后续问答流程;发票扫描件只有 18 个字符,规则直接判定为需要 OCR;普通会议纪要没有命中任何规则,那就走默认的向量库索引。我在实际项目里经常用这个日志来反推规则配置是否合理,比如某个文档明明包含“违约金”却没命中规则,十有八九是解析层把它拆进了两个分片,导致单片的body_contains漏掉。这也印证了一个观点:分片质量是规则调度的地基。
4.3 接入 Ollama/OpenAI 兼容接口的最后一道防线
预处理管道输出的 JSONL,最终要喂给本地模型。我用的是 Ollama 的 OpenAI 兼容接口,fetch一行就能调通。分片之后把多块内容分别发送,比一次性塞全文稳定得多。这里我还会加一层 token 守卫,防止单个分片因为 tokenizer 差异超限:
async function askLocalModel(model, systemPrompt, chunkText, maxTokens = 2048) { const estimated = estimateTokenNums(chunkText); if (estimated > maxTokens) { throw new Error(`chunk too large: ${estimated} tokens`); } const resp = await fetch('http://127.0.0.1:11434/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: chunkText }, ], max_tokens: maxTokens - estimated, }), }); const data = await resp.json(); return data.choices?.[0]?.message?.content ?? ''; }这一步不是技术上的炫耀,而是“最后的防线”。即使前面分片参数估算错了,这个守卫也能兜住,避免模型接口返回context_length_exceeded让整个任务崩掉。实测下来,把 40 页合同分成 7 片分别提问,得到的回答质量明显好于一次性塞全文。没有超限报错,模型回答的条理性也更好,因为每个分片聚焦的内容变短了,模型不会在长上下文里“走神”。
5. 踩坑实录与速查清单
5.1 文档解析与编码的隐性坑
这个模块跑了一个多月,前两周几乎天天在填解析层的坑。pdf-parse在 Node 22 的 Buffer 类型问题是最早碰到的,升级 Node 后旧代码直接崩,改成Uint8Array才消停。docx 抽取还有一个隐蔽问题:mammoth 会把页眉页脚也当成正文抽出来,导致每个分片里混入同样的公司名和页码,向量检索时这些噪点词会被反复索引。后来我在normalizeText里加了噪声词过滤,把频繁出现的页眉、页脚、页号模式统一干掉。
老式.doc文件也让我头疼了一阵。尝试过几个 npm 解析包,效果都不稳定,后来干脆走 LibreOffice 批量转格式,这才算彻底解决。如果你要在 Windows 环境处理.doc,建议直接装一个 LibreOffice,命令行转换最省心。还有就是编码问题,办公室内部产生的 txt 文件经常是 GBK 编码,纯toString('utf8')会得到一堆\uFFFD,必须用iconv-lite兜底。这个处理建议放在解析函数最前面,不要等文本已经污染了再做清洗。
5.2 分片语义与规则误判的修正手段
分片语义问题集中在表格和列表上。我的第一个版本用纯字符长度切,结果合同里的付款计划表被切成了两半,后半部分完全没有表头,模型回答时根本不知道那些数字指的是什么。后来我改成“表格独立成片”规则:凡是发现段落以|开头,或者包含 tab 分隔符,就强制整块输出,绝不与前后段落拼接。这个调整让合同类问答的准确率提高了不少。
overlap 导致的列表序号错乱是另一个高频问题。我在做重叠时把前一片的尾巴拼到后一片开头,如果前一片正好是以“2.3.1”这种层级编号结尾,拼过去后后一片开头就会出现一个来历不明的编号。解决方式是在 overlap 前先判断尾部是否是纯编号行,如果是,就只把编号后面的正文拼过去,或者干脆去掉这个 overlap 残留。规则误判方面我踩过最典型的一坑:关键词出现在正文里却被规则当成了标题匹配。所以在规则引擎里一定要严格区分title_contains和body_contains,不要用一个统一的关键词匹配糊弄过去。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查与修复 |
|---|---|---|
| pdf-parse 报 Buffer 类型错误 | Node 22 下 buffer 传入姿势不对 | 将传入参数改为new Uint8Array(buffer) |
| docx 抽取后全是 XML 标签 | 直接读了压缩包或用了错误的解析库 | 使用mammoth.extractRawText |
| txt 中文乱码 | 编码被误判为 UTF-8 实为 GBK/GB18030 | 检测\uFFFD后用iconv-lite重新解码 |
| 分片把表格切断 | 按字符数硬切导致表格语义断裂 | 检测 ` |
| 分片后下片开头出现孤立编号 | overlap 拼接带入上一列表尾编号 | overlap 前清理编号行,只拼有效正文 |
| 规则全不命中 | 关键词分布在两片或大小写不匹配 | 检查分片边界,提前做小写归一化 |
| 单个分片仍然超限报错 | 估算 token 过于乐观 | 使用“1 汉字=1 token”的保守估算法,再叠加守卫 |
| 并发批处理时报“打开文件过多” | 并发数太高,文件描述符耗尽 | 并发控制池降低到 4 到 6,必要时按目录串行 |
这个速查表基本覆盖了我在本地文档预处理阶段遇到的高频故障。实际使用时,我最推荐的排查路径是:先看解析层输出是否干净,再看分片切得是否合理,最后才怀疑规则和模型。绝大多数诡异的 bug 都出在前两层。
这个模块现在还在办公室内部跑着,最让我有感觉的反而是 L0 硬规则带来的确定性收益。以前用模型自动分类,总有几个文档会被分错,现在规则层先把能确定的全确定了,剩下一小部分才交给上层模型判断,整体准确率一下就上去了。后续我准备把 L0 规则做成可视化配置界面,让行政同事也能自己加规则。如果你也准备在本地模型前面加一道预处理,建议从这条链路的“分片边界”开始折腾,那才是最出活的地方。