ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

混合格式文档解析与OpenFiles实测:格式嗅探、转换、索引与摘要

混合格式文档解析与OpenFiles实测:格式嗅探、转换、索引与摘要

1. 三种后缀看着都像文档,底层其实是三套完全不同的东西

同一批资料里扔进.docx、.doc、.rtf三种后缀,是最容易被忽略的麻烦源头。多数人以为它们只是"新旧版本差异",点开就能看,实际这三者从容器结构到编码方式没有任何血缘关系,能打开其中一种,不代表能打开另外两种。我最近拿一批约 3000 个文件的混合样本做实测,就是想验证一件事:一款文档工具号称支持这三种格式,到底是真解析,还是靠系统里装着的办公套件在后面兜底。这个区别在批量场景下会被放大到无法忽视——单机随手开一个文件,兜底方案毫无痛感;一旦要遍历目录、建索引、做全文检索,兜底方案会直接把速度拖到不可用。

这里说的 OpenFiles,我把它当成一个"本地文档工作台"来用:给一个目录,它负责把里面的文档渲染成可预览的样子、抽出文字建可搜索的索引、再按需调用模型做摘要。下面所有内容都是围绕这三个能力做的实测拆解,包括它做得好的地方和它明显有边界的地方。不管你手上是一堆合同、论文、还是十几年前的存档资料,只要格式混杂,这套思路都能直接搬过去用。

1.1 把 .docx 当成一个压缩包来看,很多问题当场就通了

.docx是 Office 2007 之后的标准格式,本质是一个 ZIP 容器,里面装的是若干 XML 和资源文件。这条认知极其重要,因为它意味着判断一个 .docx 是否损坏,第一步根本不需要打开办公软件。把后缀改成.zip直接解压,或者在命令行里列一下目录,立刻就能知道文件是完整的容器、还是只剩一个空壳。

# 不需要任何 Office 组件,直接看容器里有什么 unzip -l "sample.docx" | head -30

正常情况下你会看到类似这样的结构:[Content_Types].xml、_rels/.rels、word/document.xml、word/styles.xml、word/numbering.xml、word/media/(图片)、word/footnotes.xml等。其中word/document.xml是正文主体,word/styles.xml决定样式,word/numbering.xml决定编号和项目符号。如果word/document.xml缺失或者被截断,这个文件在绝大多数工具里都会打不开,而如果只是word/media/里的某张图坏了,文件照样能开,只是图显示不出来。

这条判断逻辑在排查里特别好用。我遇到过一个案例:文件能打开,但打开后是全空白,一个字符都没有。解压一看,word/document.xml只有 300 多字节,内容是空的<w:body/>。也就是说文件本身没坏,是当时生成它的程序写了个空文档进去。这种情况你换十个工具都救不回来,因为里面确实没东西——先确认"有没有内容",再去纠结"为什么显示不出来",能省掉大量瞎折腾的时间。

1.2 .doc 是二进制老古董,.rtf 反而是纯文本

.doc走的是完全另一条路。它是 OLE 复合文档结构(Compound File Binary Format),一个文件里像小型文件系统一样挂着多个"流"。它的文件头是固定的 8 个字节:D0 CF 11 E0 A1 B1 1A E1。这串东西记不住没关系,工具会自动识别,但你要知道它的含义:它是二进制容器,不是文本,任何试图用记事本打开它的行为都只会看到乱码。

.rtf则完全相反,它是纯 ASCII 的控制字标记语言,开头一定是{\rtf1\ansi这类字样,正文用\'xx表示特殊字符,图片以十六进制字符串直接内嵌在文本里。RTF 的历史使命就是"跨软件交换格式",所以它的兼容性在三种里其实是最好的——很多老系统导出的"Word 文档"实际是 RTF 改了后缀,这恰恰是后面要讲的一大坑。

格式容器本质文件头特征纯文本可读性常见损坏表现
.docxZIP + XMLPK\x03\x04解压后可读容器截断、document.xml 为空
.docOLE 复合文档D0 CF 11 E0完全不可读流结构损坏、被误加密
.rtf纯文本标记{\rtf1直接可读编码声明错误、内嵌对象截断

这张表建议存下来。后面所有的排查动作,本质上都是在回答一个问题:这个文件声称自己是什么,它实际上是什么。

1.3 扩展名会说谎,这是打不开场景里最高频的一条

我统计了手上那批样本里"打不开"的文件,大约六成的原因不是文件坏了,而是后缀和真实格式不匹配。典型来源有几类:邮件系统转发时给附件统一加了后缀;下载工具在文件名后面追了.docx;某业务系统导出的报表,实际是 HTML 或者 CSV,但被强行命名成.doc;还有人手工把.rtf改成.doc想"升级格式",结果只是换了个皮。

判断方法非常直接,读前几个字节就够了,不需要装任何东西:

# 只读前 8 字节做格式嗅探,比看后缀靠谱得多 def sniff(path): with open(path, "rb") as f: head = f.read(8) if head[:2] == b"PK": return "zip-based (docx/xlsx/pptx 等)" if head == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1": return "ole-based (doc/xls/ppt 等)" if head[:5] == b"{\\rtf1": return "rtf" if head[:5].lower() in (b"<!doc", b"<html"): return "html" return "unknown / 可能是纯文本"

跑一遍这个函数,你会对自己手上的资料库产生全新的认识。我那次跑完,发现有 200 多个.doc实际是 HTML,还有几十个.docx其实是 RTF。面对这种文件,任何严格格式解析的工具都会直接报错,而正确的做法是先归位、再处理——把后缀改成真实格式,往往文件立刻就能开了,什么问题都没有。

2. OpenFiles 的三块能力,得拆开来看才知道边界在哪

把"支持文档预览"这句话拿去衡量任何工具都不够用,因为它至少包含三件不同的事:渲染、提取、理解。渲染是把排版还原给人看,提取是把文字抽出来给搜索用,理解是交给模型做摘要。这三件事的技术难度和失败模式完全不同,混在一起谈就很容易踩坑。我在 OpenFiles 上的实测结论是:它的预览层做得比较"重",走的是真实排版还原;搜索层依赖它自己的索引结构,速度快但受提取质量制约;AI 总结层则完全取决于前面两步的产出干不干净。

2.1 预览层:渲染得好不好,Fonts 是一道硬门槛

预览这一层最容易被低估。文本内容抽对了不等于"看起来对",排版还原涉及字体替换、表格边框、页眉页脚定位、编号续接、图文混排等一系列问题。我在三类文件上分别测了同一份带表格和多级编号的文档,结果差异明显:.docx还原度最高,编号和表格基本正确;.doc会出现行距偏移和部分边框丢失;.rtf在字体回退上最容易出问题,尤其是用了系统里没有的字体时。

字体这条我要单独说,因为它造成的误解最多。文档在预览里"变形"了,九成不是工具的问题,是这台机器上没有原文档用的字体。比如文档正文用的是某种较窄的衬线字体,你机器上只有默认字体,替换之后字符宽度变了,原本一行的文字被挤成两行,整个版面就跟着往下推,看起来像"排版全乱了"。解决办法不是换工具,而是把字体装上,或者在导出时做字体嵌入。

提示:如果一份文档在别人的机器上正常、在你的机器上变形,先查字体,再看版本,最后才怀疑工具。

表格还原是另一个观察点。横跨多页的长表格、嵌套表格、合并单元格,这三类是渲染引擎的老大难。我的建议是:预览用来快速判断"这是什么文件、大概讲了什么",不要用它做最终版式确认。真要确认版式,还是得在原生环境里打开。这个定位想清楚了,预览层的很多小瑕疵就不再是问题了。

2.2 搜索层:能打开只是及格,能搜到才是本事

全文搜索的价值比预览高一个数量级,尤其当你面对几百上千份文档时。但这里有个容易被忽略的技术细节:中文文档的搜索和英文完全不是一回事。英文靠空格天然分词,中文没有空格,必须做分词处理,否则"合同期限"这四个字必须完整出现才能命中,"合同"和"期限"分开搜就搜不到。

OpenFiles 的搜索走的是先建索引再查询的路线,所以第一次导入目录时会有一个明显的建索引过程。这一步的耗时和你有没有做预处理强相关。我的实测经验是:把混合格式先统一转换一遍,再导入,整体时间比直接扔进去让工具硬扛要短得多,而且失败率低得多。原因是.doc这类二进制格式的解析本身慢,还容易在某些畸形文件上卡住重试,统一转成.docx或纯文本之后,解析路径就变成了一条直线。

搜索命中之后还有一个体验差异点:是给出页码/位置,还是只给你一整篇文档让你自己找。前者在长文档场景下几乎是刚需。我测的一份 180 页的说明文档,搜到关键词后能直接跳转到对应段落,这个体验和"打开文件再按查找"是两个世界。选工具的时候,搜索能不能定位到段落,比搜索能不能搜到,更值得关注。

2.3 摘要层:喂给模型的内容干净不干净,决定结果能不能用

AI 总结这一层最容易被神化,也最容易被一票否决。我的判断标准很朴素:摘要有没有说出文档里具体的东西,还是只说了"本文介绍了某某相关内容"。前者说明模型真的看到了内容,后者说明它只看到了标题或者一段含糊的开场。

实测下来的关键变量是喂进去的文本质量,而不是模型本身。同一份文档,我用两种方式喂给摘要功能:一种是把提取到的原始文本直接丢进去,另一种是做了基础清洗(去页眉页脚重复、去空行、合并被硬断开的行、把表格转成行列结构)。结果差别很大:未清洗的版本,摘要里会出现大量"该文档分为若干部分"这种没有信息量的话,因为页眉页脚在每一页重复,把有效信息稀释掉了;清洗过的版本,能准确说出文档的核心结论、涉及的数量、时间节点。

这里补充一个必须知道的机制:模型有上下文长度上限,长文档一定要分块。一份 200 页的文档不可能一次塞进去,正确的做法是按章节或按固定长度切块,分别摘要,再对摘要做二次归并。这个流程听起来麻烦,但如果不做,结果就是前面几章被详细总结、后面全部丢失,而使用者往往察觉不到——摘要看起来完整,其实只覆盖了文档的三分之一,这是最危险的一种错误。

3. 从一堆杂乱文件到可检索可摘要的文档库:完整实操链路

下面这条链路是我反复跑过、也在不同规模的样本上验证过的顺序。核心原则只有一句:先归位、再统一、后加工。顺序错了,后面每一步都在给前面还债。

3.1 第一步:格式探测与分类,别急着转

拿到目录的第一件事不是转换,而是盘点。把所有文件过一遍格式嗅探,产出一张清单:后缀声称是什么、实际是什么、两者是否一致、文件大小是否异常(比如 0 字节或者只有几百字节)。

import os, csv rows = [] for root, _, files in os.walk("./docs"): for name in files: p = os.path.join(root, name) size = os.path.getsize(p) rows.append({ "path": p, "ext": os.path.splitext(name)[1].lower(), "real": sniff(p), "size": size, "suspicious": size < 1024 }) with open("inventory.csv", "w", newline="", encoding="utf-8-sig") as f: w = csv.DictWriter(f, fieldnames=rows[0].keys()) w.writeheader(); w.writerows(rows)

这一步的收益远超预期。我那次盘点直接筛出了三类必须单独处理的对象:零字节和超小文件(多半是生成时失败的空壳)、后缀与内容不符的文件(改名即可修复)、加密文件(这类会在下一步全失败,得先识别出来)。如果不做盘点直接批量转换,你会得到一大堆失败日志,然后再一条条去查原因,效率差好几倍。

还有一个细节值得提:加密的 .docx 在磁盘上其实不是 ZIP 容器,而是 OLE 复合文档。也就是说,一个后缀写着.docx、文件头却是D0 CF 11 E0的文件,大概率是被设了打开密码。这个判断能让你在转换之前就把加密文件挑出来,而不是等报错。

3.2 第二步:统一转换,把三条解析路径合成一条

转换的目标很明确:把.doc和.rtf都变成.docx或者纯文本,让后续只需要处理一种格式。转换工具的选择上有两条路,各有取舍。

第一条是办公套件的无头模式,也就是不弹界面、命令行直接转换。优点是还原度高,编号、表格、页眉页脚基本原样保留;缺点是启动开销大,每个文件都要起一个进程,批量跑起来慢,而且遇到畸形文件可能卡死,必须加超时。

# 无头模式批量转换:把 doc/rtf 统一转成 docx # 注意:每个文件单独调用,避免一个坏文件拖垮整批 for f in ./docs/*.doc ./docs/*.rtf; do timeout 60 soffice --headless --convert-to docx "$f" --outdir ./out 2>>convert.log done

第二条是纯文本提取库,比如处理.docx的直接读 XML,处理.rtf的用专门的解析器,处理.doc的用反编译工具。优点是快、稳、无依赖界面;缺点是完全丢格式,表格会被拍平成一行行文字,编号可能丢失。如果你只做搜索和摘要,这条路其实够用。

# 只提取文字:适合建索引和做摘要,不做版式还原 from docx import Document def extract_docx(path): doc = Document(path) parts = [] for para in doc.paragraphs: if para.text.strip(): parts.append(para.text) for table in doc.tables: for row in table.rows: cells = [c.text.strip().replace("\n", " ") for c in row.cells] parts.append(" | ".join(cells)) return "\n".join(parts)

我的实际选择是混合策略:需要保留版式的走无头转换,只需要文字的直接提取。判断依据很简单——这份文档我会不会拿去看排版?会,就走前者;只用来搜索和摘要,走后者。分批处理下来,整体时间能压掉一半以上。

注意:转换一定要设超时。我遇到过一份.doc让转换进程挂了十几分钟不返回,没有超时保护的话整批任务就卡在那里。单个文件 60 秒足够,超过就是有问题,直接记日志跳过。

3.3 第三步:导入 OpenFiles,把索引建对

转换完成后的目录结构建议保持扁平,文件名做一次规范化:去掉空格和特殊符号,统一加时间前缀,同名的文件加序号。这一步听起来琐碎,但直接影响后面搜索结果的排序和标题显示。索引里最难看的就是一屏"未命名文档"和"副本 副本 副本"。

导入时有一个参数值得单独调:分块大小。这个值决定索引的粒度——切得太粗,搜索命中定位不到具体段落;切得太细,上下文被打断,摘要质量下降。我的经验值是技术类和合同类文档按 500 到 800 字切块比较合适,叙事类可以放大到 1200 字左右。这个没有标准答案,跑一轮搜几个自己熟悉的关键词,看返回的段落是不是"能读懂的一段话",就能判断调得对不对。

导入完成后,别急着做摘要,先验证搜索。挑三到五个你确定出现在某份文档里的词组,逐个搜一遍,看命中位置准不准、返回的片段是不是完整句子。这一步相当于给整个索引做体检,能提前发现编码问题、截断问题、提取为空的问题。我见过提取出来是乱码的.rtf,根源是文件里声明的编码是某西欧代码页,而实际内容是中文,这种文件在搜索里永远是搜不到的,必须在导入前就修掉。

3.4 第四步:AI 摘要的提示词和参数怎么定

摘要这块我踩过的坑最多,总结下来就三条。

第一,明确告诉它输出结构。不要只说"总结一下",而是说清楚要什么:"按三部分输出:这份文档在讲什么业务、涉及哪些具体数字和时间、结论或要求是什么;不要复述目录,不要写'本文介绍了'这类开场"。结构一旦明确,废话立刻少一大半。

第二,给足上下文边界。分块摘要时,每一块都要带上文档标题和"这是第 N 部分,共 M 部分"的说明,否则模型会把中间某一块当成完整文档,写出一个局部结论冒充全局结论。做二次归并时,也要明确告知这是对多段摘要的整合。

第三,温度参数往低调。文档摘要属于信息还原任务,不是创作任务,温度调低能让输出更贴原文、更少发挥。我一般把这个值压到很低,实测下来对"编造数字"这个毛病有明显抑制作用。

还有一个必须做的动作:摘要结果一定要能溯源。也就是说,摘要里提到的每个关键结论,都要能点回到原文的对应位置去核对。没有溯源能力的摘要,在正式场景里基本没法用,因为你无法确认它是读出来的还是编出来的。这条标准比摘要写得多漂亮重要得多。

4. 打不开的十种现场:一条能复现的排查链路

下面这条链路是我处理"文件打不开"类问题的固定顺序,从最省事的判断开始,逐步深入。核心思路是二分法:先用最便宜的手段判断"内容在不在",再决定往哪个方向查。

4.1 从"容器是否完整"开始二分

第一个动作永远是看文件大小和文件头。大小是 0,直接结束,没有内容。大小正常但文件头不对,说明后缀在说谎,改名或者按真实格式处理。文件头正确但打不开,进入下一层。

.docx的下一层判断是能不能解压。命令行列一下目录,如果能列出结构但word/document.xml异常小,说明是空文档或者写入中断;如果连解压都失败,说明容器被截断,文件传输过程中断了。.doc的下一层判断是能不能被解析器识别出内部流,识别不出来基本就是结构损坏。.rtf最简单,用文本编辑器打开看开头是不是{\rtf1,如果是但显示乱码,那是编码声明的问题,改声明或者按字节重新解码即可。

现象最可能的原因快速验证动作处理方式
双击无反应或提示格式不符后缀与真实格式不符读文件头字节改回真实后缀
能打开但内容全空生成了空文档解压看 document.xml 大小无法恢复,找源文件
提示需要密码文件被加密看文件头是否为 OLE 结构需要密码或走解密流程
打开后排版全乱缺字体换一台装全字体的机器试安装字体或做字体嵌入
图片全部显示为红叉内嵌资源丢失解压看 media 目录只能恢复文字部分
大文件打开卡死单文件过大或含大量对象看文件体积和图片数量拆分或转纯文本处理
搜索搜不到某些文档提取为空或编码错误单独检查提取结果修复编码后重建索引
摘要明显只覆盖前半部分上下文超限未分块对比摘要与原文后半段改成分块加归并
表格内容在摘要里消失提取时表格被丢弃检查提取脚本是否处理表格补上表格提取逻辑
转换批量失败率高混入了畸形文件和加密文件看失败日志的分布先分类再分批处理

4.2 加密、只读标记和宏这三类干扰要单独防

加密这件事前面提过一半,这里补完整。被密码保护的.docx,磁盘上是 OLE 结构,这意味着它连"是不是 ZIP"这个判断都会失败,容易被误判成"文件损坏"。正确的处理是:识别出 OLE 结构且后缀是.docx,就按加密处理,不要再去尝试解压。这类文件在批量流程里必须显式跳过并记录,否则会污染失败统计,让你误以为文件损坏率很高。

只读标记是另一回事。文件属性里的"只读"、文件系统层面的写保护、从只读介质拷贝过来的文件,这些都可能导致"能打开但不能编辑",用户描述成"打不开",实际是完全不同的两类问题。排查时一定要问清楚:是打不开,还是打开了不能改。这一句问清楚,能省掉半小时。

宏的情况更微妙。带宏的文档后缀通常是.docm,但也有大量.doc里嵌着宏。很多工具出于安全考虑默认不执行、甚至直接拒绝渲染带宏的文档。这属于设计上的保护,不是故障,但对使用者来说表现就是"打不开"。我的建议是:处理来路不明的文档时,一律先转成纯文本或者无宏格式,再去做预览和摘要,既安全又不影响文字内容的利用。

4.3 修复不了的时候,接受"部分恢复"这个结果

有些文件是真的坏了,尤其是存储介质出过问题、或者写入过程中断电的情况。这时候要建立的心态是:能救回文字就是胜利,不要执着于还原版式。

.docx的抢救空间相对大,因为它是分文件的,少了styles.xml顶多样式乱,正文还在;少了部分media只是图丢了。可以尝试只解压word/document.xml然后做纯文本提取,往往能捞回全部文字。.doc的抢救空间小得多,二进制流一旦错位,后面全乱,能提取出前几页就算不错。.rtf因为本身是文本,抢救反而容易——直接把文件当纯文本读,用规则把控制字剥掉,剩下的就是内容。

import re def strip_rtf(raw: str) -> str: # 粗暴但有效的 RTF 去标记:先去掉十六进制字符,再去控制字 raw = re.sub(r"\\'([0-9a-fA-F]{2})", lambda m: bytes([int(m.group(1), 16)]).decode("latin-1"), raw) raw = re.sub(r"\\[a-zA-Z]+-?\d* ?", "", raw) raw = raw.replace("{", "").replace("}", "") return re.sub(r"\s{2,}", " ", raw).strip()

这个函数当然不完美,复杂表格和嵌套结构会乱,但作为"最后手段"够用了。我拿它救过一批十几年前的存档文件,文字全部拿回来,格式惨不忍睹,但对于"我要能搜到里面的内容"这个需求来说,完全达标。

5. 跑完这一轮才明白的几个细节

有些东西是文档里不会写、只有真跑一遍才会撞上的。

编码问题的表现非常隐蔽。一份.rtf在整个流程里都不报错,转换成功、导入成功、摘要也能生成,但搜索永远搜不到。查了半天才发现提取出来的是一串问号。原因在于文件头部声明的字符集和实际内容不匹配,转换时按错误声明解码,中文字符全变成了替代符号。这类问题的检测方法很简单:转换后随机抽几个文件,看一眼提取结果的中间部分,别只看开头,开头往往是标题,标题经常是英文或者数字,看不出问题。

页眉页脚会系统性地污染摘要质量。一份正式文档,页眉写着文件名,页脚写着页码和编号,每页重复一次。提取出来之后,这些内容会均匀散布在全文里,占比可能超过一成。模型的注意力被这些重复内容占掉一部分,摘要就变得空泛。我现在的固定做法是:提取时按"段落是否在全文高频重复"做一次过滤,重复次数超过阈值且长度很短的段落直接丢掉,效果立竿见影。

批量处理的资源占用要有心理准备。无头转换是重操作,同时开太多进程会把机器拖死,反而更慢。我的经验是并发数控制在 CPU 核心数的一半左右比较稳,宁可慢一点,也别把机器跑到无响应。另外输出目录一定要和输入目录分开,转换过程中会生成临时文件,混在一起会导致下一轮遍历时把半成品也当成输入,产生一堆莫名其妙的重复项。

命名规范比想象中重要。索引里的文档标题来自文件名,一屏"文档1""副本""未命名"会让整个搜索体验崩掉。转换阶段顺手做一次重命名,规则可以很简单:来源标识 + 日期 + 原名的有效部分,去掉空格和特殊符号。这一步花五分钟,后面省五小时。

最后一条,关于什么时候不该用 AI 摘要。如果文档的核心价值在表格和数字上——比如对账单、参数表、报价单——摘要基本没用,甚至会误导,因为它会把精确的数字关系压缩成一句含糊的描述。这类文档正确用法是直接看表格,或者用规则做结构化提取。摘要是给叙事型内容准备的工具,不是万能钥匙。这个边界想清楚了,就不会在错误的地方反复怀疑工具不行。

如果这批文档后续还要持续增长,我建议把转换和索引做成定期跑的脚本,而不是每次手工来一遍。新增文件走增量导入,只处理"没见过的路径",老索引不动,这样每次的开销都很小。这条是我在样本量从几百涨到几千之后才补上的,早该这么做。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进