多模态文档拆分

一、问题:为什么文本 splitter 不够用

给 Agent 做 RAG 的时候,第一个坑就是文档拆分。最开始我用 LangChain 的 RecursiveCharacterTextSplitter,纯文本场景跑得好好的,直到有一天用户传了一份 PDF——里面有表格、有架构图、有注释——拆完之后搜出来的 chunk 全是碎片,表格的行列关系全丢了,架构图直接消失。

试了几个开源方案,都不太行:

  • PyMuPDF 直接提文本:表格变成一团乱麻,"单元格1单元格2单元格3"全糊在一起
  • LangChain 的 UnstructuredPDFLoader:对中英文混排效果一般,表格提取偶尔错行
  • 纯 OCR(Tesseract):慢,而且只能拿文字,图表全丢

然后我发现,这几个方案就没有一个能同时处理"文字 + 表格 + 图片 + 排版"这四样东西的。每个都只解决了一个子问题。

想了几天,核心矛盾其实是:文本 splitter 是"盲人"——它对着一堆字符做切割,根本不知道这一页上还有一张架构图和两个表格。 要解决这个问题,得让拆分工具先"看到"整页内容。

二、解决思路

一句话概括:在拆分之前,先用多模态 LLM 把每一页"翻译"成结构化的文字描述,然后在这个描述上做语义拆分。

翻译完之后,图片变成了 "一张描述微服务架构的数据流图,展示了 API Gateway → Service A/B/C → Database 的调用链路",表格变成了标准的 Markdown 表格。再从这些文字里切 chunk,就不会丢失任何东西了。

三、为什么选了千问 Qwen-VL

当时对比了几个多模态模型:

  • GPT-4o:效果最好但贵,批量处理 100 页 PDF 的话成本控制不住
  • Gemini 1.5 Flash:便宜但中文 OCR 偶尔翻车,表格识别有时丢列
  • 千问 Qwen-VL-Max:中文好、价格适中、128K 上下文一次能塞二三十页

最后选了千问,主要是三点:

  1. 中文识别靠谱——我用一份中文技术手册测了 20 页,OCR 结果基本没改字
  2. 能输出 JSON——我做结构化抽取不用再写后处理正则
  3. 支持阿里云 DashScope API,OpenAI 兼容格式,一行代码都不用改

价格方面,qwen-vl-max 按 token 计费,一页 A4 大概 9800 token,20 页的文档处理下来不到一块钱。

四、我的拆分管线

整套流程分三步:

任意文档 → [格式解析:每页转图片] → [千问VL:视觉→结构化JSON] → [语义拆分:在描述文本上切chunk]

4.1 第一步:格式解析,把任何文档变成"图片+文本预览"

PDF — 我用 pdf2image 把每页转成 200 DPI 的 PNG,同时用 PyMuPDF 快速提一下文本做预览。预览的作用是给千问提供一个文字锚点,降低 OCR 错字率。

from pdf2image import convert_from_bytes
import fitz

def pdf_to_units(file_bytes):
    doc = fitz.open(stream=file_bytes, filetype="pdf")
    images = convert_from_bytes(file_bytes, dpi=200)
    units = []
    for i, (page, img) in enumerate(zip(doc, images)):
        text = page.get_text()
        is_image_heavy = len(text.strip()) < 50
        img_b64 = pil_to_base64(img)
        units.append({
            "page": i + 1,
            "text_preview": text[:500],
            "is_image_heavy": is_image_heavy,
            "image_base64": img_b64
        })
    return units

这里有一个教训:DPI 别设太高。我最开始设了 300 DPI,想着越清晰越好,结果单页 token 直接飙到 2 万,30 页的文档塞不进千问的 128K 窗口。后来降到 200 DPI,肉眼完全看不出差别,token 却少了将近一半。

PPT — 转成图片最简单,LibreOffice 一个命令就能把 .pptx 的每一页 slide 渲染成 PNG:

soffice --headless --convert-to png --outdir /tmp/slides input.pptx

拿到图片之后跟 PDF 一样送千问。PPT 的特点是视觉布局很重要——标题在哪、正文在哪、截图放哪——这些排版信息文本提取会丢干净,但千问看图能准确区分。

Markdown — 遇到 ![图片](path) 引用时,我写了个小函数把引用的图片文件读出来转成 base64,跟 markdown 文字一起送千问。这样千问能同时看到"这段文字说了什么"和"这张截图显示了什么",chunk 的语义完整度高很多。

纯图片(截图、白板照、扫描件) — 最简单,整张直接送,一行额外代码都不用写。

4.2 第二步:千问 VL 描述

这是核心环节。我要求千问把每一页输出成一个 JSON:

DESCRIBE_PROMPT = """分析这个文档页面,输出严格 JSON。

格式:
{
  "text": "页面全部文字,按阅读顺序。表格用 Markdown 格式",
  "figures": [{"type": "diagram/chart/screenshot/photo", "caption": "图注", "summary": "图的内容概括"}],
  "tables": [{"headers": [...], "rows": [[...]]}],
  "key_points": ["关键信息1", ...],
  "language": "zh/en"
}
"""

几个关键细节:

  • temperature 设 0.1。设高了 JSON 格式会飘,同一页跑两次输出结构不一样,后续的语义拆分就没法做了
  • text_preview 提前传进去。这是 OCR 的锚点——先让 PyMuPDF 粗提一遍文字,千问看到这些文字后会校准自己的 OCR 输出,改字率明显下降
  • is_image_heavy 标记。纯图的页面(比如整页的架构图),千问需要分配更多注意力到视觉元素上,文字反而次要

实际跑了一个月,JSON 格式的解析成功率大概 95%。剩下的 5% 里,一半是千问在 JSON 外面套了 markdown 代码块(我加了正则自动剥离),另一半是某些特殊排版(比如竖排文字)导致 OCR 结果异常。

4.3 第三步:在描述文本上做语义拆分

到这一步,所有视觉信息已经变成了文字。接下来随便用什么 text splitter 都可以:

from langchain_text_splitters import RecursiveCharacterTextSplitter

def semantic_chunk_from_description(desc, page_num):
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=800, chunk_overlap=100,
        separators=["\n\n", "\n", "。", ".", ";", ";", " "]
    )
    # 把图描述、表格、关键信息全拼进文字里
    composite = desc.get("text", "") + "\n"
    for fig in desc.get("figures", []):
        composite += f"[{fig['type']}]: {fig['summary']}\n"
    composite += "\n".join(desc.get("key_points", []))
    return splitter.split_text(composite)

对于千问识别出的每个 figure(架构图/截图/图表),我单独建一个 "figure chunk"。它的 content 是千问对这张图的自然语言描述,可以做 embedding 被检索到。metadata 里存原始页面的 base64 图片,前端渲染时可以不看描述直接看原图。

五、存储怎么搞

我的选择是不搞太复杂——一个 Redis Stack 实例同时搞定向量搜索和元数据存储。RediSearch 的 KNN 做语义检索,ReJSON 存完整元数据。不用额外部署 MongoDB。

text chunk + figure chunk → embedding → Redis Stack (向量索引 + JSON 文档)
原始 PDF/PPT/MD           → 阿里云 OSS (对象存储,可下载)

索引就一个:

FT.CREATE idx:chunks ON JSON PREFIX 1 chunk: SCHEMA
  $.content AS content TEXT
  $.chunk_type AS chunk_type TAG
  $.embedding AS embedding VECTOR FLAT 6 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE

六、踩过的坑

  1. 多栏排版翻车。学术论文那种双栏布局,直接送千问的话阅读顺序会乱——它会从左栏读到右栏再跳到下一行。解决方案是先跑 LayoutParser 做物理分割,切成单栏再送千问。

  2. 图片分辨率越高不一定越好。4096×3072 以上的图,千问能处理的"有效像素"是有上限的。我踩过的坑是传了一张 6000×4000 的照片,token 爆了,识别效果反而比缩到 2000px 宽更差。

  3. JSON 输出偶尔不可靠。虽然要求了 temperature=0.1,千问偶尔还是会在 JSON 前后加废话——"以下是对该页面的分析:{...}"。加一个正则清理函数就行,不是什么大问题。

  4. 复杂公式别指望 OCR。LaTeX 级别的数学公式,千问只能近似识别,做不到等价还原。我有一次拆一篇 ML 论文,公式全变成了乱码,最后不得不把公式页单独抽出来用 MathPix 处理。

七、值不值得搞

说实话,如果你只处理纯文本的 Markdown,这套管线完全多余——一个 MarkdownHeaderTextSplitter 就搞定了。

但如果你的文档里有图片、有表格、有 PPT、有扫描件——那这可能是目前唯一靠谱的方案。20 页 PDF 处理成本不到一块钱,但检索准确率从 40% 直接拉到 85% 以上。我自己项目的 RAG 回复质量提升是肉眼可见的——之前用户问"这个架构图里的 Redis 集群怎么部署的",向量检索完全瞎猜;用了这个管线之后,千问在 chunk 里直接写了"该图展示了 Redis Cluster 的主从复制架构,包含 3 主 3 从",检索一击命中。

还有一个小好处:因为千问已经把视觉翻译成了文字,所以你不需要在检索环节维护两套系统(文本检索 + 图片检索)。所有内容统一走向量搜索,前端根据 chunk_type 决定渲染文字还是原图。简单且有效。