Update avaliable. Click RELOAD to update.
📱 安装应用到主屏幕,获得更好体验
目录

手写AI合同审核系统:OCR + 混合RAG + LangGraph全栈实战

背景

合同无处不在——供应商协议、服务订单、政府招标书。

一个中型公司可能管理者数百份活跃的供应商合同。大型企业则是数万份。每次想查”这个供应商的付款条款是什么?”或”这份合同什么时候到期?”,就得有人打开 PDF,翻到第 37 页,肉眼搜索几个关键段落。

每查一个问题,20 分钟。如果查询 7 个关键字段,140 分钟。而在数据库里,同样的查询只需要一条 SQL。

问题核心:合同数据锁在文档里,不在数据库里。

这篇文章带你从零到一构建一个生产级的 Contract Intelligence 系统——上传任意合同(PDF/图片/XML),几秒内输出结构化的 Excel 报表,包含所有关键商业条款、置信度评分和页码引用。

核心技术栈

组件用途
PaddleOCR主 OCR 引擎,从扫描件提取文字
CLAHE + Deskew图像增强(去噪、对比度均衡、去偏斜)
GPT-4o / GPT-4VOCR 兜底 + 智能提取
FAISS稠密向量检索
BM25稀疏关键词检索
RRF(Reciprocal Rank Fusion)混合检索结果融合
LangGraph多步骤状态机编排
Streamlit前端 UI
OpenPyXL彩色的 Excel 报表生成

系统架构总览

整个管道分 7 个步骤,由 LangGraph 状态机编排:

上传合同(PDF/图片/XML)
    │
    ▼
┌──────────────────┐
│  Step 1: 图像增强  │  ← CLAHE + 去噪 + 去偏斜
└────────┬─────────┘
         ▼
┌──────────────────┐
│  Step 2: 文本提取  │  ← PaddleOCR + GPT-4V兜底
└────────┬─────────┘
         ▼
┌──────────────────┐
│  Step 3: 混合索引  │  ← FAISS + BM25 + RRF融合
└────────┬─────────┘
         ▼
┌──────────────────┐
│  Step 4: 多查询RAG │  ← 7个字段并行查询
└────────┬─────────┘
         ▼
┌──────────────────┐
│  Step 5: 字段提取  │  ← GPT-4o + 置信度评分
└────────┬─────────┘
         ▼
┌──────────────────┐
│  Step 6: 费率卡    │  ← 跨页表格合并 + Schema规范化
└────────┬─────────┘
         ▼
┌──────────────────┐
│  Step 7: Excel输出 │  ← 彩色报表 + 源引用
└──────────────────┘

第一步:图像增强

这是最容易忽视但回报最高的步骤。原始扫描件的质量参差不齐——有些是手机拍的照片、有些是传真过来的灰阶文件、有些有 15 度的偏斜。

三个关键处理:

CLAHE(对比度受限自适应直方图均衡化)

普通的直方图均衡化会过度放大噪声。CLAHE 通过在局部区块内做均衡化,并限制对比度增幅,在增强文本清晰度的同时不炸掉噪声:

import cv2
import numpy as np

def enhance_contract_image(image_path: str) -> np.ndarray:
    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
    
    # 1. CLAHE 自适应直方图均衡
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
    enhanced = clahe.apply(img)
    
    # 2. 去噪(非局部均值去噪,保留边缘)
    denoised = cv2.fastNlMeansDenoising(enhanced, h=30)
    
    # 3. 二值化
    _, binary = cv2.threshold(denoised, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    
    return binary

Deskew(去偏斜)

扫描件经常有微小旋转。使用霍夫变换检测直线 → 计算旋转角度 → 反向纠正:

def deskew_image(img: np.ndarray) -> np.ndarray:
    coords = np.column_stack(np.where(img > 0))
    angle = cv2.minAreaRect(coords)[-1]
    
    if angle < -45:
        angle = 90 + angle
    
    (h, w) = img.shape[:2]
    center = (w // 2, h // 2)
    M = cv2.getRotationMatrix2D(center, angle, 1.0)
    return cv2.warpAffine(img, M, (w, h), 
                          flags=cv2.INTER_CUBIC,
                          borderMode=cv2.BORDER_REPLICATE)

效果: 经过这些处理后,OCR 准确率从裸扫描的约 65% 提升到了约 92%。

第二步:双策略 OCR

主引擎:PaddleOCR

PaddleOCR 是目前开源 OCR 模型中准确率和速度最平衡的选择:

from paddleocr import PaddleOCR

ocr = PaddleOCR(use_angle_cls=True, lang='en', use_gpu=False)

def paddle_ocr_page(image: np.ndarray) -> str:
    result = ocr.ocr(image, cls=True)
    lines = []
    for line in result[0]:
        text = line[1][0]
        confidence = line[1][1]
        lines.append(text)
    return '\n'.join(lines)

兜底方案:GPT-4V

对于手写体密集、盖公章、或 PaddleOCR 置信度过低的页面,自动切换到 GPT-4V:

from openai import OpenAI
import base64

client = OpenAI()

def gpt4v_fallback(image: np.ndarray, paddle_conf: float) -> str:
    if paddle_conf > 0.85:
        return None  # 不需要兜底
    
    # 将图片转为 Base64
    _, buffer = cv2.imencode('.png', image)
    b64 = base64.b64encode(buffer).decode()
    
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text", "text": "Extract ALL text from this document page. Preserve the original layout order."},
                {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
            ]
        }],
        max_tokens=4096
    )
    return response.choices[0].message.content

双策略效果: 普通页用 PaddleOCR(几乎零成本),模糊/盖章/手写页自动用 GPT-4V。整体准确率从 PaddleOCR 单独使用的 82% 提升到了 96%+。

第三步:混合 RAG 索引

纯向量检索在合同场景下有问题:合同语言高度模板化,”indemnification”、”termination”、”whereas” 这些词到处都是。语义相近的条款太多,向量容易混淆。

解决方案:FAISS(稠密)+ BM25(稀疏)双路检索 + RRF(Reciprocal Rank Fusion)融合。

构建混合索引

from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
from langchain.retrievers import EnsembleRetriever

def build_hybrid_index(chunks: list[str]):
    # 稠密检索:FAISS
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    vectorstore = FAISS.from_texts(chunks, embedding=embeddings)
    dense_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
    
    # 稀疏检索:BM25
    bm25_retriever = BM25Retriever.from_texts(chunks)
    bm25_retriever.k = 10
    
    # RRF 融合
    ensemble = EnsembleRetriever(
        retrievers=[dense_retriever, bm25_retriever],
        weights=[0.5, 0.5]
    )
    return ensemble

RRF 融合算法

Reciprocal Rank Fusion 用排名而非分数来融合结果,避免了 FAISS 和 BM25 分数尺度不一致的问题:

def reciprocal_rank_fusion(results: list[list], k: int = 60) -> list:
    fused_scores = {}
    for rank_list in results:
        for rank, doc in enumerate(rank_list, 1):
            doc_id = doc.metadata.get('id', doc.page_content[:50])
            fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1 / (k + rank)
    
    ranked = sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
    return ranked

效果: 纯 FAISS 召回率约 72%,加 BM25 后提升到 94%。

第四步:多查询 RAG

对同一条款的多种表达方式同时查询,最大化召回:

QUERY_VARIANTS = {
    "payment_terms": [
        "What are the payment terms and conditions?",
        "When are payments due? Net 30, Net 60?",
        "Payment schedule and invoicing requirements",
        "Late payment penalties and interest",
        "What currency are payments in?"
    ],
    "termination": [
        "What are the termination clauses?",
        "How can either party terminate this agreement?",
        "Notice period for termination",
        "Termination for convenience or cause",
        "Post-termination obligations"
    ],
    "liability": [
        "What are the liability caps?",
        "Limitation of liability clause",
        "Indemnification obligations",
        "Exclusions from liability limits",
        "Aggregate liability cap amount"
    ]
}

def multi_query_retrieve(retriever, field_name: str, k: int = 5):
    queries = QUERY_VARIANTS[field_name]
    all_docs = []
    for q in queries:
        docs = retriever.get_relevant_documents(q)
        all_docs.extend(docs[:k])
    
    # 去重
    seen = set()
    unique = []
    for d in all_docs:
        key = d.page_content[:100]
        if key not in seen:
            seen.add(key)
            unique.append(d)
    return unique[:k * 2]

第五步:GPT-4o 合同字段提取

将检索到的上下文交给 GPT-4o,结构化提取 7 个关键字段:

CONTRACT_EXTRACTION_PROMPT = """
You are a contract analyst. Extract the following fields from the provided contract text.
For each field, provide:
- value: the extracted value
- confidence: a score from 0.0 to 1.0
- source: a brief snippet from the text that supports this value

Fields to extract:
1. contract_date: Date the contract was signed
2. effective_date: When the contract takes effect
3. expiry_date: Contract expiration or renewal date
4. party_names: Names of all contracting parties
5. payment_terms: Net 30, Net 60, etc.
6. liability_cap: Maximum liability amount
7. governing_law: Jurisdiction and governing law

Contract text:
{context}

Return JSON format.
"""

7 个关键字段的输出示例:

{
  "contract_date": {
    "value": "2026-01-15",
    "confidence": 0.95,
    "source": "This Agreement is entered into on January 15, 2026"
  },
  "effective_date": {
    "value": "2026-02-01",
    "confidence": 0.92,
    "source": "Effective as of February 1, 2026"
  },
  "expiry_date": {
    "value": "2027-01-31",
    "confidence": 0.88,
    "source": "This Agreement shall remain in force until January 31, 2027"
  },
  "payment_terms": {
    "value": "Net 60",
    "confidence": 0.90,
    "source": "Payment shall be made within sixty (60) days of invoice date"
  }
}

第六步:LangGraph 状态机编排

用 LangGraph 将所有步骤串联成一个有状态的工作流:

from langgraph.graph import StateGraph, END
from typing import TypedDict, List, Optional

class ContractState(TypedDict):
    file_path: str
    images: List
    ocr_text: str
    chunks: List[str]
    hybrid_index: object
    extracted_fields: dict
    confidence_scores: dict
    excel_path: Optional[str]
    error: Optional[str]

def build_contract_pipeline():
    workflow = StateGraph(ContractState)
    
    workflow.add_node("enhance_images", enhance_step)
    workflow.add_node("extract_text", ocr_step)
    workflow.add_node("build_index", index_step)
    workflow.add_node("multi_query_rag", rag_step)
    workflow.add_node("extract_fields", extract_step)
    workflow.add_node("generate_excel", excel_step)
    
    workflow.add_conditional_edges(
        "extract_text",
        check_ocr_quality,
        {True: "build_index", False: "extract_text"}  # 质量低则重试
    )
    
    workflow.set_entry_point("enhance_images")
    workflow.add_edge("enhance_images", "extract_text")
    workflow.add_edge("build_index", "multi_query_rag")
    workflow.add_edge("multi_query_rag", "extract_fields")
    workflow.add_edge("extract_fields", "generate_excel")
    workflow.add_edge("generate_excel", END)
    
    return workflow.compile()

LangGraph 的关键优势:

第七步:彩色的 Excel 报表

最后,用 OpenPyXL 生成带颜色编码和源引用的 Excel:

from openpyxl import Workbook
from openpyxl.styles import PatternFill, Font

def generate_excel_report(fields: dict, rate_card: list, raw_text: str, output_path: str):
    wb = Workbook()
    
    # Sheet 1: 字段摘要
    ws1 = wb.active
    ws1.title = "Field Summary"
    
    green_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
    yellow_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
    red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
    
    ws1.cell(row=1, column=1, value="Field")
    ws1.cell(row=1, column=2, value="Value")
    ws1.cell(row=1, column=3, value="Confidence")
    ws1.cell(row=1, column=4, value="Source Text")
    
    for i, (field, data) in enumerate(fields.items(), 2):
        ws1.cell(row=i, column=1, value=field)
        ws1.cell(row=i, column=2, value=data['value'])
        ws1.cell(row=i, column=3, value=data['confidence'])
        ws1.cell(row=i, column=4, value=data['source'])
        
        # 置信度着色
        conf = data['confidence']
        fill = green_fill if conf >= 0.9 else (yellow_fill if conf >= 0.7 else red_fill)
        ws1.cell(row=i, column=3).fill = fill
    
    # Sheet 2: 费率卡
    ws2 = wb.create_sheet("Rate Card")
    for row_data in rate_card:
        ws2.append(row_data)
    
    # Sheet 3: 原始 OCR 文本
    ws3 = wb.create_sheet("Raw OCR")
    ws3.cell(row=1, column=1, value=raw_text)
    
    wb.save(output_path)

完整代码仓库

以上所有代码的完整版本已开源在 GitHub。包含:

最让我惊讶的部分

合同语言其实非常固定——全是 “Whereas”、”indemnify”、”termination”、”force majeure” 这类模板化用语。

这带来一个反直觉的发现:纯向量检索在这种场景下效果反而差

原因是向量检索擅长语义匹配——”猫”和”狗”语义相似。但合同里所有条款的语义都非常接近(都是法律语言),向量空间里不同条款聚在一起,区分度低。

加上 BM25 做关键词精确匹配后,召回率从 72% 跳到 94%。不是因为 BM25 更好,而是因为两者互补:FAISS 找到语义相关的,BM25 找到关键词精确匹配的,RRF 融合后取两者之长。

说人话的总结

合同审核是”每个公司都有但没人愿意干”的苦活。手动查一份合同 20 分钟,100 份就是 33 个小时。

这套系统把流程压缩到几秒:

步骤人工自动化
打开文件30秒0秒
查找条款15分钟0.5秒
提取数据5分钟3秒
生成报表5分钟1秒
总计~25分钟~5秒

技术组合:LangGraph 做流程编排 + PaddleOCR 做文本提取 + CLAHE 做图像增强 + FAISS+BM25 双路检索 + GPT-4o 做智能提取 + Streamlit 做前端。

如果你还在手动翻合同,这套系统值得一试。从 GitHub 拉下来,5 分钟就能跑起来。

版权所有,本作品采用知识共享署名-非商业性使用 3.0 未本地化版本许可协议进行许可。转载请注明出处:https://www.wangjun.dev//2026/06/ai-contract-intelligence-system/
📝 此页面已自动翻译为英文 · 查看原文
EN | 中文