简介:FIDIC合同(银皮书中文版)是一份面向国际工程总承包(EPC/交钥匙)项目从业者的标准合同范本PDF,适用于工程项目业主、承包商、监理与法务人员查阅条款、编制招标文件或开展合同风险评审。文档围绕银皮书核心条款逐项展开,涵盖一般规定中的定义与解释、通信交流、法律和语言、文件优先次序、合同协议书、权益转让、文件的照管与提供、保密性,直至雇主与承包商互相使用对方文件、遵守法律、共同和各自的责任,并延伸至雇主的现场进入权、许可执照批准、资金安排与索赔等章节,目录层级分明,便于按条检索。包内仅含1个PDF文件,压缩包约2.23MB,体量轻便,适合在电脑与移动端快速翻阅、标注与摘录。目前已有2341人学习下载,可作为对照原版条款、梳理合同知识点的案头资料。
1. FIDIC银皮书中文版PDF为什么不能直接丢给大模型读
投标前一天的晚上,项目群里丢过来一份三百多页的 FIDIC 银皮书中文版 PDF,问题只有一个:第 4.12 款里的"不可预见的物质条件"怎么界定,承包商能不能据此索赔工期。你按 Ctrl+F 搜"不可预见",跳出来的结果一半在脚注、一半在页眉,条号 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(full=True) # 页面内嵌图片对象 print(f"P{pno+1} chars={len(txt.strip())} images={len(imgs)}") print(repr(txt[:120])) # 前 120 字符,看有没有条号和换行判定逻辑很直接:chars低于 50 且images大于等于 1,基本可以定性为扫描件,直接跳到第 3 章;chars上千并且能看到"第 4 条""4.1""4.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], key=lambda b: b[1]) right = sorted([b for b in body if (b[0] + b[2]) / 2 >= w / 2], key=lambda 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])))坐标单位是 point,1 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 默认 DPI,300/72 得到 300 DPI 位图 zoom = 300 / 72 pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), colorspace=fitz.csGRAY) pix.save("page_087.png") print(pix.width, pix.height) # A4 在 300 DPI 下约 2480 x 3508colorspace=fitz.csGRAY转灰度,对纯黑白扫描的合同文本足够用,还能把文件体积压到彩色图的三分之一。倾斜校正用 PaddleOCR 自带的use_angle_cls就够,不需要单独跑霍夫变换,除非扫描倾斜超过 15 度。
3.2 PaddleOCR 的关键参数怎么设
from paddleocr import PaddleOCR ocr = PaddleOCR( lang="ch", # 中文模型,中英混排也用它 use_angle_cls=True, # 开启 180 度方向分类,纠正倒置页 use_space_char=True, # 输出中英之间的空格,条款号识别依赖它 det_db_box_thresh=0.5, # 检测框置信度阈值,噪点多时调到 0.6 drop_score=0.5, # 识别置信度低于该值的行直接丢弃 ) res = ocr.ocr("page_087.png", cls=True) for box, (text, score) in res[0]: if score >= 0.85: # 高置信度行直接入库 print(f"{score:.3f}\t{text}")use_space_char=True这一项对合同文档特别重要,4.12前后的空格是后续正则切分的边界依据,关掉之后数字会和前一个汉字粘在一起变成"款4.12"。det_db_box_thresh调高会漏检浅色印刷的条款号,调低会把页面黑边的噪点当成文本行。drop_score是最后一道闸,低于阈值的行宁可不入库,也不要留一堆错字污染检索结果,这些行单独存到待人工核对的表里。
3.3 OCR 错字的清洗规则表
中文 OCR 在合同文本上的错误是有规律的,集中在数字、标点和形近字三类。按下面这张表写规则,一次能修掉八成以上。
| 现象 | 错误样例 | 正确形态 | 处理方式 |
|---|---|---|---|
| 条款号点号被吞 | 4 12/4,12 | 4.12 | 正则回填,限定在行首 |
| 数字 1 与字母 l 混淆 | l.1/1.l | 1.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, -- 即 rowid,FTS5 外部内容表依赖它 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, content='clause', content_rowid='id', tokenize='trigram' -- 中文按三字滑窗切分 ); -- 外部内容表必须自己维护同步触发器 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_key | TEXT UNIQUE | 跨版本比对时的对齐主键 |
level | INTEGER | 检索时可只返回款级,不给用户看子项噪音 |
path | TEXT | LIKE '4.12.%'一次取出一款带全部子项 |
page_from/page_to | INTEGER | 追溯原文页码,跨页条款要记两页 |
doc_id | TEXT | 同一张表装银皮书、红皮书、黄皮书 |
tokenize='trigram'需要 SQLite 3.34 以上,它按三字符滑窗建索引,中文短语检索的召回明显好于默认的unicode61。代价是索引体积大约翻倍,几百页的合同文档一般几兆到十几兆,可以接受。如果运行环境的 SQLite 版本不够,退路是用 jieba 预分词,把词之间插空格写进 FTS 表,查询时同样先分词再拼 MATCH 表达式。
5. 条款级检索:FTS5 中文分词与向量召回怎么配权重
条款切完、落库之后,检索层的目标很明确:用户输入一句口语化的问题,返回不超过十条精确到"款"的条款,每条带页码和原文。这个目标用纯向量检索做不到精确定位条款号,用纯关键词检索又招架不住口语提问,混合是常见解法。
5.1 trigram 索引下的查询构造
FTS5 的 MATCH 语法要求 trigram 分词器下查询串至少三个字符,单字查询会直接返回空集。用户输入"索赔"这种两字词,得先扩展成短语再查。
import sqlite3 def search_clause(conn, user_input, topk=10): # 少于 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 |
| 扣款和索赔流程 | 索赔、争端、仲裁 | 20 |
TERM_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, k=60, w_kw=1.0, w_vec=0.8): scores = {} for rank, (key, _) in enumerate(keyword_hits, start=1): scores[key] = scores.get(key, 0) + w_kw / (k + rank) for rank, (key, _) in enumerate(vector_hits, start=1): scores[key] = scores.get(key, 0) + w_vec / (k + rank) return sorted(scores.items(), key=lambda x: -x[1])| 参数 | 建议值 | 调整方向 |
|---|---|---|
k | 60 | 越小头部结果差异越明显 |
w_kw | 1.0 | 用户输入含明确条号时调到 1.5 |
w_vec | 0.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, threshold=0.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, f"fuzzy:{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 这种不对称,说明匹配阈值的边界上有歧义条目,把这些条目单独列出来人工确认,比整体调阈值更有效。合同条款对齐的目标从来不是全自动,而是把人工复核的范围从三百页压到十几条。
本文还有配套的精品资源,点击获取