土木标准图的合规审查,在设计院和审图机构里至今仍是一条高度依赖人工的工序。审查人员拿到一套 PDF 图纸,需要逐页翻图、定位构件、对照规范条文,再把结论整理成审图意见。这个过程不仅慢,而且消耗大量有经验的工程师时间。PlanSightRAG 的出发点,是把这套流程中的“看图”和“查规范”拆成可自动化的检索与生成任务:对平面图、立面图、节点详图这类以图像为载体的土木标准图,采用视觉优先(Visual-First)的多模态 RAG 方案,同时覆盖两个目标场景——面向图纸内容的事实问答(Question Answering),以及面向规范条文的合规检查(Compliance Checking)。
这篇文章是一篇工程落地笔记,不是论文复现。文中给出的代码用于说明最小可运行链路,读者可以按自己的图纸类型、模型版本和规范文本库替换其中的关键模块。如果你所在团队正在做图纸智能审查、施工图问答、设计规范辅助检查,或者只是想在 RAG 基础上增加对图片文档的处理能力,这篇文章的架构思路和排错路线都有参考价值。
1. 土木标准图为什么需要“视觉优先”的多模态 RAG
1.1 标准图里到底有什么信息
要理解 PlanSightRAG 为什么采用视觉优先,先要看一套标准图里实际存在哪些信息。土木标准图通常不是单一模态的文档,而是“图为主、文为辅、尺寸标注连接两者”的复合文档。
一套典型图纸中会出现以下内容:平面图表达墙体、门窗、楼梯、房间的空间关系;立面图和剖面图表达竖向尺寸和构造层次;节点详图表达构件连接方式;标题栏记录工程名称、图号、版本;尺寸线和标高符号给出精确数值;设计说明和表格承载材料、做法、规范引用等文字信息。
用表格拆开看,每种信息依赖的载体完全不同:
| 信息类型 | 典型载体 | 文本层 | 视觉层 | 说明 |
|---|---|---|---|---|
| 空间关系 | 墙、门、窗、楼梯的图形 | 弱 | 强 | 需要看图才能理解布局 |
| 尺寸数值 | 尺寸线、延伸线、数字标记 | 弱 | 强 | OCR 能读数字,但容易与图元脱节 |
| 构件编号 | C-1、M-2、M1521 等引线标注 | 弱 | 中 | 需要结合图例和编号规则 |
| 设计说明 | 文字段落、材料表 | 强 | 中 | 纯文本也能处理 |
| 规范引用 | 条文、表格、附注 | 强 | 中 | 文本检索更合适 |
结论很清楚:如果把图纸当成普通 PDF,只抽文字,实际上丢弃了最重要的空间和几何信息。这正是传统 RAG 处理标准图时最大的问题。
1.2 纯文本 RAG 为什么在图纸场景失效
很多团队第一次接入图纸问答时,会直接复用文本 RAG 流水线:解析 PDF、分段、向量化、检索、交给大模型生成。落到标准图场景,这个流程在多个环节失效。
第一,图纸 PDF 经常没有文本层。大量旧图纸是扫描件,或者由 CAD 打印成 PDF,程序用常规文本抽取工具拿不到任何字符。即使有文本层,文字顺序也未必与视觉阅读顺序一致。
第二,OCR 结果无序。CAD 导出的图纸经过 OCR 后,输出的是“坐标 + 文字”的散点,没有段落、句子、标题结构。如果按照字符顺序强行拼接,尺寸数字会和说明文字混在一起,语义被彻底打散。
第三,语义单元是图形而不是段落。门的净宽是不是 900mm,在图纸上表现为一条尺寸线、两根延伸线和数字“900”。文本 RAG 只看到一堆零散数字,无法知道这条标注属于哪个门、对应哪个构件。
第四,检索表示不匹配。通用文本嵌入模型按自然语言训练,查询“疏散门净宽要求”和图纸上的“M1521”“900”之间没有直接的语义桥接。文本向量检索很难把一句自然语言问题映射到图纸上的具体区域。
因此,检索单元必须回到“图”本身:以图纸上的视觉区域为最小检索单位,文字作为附属元数据。这就是视觉优先的基本动机。
1.3 Visual-First 到底是什么
用一句通俗的话说:先看图,再查字。不是先把图转成文字,而是把图本身当作可以检索的对象。
技术定义是:在文档处理链路中,将页面图像切分为视觉区域(visual region),每个区域同时保存图像特征、坐标和 OCR 文本;检索时以图像特征作为主召回通道,文本特征用于过滤和重排。
放到 PlanSightRAG 里,它的作用是让用户问“M1521 的防火等级是什么”时,系统先定位到门节点详图所在的图像区域,再读取该区域内附带的 OCR 文本“防火门 甲级”,而不是在整个页面范围里盲目匹配。
最小示例可以这样理解:一个门节点详图区域被视觉编码模型转换成 512 维向量,元数据中记录“节点详图、门、M1521、净宽、防火等级”。用户提问后,文本召回先锁定包含关键词的区域,图像召回再确认该区域确实是门节点而不是楼梯节点,两者互为校验。
这里有一个容易误解的点:Visual-First 不是不要文本。文本从主索引降级为辅助信号,但仍然是定位、量化和复核的关键。真正的设计原则是:图像决定检索边界,文本决定数值精度,规则决定最终结论。
2. PlanSightRAG 的整体架构:从图纸入库到合规报告
2.1 五层架构总览
PlanSightRAG 不是一个单独模型,而是一条处理链路。整套系统按职责拆成五层,每一层只做一件事,问题可以逐层定位。
| 层级 | 职责 | 关键组件 | 输入 | 输出 |
|---|---|---|---|---|
| 接入层 | 统一图纸格式,登记版本 | PDF 解析、CAD 导出、图片归一化 | 原始图纸文件 | 标准页面图像和元数据 |
| 解析层 | 版面分析、OCR、视觉区域切块 | 版面检测、OCR、区域裁剪 | 页面图像 | visual chunk 列表 |
| 索引层 | 构建多路索引 | 图像向量索引、文本向量索引、关键词索引 | visual chunk | 可查询的索引文件 |
| 检索层 | 多路召回与重排 | 向量检索、关键词检索、重排器 | 用户问题 | 排序后的候选片段 |
| 生成与判定层 | 生成回答和候选值,规则复核 | LLM、规则引擎 | 候选片段 | 问答结果、合规报告 |
这个分层的关键在于,接入层不判断内容,解析层不生成结论,检索层只负责候选排序,生成层不直接输出合规结论而是输出候选值,最终合规结论交给规则引擎。这样即使某一步出错,也能单独验证,不会出现“模型一句话掩盖了整条链路问题”的情况。
2.2 解析层如何把图纸切成“视觉片段”
解析层把一页图纸按照“视觉区域”切块,原则是让每个检索单元对应一个语义完整的图元区域,而不是简单按页面或按固定字数切。
处理过程是:PDF 页面转成 200 到 300 DPI 图像,版面分析识别图框、标题栏、说明区、节点详图区域,对每个区域裁剪出子图,再对子图做 OCR,得到区域内文字,最终形成一个 visual chunk。
visual chunk 的数据结构可以用 JSON 描述:
{ "chunk_id": "A-102_p01_r07", "source": "A-102.pdf", "page": 1, "bbox": [120, 340, 480, 620], "crop_image": "chunks/A-102_p01_r07.png", "ocr_text": "防火门 M1521 净宽1500 耐火等级甲级", "region_type": "node_detail", "plan_id": "A-102", "revision": "R2" }其中bbox是页面像素坐标,crop_image是裁剪下来的子图路径,ocr_text来自 OCR,region_type由版面分析给出。后面图像检索返回这个 chunk 时,生成层既可以直接读取子图,也可以读取 OCR 文本,还可以拿到图号、版本信息作为证据。
2.3 检索层为什么采用多路召回
图纸问答最难的部分是检索,因为同一个问题要同时匹配图形和文字。PlanSightRAG 采用三路召回,而不是单一索引。
图像向量召回:把用户问题用视觉语言模型的文本编码器编码,与所有 visual chunk 的图像向量计算余弦相似度。优势是能捕捉“这是门节点”“这是疏散通道”这类视觉语义。
文本向量召回:把问题与 OCR 文本、设计说明做向量检索。优势是能精确匹配“防火门”“净宽”“甲级防火门”等术语。
关键词召回:对构件编号和规范编号做精确索引。M1521、GB 50016-2014 这类编号,向量模型经常分词错误,精确匹配更可靠。
单一路由都不够用。纯图像召回对文字密集的说明区效果不好;纯文本召回看不懂图元;纯关键词召回无法处理口语化问题。三路召回后做加权融合,再由重排器过滤,才能兼顾覆盖率和精度。
2.4 合规判定层:生成不是终点,规则复核才是
合规检查不能接受“看起来合理”的结论。LLM 生成的内容存在幻觉风险,而合规结论直接影响工程决策,因此主流程分成两步。
第一步,LLM 从检索到的视觉片段中提取候选字段值,比如“疏散门净宽 = 1.5m”“楼梯踏步高 = 0.18m”,并给出对应证据片段 ID。这一步只负责提取,不负责判断。
第二步,规则引擎用规范配置表对候选值做数值比较,输出 PASS、FAIL 或 UNKNOWN。规范阈值不放在 prompt 里,避免模型凭记忆写错。规则引擎判定结果可追踪,每条结论都有数值来源和证据 ID。
这套设计的核心价值是:把“模型能力”和“规范确定性”分开。模型负责理解图纸,规则负责执行规范,两边各司其职,出错时不互相掩盖。
3. 环境准备与依赖清单
3.1 运行环境与硬件要求
图像向量化比纯文本 RAG 更吃资源。PlanSightRAG 的小规模实验环境可以参考下面的配置:
| 环境项 | 推荐配置 | 说明 |
|---|---|---|
| 操作系统 | Ubuntu 20.04/22.04,Windows 10/11 也可 | 生产环境优先 Linux |
| Python | 3.10 或 3.11 | 依赖库对 3.12 的支持仍不稳定 |
| GPU | 至少 8GB 显存 | 跑 CLIP/SigLIP 编码;如果还要跑 VLM 图文理解,建议 16GB 以上 |
| 内存 | 16GB | 多个模型同时加载时预留更多 |
| 存储 | 单套图纸 200 DPI 切图后每页约 1 到 3MB | 按项目规模预留 |
纯文本 RAG 可以用 CPU 跑,但图像向量化在 GPU 上快很多。没有 GPU 时可以走服务化接口,但要评估单页处理延迟和请求并发是否满足业务要求。
3.2 Python 依赖
下面是一份经过验证的最小依赖组合。写这篇博客时它可以跑通,但 PyMuPDF、PaddleOCR、Transformers 升级较快,落地前要重新锁版测试。
pymupdf==1.24.10 paddleocr==2.7.3 paddlepaddle==2.6.2 transformers==4.44.2 torch==2.3.1 sentence-transformers==3.2.1 faiss-cpu==1.8.0 Pillow==10.4.0 numpy==1.26.4 pydantic==2.8.2 PyYAML==6.0.2依赖用途说明:
pymupdf:PDF 页面渲染成高清 PNG。paddleocr、paddlepaddle:OCR 文字提取,可替换成其他 OCR 引擎。transformers、torch:加载 CLIP 等视觉语言模型。sentence-transformers:中文文本向量编码。faiss-cpu:向量索引和相似度检索。Pillow:图像裁剪和格式处理。
视觉语言模型权重需要从模型仓库下载,建议团队内部准备统一缓存目录或镜像,避免每台机器重复拉取,也方便版本固定。
3.3 模型选型建议
不同任务对模型的要求不同,不必为所有环节选择最大的模型。PlanSightRAG 常见选型如下:
| 任务类型 | 可选模型 | 参数规模 | 说明 |
|---|---|---|---|
| 图像向量编码 | CLIP ViT-B/32、SigLIP、EVA-CLIP | 150M 到 300M | 把图变成向量,用于相似度召回 |
| 文本向量编码 | BAAI/bge-large-zh-v1.5 | 约 400M | 中文检索效果好,用于 OCR 文本召回 |
| 图文理解与候选值提取 | Qwen2-VL、MiniCPM-V 等 VLM | 7B 到 8B | 从图纸区域提取数值,读取裁剪图 |
| OCR | PaddleOCR、PP-StructureV2 | 不固定 | 文字识别和版面分析 |
具体版本以实际可用情况为准。领域精度要求高时,可以对视觉模型做少量图纸微调,但成本较高,建议先跑通链路再评估是否值得。
4. 最小可运行实现:从图纸入库到问答与合规检查
4.1 项目目录结构
演示项目按下面结构组织,实际项目可以根据团队习惯调整:
plansightrag/ ├── config.yaml ├── requirements.txt ├── data/ │ ├── raw/ │ │ └── A-102.pdf │ ├── chunks/ │ └── index/ ├── src/ │ ├── ingest/ │ │ ├── pdf_loader.py │ │ ├── chunker.py │ │ └── embedder.py │ ├── retrieval/ │ │ ├── indexer.py │ │ ├── multi_recall.py │ │ └── reranker.py │ ├── engine/ │ │ ├── qa_engine.py │ │ └── compliance_engine.py │ └── cli.py └── tests/ingest负责入库,retrieval负责检索,engine负责问答和合规判定。路径在演示代码里使用相对路径,生产环境建议统一放到配置中心。
4.2 图纸解析:PDF 转页面高清图像
第一步是把 PDF 每一页渲染成 PNG。这里用 PyMuPDF 实现:
# src/ingest/pdf_loader.py from pathlib import Path import fitz def pdf_to_page_images(pdf_path: Path, output_dir: Path, dpi: int = 200) -> list[Path]: """把 PDF 每一页渲染成 PNG,返回图片路径列表。""" output_dir.mkdir(parents=True, exist_ok=True) doc = fitz.open(str(pdf_path)) image_paths = [] for page_no in range(len(doc)): page = doc.load_page(page_no) pix = page.get_pixmap(dpi=dpi) out_file = output_dir / f"{pdf_path.stem}_p{page_no + 1:03d}.png" pix.save(str(out_file)) image_paths.append(out_file) print(f"[