FIDIC银皮书中文版PDF条款抽取、切分与检索实战

发布时间:2026/9/17 13:08:48
FIDIC银皮书中文版PDF条款抽取、切分与检索实战
简介FIDIC合同银皮书中文版是一份面向国际工程总承包EPC/交钥匙项目从业者的标准合同范本PDF适用于工程项目业主、承包商、监理与法务人员查阅条款、编制招标文件或开展合同风险评审。文档围绕银皮书核心条款逐项展开涵盖一般规定中的定义与解释、通信交流、法律和语言、文件优先次序、合同协议书、权益转让、文件的照管与提供、保密性直至雇主与承包商互相使用对方文件、遵守法律、共同和各自的责任并延伸至雇主的现场进入权、许可执照批准、资金安排与索赔等章节目录层级分明便于按条检索。包内仅含1个PDF文件压缩包约2.23MB体量轻便适合在电脑与移动端快速翻阅、标注与摘录。目前已有2341人学习下载可作为对照原版条款、梳理合同知识点的案头资料。1. FIDIC银皮书中文版PDF为什么不能直接丢给大模型读投标前一天的晚上项目群里丢过来一份三百多页的 FIDIC 银皮书中文版 PDF问题只有一个第 4.12 款里的不可预见的物质条件怎么界定承包商能不能据此索赔工期。你按 CtrlF 搜不可预见跳出来的结果一半在脚注、一半在页眉条号 4.12 本身还跨在第 87、88 两页之间。整份丢给大模型同样不省心双栏排版被拉成断行(a)(b)(c)子项混进正文模型张口就编出一个不存在的条号你还得逐条回原文核对。这不是法律问题是文档工程问题。FIDIC 银皮书是 EPC 交钥匙项目的合同条件中文版 PDF 的真正价值在于每一款都能被精确定位、引用、比对而不是被当成一坨文本喂给模型。后面按抽取、切分、入库、检索、跨皮书比对这条链路走一遍做工程数字化、合同管理系统或内网知识库的 IT 人员可以直接照着搭。2. FIDIC银皮书中文版PDF的文本层探测与版式还原拿到一份银皮书 PDF第一件事不是写正则而是搞清楚它到底是原生文本 PDF还是扫描图 PDF。这两条路的工具链完全不同走错了后面所有工作都是白费扫描件上用 pdfplumber 抽出来的是一堆空字符串文本层 PDF 上跑 OCR 则是把已经正确的文字再糟蹋一遍。2.1 用 PyMuPDF 判断这份银皮书有没有文本层PyMuPDF 的导入名是fitz它能在不渲染页面的情况下直接读文本对象和图片对象速度足够扫完几百页。import fitz # PyMuPDF 的导入名固定为 fitz doc fitz.open(FIDIC_银皮书_中文版.pdf) print(总页数:, doc.page_count) for pno in range(min(5, doc.page_count)): page doc[pno] txt page.get_text(text) # 纯文本流 imgs page.get_images(fullTrue) # 页面内嵌图片对象 print(fP{pno1} chars{len(txt.strip())} images{len(imgs)}) print(repr(txt[:120])) # 前 120 字符看有没有条号和换行判定逻辑很直接chars低于 50 且images大于等于 1基本可以定性为扫描件直接跳到第 3 章chars上千并且能看到第 4 条4.14.12这类编号说明有原生文本层走规则抽取。需要警惕的是第三种情况——扫描件外面套了一层隐形 OCR 文本层这类文件chars看着正常但字符顺序是按识别框排的英文和数字中间会莫名多出空格4.12可能变成4 . 1 2。抽样时打印repr而不是直接print就是为了看清这些肉眼不可见的空格和换行符。get_text的四个模式要按用途选text拿连续文本、blocks拿带坐标的段落块、words拿带坐标的词、dict拿到字体和字号信息。还原版式用blocks判断标题层级用dict。2.2 用 blocks 坐标切回双栏阅读顺序中文版银皮书正文常排成双栏或者单栏但页边距里有条款号。直接按文本流读出来的顺序会把左右两栏交叉串在一起读起来完全不通。import fitz def read_two_column(page): # block 结构: (x0, y0, x1, y1, text, block_no, block_type) blocks [b for b in page.get_text(blocks) if b[6] 0] w, h page.rect.width, page.rect.height # 掐掉页眉页脚常见高度区间是上下各 6% body [b for b in blocks if 0.06 * h b[1] 0.94 * h] # 用块中心 x 坐标判断落在左栏还是右栏 left sorted([b for b in body if (b[0] b[2]) / 2 w / 2], keylambda b: b[1]) right sorted([b for b in body if (b[0] b[2]) / 2 w / 2], keylambda b: b[1]) return [b[4].strip() for b in left right] doc fitz.open(FIDIC_银皮书_中文版.pdf) print(\n.join(read_two_column(doc[86])))坐标单位是 point1 point 等于 1/72 英寸A4 纵向页面高度约 842。0.06 * h和0.94 * h这两个阈值是根据中文版常见的页眉页脚位置定的经验值换一份排版就得重新看一眼实际坐标再调。左右栏判定用块中心而不是块左边界是因为首行缩进会让左边界偏移中心点更稳。这里有个绕不过去的问题条款号跨页。第 4.12 款正文分在两页上抽出来就是两个块靠坐标切栏解决不了。跨页合并要放到条款切分环节处理判断依据是上一块结尾没有句号、下一块开头是小写字母或括号序号。2.3 抽取工具的选型边界工具适用页面中文表现输出粒度PyMuPDF (fitz)有文本层、需要坐标好块 / 行 / 词 坐标pdfplumber附件页、担保格式表好字符 / 线 / 表格pdfminer.six纯文本流、无版式需求一般字符级PaddleOCR扫描件、中文为主好文本 四点多边形Tesseract扫描件、英文为主中文一般文本 框银皮书正文用 fitz 就够真正需要 pdfplumber 的是书末那几十页附件——履约担保格式、预付款担保格式、争端裁决协议格式这些都是规整表格pdfplumber 的extract_tables比手写坐标判定省事得多。2.4 抽完必须抽查的三处抽取出文本不等于抽取正确交下游之前固定查三个地方。第一处是目录页和正文的实际页码差中文版前面有几十页前言目录写第 4.1 款…… 35实际在 PDF 的第 78 页这个偏移量必须记下来否则后面所有页码引用都是错的。第二处是条款号是否被排到行末断开4.在一行末尾、12在下一行开头正则匹配不到。第三处是脚注中文版有的翻译把原文注释放在页面下部字体比正文小两号用dict模式过滤size明显偏小的 span 就能切掉。# 用字号过滤脚注正文常见 10.5pt脚注常见 8pt d page.get_text(dict) body_spans [s[text] for blk in d[blocks] if blk.get(type) 0 for line in blk[lines] for s in line[spans] if s[size] 9.0]3. 扫描版银皮书PDF的OCR预处理与中文条款清洗不少项目手上那份银皮书中文版是纸质版扫出来的图像歪一点、页码栏有黑边、条款号里的点号被吃掉这些都得在 OCR 之前和之后各处理一轮。直接把扫描图丢进识别引擎出来的文本用正则匹配条款号会漏掉一大半。3.1 渲染分辨率与图像预处理参数OCR 的输入是位图位图质量决定了识别上限。扫描件本身如果是 150 DPI再放大到 300 DPI 渲染只是插值救不回细节但如果原始扫描是 600 DPI用 300 DPI 渲染反而能减少噪点干扰。import fitz doc fitz.open(FIDIC_银皮书_扫描版.pdf) page doc[86] # matrix 缩放72 是 PDF 默认 DPI300/72 得到 300 DPI 位图 zoom 300 / 72 pix page.get_pixmap(matrixfitz.Matrix(zoom, zoom), colorspacefitz.csGRAY) pix.save(page_087.png) print(pix.width, pix.height) # A4 在 300 DPI 下约 2480 x 3508colorspacefitz.csGRAY转灰度对纯黑白扫描的合同文本足够用还能把文件体积压到彩色图的三分之一。倾斜校正用 PaddleOCR 自带的use_angle_cls就够不需要单独跑霍夫变换除非扫描倾斜超过 15 度。3.2 PaddleOCR 的关键参数怎么设from paddleocr import PaddleOCR ocr PaddleOCR( langch, # 中文模型中英混排也用它 use_angle_clsTrue, # 开启 180 度方向分类纠正倒置页 use_space_charTrue, # 输出中英之间的空格条款号识别依赖它 det_db_box_thresh0.5, # 检测框置信度阈值噪点多时调到 0.6 drop_score0.5, # 识别置信度低于该值的行直接丢弃 ) res ocr.ocr(page_087.png, clsTrue) for box, (text, score) in res[0]: if score 0.85: # 高置信度行直接入库 print(f{score:.3f}\t{text})use_space_charTrue这一项对合同文档特别重要4.12前后的空格是后续正则切分的边界依据关掉之后数字会和前一个汉字粘在一起变成款4.12。det_db_box_thresh调高会漏检浅色印刷的条款号调低会把页面黑边的噪点当成文本行。drop_score是最后一道闸低于阈值的行宁可不入库也不要留一堆错字污染检索结果这些行单独存到待人工核对的表里。3.3 OCR 错字的清洗规则表中文 OCR 在合同文本上的错误是有规律的集中在数字、标点和形近字三类。按下面这张表写规则一次能修掉八成以上。现象错误样例正确形态处理方式条款号点号被吞4 12/4,124.12正则回填限定在行首数字 1 与字母 l 混淆l.1/1.l1.1按条款号白名单校正全角括号a(a)统一转半角连字符变体–—‐-Unicode 归一化页眉混入正文第 87 页删除按 y 坐标区间过滤import re, unicodedata def fix_ocr_line(s: str) - str: s unicodedata.normalize(NFKC, s) # 全角转半角 s re.sub(r[–—‐], -, s) # 连字符归一 # 行首形如 4 12 或 4,12 的回填点号 s re.sub(r^(\d{1,2})[\s,](\d{1,2})(?\s|$), r\1.\2, s) # 行首字母 l 出现在数字位置时替换成 1 s re.sub(r^l(?[.\d]), 1, s) return s.strip()unicodedata.normalize(NFKC, s)会把全角字符、兼容字符统一成标准形式这一步放在最前面后面所有正则都按半角写。条款号回填的(\d{1,2})[\s,](\d{1,2})加了行首锚点和后向空白断言避免误伤正文里正常的数字表达比如共计 20 台设备。清洗之后要留一份原始文本对照审计时能追回到底是原文如此还是识别错了——合同文档场景下这两者性质完全不同。4. 把银皮书切成分级条款树正则、栈与落库文本抽干净之后真正决定检索质量的是切分粒度。按页切、按段落切都没法回答第 4.12 款怎么规定的这种问题必须切到条款级一条、一款、一项每一级都能单独取出来引用。4.1 中文版银皮书的条款编号写法与匹配优先级中文版银皮书的层级编号有几套写法混着用。一级是第 4 条 承包商或纯数字4二级是4.12三级是4.12.1第四层是子项(a)(b)(c)。翻译版本不同还会出现第 4.12 款这种带款字的写法。import re PATTERNS [ # 顺序不能乱三级必须在二级之前匹配否则 4.12.1 会被吃掉成 4.12 (l3, re.compile(r^\s*(\d{1,2})\.(\d{1,2})\.(\d{1,2})\s\S)), (l2, re.compile(r^\s*(\d{1,2})\.(\d{1,2})\s\S)), (l1, re.compile(r^\s*(?:第\s*)?(\d{1,2})\s*条\s*[\s、.])), (li, re.compile(r^\s*\(([a-z])\)\s\S)), ] def match_level(line: str): for name, pat in PATTERNS: m pat.match(line) if m: return name, m return None, None三级正则排在前面是这套代码里最容易踩的坑。4.12.1用二级正则也能匹配成功结果是4.12后面跟着孤零零的.1条款树就少了一层。\s\S这个尾断言的作用是排除正文里出现的数字比如合同价格 4.12 亿元这种句子虽然含4.12但后面跟的是中文字符和空格锚定在行首加空白断言的组合下不会误判。4.2 用父子栈把平铺的行还原成三级条款树抽出来的文本是一行行的平铺序列层级关系已经丢了要用栈重新组装。def build_tree(lines): root {key: 0, level: 0, heading: , body: [], children: []} stack [root] for line in lines: lvl, m match_level(line) if lvl is None: stack[-1][body].append(line) # 普通正文挂到当前节点 continue depth {l1: 1, l2: 2, l3: 3, li: 4}[lvl] key ..join(g for g in m.groups() if g) # 4.12.1 # 弹栈直到找到比自己浅一级的父节点 while len(stack) 1 and stack[-1][level] depth: stack.pop() node {key: key, level: depth, heading: line.strip(), body: [], children: []} stack[-1][children].append(node) stack.append(node) return rootwhile循环里的条件stack[-1][level] depth是关键同级条款要先把前一个同类节点弹出去再挂到同一个父节点下。用li层的(a)序号当 key 时要注意同一款下面可能有多个(a)出现在不同子条款里落库前的clause_key得拼上完整父路径否则唯一约束会冲突。跨页断开的条款号也要在这一步合并如果一行只有4.结尾、下一行以12开头先把两行拼起来再进match_level。判断依据是行尾没有标点且行首是纯数字。4.3 落库clause 表与 FTS5 外部内容表结构化结果落 SQLite 最省事单文件、可离线、内网部署不用另起服务。CREATE TABLE clause ( id INTEGER PRIMARY KEY, -- 即 rowidFTS5 外部内容表依赖它 clause_key TEXT NOT NULL UNIQUE, -- 完整路径键如 4.12.1.(a) doc_id TEXT NOT NULL, -- silver / red / yellow level INTEGER NOT NULL, -- 1 条 2 款 3 项 4 子项 parent_key TEXT, -- 上级 clause_key path TEXT NOT NULL, -- 前缀查询用如 4.12. page_from INTEGER, page_to INTEGER, heading TEXT, body TEXT ); CREATE VIRTUAL TABLE clause_fts USING fts5( heading, body, contentclause, content_rowidid, tokenizetrigram -- 中文按三字滑窗切分 ); -- 外部内容表必须自己维护同步触发器 CREATE TRIGGER clause_ai AFTER INSERT ON clause BEGIN INSERT INTO clause_fts(rowid, heading, body) VALUES (new.id, new.heading, new.body); END;字段类型为什么这么设计clause_keyTEXT UNIQUE跨版本比对时的对齐主键levelINTEGER检索时可只返回款级不给用户看子项噪音pathTEXTLIKE 4.12.%一次取出一款带全部子项page_from/page_toINTEGER追溯原文页码跨页条款要记两页doc_idTEXT同一张表装银皮书、红皮书、黄皮书tokenizetrigram需要 SQLite 3.34 以上它按三字符滑窗建索引中文短语检索的召回明显好于默认的unicode61。代价是索引体积大约翻倍几百页的合同文档一般几兆到十几兆可以接受。如果运行环境的 SQLite 版本不够退路是用 jieba 预分词把词之间插空格写进 FTS 表查询时同样先分词再拼 MATCH 表达式。5. 条款级检索FTS5 中文分词与向量召回怎么配权重条款切完、落库之后检索层的目标很明确用户输入一句口语化的问题返回不超过十条精确到款的条款每条带页码和原文。这个目标用纯向量检索做不到精确定位条款号用纯关键词检索又招架不住口语提问混合是常见解法。5.1 trigram 索引下的查询构造FTS5 的 MATCH 语法要求 trigram 分词器下查询串至少三个字符单字查询会直接返回空集。用户输入索赔这种两字词得先扩展成短语再查。import sqlite3 def search_clause(conn, user_input, topk10): # 少于 3 字的关键词在 trigram 下查不到补上同义扩展 terms expand_terms(user_input) # 见 5.2 的映射表 # 用 OR 连接短语加双引号走精确匹配 match_expr OR .join(f{t} for t in terms if len(t) 3) sql SELECT c.clause_key, c.page_from, snippet(clause_fts, 1, [, ], …, 12) AS snip, bm25(clause_fts, 5.0, 1.0) AS score FROM clause_fts f JOIN clause c ON c.id f.rowid WHERE clause_fts MATCH ? ORDER BY score LIMIT ? return conn.execute(sql, (match_expr, topk)).fetchall()bm25(clause_fts, 5.0, 1.0)里两个数字对应heading和body两列的权重标题权重给到 5 是让第 4.12 款 不可预见的物质条件这种标题行比正文更容易命中。snippet的第五个参数 12 是片段长度中文场景下 12 个 token 大约对应一小段话够用户判断是不是要找的那一款。bm25 返回的是负数值越小相关性越高所以ORDER BY score直接升序就是对的不用加 DESC。5.2 口语问法到条款的映射表工程现场的人不会说第 8.4 款竣工时间的延长他会说业主拖了三个月没给场地能不能顺延工期。中间这层映射不靠模型硬猜维护一张可审计的映射表更稳。口语问法扩展词常见对应条款业主没给场地现场进入权、进入现场2.1工期能不能顺延竣工时间、延长、延期8.4设计改了怎么算钱变更、调整、估价13地震台风算谁的风险不可抗力、风险与职责17 / 19扣款和索赔流程索赔、争端、仲裁20TERM_MAP { 场地: [现场进入权, 进入现场], 顺延: [竣工时间的延长, 延长], 改设计: [变更, 调整], 地震: [不可抗力], } def expand_terms(q: str): terms [q] for k, vs in TERM_MAP.items(): if k in q: terms.extend(vs) return list(dict.fromkeys(terms)) # 去重且保持顺序这张表是人工维护的资产条款号一栏在比对不同皮书时要能替换。表里的映射不是法律结论只是检索入口用户点进条款原文自己看系统不替他下判断。5.3 混合检索的权重与自测方法关键词保精确、向量保语义两路结果用 RRF 融合比直接加权更省调参。def rrf_fuse(keyword_hits, vector_hits, k60, w_kw1.0, w_vec0.8): scores {} for rank, (key, _) in enumerate(keyword_hits, start1): scores[key] scores.get(key, 0) w_kw / (k rank) for rank, (key, _) in enumerate(vector_hits, start1): scores[key] scores.get(key, 0) w_vec / (k rank) return sorted(scores.items(), keylambda x: -x[1])参数建议值调整方向k60越小头部结果差异越明显w_kw1.0用户输入含明确条号时调到 1.5w_vec0.8口语长句占多数时调到 1.2召回条数关键词 30 / 向量 30融合后取前 10自测不要凭感觉攒三十条真实提问每条人工标出正确条款号算 Top-1 命中率和 Top-5 命中率。中文合同检索的常见达标线是 Top-5 命中 85% 以上达不到就回头查是切分漏了条款还是映射表缺词这两处占了失败的绝大多数。6. 进阶银皮书与红皮书条款差异对齐的校验技巧同一份 EPC 项目上业主和承包商手里往往同时有银皮书和红皮书两个中文版 PDF。银皮书是交钥匙总承包模式风险大量向承包商倾斜合同里没有独立的工程师角色红皮书是施工合同条件由工程师管理合同。要讲清楚换个皮合同风险变了多少就得把两份文件的条款按编号对齐再逐条比。对齐用编号做主键最省事但两套文件的条款编号并不完全对应有些款在红皮书里有、银皮书里被合并或删除。所以对齐分两步走先按clause_key精确匹配剩下的用标题文本相似度兜底。from difflib import SequenceMatcher def align_clauses(silver, red, threshold0.62): pairs, used [], set() for s in silver: hit next((r for r in red if r[clause_key] s[clause_key]), None) if hit: pairs.append((s, hit, exact)) used.add(hit[clause_key]) continue # 编号对不上的用标题相似度找最近邻 best, ratio None, 0.0 for r in red: if r[clause_key] in used: continue v SequenceMatcher(None, s[heading], r[heading]).ratio() if v ratio: best, ratio r, v if best and ratio threshold: pairs.append((s, best, ffuzzy:{ratio:.2f})) used.add(best[clause_key]) return pairsthreshold定在 0.62 是中文标题比对的经验值再低会把竣工试验错配到竣工后试验这两款在银皮书里分别对应第 9 条和第 12 条配错了风险判断正好相反。用SequenceMatcher之前要先把标题里的条款号剥掉否则4.1和4.2这种数字上的差异会稀释文本相似度。比出差异之后别急着把全部 diff 推给业务方看。实用的做法是先按条款章节归类第 17 条风险与职责、第 13 条变更和调整、第 20 条索赔争端仲裁这三处的差异最影响报价优先看这三块。差异块本身用词级 diff 呈现让业务方看到的是银皮书多了承包商的费用这几个字这种细粒度变化而不是整段整段的高亮。最后一个校验技巧值得单独提把对齐结果反向跑一遍。用红皮书的条款去匹配银皮书如果出现 A 配对 B、B 又配对 C 这种不对称说明匹配阈值的边界上有歧义条目把这些条目单独列出来人工确认比整体调阈值更有效。合同条款对齐的目标从来不是全自动而是把人工复核的范围从三百页压到十几条。本文还有配套的精品资源点击获取