多模态文档拆分

一、问题:文本拆分器为什么不够用

给湖北理工计算机学院官网助手做 RAG,第一个坑就是文档拆分。教务处上传的资料什么格式都有——PDF 实验指导书、PPT 课件、Markdown 笔记里夹着截图。

最开始用纯文本拆分,一条规则切到底:

输入: PDF 的一页,包含一段文字 + 一个表格 + 一张架构图

文本拆分器看到的:
  "第3章 数据库设计...学生表包含字段...图3-1系统架构图..."

拆出来的 chunk:
  chunk_01: "第3章 数据库设计...学生表包含字段..."
  chunk_02: "图3-1系统架构图"  ← 只有标题,图的内容全丢了
  chunk_03: "..."              ← 表格被拆成了碎片,行列关系全乱

架构图里的 "API Gateway → Service A → Database" 这条关键信息,文本拆分器根本不知道存在。它是"盲人"——对着一堆字符做切割,看不到视觉元素。

二、解决思路

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

翻译完之后:

  • 架构图变成了 "该图展示了微服务调用链路:API Gateway → Service A/B/C → Database"
  • 表格变成了标准 Markdown 表格
  • 截图上的文字被 OCR 出来,接在页面描述后面

再从这些文字里切 chunk,什么都不会丢。

三、统一管线:三步走

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

核心思想:不在原始格式上拆分,先统一"翻译"成结构化文字,再拆分。

四、第一步:把任何文档变成"图片+文本预览"

不同格式策略不同,但最终输出统一的结构:

class PageUnit:
    page_num: int
    text_preview: str       # 粗提取的文字,给视觉模型当 OCR 锚点
    is_image_heavy: bool    # 纯图页需要更多视觉注意力
    image_base64: str       # 该页的截图

PDF

def pdf_to_units(file_bytes):
    doc = PDFParser(file_bytes)
    images = pdf_to_images(file_bytes, dpi=200)    # 每页渲染成 PNG
    units = []
    for i, (page, img) in enumerate(zip(doc, images)):
        text = page.extract_text()
        units.append(PageUnit(
            page_num=i + 1,
            text_preview=text[:500],
            is_image_heavy=len(text.strip()) < 50,
            image_base64=img_to_base64(img),
        ))
    return units

教训:DPI 别设太高。 最开始设了 300 DPI,单页 token 飙到 2 万,30 页文档塞不进模型窗口。降到 200 DPI,肉眼没差别,token 少了近一半。

PPT

PPT 的特点是视觉布局很重要——标题在哪、正文在哪、截图放哪——排版信息文本提取全丢。

def ppt_to_units(file_bytes):
    slide_images = PPTRenderer.render_slides(file_bytes)  # 每页 slide 渲染成 PNG
    return [
        PageUnit(
            page_num=i + 1,
            text_preview="",             # PPT 文字嵌在图片里,靠视觉模型 OCR
            is_image_heavy=True,         # PPT 每页都是视觉密集型
            image_base64=img_to_base64(img),
        )
        for i, img in enumerate(slide_images)
    ]

Markdown

Markdown 文字已在文件中,但如果引用了图片,模型需要同时看到图片和周围文字:

def markdown_to_units(file_bytes):
    ast = MarkdownParser(file_bytes)
    units = []
    for i, block in enumerate(ast.blocks):
        ref_images = []
        for img_ref in block.image_refs:
            img_data = read_referenced_file(img_ref.path)
            ref_images.append(img_to_base64(img_data))

        units.append(PageUnit(
            page_num=i + 1,
            text_preview=block.text,
            is_image_heavy=False,
            image_base64=ref_images[0] if ref_images else "",
            extra_images=ref_images[1:],    # Markdown 可能有多张图
        ))
    return units

五、第二步:视觉模型"看图说话"

这是核心环节。把每页图片 + 文字预览送多模态模型,输出结构化 JSON。

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

格式:
{
  "text": "页面全部文字,按阅读顺序。表格用 Markdown 格式",
  "figures": [{
    "type": "diagram/chart/screenshot/photo",
    "caption": "图注",
    "summary": "用自己的话描述图的内容——架构图要写清组件和调用关系"
  }],
  "tables": [{"headers": [...], "rows": [[...]]}],
  "key_points": ["关键信息1", "关键信息2"],
  "language": "zh/en"
}

"""  // ← 注意:不要预设 entity type,让模型自己判断图片是架构图/截图/照片


def describe_page(unit: PageUnit) -> dict:
    prompt = DESCRIBE_PROMPT + f"\n文字预览: {unit.text_preview}"

    response = multimodal_llm(
        model="qwen-vl-max",
        images=[unit.image_base64],
        text=prompt,
        temperature=0.1,        # 设低,确保 JSON 结构稳定
        max_tokens=4000,
    )

    # 模型偶尔在 JSON 外面套 markdown 代码块,或前面加废话
    json_text = clean_json_wrapper(response)
    return json.loads(json_text)


def clean_json_wrapper(text: str) -> str:
    # 去掉 ```json ... ``` 包裹
    text = re.sub(r'```\w*\n?', '', text)
    # 如果 JSON 前有 "以下是对该页面的分析:" 之类的废话,从第一个 { 截起
    start = text.find('{')
    end = text.rfind('}')
    if start != -1 and end != -1:
        return text[start:end+1]
    return text

几个关键决策:

  1. temperature=0.1 ——设高了 JSON 结构会飘,同一页跑两次输出不一样,后续拆分就没法做了。

  2. text_preview 传进去 ——先用传统工具粗提文字,模型看到后会校准自己的 OCR 输出,改字率明显下降。对中文尤其重要。

  3. is_image_heavy 标记 ——整页架构图时模型会分配更多注意力到视觉元素,文字反而次要。这个标记可以在 prompt 里加一句"这个页面以视觉内容为主"来引导模型。

六、第三步:在描述文字上做语义拆分

到这一步,所有视觉信息已经变成了文字。接下来用任意文本拆分器:

def semantic_chunk_from_description(desc: dict, page_num: int) -> list[dict]:
    # 把图描述、表格、关键信息全拼进完整文字
    composite = desc.get("text", "") + "\n"
    for fig in desc.get("figures", []):
        composite += f"[{fig['type']}]: {fig['summary']}\n"
    for table in desc.get("tables", []):
        composite += table_to_markdown(table) + "\n"
    composite += "\n".join(desc.get("key_points", []))

    # 在这段文字上切 chunk(用任何 text splitter)
    raw_chunks = text_splitter(composite, chunk_size=800, overlap=100)

    return [
        {"content": c, "page_num": page_num, "chunk_type": "text"}
        for c in raw_chunks
    ]

对图片单独建 figure chunk:

def extract_figure_chunks(desc: dict, page_image_b64: str, page_num: int) -> list[dict]:
    figure_chunks = []
    for fig in desc.get("figures", []):
        figure_chunks.append({
            "content": f"[{fig['type']}] {fig.get('caption','')}: {fig['summary']}",
            "page_num": page_num,
            "chunk_type": "figure",
            "figure_summary": fig["summary"],
            "source_image_b64": page_image_b64,   // 前端渲染原图用
        })
    return figure_chunks

figure chunk 的 content 是自然语言描述,能做 embedding 被向量检索命中。source_image_b64 存原图,前端渲染时展示原图。

七、完整管线串起来

def process_document(file_bytes, file_type: str) -> int:
    all_chunks = []

    # Step 1: 格式解析 → 统一的 PageUnit 列表
    if file_type == "PDF":
        units = pdf_to_units(file_bytes)
    elif file_type == "PPT":
        units = ppt_to_units(file_bytes)
    elif file_type == "Markdown":
        units = markdown_to_units(file_bytes)
    else:
        units = [PageUnit(page_num=1, text_preview="",
                          is_image_heavy=True, image_base64=img_to_base64(file_bytes))]

    # Step 2 + 3: 每页描述 → 拆分
    for unit in units:
        desc = describe_page(unit)
        text_chunks = semantic_chunk_from_description(desc, unit.page_num)
        figure_chunks = extract_figure_chunks(desc, unit.image_base64, unit.page_num)
        all_chunks.extend(text_chunks)
        all_chunks.extend(figure_chunks)

    # Step 4: 向量化入库
    for chunk in all_chunks:
        chunk["vector"] = embedding(chunk["content"])
        vector_db.insert(chunk)

    return len(all_chunks)

八、检索时的处理

存入了两类 chunk(text / figure),检索时统一走向量搜索,前端根据 chunk_type 决定渲染方式:

def retrieve_and_render(query: str, top_k: int = 10):
    chunks = vector_search(query, top_k=top_k)

    for chunk in chunks:
        if chunk["chunk_type"] == "figure":
            render_image(chunk["source_image_b64"])      # 显示原图更直观
            render_caption(chunk["content"])
        else:
            render_text(chunk["content"])

不需要维护两套检索系统。统一向量搜索,chunk_type 决定前端渲染文字还是原图。

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

几个多模态模型的对比:

指标 GPT-4o Gemini Flash 千问 Qwen-VL-Max
中文 OCR 偶尔翻车
JSON 输出 稳定 较稳定 较稳定(temperature=0.1 配合)
单页成本 ~0.08元 ~0.01元 ~0.05元
窗口大小 128K 1M 128K

选千问的原因:中文识别靠谱 + 价格适中 + 阿里云 DashScope API 兼容 OpenAI 格式,一行代码不用改。

十、踩过的坑

  1. 多栏排版翻车。学术论文的双栏布局,直接送模型阅读顺序会乱——从左栏读到右栏再跳下一行。解决:先用布局分析工具做物理分割,切成单栏再送模型。

  2. 图片不是越大越好。4000px 以上模型能处理的"有效像素"有上限。传 6000×4000 的照片 token 爆了,识别效果反而比缩到 2000px 宽更差。预处理时把长边缩到 2000px。

  3. 复杂公式别指望 OCR。LaTeX 级别公式模型只能近似识别,做不到等价还原。离散数学、算法复杂度那类公式密集的页面,抽出来单独用公式识别工具处理。

  4. 原图存储成本。如果每页都存 base64 图片,数据库膨胀很快。只给 figure chunk 存原图,text chunk 不存。区分后存储量降 70%。

十一、什么时候值得搞

如果只处理纯文本 Markdown,这套管线完全多余——一个标题拆分器就搞定了。

但如果有图片、表格、PPT、扫描件——这可能是目前唯一靠谱的方案。一页 A4 处理成本约 0.05 元,但检索准确率从 40% 拉到 85% 以上。

湖北理工的真实场景:教务处上传了《计算机学院实验室设备配置图》,纯图片。文本拆分器直接跳过。用了这套管线后,视觉模型输出 "该图展示实验室网络拓扑:核心交换机 → 6 台接入交换机 → 120 台学生机,服务器区包含 3 台戴尔 R740",学生搜"实验室服务器型号"直接命中。

十二、总结

核心三条:

  1. 拆分前先"翻译" ——用视觉模型把图片变成结构化 JSON,再在文字上拆分。不要直接拆原始格式。
  2. 两类 chunk ——text chunk 存文字描述,figure chunk 存图片描述 + 原图。检索统一,渲染分流。
  3. 参数要点 ——DPI 200、temperature 0.1、长边缩到 2000px。三个参数直接影响 token 成本和 JSON 稳定性。