面对一堆来源未知的文档——发票、合同、报关单、体检报告混在一起——传统 IDP 流程的第一步就卡住了:你得先人工识别文档类型,再逐个手写提取 Schema。文档种类多、格式杂,这一步往往比后续抽取本身更耗时。
本文介绍的多文档发现(Multi-Document Discovery)功能,把这个前置步骤自动化了:先用视觉嵌入把文档按类型聚类,再让 Agent 为每个簇生成可直接用于 IDP Accelerator 的 Schema。下面拆解它的运作方式,并给出可复用的实践代码。
从"未知文档堆"到"可处理 Schema"的全流程
整体分三个阶段:
- 文档摄入——把 PDF/图片丢进系统,不做任何人工标注。
- 视觉聚类——对每份文档提取视觉嵌入向量,用聚类算法自动分组,同一类型的发票、合同自然归到同一簇。
- Agent 生成 Schema——对每个簇采样几份代表文档,Agent 读取内容后输出结构化 Schema(字段名、类型、必填、示例值),直接对接 IDP Accelerator。
关键点在于:聚类靠的是文档的视觉特征而非文本内容,所以即使同一类型的文档排版差异大(不同供应商的发票模板),只要视觉结构相似就能归到一起。
视觉嵌入:为什么不用 OCR 先抽文本?
传统做法是先 OCR 再按文本特征聚类,但这一步有几个实际问题:
- OCR 对低质量扫描件、手写体、表格边框的识别不稳定,文本本身就有噪声,聚类结果跟着抖。
- 有些文档类型(比如带印章的合同 vs 无印章的协议)的区别更多在视觉布局而非文字内容。
- OCR 是重计算步骤,在"只是想先分个类"的阶段跑全量 OCR 性价比低。
视觉嵌入的做法是:把文档页面当作图片,过一个视觉编码器(如基于 ViT 的模型),拿到一个固定维度的向量。这个向量编码了页面的布局、字体大小分布、图表/表格区域等视觉信号,两张视觉相似的页面在向量空间中距离就近。
下面是一个用 Hugging Face 视觉模型提取文档嵌入并做聚类的最小可运行示例:
# 依赖:pip install transformers torch scikit-learn pillow pdf2image
# 还需安装 poppler(pdf2image 的底层):brew install poppler (macOS) 或 apt install poppler-utils (Linux)
import os
import numpy as np
from pdf2image import convert_from_path
from PIL import Image
from transformers import AutoModel, AutoProcessor
from sklearn.cluster import AgglomerativeClustering
# 1. 把 PDF 转成图片并提取视觉嵌入
model_name = "google/siglip-so400m-patch14-384" # 视觉编码器,也可换用其他 ViT 模型
processor = AutoProcessor.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name)
def pdf_to_images(pdf_path: str, dpi: int = 200) -> list[Image.Image]:
"""将 PDF 每页转为 PIL Image,只取第一页做代表。"""
images = convert_from_path(pdf_path, dpi=dpi, first_page=1, last_page=1)
return images[0]
def get_visual_embedding(image: Image.Image) -> np.ndarray:
"""单页文档 -> 视觉嵌入向量。"""
inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
embeddings = model.get_image_features(**inputs)
# 归一化,方便后续用余弦距离聚类
emb = embeddings.cpu().numpy().flatten()
emb = emb / np.linalg.norm(emb)
return emb
# 假设你的文档都在这个目录下
doc_dir = "./raw_docs"
pdf_files = [f for f in os.listdir(doc_dir) if f.lower().endswith(".pdf")]
embeddings = []
for pdf in pdf_files:
img = pdf_to_images(os.path.join(doc_dir, pdf))
emb = get_visual_embedding(img)
embeddings.append(emb)
X = np.stack(embeddings) # shape: (num_docs, emb_dim)
# 2. 聚类——不预设簇数,用距离阈值自动决定
clusterer = AgglomerativeClustering(
metric="cosine", # 视觉嵌入用余弦距离更合理
linkage="average",
distance_threshold=0.35, # 阈值需根据你的文档集微调
n_clusters=None # 不固定簇数,由阈值决定
)
labels = clusterer.fit_predict(X)
# 3. 输出聚类结果
for cluster_id in set(labels):
members = [pdf_files[i] for i in range(len(labels)) if labels[i] == cluster_id]
print(f"簇 {cluster_id}({len(members)} 份文档): {members}")
运行前需要准备 ./raw_docs 目录并放入若干 PDF。distance_threshold 是最需要调的参数——阈值越小簇越多(分类更细),阈值越大簇越少(合并更激进)。建议先用 0.3–0.5 跑一遍,看输出是否把明显不同类型的文档分开了,再微调。
Agent 生成 Schema:从簇到可用的提取定义
聚类完成后,每个簇对应一种文档类型。下一步是为每类文档生成 Schema——字段名、数据类型、是否必填、取值示例。
这一步由 Agent 完成:它从簇中采样 2–3 份代表文档,读取其内容(此时才做 OCR/文本抽取),然后基于内容推理出合理的 Schema。Agent 的优势在于:
- 能理解语义上下文,区分"金额"和"税额"这类相近字段。
- 能根据文档内容推断字段类型(日期、数字、枚举)。
- 能处理同一字段在不同文档中名称不一致的情况("发票号" vs "Invoice No")。
下面是用 OpenAI API 实现 Schema 生成 Agent 的示例,可直接改造接入其他 LLM:
# 依赖:pip install openai pytesseract pillow pdf2image
# 还需安装 Tesseract OCR:brew install tesseract (macOS) 或 apt install tesseract-ocr (Linux)
import json
import os
import pytesseract
from pdf2image import convert_from_path
from openai import OpenAI
client = OpenAI() # 默认读取环境变量 OPENAI_API_KEY
def extract_text_from_pdf(pdf_path: str) -> str:
"""OCR 抽取 PDF 第一页文本,供 Agent 阅读。"""
images = convert_from_path(pdf_path, dpi=200, first_page=1, last_page=1)
text = pytesseract.image_to_string(images[0], lang="chi_sim+eng")
return text
def generate_schema(sample_texts: list[str], cluster_label: str) -> dict:
"""让 Agent 根据采样文档文本生成提取 Schema。"""
combined = "\n---\n".join(sample_texts)
prompt = f"""你是一个文档处理专家。以下是同一类文档的 {len(sample_texts)} 份样本内容:
{combined}
请为这类文档生成一个 JSON Schema,用于后续结构化提取。要求:
1. 字段名用英文 snake_case,但每个字段加中文注释(description)。
2. 标明字段类型(string / number / date / enum 等)。
3. 标明是否必填(required)。
4. 如果能推断出枚举值,列在 enum 中。
5. 只输出 JSON,不要额外解释。
输出格式示例:
{
"schema_name": "invoice",
"fields": [
{"name": "invoice_number", "type": "string", "required": true, "description": "发票号码"},
{"name": "total_amount", "type": "number", "required": true, "description": "总金额"}
]
}"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
temperature=0.1 # 低温度保证输出稳定
)
return json.loads(response.choices[0].message.content)
# 假设上一步聚类结果已存为 dict: cluster_id -> [pdf_filenames]
cluster_map = {
0: ["invoice_001.pdf", "invoice_002.pdf", "invoice_003.pdf"],
1: ["contract_001.pdf", "contract_002.pdf"],
}
doc_dir = "./raw_docs"
schemas = {}
for cluster_id, members in cluster_map.items():
# 每簇采样 2 份
samples = members[:2]
texts = [extract_text_from_pdf(os.path.join(doc_dir, f)) for f in samples]
schema = generate_schema(texts, str(cluster_id))
schemas[cluster_id] = schema
print(f"簇 {cluster_id} Schema:")
print(json.dumps(schema, indent=2, ensure_ascii=False))
生成的 Schema 可以直接喂给 IDP Accelerator 或任何文档抽取框架。如果你的 Accelerator 有特定 Schema 格式要求,只需调整 prompt 中的输出格式描述即可。
实际部署时的几个取舍
| 决策点 | 选项 | 建议 |
|---|---|---|
| 视觉编码器选型 | 通用 ViT vs 专用文档模型 | 文档场景优先试 LayoutLMv3 / DiT 等文档预训练模型,通用 ViT 是快速起步的备选 |
| 聚类算法 | Agglomerative vs KMeans vs DBSCAN | 文档数量未知、簇数不定时,Agglomerative + 距离阈值最灵活;已知类型数可用 KMeans |
| OCR 时机 | 聚类前全量 OCR vs 聚类后仅采样 OCR | 先聚类后 OCR 算力省一半以上,除非你的文档文本特征比视觉特征区分度更高 |
| Agent 模型 | GPT-4o vs Claude vs 本地模型 | Schema 生成对推理能力要求中等,GPT-4o 稳定性好;成本敏感可试 Claude Haiku 或 Qwen2.5-7B |
| 聚类阈值 | 固定值 vs 自适应 | 先固定值跑基准,后续可按簇内平均距离自动调整(距离 = 簇内均值 / 簇间均值 < 0.5 算合理) |
跑通后的检查清单
在把自动生成的 Schema 接入生产前,逐项确认:
- 聚类质量:随机抽几个簇,人工看成员是否确实同类型。如果发票和收据混在一簇,调低距离阈值或换更细粒度的视觉模型。
- Schema 完整性:拿 5 份未参与采样的文档,逐字段核对——有没有遗漏字段?有没有字段类型标错(数字标成字符串)?
- 边界文档:单页 vs 多页、高清 vs 低清扫描件,是否被正确聚类?视觉嵌入对分辨率敏感,建议摄入前统一 DPI。
- 增量更新:新文档类型出现时,它会自成新簇还是被错误归入已有簇?定期重跑聚类并对比簇数变化。
多文档发现功能本质上把 IDP 流程中最枯燥的"看文档、分类型、写 Schema"三步压缩成了一次自动化预处理。视觉聚类让你不必先 OCR 全量文档,Agent 让你不必手写每个字段定义。投入半小时调好聚类阈值和 Agent prompt,后续面对新文档集时就能一键跑通从分类到 Schema 的全流程。