多模态文档拆分
一、问题:文本拆分器为什么不够用
给湖北理工计算机学院官网助手做 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 # 该页的截图
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
几个关键决策:
-
temperature=0.1——设高了 JSON 结构会飘,同一页跑两次输出不一样,后续拆分就没法做了。 -
text_preview 传进去 ——先用传统工具粗提文字,模型看到后会校准自己的 OCR 输出,改字率明显下降。对中文尤其重要。
-
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 格式,一行代码不用改。
十、踩过的坑
-
多栏排版翻车。学术论文的双栏布局,直接送模型阅读顺序会乱——从左栏读到右栏再跳下一行。解决:先用布局分析工具做物理分割,切成单栏再送模型。
-
图片不是越大越好。4000px 以上模型能处理的"有效像素"有上限。传 6000×4000 的照片 token 爆了,识别效果反而比缩到 2000px 宽更差。预处理时把长边缩到 2000px。
-
复杂公式别指望 OCR。LaTeX 级别公式模型只能近似识别,做不到等价还原。离散数学、算法复杂度那类公式密集的页面,抽出来单独用公式识别工具处理。
-
原图存储成本。如果每页都存 base64 图片,数据库膨胀很快。只给 figure chunk 存原图,text chunk 不存。区分后存储量降 70%。
十一、什么时候值得搞
如果只处理纯文本 Markdown,这套管线完全多余——一个标题拆分器就搞定了。
但如果有图片、表格、PPT、扫描件——这可能是目前唯一靠谱的方案。一页 A4 处理成本约 0.05 元,但检索准确率从 40% 拉到 85% 以上。
湖北理工的真实场景:教务处上传了《计算机学院实验室设备配置图》,纯图片。文本拆分器直接跳过。用了这套管线后,视觉模型输出 "该图展示实验室网络拓扑:核心交换机 → 6 台接入交换机 → 120 台学生机,服务器区包含 3 台戴尔 R740",学生搜"实验室服务器型号"直接命中。
十二、总结
核心三条:
- 拆分前先"翻译" ——用视觉模型把图片变成结构化 JSON,再在文字上拆分。不要直接拆原始格式。
- 两类 chunk ——text chunk 存文字描述,figure chunk 存图片描述 + 原图。检索统一,渲染分流。
- 参数要点 ——DPI 200、temperature 0.1、长边缩到 2000px。三个参数直接影响 token 成本和 JSON 稳定性。