)
week22_project_core_impl最后四周我们一起实战一个基于 OCR 与 Qwen-VL 的文档多模态问答系统。给这个系统起个名字叫 DocuMind-VL。代码已开源From0to1-MLLM-StudyLogweek21 主要是在梳理 OCR、Document AI 和 VLM 文档理解的整体思路。week22 开始把这个思路落成一个可运行的最小项目上传一份 PDF先把 PDF 拆成页面图片再对每页做基础 OCR保存结构化 JSON最后把 OCR 全文和部分页面图片一起交给本地 Qwen3-VL 做问答。这个项目采用“传统 OCR VLM”的混合方案没有直接让 VLM 盲看整份 PDF。OCR 负责把文档中的文字稳定抽出来VLM 负责结合问题、OCR 文本和页面图像做语义理解。这样可以减少视觉 token 压力也方便把中间结果保存下来用于调试、复查和后续扩展。项目目标本周实现的是一个文档问答 alpha 版本核心目标有三个能把 PDF 变成可处理的页面图片。能把每页 OCR 结果保存成结构化数据。能基于 OCR 文本和页面图片调用 Qwen3-VL 回答问题。完整流程如下上传 PDF保存 input.pdfPyMuPDF 渲染页面page_001.png / page_002.png / ...PaddleOCR 基础 OCRocr.json拼接全文 OCR 文本按问题选择相关页面最多 8 页页面图片Qwen3-VL Promptanswer.json输出默认保存在week22_project_core_impl/outputs/{request_id}每次请求会形成一个独立目录里面包含原始 PDF、页面图片、OCR JSON、artifacts 和最终答案。这样做的好处是一次请求的所有中间产物都可以追踪后面发现回答不准时可以先判断问题出在 PDF 渲染、OCR 识别、页面选择还是 VLM 生成。目录结构week22_project_core_impl/ ├── core/ │ ├── config.py # 环境变量和运行参数 │ ├── document_store.py # request_id 目录和输入 PDF 持久化 │ ├── pdf_splitter.py # PDF 拆成页面 PNG │ ├── ocr_engine.py # PaddleOCR 和 PPStructure │ ├── vlm_engine.py # Qwen3-VL 文档问答 │ ├── pipeline.py # 串联 parse_pdf / ask │ └── schemas.py # PageImage / OCRPage / DocumentArtifacts ├── service/ │ └── app.py # FastAPI 服务 ├── scripts/ │ ├── client.py # 调用 HTTP 服务 │ ├── run_pipeline.py # 本地直接跑 pipeline │ └── smoke_test.py # 冒烟测试 ├── frontend/ │ └── index.html # 最小上传问答页面 ├── README.md └── requirements.txt这次实现把逻辑拆成core和service两层FastAPI 里只保留服务入口相关代码。core可以被 CLI、测试脚本和服务复用service只负责 HTTP 上传、参数校验、并发队列和响应返回。核心数据结构schemas.py里定义了几个中间对象PageImage一页 PDF 渲染后的图片路径、页码、宽高。OCRBlock一个文本块包含文字、位置框、置信度和页码。OCRPage一页的 OCR 结果包含页面图片和所有文本块。DocumentArtifacts一次文档解析的完整产物包含 request_id、工作目录、输入 PDF、页面图片、OCR JSON 和可选 Markdown。其中DocumentArtifacts.full_text会把每页 OCR 文本拼成[Page 1] ... [Page 2] ...这个格式很简单但对问答很有用。模型回答时可以知道文本来自哪一页后续如果要让答案带引用页码也可以基于这个结构继续扩展。PDF 拆页PDF 不能直接交给基础 OCR所以第一步是用 PyMuPDF 渲染成 PNGzoomdpi/72.0matrixfitz.Matrix(zoom,zoom)pixpage.get_pixmap(matrixmatrix,alphaFalse)pix.save(str(image_path))这里的DOC_QA_DPI默认是180。DPI 太低会影响 OCR特别是论文、表格和小字号文本DPI 太高会让图片变大OCR 和 VLM 都会更慢。180 是一个偏实用的折中值。拆页后会得到pages/page_001.png pages/page_002.png pages/page_003.png ...如果只想快速调试可以设置DOC_QA_MAX_PAGES3这样只处理前几页避免每次调试都跑完整 PDF。基础 OCROCR 部分使用 PaddleOCR默认配置是PaddleOCR(langlang,ocr_versionPP-OCRv5,use_doc_orientation_classifyFalse,use_doc_unwarpingFalse,use_textline_orientationFalse,devicedevice,)这里先关闭了方向分类、文档矫正和文本行方向识别原因是当前项目优先验证完整链路。如果输入主要是正常论文 PDF页面方向一般是正的先把基础 OCR 跑通更重要。后面要支持扫描件、拍照文档、旋转页面时可以再打开这些预处理能力。OCR 输出会保存到ocr.json{pages:[{page_number:1,image_path:.../pages/page_001.png,width:1488,height:2105,text:...,blocks:[{page_number:1,text:BLIP: Bootstrapping Language-Image Pre-training,box:[[...],[...],[...],[...]],score:0.98}]}]}保存box和score的意义在于现在问答只用了纯文本和页面图片但后续如果要做高亮、引用定位、版面分析或结果可视化位置框和置信度都是必要信息。PPStructure Markdown项目里还保留了可选的 PPStructure 路径DOC_QA_ENABLE_PPSTRUCTUREtrue或者 CLI 加--enable-ppstructurePPStructure 会尝试输出 Markdown更接近文档结构化结果例如标题、段落、图片区域等。但是这条链路比基础 OCR 更重而且表格、公式等模块也可能带来额外依赖和耗时所以默认没有开启。当前实现中PPStructureV3(enginetransformers,langlang,use_table_recognitionFalse,use_formula_recognitionFalse,)也就是说week22 先把结构化 Markdown 作为增强项。主链路仍然保持为基础 OCR JSON 页面图片 VLM。页面选择Qwen3-VL 不能无限制接收整份文档的所有页面图片。项目中默认最多送入 8 页图片DOC_QA_MAX_IMAGES8页面选择逻辑在vlm_engine.pyscoresum(1forkeywordinkeywordsifkeywordinpage.text.lower())它会从问题中抽取关键词然后按关键词和每页 OCR 文本的重合度排序。如果没有足够的相关页面就用前面的页面补齐。这个策略很简单但在 alpha 版本里有两个好处不依赖向量数据库部署和调试都更轻。可以快速验证“先 OCR 检索页面再交给 VLM 理解”的项目思路。它的缺点也明显同义词、跨语言表达、复杂语义问题召回能力有限。后续可以把这里替换成 embedding 检索、BM25、reranker 或者章节级检索。VLM Prompt 设计传给 Qwen3-VL 的内容包含三部分OCR 全文文本。被选中的页面图片。用户问题。系统提示大意是你是一名文档问答助手。请只根据 OCR 文本和页面图片回答问题 如果文档里没有依据请说明未在文档中找到明确依据。这里强调“只根据文档回答”是为了减少模型凭常识补答案。文档问答更看重答案能否回到文档证据上单纯让模型生成一段流畅文本并不够。为了避免 prompt 过长OCR 文本还会按字符数截断DOC_QA_MAX_INPUT_CHARS24000如果超过限制会在文本末尾追加“文本已截断”的提示。这个参数需要根据显存、模型上下文长度和文档规模调整。FastAPI 服务服务入口是conda run-ndoc_parser uvicorn week22_project_core_impl.service.app:app\--host0.0.0.0\--port9100\--workers1接口主要有三个接口作用GET /打开最小前端GET /health查看模型路径、输出目录、模型是否加载POST /api/parse只解析 PDF返回 OCR 和页面产物POST /api/ask上传 PDF 或使用默认 PDF然后问答服务启动时默认预加载 OCR 和 VLMDOC_QA_PRELOAD_OCRtrue DOC_QA_PRELOAD_VLMtrue这会让第一次请求更快但启动时间更长。如果只是调接口格式可以临时关闭DOC_QA_PRELOAD_OCRfalseDOC_QA_PRELOAD_VLMfalse\conda run-ndoc_parser uvicorn week22_project_core_impl.service.app:app\--host0.0.0.0\--port9100\--workers1生产或真实 GPU 环境建议--workers 1。因为每个 worker 都会各自加载一份 PaddleOCR 和 Qwen3-VL多 worker 很容易重复占用显存。并发控制service/app.py里实现了一个很小的请求队列self._semaphoreasyncio.Semaphore(max(1,max_concurrency))默认DOC_QA_MAX_CONCURRENCY1 DOC_QA_QUEUE_TIMEOUT_S30OCR 和 VLM 都是重任务尤其在单卡本地服务里并发开太大不一定会更快反而可能导致显存峰值、排队时间和失败率上升。这里先用信号量限制并发把请求串行或小并发执行保证服务行为可控。本地调试路径如果不想启动 FastAPI可以直接跑 pipelineconda run-ndoc_parser python-mweek22_project_core_impl.scripts.run_pipeline\--pdfdocs/notes/BLIP.pdf\--question根据这篇文章总结文章创新点只验证 PDF 拆页和 OCRconda run-ndoc_parser python-mweek22_project_core_impl.scripts.run_pipeline\--pdfdocs/notes/BLIP.pdf\--parse-only这条路径适合排查 OCR、文件路径和输出结构不需要考虑 HTTP 上传和前端。启动服务后也可以用 client 脚本走 HTTPconda run-ndoc_parser python-mweek22_project_core_impl.scripts.client\--urlhttp://127.0.0.1:9100/api/ask\--use-default-pdf\--question根据这篇文章总结文章创新点这条路径更接近真实使用方式可以验证 FastAPI、上传参数和返回 JSON。一次请求的输出一次完整问答会生成类似outputs/{request_id}/ ├── input.pdf ├── pages/ │ ├── page_001.png │ ├── page_002.png │ └── ... ├── ppstructure/ ├── ocr.json ├── artifacts.json └── answer.json其中input.pdf本次请求实际处理的 PDF。pages/PyMuPDF 渲染出来的页面图片。ocr.json基础 OCR 结果。artifacts.json本次 pipeline 产物索引。answer.json问题、答案、被选中的页面和 artifacts。调试文档问答时建议按这个顺序看页面图片是否清晰。ocr.json是否识别到关键内容。answer.json里的selected_pages是否选中了正确页面。如果前三步都正常再判断 VLM 的回答质量。当前方案的边界这个 alpha 版本已经能跑通文档问答但还有一些明显边界页面选择基于关键词重合还没有引入语义检索。OCR 全文是简单拼接没有按标题、章节、表格结构组织。默认只送最多 8 页图片长文档的问题可能需要更强的检索策略。表格和公式没有作为主链路解析。答案还没有强制输出引用页码和证据片段。上传文件只做了基础 PDF 校验没有更完整的文件安全扫描。这些边界来自当前项目阶段的取舍。week22 的重点是先把端到端链路做出来PDF 进入系统后能留下可复查的结构化中间结果并能被本地 VLM 消费。整体来看week22 是把 week21 的“文档 OCR VLM 理解”设计落成工程骨架。现在的实现已经具备可运行、可调试、可扩展三个基础条件后面再往检索、引用、结构化和前端可视化方向扩展会比较顺。我正在连载一个从零到一的多模态大模型学习笔记。如果你对多模态大模型感兴趣或者也在准备往大模型方向转可以点赞/Fork我的仓库 From0to1-MLLM-StudyLog也可评论区留言交流后面我会继续把每周的学习记录、踩坑经验陆续更新到仓库和这里。抱团关注“1个算法工程师” 后台私信“mllm”加入mllm交流学习群。