docs(asl): Complete Tool 3 extraction workbench V2.0 development plan (v1.5)
ASL Tool 3 Development Plan: - Architecture blueprint v1.5 (6 rounds of architecture review, 13 red lines) - M1/M2/M3 sprint checklists (Skeleton Pipeline / HITL Workbench / Dynamic Template Engine) - Code patterns cookbook (9 chapters: Fan-out, Prompt engineering, ACL, SSE dual-track, etc.) - Key patterns: Fan-out with Last Child Wins, Optimistic Locking, teamConcurrency throttling - PKB ACL integration (anti-corruption layer), MinerU Cache-Aside, NOTIFY/LISTEN cross-pod SSE - Data consistency snapshot for long-running extraction tasks Platform capability: - Add distributed Fan-out task pattern development guide (7 patterns + 10 anti-patterns) - Add system-level async architecture risk analysis blueprint - Add PDF table extraction engine design and usage guide (MinerU integration) - Add table extraction source code (TableExtractionManager + MinerU engine) Documentation updates: - Update ASL module status with Tool 3 V2.0 plan readiness - Update system status document (v6.2) with latest milestones - Add V2.0 product requirements, prototypes, and data dictionary specs - Add architecture review documents (4 rounds of review feedback) - Add test PDF files for extraction validation Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
| **LLM网关** | `common/llm/` | ✅ | 统一LLM适配器(5个模型) |
|
||||
| **流式响应** | `common/streaming/` | ✅ 🆕 | OpenAI Compatible流式输出 |
|
||||
| **🎉RAG引擎** | `common/rag/` | ✅ 🆕 | **完整实现!pgvector+DeepSeek+Rerank** |
|
||||
| **文档处理** | `extraction_service/` | ✅ 🆕 | pymupdf4llm PDF→Markdown |
|
||||
| **文档处理** | `extraction_service/` | ✅ V2 | pymupdf4llm (全文) + **PDF 表格提取引擎** (多引擎可插拔) |
|
||||
| **认证授权** | `common/auth/` | ✅ | JWT认证 + 权限控制 |
|
||||
| **Prompt管理** | `common/prompt/` | ✅ | 动态Prompt配置 |
|
||||
| **🆕R统计引擎** | `r-statistics-service/` | ✅ | Docker化R统计服务(plumber) |
|
||||
@@ -525,11 +525,26 @@ const final = await searchService.rerank(queries[0], results, { topK: 5 });
|
||||
|
||||
---
|
||||
|
||||
### 9. 🎉 文档处理引擎(✅ 2026-01-21 增强完成)
|
||||
### 9. 🎉 文档处理引擎(✅ V2 — 2026-02-23 表格提取引擎升级)
|
||||
|
||||
**路径:** `extraction_service/` (Python 微服务,端口 8000)
|
||||
**路径:** `extraction_service/` (Python 微服务) + `backend/src/common/document/tableExtraction/` (TypeScript)
|
||||
|
||||
**功能:** 将各类文档统一转换为 **LLM 友好的 Markdown 格式**
|
||||
**功能:** 将各类文档统一转换为 LLM 友好的 Markdown 格式 + **PDF 结构化表格提取**
|
||||
|
||||
**V2 分层架构 — 全文文本 + 结构化表格 分离:**
|
||||
| 引擎层 | 定位 | 输出 | 状态 |
|
||||
|--------|------|------|------|
|
||||
| **pymupdf4llm** | 全文文本提取 | Markdown | ✅ 已有 |
|
||||
| **PDF 表格提取引擎** | 结构化表格提取 (统一抽象层) | ExtractedTable[] | ✅ V2 新增 |
|
||||
|
||||
**PDF 表格提取引擎 — 候选引擎 (可插拔):**
|
||||
| 引擎 | 状态 | 特点 |
|
||||
|------|------|------|
|
||||
| MinerU Cloud API (VLM) | ✅ 已接入 (当前默认) | 综合 4.6/5 |
|
||||
| Qwen3-VL | 📋 待评测 | 多模态理解最强 |
|
||||
| PaddleOCR-VL 1.5 | 📋 待评测 | 医学场景案例多 |
|
||||
| Qwen-OCR + Qwen-Long | 📋 待评测 | 成本最低 |
|
||||
| Docling (IBM) | 📋 待评测 | MIT 开源,离线部署 |
|
||||
|
||||
**核心 API:**
|
||||
```
|
||||
@@ -540,16 +555,11 @@ Content-Type: multipart/form-data
|
||||
返回:{ success: true, text: "Markdown内容", metadata: {...} }
|
||||
```
|
||||
|
||||
**技术升级:**
|
||||
- ✅ PDF 处理:pymupdf4llm(保留表格、公式、结构)
|
||||
- ✅ 统一入口:DocumentProcessor 自动检测文件类型
|
||||
- ✅ 零 OCR:电子版文档专用,扫描件返回友好提示
|
||||
- ✅ 与 RAG 引擎无缝集成
|
||||
|
||||
**支持格式:**
|
||||
| 格式 | 工具 | 输出质量 | 状态 |
|
||||
|------|------|----------|------|
|
||||
| PDF | pymupdf4llm | 表格保真 | ✅ |
|
||||
| PDF (全文) | pymupdf4llm | Markdown 文本 | ✅ |
|
||||
| PDF (表格) | **MinerU VLM** | HTML 结构化表格 | ✅ V2 |
|
||||
| Word | mammoth | 结构完整 | ✅ |
|
||||
| Excel/CSV | pandas | 上下文丰富 | ✅ |
|
||||
| PPT | python-pptx | 按页拆分 | ✅ |
|
||||
@@ -592,7 +602,9 @@ const markdown = await client.extractText(buffer, 'pdf');
|
||||
- 🔜 AIA - 附件处理
|
||||
|
||||
**详细文档:**
|
||||
- 📖 [文档处理引擎使用指南](./02-文档处理引擎/02-文档处理引擎使用指南.md) ⭐ **推荐阅读**
|
||||
- 📖 [PDF 表格提取引擎使用指南](./02-文档处理引擎/04-PDF表格提取引擎使用指南.md) ⭐ **5 秒上手 + 实战场景**
|
||||
- 📖 [PDF 表格提取引擎设计方案](./02-文档处理引擎/03-PDF表格提取引擎设计方案.md) — 统一抽象 + 多引擎可插拔
|
||||
- 📖 [文档处理引擎使用指南](./02-文档处理引擎/02-文档处理引擎使用指南.md)
|
||||
- [文档处理引擎设计方案](./02-文档处理引擎/01-文档处理引擎设计方案.md)
|
||||
|
||||
---
|
||||
|
||||
584
docs/02-通用能力层/02-文档处理引擎/03-PDF表格提取引擎设计方案.md
Normal file
584
docs/02-通用能力层/02-文档处理引擎/03-PDF表格提取引擎设计方案.md
Normal file
@@ -0,0 +1,584 @@
|
||||
# PDF 表格提取引擎设计方案
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **创建日期**: 2026-02-23
|
||||
> **最后更新**: 2026-02-23
|
||||
> **文档目的**: 定义 PDF 表格提取引擎的统一架构,为系统综述/Meta 分析等场景提供精确的结构化表格数据
|
||||
> **核心原则**: 引擎对使用者透明 — 提交 PDF,返回结构化表格,无需关心底层实现
|
||||
> **当前状态**: MinerU Cloud API (VLM) 已接入并完成测试,其他引擎待逐步评测
|
||||
|
||||
---
|
||||
|
||||
## 1. 业务背景
|
||||
|
||||
### 1.1 核心需求
|
||||
|
||||
ASL 智能文献模块的**全文复筛**环节,需要从医学 PDF 文献中精确提取数据表格:
|
||||
|
||||
- **系统综述 (Systematic Review)**: 基线特征表、结局指标表、不良事件表
|
||||
- **Meta 分析**: 效应值、置信区间、样本量等关键数值
|
||||
- **数据核验**: 数值必须与原文 100% 一致,不容许任何精度损失
|
||||
|
||||
### 1.2 为什么独立建设
|
||||
|
||||
当前文档处理引擎基于 `pymupdf4llm`,定位是 **PDF → Markdown 全文文本转换**,在表格提取场景中存在严重缺陷:
|
||||
|
||||
| 问题 | 实测数据 |
|
||||
|------|----------|
|
||||
| 8 篇 PDF 仅 1 篇输出结构化表格 | 表格检出率 12.5% |
|
||||
| 其余 7 篇表格退化为纯文本 | 行列结构完全丢失 |
|
||||
| 不支持合并单元格 | 医学表格大量使用 rowspan/colspan |
|
||||
|
||||
**结论:全文文本提取和结构化表格提取是两个不同的能力,需要分别建设。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 引擎架构设计
|
||||
|
||||
### 2.1 核心理念
|
||||
|
||||
> **使用者不需要关心底层用了什么技术,只需要:提交 PDF → 获取结构化表格。**
|
||||
|
||||
底层引擎可以是 MinerU、Qwen-VL、PaddleOCR、Docling 或任意其他方案,通过统一接口抽象,实现热切换和渐进升级。
|
||||
|
||||
### 2.2 统一架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 业务层 (使用者) │
|
||||
│ ASL 全文复筛 / 系统综述数据提取 / Meta 分析 │
|
||||
│ │
|
||||
│ const tables = await tableEngine.extract(pdfBuffer); │
|
||||
│ // 只关心输入 PDF 和输出 tables,不关心底层引擎 │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ PDF 表格提取引擎 (统一抽象层) │
|
||||
│ │
|
||||
│ interface TableExtractionEngine { │
|
||||
│ extract(pdf: Buffer): Promise<ExtractedTable[]> │
|
||||
│ extractFromUrl(url: string): Promise<ExtractedTable[]> │
|
||||
│ } │
|
||||
│ │
|
||||
│ 统一输出:ExtractedTable[] │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ { title, headers, rows, mergedCells, footnotes, │ │
|
||||
│ │ pageNumber, confidence, rawHtml } │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 引擎适配器 (可插拔) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ MinerU │ │ Qwen3-VL │ │ PaddleOCR-VL │ │
|
||||
│ │ Cloud API │ │ 多模态 LLM │ │ 百度 OCR │ │
|
||||
│ │ (VLM) │ │ │ │ │ │
|
||||
│ │ ✅ 已接入 │ │ 📋 待评测 │ │ 📋 待评测 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Qwen-OCR + │ │ Docling │ │ DeepSeek │ │
|
||||
│ │ Qwen-Long │ │ (IBM) │ │ LLM │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ 📋 待评测 │ │ 📋 待评测 │ │ ✅ 已测试 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 统一输出格式
|
||||
|
||||
无论底层使用哪个引擎,输出都遵循统一的 `ExtractedTable` 结构:
|
||||
|
||||
```typescript
|
||||
interface ExtractedTable {
|
||||
/** 表格标题 (如 "Table 1 Baseline characteristics") */
|
||||
title: string;
|
||||
/** 表头行 */
|
||||
headers: string[];
|
||||
/** 数据行 (二维数组) */
|
||||
rows: string[][];
|
||||
/** 合并单元格信息 */
|
||||
mergedCells?: MergedCell[];
|
||||
/** 脚注 */
|
||||
footnotes?: string[];
|
||||
/** 所在 PDF 页码 */
|
||||
pageNumber?: number;
|
||||
/** 引擎自信度 (0-1) */
|
||||
confidence?: number;
|
||||
/** 原始 HTML (供前端渲染或调试) */
|
||||
rawHtml?: string;
|
||||
/** 原始 Markdown (备选格式) */
|
||||
rawMarkdown?: string;
|
||||
}
|
||||
|
||||
interface MergedCell {
|
||||
row: number;
|
||||
col: number;
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 候选引擎全景
|
||||
|
||||
### 3.1 引擎候选清单
|
||||
|
||||
| 引擎 | 类型 | 特点 | 成本 | 状态 |
|
||||
|------|------|------|------|------|
|
||||
| **MinerU Cloud API** | VLM 云端 | 表格结构最完整,rowspan/colspan 支持 | 2000 页/天免费 | ✅ 已接入 |
|
||||
| **Qwen3-VL** | 多模态 LLM | 多模态理解最强,复杂表格语义识别好 | 按 token 计费 | 📋 待评测 |
|
||||
| **Qwen-OCR + Qwen-Long** | OCR + LLM 组合 | 成本最低、功能最全的组合方案 | 极低 | 📋 待评测 |
|
||||
| **百度 PaddleOCR-VL 1.5** | VL OCR | 医学场景案例多,准确率高,免费额度最多 | 官方免费额度多 | 📋 待评测 |
|
||||
| **Docling (IBM)** | 本地部署 | MIT 开源,TableFormer 模型,可完全离线 | 免费 (本地部署) | 📋 待评测 |
|
||||
| **DeepSeek LLM** | 文本 LLM | 从原始文本重构表格,Markdown 输出 | ~0.14 元/万 token | ✅ 已测试 |
|
||||
|
||||
### 3.2 推荐分类
|
||||
|
||||
**最佳性价比组合:**
|
||||
1. **Qwen-OCR + Qwen-Long** — 成本最低,功能最全
|
||||
2. **百度 PaddleOCR-VL** — 官方免费额度最多,技术最成熟
|
||||
|
||||
**医学文献表格提取最佳选择:**
|
||||
1. **Qwen3-VL** — 多模态理解最强,支持复杂表格
|
||||
2. **百度 PaddleOCR-VL 1.5** — 医学场景案例多,准确率高
|
||||
|
||||
**数据合规 / 离线场景:**
|
||||
1. **Docling (IBM)** — MIT 开源,完全本地部署
|
||||
|
||||
### 3.3 评测计划
|
||||
|
||||
按优先级逐步评测,使用同一组 8 篇医学 PDF 文献作为基准:
|
||||
|
||||
| 阶段 | 引擎 | 优先级 | 评测重点 |
|
||||
|------|------|--------|----------|
|
||||
| ✅ 已完成 | MinerU Cloud API | — | 作为 baseline |
|
||||
| ✅ 已完成 | DeepSeek LLM | — | 文本 LLM 方案的上限 |
|
||||
| P1 待测 | Qwen3-VL | 高 | 多模态 vs MinerU VLM 的表格精度 |
|
||||
| P1 待测 | PaddleOCR-VL 1.5 | 高 | 免费额度 + 医学场景准确率 |
|
||||
| P2 待测 | Qwen-OCR + Qwen-Long | 中 | 验证最低成本方案的可行性 |
|
||||
| P2 待测 | Docling | 中 | 离线方案,评估部署成本 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 已完成测试:MinerU vs pymupdf4llm vs DeepSeek
|
||||
|
||||
### 4.1 测试概要
|
||||
|
||||
- **测试对象**: 8 篇真实医学 PDF 文献(含 1 篇中文),涵盖 RCT、队列研究
|
||||
- **测试方法**: pymupdf4llm (本地) / MinerU Cloud API (VLM) / DeepSeek LLM (deepseek-chat)
|
||||
|
||||
### 4.2 核心结果
|
||||
|
||||
| 指标 | pymupdf4llm | MinerU API (VLM) | DeepSeek LLM |
|
||||
|------|-------------|------------------|--------------|
|
||||
| 结构化表格检出 | 3 个 (12.5%) | **28 个 (100%)** | 24 个 (85%) |
|
||||
| 输出格式 | 纯文本 | **HTML `<table>`** | Markdown `\|..\|` |
|
||||
| 合并单元格 | ❌ | **✅ rowspan/colspan** | ⚠️ 文字描述 |
|
||||
| 数值精度 | ✅ 原始 | **✅ 100% 保真** | ⚠️ 可能翻译 |
|
||||
| 总耗时 (8 篇) | 16.1s | ~50s | 234.6s |
|
||||
| 综合评分 | 2.7/5 | **4.6/5** | 3.4/5 |
|
||||
|
||||
### 4.3 逐文件对比
|
||||
|
||||
| # | 文件 | pymupdf4llm | MinerU API | DeepSeek LLM |
|
||||
|---|------|-------------|------------|--------------|
|
||||
| 1 | S2589537025 (EClinMed) | 0 表格 | **1 HTML** | 1 MD |
|
||||
| 2 | Dongen 2003 | 0 结构化 | **4 HTML** | 3 MD |
|
||||
| 3 | Ginkgo+Donepezil | 0 结构化 | **3 HTML** | 3 MD |
|
||||
| 4 | Ginkgo Community | 0 结构化 | **6 HTML** | 6 MD |
|
||||
| 5 | Ginkgo NPS | 3 MD | **3 HTML** | 3 MD |
|
||||
| 6 | Herrschaft 2012 | 0 结构化 | **3 HTML** | 3 MD |
|
||||
| 7 | Ihl 2011 | 0 结构化 | **3 HTML** | 3 MD |
|
||||
| 8 | NIRS 队列研究 (中文) | 0 结构化 | **5 HTML** | 2 MD |
|
||||
|
||||
### 4.4 质量深度分析 (Herrschaft 2012 — Table 1)
|
||||
|
||||
原始表格: 5 列、18 行,"Type of dementia" 合并 3 行。
|
||||
|
||||
| 特征 | pymupdf4llm | MinerU API | DeepSeek LLM |
|
||||
|------|-------------|------------|--------------|
|
||||
| 列数正确 | ❌ 无结构 | **✅ 5 列** | ✅ 4 列 |
|
||||
| 行数完整 | ✅ 数据在 | **✅ 18 行** | ✅ 18 行 |
|
||||
| 合并单元格 | ❌ | **✅ rowspan=3** | ⚠️ 加粗标注 |
|
||||
| 数值保真 | ✅ | **✅ 含 ±** | ⚠️ 翻译行名 |
|
||||
|
||||
### 4.5 综合评分
|
||||
|
||||
| 维度 | pymupdf4llm | MinerU API | DeepSeek LLM |
|
||||
|------|:-----------:|:----------:|:------------:|
|
||||
| 表格检测率 | 1/5 | **5/5** | 4/5 |
|
||||
| 结构保真度 | 1/5 | **5/5** | 4/5 |
|
||||
| 数值精度 | 5/5 | **5/5** | 4/5 |
|
||||
| 速度 | 5/5 | 3/5 | 2/5 |
|
||||
| 合并单元格 | 1/5 | **5/5** | 3/5 |
|
||||
| 中文支持 | 3/5 | **5/5** | 4/5 |
|
||||
| 成本 | 5/5 | 4/5 | 3/5 |
|
||||
| **综合** | **2.7** | **4.6** | **3.4** |
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术实现设计
|
||||
|
||||
### 5.1 接口抽象
|
||||
|
||||
```typescript
|
||||
// common/document/tableExtraction/types.ts
|
||||
|
||||
/** 统一引擎接口 — 所有适配器必须实现 */
|
||||
interface ITableExtractionEngine {
|
||||
readonly name: string;
|
||||
extract(pdf: Buffer, options?: ExtractionOptions): Promise<ExtractionResult>;
|
||||
}
|
||||
|
||||
interface ExtractionOptions {
|
||||
language?: 'ch' | 'en' | 'auto';
|
||||
/** 指定页码范围,如 "1-5,8" */
|
||||
pageRanges?: string;
|
||||
/** 是否启用公式识别 */
|
||||
enableFormula?: boolean;
|
||||
}
|
||||
|
||||
interface ExtractionResult {
|
||||
tables: ExtractedTable[];
|
||||
/** 引擎名称 */
|
||||
engine: string;
|
||||
/** 处理耗时 (ms) */
|
||||
duration: number;
|
||||
/** PDF 总页数 */
|
||||
pageCount: number;
|
||||
/** 原始 Markdown 全文 (可选) */
|
||||
fullMarkdown?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 引擎管理器
|
||||
|
||||
```typescript
|
||||
// common/document/tableExtraction/engineManager.ts
|
||||
|
||||
class TableExtractionEngineManager {
|
||||
private engines: Map<string, ITableExtractionEngine> = new Map();
|
||||
private defaultEngine: string = 'mineru';
|
||||
|
||||
/** 注册引擎适配器 */
|
||||
register(engine: ITableExtractionEngine): void {
|
||||
this.engines.set(engine.name, engine);
|
||||
}
|
||||
|
||||
/** 设置默认引擎 */
|
||||
setDefault(name: string): void {
|
||||
this.defaultEngine = name;
|
||||
}
|
||||
|
||||
/** 提取表格 — 使用者唯一入口 */
|
||||
async extract(
|
||||
pdf: Buffer,
|
||||
options?: ExtractionOptions & { engine?: string }
|
||||
): Promise<ExtractionResult> {
|
||||
const engineName = options?.engine || this.defaultEngine;
|
||||
const engine = this.engines.get(engineName);
|
||||
if (!engine) throw new Error(`Engine not found: ${engineName}`);
|
||||
return engine.extract(pdf, options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 MinerU 适配器 (第一个实现)
|
||||
|
||||
```typescript
|
||||
// common/document/tableExtraction/engines/mineruEngine.ts
|
||||
|
||||
class MinerUEngine implements ITableExtractionEngine {
|
||||
readonly name = 'mineru';
|
||||
|
||||
async extract(pdf: Buffer, options?: ExtractionOptions): Promise<ExtractionResult> {
|
||||
// 1. 请求上传 URL
|
||||
// 2. 上传 PDF
|
||||
// 3. 轮询等待解析完成
|
||||
// 4. 下载结果 ZIP
|
||||
// 5. 解析 HTML 表格 → ExtractedTable[]
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 未来适配器 (预留接口)
|
||||
|
||||
```typescript
|
||||
// 后续逐步实现
|
||||
class Qwen3VLEngine implements ITableExtractionEngine { ... }
|
||||
class PaddleOCRVLEngine implements ITableExtractionEngine { ... }
|
||||
class QwenOCRLongEngine implements ITableExtractionEngine { ... }
|
||||
class DoclingEngine implements ITableExtractionEngine { ... }
|
||||
```
|
||||
|
||||
### 5.5 文件规划
|
||||
|
||||
```
|
||||
backend/src/common/document/tableExtraction/
|
||||
├── types.ts # 统一类型定义
|
||||
├── engineManager.ts # 引擎管理器 (统一入口)
|
||||
├── htmlTableParser.ts # HTML <table> → ExtractedTable 转换
|
||||
├── engines/
|
||||
│ ├── mineruEngine.ts # MinerU Cloud API 适配器 ✅ 首个实现
|
||||
│ ├── qwen3vlEngine.ts # Qwen3-VL 适配器 (待实现)
|
||||
│ ├── paddleOcrEngine.ts # PaddleOCR-VL 适配器 (待实现)
|
||||
│ ├── qwenOcrLongEngine.ts # Qwen-OCR + Qwen-Long 适配器 (待实现)
|
||||
│ ├── doclingEngine.ts # Docling 适配器 (待实现)
|
||||
│ └── deepseekEngine.ts # DeepSeek LLM 适配器 (已测试,可选)
|
||||
└── index.ts # 导出统一入口
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 使用方式
|
||||
|
||||
### 6.1 业务层调用 (使用者视角)
|
||||
|
||||
```typescript
|
||||
import { getTableExtractionEngine } from '@/common/document/tableExtraction';
|
||||
|
||||
// 使用者不需要知道底层是 MinerU 还是 Qwen-VL
|
||||
const engine = getTableExtractionEngine();
|
||||
const result = await engine.extract(pdfBuffer, { language: 'auto' });
|
||||
|
||||
for (const table of result.tables) {
|
||||
console.log(`${table.title}: ${table.rows.length} 行 × ${table.headers.length} 列`);
|
||||
// 直接使用结构化数据
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 管理员切换引擎
|
||||
|
||||
```bash
|
||||
# backend/.env — 切换默认引擎
|
||||
TABLE_EXTRACTION_ENGINE=mineru # 当前默认
|
||||
# TABLE_EXTRACTION_ENGINE=qwen3vl # 未来切换
|
||||
# TABLE_EXTRACTION_ENGINE=paddle # 未来切换
|
||||
|
||||
# MinerU 配置
|
||||
MINERU_API_TOKEN=your_token
|
||||
MINERU_API_BASE=https://mineru.net/api/v4
|
||||
MINERU_MODEL_VERSION=vlm
|
||||
```
|
||||
|
||||
### 6.3 场景决策矩阵
|
||||
|
||||
| 场景 | 推荐引擎 | 说明 |
|
||||
|------|----------|------|
|
||||
| ASL 标题摘要初筛 | pymupdf4llm (文本引擎) | 不需要表格,只需全文文本 |
|
||||
| ASL 全文复筛 — 表格提取 | **PDF 表格提取引擎** | 自动选择最优引擎 |
|
||||
| 系统综述数据提取 | **PDF 表格提取引擎** | 需要精确数值表格 |
|
||||
| Meta 分析效应值识别 | 表格引擎 + LLM 语义理解 | 提取 → 理解两步走 |
|
||||
| PKB 知识库入库 | pymupdf4llm (文本引擎) | 只需 Markdown 文本 |
|
||||
|
||||
---
|
||||
|
||||
## 7. MinerU Cloud API 接入指南 (当前默认引擎)
|
||||
|
||||
### 7.1 API 概览
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 服务商 | OpenDataLab (上海人工智能实验室) |
|
||||
| API 地址 | `https://mineru.net/api/v4` |
|
||||
| 认证方式 | Bearer Token |
|
||||
| 模型版本 | `vlm` (视觉语言模型,推荐) |
|
||||
| 免费额度 | 2000 页/天 |
|
||||
| 文件限制 | 单文件 ≤ 200MB,≤ 600 页 |
|
||||
|
||||
### 7.2 核心流程
|
||||
|
||||
```
|
||||
PDF 文件
|
||||
│
|
||||
▼
|
||||
Step 1: POST /file-urls/batch → 获取预签名上传 URL + batch_id
|
||||
│
|
||||
▼
|
||||
Step 2: PUT {pre-signed URL} → 上传 PDF 文件
|
||||
│
|
||||
▼
|
||||
Step 3: 云端 VLM 模型自动解析 → 识别表格/文本/图片
|
||||
│
|
||||
▼
|
||||
Step 4: GET /extract-results/batch/{batch_id} → 轮询状态
|
||||
│
|
||||
▼
|
||||
Step 5: 下载结果 ZIP → 含 .md (内嵌 HTML 表格) + .json + images
|
||||
```
|
||||
|
||||
### 7.3 代码示例
|
||||
|
||||
```python
|
||||
import requests, time, zipfile, io
|
||||
|
||||
TOKEN = "your_token"
|
||||
API = "https://mineru.net/api/v4"
|
||||
headers = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
|
||||
|
||||
# Step 1: 请求上传 URL
|
||||
resp = requests.post(f"{API}/file-urls/batch", headers=headers, json={
|
||||
"files": [{"name": "paper.pdf", "data_id": "paper1"}],
|
||||
"enable_table": True,
|
||||
"model_version": "vlm",
|
||||
})
|
||||
batch_id = resp.json()["data"]["batch_id"]
|
||||
upload_url = resp.json()["data"]["file_urls"][0]
|
||||
|
||||
# Step 2: 上传文件
|
||||
with open("paper.pdf", "rb") as f:
|
||||
requests.put(upload_url, data=f)
|
||||
|
||||
# Step 3-4: 轮询等待
|
||||
while True:
|
||||
time.sleep(10)
|
||||
r = requests.get(f"{API}/extract-results/batch/{batch_id}", headers=headers)
|
||||
results = r.json()["data"]["extract_result"]
|
||||
if all(x["state"] in ("done", "failed") for x in results):
|
||||
break
|
||||
|
||||
# Step 5: 下载解析
|
||||
for result in results:
|
||||
if result["state"] == "done":
|
||||
zr = requests.get(result["full_zip_url"])
|
||||
with zipfile.ZipFile(io.BytesIO(zr.content)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith('.md'):
|
||||
md = zf.read(name).decode('utf-8')
|
||||
# md 中包含 HTML <table> 格式的表格
|
||||
```
|
||||
|
||||
### 7.4 输出格式
|
||||
|
||||
MinerU 的表格以 HTML `<table>` 嵌入 Markdown 中,完整保留合并单元格:
|
||||
|
||||
```html
|
||||
<table>
|
||||
<tr><td rowspan="3">Type of dementia</td><td>Probable AD</td><td>107 (54)</td></tr>
|
||||
<tr><td>Possible AD with CVD</td><td>73 (36)</td></tr>
|
||||
<tr><td>Probable VaD</td><td>20 (10)</td></tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 成本估算
|
||||
|
||||
### 8.1 MinerU (当前)
|
||||
|
||||
| 场景 | 文献数 | 平均页数 | 总页数 | 天数 | 费用 |
|
||||
|------|--------|----------|--------|------|------|
|
||||
| 小型综述 | 20 篇 | 10 页 | 200 页 | 1 天 | 免费 |
|
||||
| 中型综述 | 100 篇 | 10 页 | 1000 页 | 1 天 | 免费 |
|
||||
| 大型综述 | 500 篇 | 10 页 | 5000 页 | 3 天 | 免费 |
|
||||
|
||||
### 8.2 各引擎预估成本对比
|
||||
|
||||
| 引擎 | 免费额度 | 超出后单价 | 500 篇 (5000 页) 预估 |
|
||||
|------|----------|-----------|----------------------|
|
||||
| MinerU | 2000 页/天 | 待确认 | 免费 (分 3 天) |
|
||||
| Qwen-OCR + Qwen-Long | 按 token | ~0.004 元/千 token | 约 10-20 元 |
|
||||
| PaddleOCR-VL | 官方免费额度多 | 极低 | 接近免费 |
|
||||
| Qwen3-VL | 按 token | ~0.02 元/千 token | 约 50-100 元 |
|
||||
| Docling | 本地部署 | 仅算力成本 | 免费 |
|
||||
| DeepSeek LLM | 按 token | ~0.14 元/万 token | 约 30-50 元 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试脚本
|
||||
|
||||
### 9.1 已有脚本
|
||||
|
||||
| 脚本 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| 三方对比测试 | `extraction_service/test_pdf_table_extraction.py` | pymupdf4llm / MinerU / DeepSeek 完整对比 |
|
||||
| 结果分析 | `extraction_service/analyze_table_results.py` | 从提取结果生成对比报告 |
|
||||
|
||||
### 9.2 运行方法
|
||||
|
||||
```bash
|
||||
cd AIclinicalresearch
|
||||
|
||||
# 运行全部三个方法
|
||||
python extraction_service/test_pdf_table_extraction.py
|
||||
|
||||
# 单独运行某个方法
|
||||
python extraction_service/test_pdf_table_extraction.py pymupdf
|
||||
python extraction_service/test_pdf_table_extraction.py mineru
|
||||
python extraction_service/test_pdf_table_extraction.py deepseek
|
||||
|
||||
# 生成对比报告
|
||||
python extraction_service/analyze_table_results.py
|
||||
```
|
||||
|
||||
### 9.3 测试输出
|
||||
|
||||
```
|
||||
extraction_service/test_output/pdf_table_extraction/
|
||||
├── pymupdf4llm/ # pymupdf4llm 提取结果
|
||||
├── mineru/ # MinerU 提取结果
|
||||
├── deepseek/ # DeepSeek 提取结果
|
||||
├── raw_results.json # 原始测试数据
|
||||
└── comparison_report.md # 综合对比报告
|
||||
```
|
||||
|
||||
### 9.4 后续评测扩展
|
||||
|
||||
新引擎的评测脚本将遵循同样的结构,添加到 `test_pdf_table_extraction.py` 中:
|
||||
|
||||
```bash
|
||||
python extraction_service/test_pdf_table_extraction.py qwen3vl
|
||||
python extraction_service/test_pdf_table_extraction.py paddle
|
||||
python extraction_service/test_pdf_table_extraction.py qwenocr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 路线图
|
||||
|
||||
### Phase 1: 基础框架 + MinerU (当前)
|
||||
|
||||
- [x] MinerU Cloud API 对比测试
|
||||
- [x] DeepSeek LLM 对比测试
|
||||
- [ ] 实现统一接口 `ITableExtractionEngine`
|
||||
- [ ] 实现 `MinerUEngine` 适配器
|
||||
- [ ] 实现 `engineManager` 引擎管理器
|
||||
- [ ] ASL 全文复筛集成
|
||||
|
||||
### Phase 2: 多引擎评测
|
||||
|
||||
- [ ] Qwen3-VL 评测 + 适配器
|
||||
- [ ] PaddleOCR-VL 1.5 评测 + 适配器
|
||||
- [ ] 同一基准集横向对比报告
|
||||
- [ ] 确定最优引擎组合策略
|
||||
|
||||
### Phase 3: 性价比优化
|
||||
|
||||
- [ ] Qwen-OCR + Qwen-Long 评测 (最低成本方案)
|
||||
- [ ] Docling 本地部署评测 (离线方案)
|
||||
- [ ] 引擎路由策略 (按文档复杂度自动选择引擎)
|
||||
|
||||
### Phase 4: 生产加固
|
||||
|
||||
- [ ] 提取结果缓存 (避免重复解析)
|
||||
- [ ] 批量提取队列 (pg-boss 异步任务)
|
||||
- [ ] 质量监控 (空表格/异常值检测)
|
||||
- [ ] 引擎降级策略 (主引擎不可用时自动切换)
|
||||
|
||||
---
|
||||
|
||||
## 11. 相关文档
|
||||
|
||||
- [文档处理引擎 README](./README.md) — 引擎总览 (含全文文本提取)
|
||||
- [文档处理引擎设计方案 V1](./01-文档处理引擎设计方案.md) — pymupdf4llm 全文文本架构
|
||||
- [文档处理引擎使用指南](./02-文档处理引擎使用指南.md) — 现有 API 调用指南
|
||||
- [MinerU 官方文档](https://mineru.net/doc/docs/index_en/) — MinerU Cloud API 在线文档
|
||||
- [对比测试报告](../../../extraction_service/test_output/pdf_table_extraction/comparison_report.md) — 完整测试数据
|
||||
|
||||
---
|
||||
|
||||
**维护人**: 技术架构师
|
||||
**设计原则**: 引擎对使用者透明,底层可热切换,以测试数据驱动选型
|
||||
471
docs/02-通用能力层/02-文档处理引擎/04-PDF表格提取引擎使用指南.md
Normal file
471
docs/02-通用能力层/02-文档处理引擎/04-PDF表格提取引擎使用指南.md
Normal file
@@ -0,0 +1,471 @@
|
||||
# PDF 表格提取引擎使用指南
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **最后更新**: 2026-02-23
|
||||
> **状态**: ✅ 已测试通过(MinerU 引擎)
|
||||
> **目标读者**: 业务模块开发者(ASL 全文复筛、系统综述数据提取等)
|
||||
> **前置条件**: `backend/.env` 中已配置 `MINERU_API_TOKEN`
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 5 秒上手
|
||||
|
||||
```typescript
|
||||
import { getTableExtractionManager } from '../common/document/tableExtraction/index.js';
|
||||
|
||||
const manager = getTableExtractionManager();
|
||||
const result = await manager.extractTables(pdfBuffer, 'paper.pdf');
|
||||
|
||||
for (const table of result.tables) {
|
||||
console.log(`${table.title}: ${table.rows.length} 行 × ${table.headers.length} 列`);
|
||||
}
|
||||
```
|
||||
|
||||
### 完整调用示例
|
||||
|
||||
```typescript
|
||||
import fs from 'fs';
|
||||
import { getTableExtractionManager } from '../common/document/tableExtraction/index.js';
|
||||
|
||||
const manager = getTableExtractionManager();
|
||||
|
||||
// 读取 PDF 文件
|
||||
const pdf = fs.readFileSync('/path/to/medical-paper.pdf');
|
||||
|
||||
// 提取表格(自动使用默认引擎 MinerU)
|
||||
const result = await manager.extractTables(pdf, 'medical-paper.pdf', {
|
||||
keepRaw: true, // 保留原始 Markdown
|
||||
});
|
||||
|
||||
console.log(`引擎: ${result.engine}`); // "mineru"
|
||||
console.log(`耗时: ${result.duration}ms`); // ~6000-20000ms
|
||||
console.log(`表格数: ${result.tables.length}`);
|
||||
|
||||
// 遍历每个表格
|
||||
for (const table of result.tables) {
|
||||
console.log(`\n[${table.title}]`);
|
||||
console.log(` 列: ${table.headers.join(' | ')}`);
|
||||
console.log(` 行数: ${table.rows.length}`);
|
||||
console.log(` 合并单元格: ${table.mergedCells.length}`);
|
||||
|
||||
// 访问具体数据
|
||||
for (const row of table.rows) {
|
||||
// row 是 string[],与 headers 一一对应
|
||||
console.log(` ${row.join(' | ')}`);
|
||||
}
|
||||
|
||||
// 原始 HTML(可直接渲染到前端)
|
||||
if (table.rawHtml) {
|
||||
console.log(` [HTML] ${table.rawHtml.substring(0, 100)}...`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 架构设计
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ 业务代码(ASL / 系统综述 / Meta 分析) │
|
||||
│ │
|
||||
│ manager.extractTables(pdf, filename) │
|
||||
│ → 返回 ExtractedTable[] │
|
||||
└──────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼─────────────────────────────┐
|
||||
│ TableExtractionManager (统一入口) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
|
||||
│ │ MinerU (VLM) │ │ Qwen-VL │ │ Paddle │ │
|
||||
│ │ ✅ 已接入 │ │ 📋 待接入 │ │ 📋 待接入 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────┘ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**核心原则:使用者不需要关心底层引擎。** 提交 PDF → 获取结构化表格。
|
||||
|
||||
### 数据结构
|
||||
|
||||
```typescript
|
||||
// 提取结果
|
||||
interface ExtractionResult {
|
||||
tables: ExtractedTable[]; // 表格列表
|
||||
engine: string; // 使用的引擎名
|
||||
duration: number; // 耗时 (ms)
|
||||
pageCount?: number; // PDF 页数
|
||||
fullMarkdown?: string; // 完整 Markdown (需 keepRaw: true)
|
||||
}
|
||||
|
||||
// 单个表格
|
||||
interface ExtractedTable {
|
||||
title: string; // "Table 1 Baseline characteristics"
|
||||
headers: string[]; // 表头列名
|
||||
rows: string[][]; // 数据行(二维数组)
|
||||
mergedCells: MergedCell[]; // 合并单元格
|
||||
footnotes: string[]; // 脚注
|
||||
pageNumber?: number; // 页码
|
||||
rawHtml?: string; // 原始 HTML
|
||||
rawMarkdown?: string; // 原始 Markdown
|
||||
}
|
||||
|
||||
// 合并单元格
|
||||
interface MergedCell {
|
||||
row: number; // 起始行 (0-based)
|
||||
col: number; // 起始列 (0-based)
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 参考
|
||||
|
||||
### `getTableExtractionManager()`
|
||||
|
||||
获取全局管理器单例。首次调用时自动注册 MinerU 引擎。
|
||||
|
||||
```typescript
|
||||
import { getTableExtractionManager } from '../common/document/tableExtraction/index.js';
|
||||
|
||||
const manager = getTableExtractionManager();
|
||||
```
|
||||
|
||||
### `manager.extractTables(pdf, filename, options?)`
|
||||
|
||||
提取 PDF 中的表格。
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `pdf` | `Buffer` | ✅ | PDF 文件内容 |
|
||||
| `filename` | `string` | ✅ | 文件名(含 .pdf 后缀) |
|
||||
| `options.language` | `'zh' \| 'en' \| 'auto'` | ❌ | 语言提示 |
|
||||
| `options.pages` | `number[]` | ❌ | 指定页码 |
|
||||
| `options.keepRaw` | `boolean` | ❌ | 保留原始 Markdown |
|
||||
| `options.engine` | `EngineType` | ❌ | 覆盖默认引擎 |
|
||||
|
||||
返回:`Promise<ExtractionResult>`
|
||||
|
||||
### `manager.availableEngines()`
|
||||
|
||||
返回已注册的引擎名称列表。
|
||||
|
||||
```typescript
|
||||
console.log(manager.availableEngines()); // ['mineru']
|
||||
```
|
||||
|
||||
### `manager.getEngine(name?)`
|
||||
|
||||
获取指定引擎实例。
|
||||
|
||||
### `manager.setDefault(name)`
|
||||
|
||||
切换默认引擎。
|
||||
|
||||
---
|
||||
|
||||
## 实战场景
|
||||
|
||||
### 场景 1:ASL 全文复筛 — 提取基线特征表
|
||||
|
||||
```typescript
|
||||
import { getTableExtractionManager } from '../common/document/tableExtraction/index.js';
|
||||
|
||||
async function extractBaselineTable(pdfBuffer: Buffer, filename: string) {
|
||||
const manager = getTableExtractionManager();
|
||||
const result = await manager.extractTables(pdfBuffer, filename);
|
||||
|
||||
// 找到 "Table 1" 或包含 "Baseline" 的表格
|
||||
const baseline = result.tables.find(
|
||||
(t) =>
|
||||
/table\s*1\b/i.test(t.title) ||
|
||||
/baseline/i.test(t.title),
|
||||
);
|
||||
|
||||
if (baseline) {
|
||||
return {
|
||||
title: baseline.title,
|
||||
columns: baseline.headers,
|
||||
data: baseline.rows,
|
||||
hasMergedCells: baseline.mergedCells.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 2:系统综述 — 提取所有表格为 JSON
|
||||
|
||||
```typescript
|
||||
async function extractAllTablesAsJson(pdfBuffer: Buffer, filename: string) {
|
||||
const manager = getTableExtractionManager();
|
||||
const result = await manager.extractTables(pdfBuffer, filename);
|
||||
|
||||
return result.tables.map((table) => ({
|
||||
title: table.title,
|
||||
headers: table.headers,
|
||||
rows: table.rows.map((row) => {
|
||||
const obj: Record<string, string> = {};
|
||||
table.headers.forEach((h, i) => {
|
||||
obj[h] = row[i] || '';
|
||||
});
|
||||
return obj;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// 输出示例:
|
||||
// [
|
||||
// {
|
||||
// title: "Table 1 Baseline characteristics",
|
||||
// headers: ["", "", "EGb 761®(N=200)", "Placebo(N=202)", "p-value"],
|
||||
// rows: [
|
||||
// { "": "Sex female", "": "", "EGb 761®(N=200)": "139 (69.5)", ... },
|
||||
// ...
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
```
|
||||
|
||||
### 场景 3:Meta 分析 — 提取效应值
|
||||
|
||||
```typescript
|
||||
async function extractEffectSizes(pdfBuffer: Buffer, filename: string) {
|
||||
const manager = getTableExtractionManager();
|
||||
const result = await manager.extractTables(pdfBuffer, filename);
|
||||
|
||||
// 找结局指标表
|
||||
const outcomeTable = result.tables.find(
|
||||
(t) => /outcome|result|efficacy|effect/i.test(t.title),
|
||||
);
|
||||
|
||||
if (!outcomeTable) return [];
|
||||
|
||||
return outcomeTable.rows.map((row) => ({
|
||||
measure: row[0],
|
||||
treatment: row[1],
|
||||
control: row[2],
|
||||
pValue: row[3],
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 4:在 API 路由中使用
|
||||
|
||||
```typescript
|
||||
import { getTableExtractionManager } from '../../../common/document/tableExtraction/index.js';
|
||||
|
||||
async function handleTableExtraction(request: FastifyRequest, reply: FastifyReply) {
|
||||
const data = await request.file();
|
||||
if (!data) return reply.status(400).send({ error: 'No file uploaded' });
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
const manager = getTableExtractionManager();
|
||||
const result = await manager.extractTables(buffer, data.filename);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
engine: result.engine,
|
||||
duration: result.duration,
|
||||
tables: result.tables.map((t) => ({
|
||||
title: t.title,
|
||||
headers: t.headers,
|
||||
rowCount: t.rows.length,
|
||||
rows: t.rows,
|
||||
mergedCells: t.mergedCells,
|
||||
})),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 必需环境变量
|
||||
|
||||
```bash
|
||||
# backend/.env
|
||||
|
||||
# MinerU Cloud API(必需)
|
||||
MINERU_API_TOKEN=your_mineru_api_token
|
||||
MINERU_API_BASE=https://mineru.net/api/v4
|
||||
MINERU_MODEL_VERSION=vlm
|
||||
```
|
||||
|
||||
### 获取 MinerU Token
|
||||
|
||||
1. 注册 [OpenDataLab](https://sso.openxlab.org.cn/login)
|
||||
2. 登录 [MinerU 控制台](https://mineru.net/)
|
||||
3. 个人中心 → API Token → 复制
|
||||
4. 写入 `backend/.env` 的 `MINERU_API_TOKEN`
|
||||
|
||||
### 免费额度
|
||||
|
||||
| 项目 | 限制 |
|
||||
|------|------|
|
||||
| 日解析页数 | 2000 页 |
|
||||
| 单文件大小 | ≤ 200 MB |
|
||||
| 单文件页数 | ≤ 600 页 |
|
||||
|
||||
小型综述 20 篇 (200 页) → 1 天免费完成。大型综述 500 篇 (5000 页) → 分 3 天免费完成。
|
||||
|
||||
---
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 测试指定 PDF(推荐)
|
||||
npx tsx src/tests/test-table-extraction.ts "../docs/03-业务模块/ASL-AI智能文献/05-测试文档/PDF/Herrschaft 2012.pdf"
|
||||
|
||||
# 自动选取测试目录中的第一个 PDF
|
||||
npx tsx src/tests/test-table-extraction.ts
|
||||
```
|
||||
|
||||
### 期望输出
|
||||
|
||||
```
|
||||
========================================
|
||||
PDF 表格提取引擎 — 集成测试
|
||||
========================================
|
||||
|
||||
文件: Herrschaft 2012.pdf
|
||||
引擎: mineru
|
||||
耗时: 6.5s
|
||||
检出表格: 3 个
|
||||
|
||||
────────────────────────────────────────
|
||||
表格 1: Table 1 Baseline characteristics...
|
||||
列数: 5
|
||||
行数: 18
|
||||
合并单元格: 2
|
||||
表头: ... | EGb 761®(N = 200) | Placebo(N = 202) | p-value
|
||||
|
||||
表格 2: Table 2
|
||||
列数: 4
|
||||
行数: 10
|
||||
|
||||
表格 3: Table 3 Adverse events...
|
||||
列数: 6
|
||||
行数: 7
|
||||
合并单元格: 4
|
||||
|
||||
测试通过
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件清单
|
||||
|
||||
```
|
||||
backend/src/common/document/tableExtraction/
|
||||
├── types.ts # 统一接口 + 类型定义
|
||||
├── htmlTableParser.ts # HTML <table> → ExtractedTable 解析器
|
||||
├── TableExtractionManager.ts # 引擎管理器(使用者入口)
|
||||
├── engines/
|
||||
│ └── MinerUEngine.ts # MinerU Cloud API 适配器
|
||||
└── index.ts # 统一导出 + 全局单例
|
||||
|
||||
backend/src/tests/
|
||||
└── test-table-extraction.ts # 集成测试脚本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 扩展新引擎
|
||||
|
||||
添加新引擎只需 3 步:
|
||||
|
||||
### Step 1: 实现接口
|
||||
|
||||
```typescript
|
||||
// engines/Qwen3VLEngine.ts
|
||||
import type { ITableExtractionEngine, ExtractionOptions, ExtractionResult } from '../types.js';
|
||||
|
||||
export class Qwen3VLEngine implements ITableExtractionEngine {
|
||||
readonly name = 'qwen3vl';
|
||||
readonly displayName = 'Qwen3-VL 多模态';
|
||||
|
||||
async extractTables(
|
||||
pdf: Buffer,
|
||||
filename: string,
|
||||
options?: ExtractionOptions,
|
||||
): Promise<ExtractionResult> {
|
||||
// 实现提取逻辑 ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: 注册引擎
|
||||
|
||||
```typescript
|
||||
// index.ts 中添加
|
||||
import { Qwen3VLEngine } from './engines/Qwen3VLEngine.js';
|
||||
|
||||
// 在 getTableExtractionManager() 中
|
||||
if (process.env.QWEN3VL_API_KEY) {
|
||||
_instance.register(new Qwen3VLEngine());
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: 使用
|
||||
|
||||
```typescript
|
||||
const manager = getTableExtractionManager();
|
||||
|
||||
// 显式指定引擎
|
||||
const result = await manager.extractTables(pdf, 'paper.pdf', {
|
||||
engine: 'qwen3vl',
|
||||
});
|
||||
|
||||
// 或切换默认引擎
|
||||
manager.setDefault('qwen3vl');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 提取耗时多久?
|
||||
|
||||
MinerU Cloud API 通常 5-20 秒(取决于 PDF 页数和云端负载)。首次请求可能较慢(云端冷启动),后续请求更快。
|
||||
|
||||
### Q: 没有检出表格?
|
||||
|
||||
1. 确认 PDF 中确实包含表格(扫描件图片中的表格也能识别)
|
||||
2. 检查 `fullMarkdown` 输出中是否有 `<table>` 标签
|
||||
3. MinerU 对极端复杂的嵌套表格可能识别不完整
|
||||
|
||||
### Q: 合并单元格数据如何处理?
|
||||
|
||||
`ExtractedTable.mergedCells` 记录了所有合并单元格的位置和跨度。在 `rows` 中,被合并的单元格只在起始位置有值,其余位置为空字符串。
|
||||
|
||||
### Q: 和文档处理引擎 (pymupdf4llm) 的关系?
|
||||
|
||||
两者分别负责不同场景:
|
||||
|
||||
| 引擎 | 路径 | 场景 |
|
||||
|------|------|------|
|
||||
| 文档处理引擎 | `ExtractionClient.ts` | 全文文本提取(标题摘要初筛、PKB 入库) |
|
||||
| **PDF 表格提取引擎** | `tableExtraction/` | 结构化表格提取(全文复筛、Meta 分析) |
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [PDF 表格提取引擎设计方案](./03-PDF表格提取引擎设计方案.md) — 架构设计 + 候选引擎 + 对比测试
|
||||
- [文档处理引擎使用指南](./02-文档处理引擎使用指南.md) — 全文文本提取 (pymupdf4llm)
|
||||
- [文档处理引擎 README](./README.md) — 引擎总览
|
||||
|
||||
---
|
||||
|
||||
**维护人**: 技术架构师
|
||||
**核心依赖**: `adm-zip` (ZIP 解析), `axios` (HTTP 请求)
|
||||
@@ -3,8 +3,8 @@
|
||||
> **能力定位:** 通用能力层
|
||||
> **复用率:** 86% (6个模块依赖)
|
||||
> **优先级:** P0
|
||||
> **状态:** 🔄 升级中(pymupdf4llm + 统一架构)
|
||||
> **最后更新:** 2026-01-20
|
||||
> **状态:** ✅ V2 — pymupdf4llm (全文) + MinerU (表格) 双引擎架构
|
||||
> **最后更新:** 2026-02-23
|
||||
|
||||
---
|
||||
|
||||
@@ -16,14 +16,46 @@
|
||||
|
||||
1. **多格式支持** - 覆盖医学科研领域 20+ 种文档格式
|
||||
2. **LLM 友好输出** - 统一输出结构化 Markdown
|
||||
3. **表格保真** - 完整保留文献中的表格信息(临床试验核心数据)
|
||||
3. **表格精准提取** - MinerU VLM 引擎支持合并单元格、数值 100% 保真(V2 新增)
|
||||
4. **可扩展架构** - 方便添加新格式支持
|
||||
|
||||
---
|
||||
|
||||
## 🔄 重大更新(2026-01-20)
|
||||
## 🔄 重大更新(2026-02-23)
|
||||
|
||||
### PDF 处理方案升级
|
||||
### V2: PDF 表格提取引擎 — 统一抽象 + 多引擎可插拔
|
||||
|
||||
新建 **PDF 表格提取引擎**,核心理念:**使用者只需提交 PDF、获取结构化表格,无需关心底层引擎实现**。
|
||||
|
||||
已完成 8 篇真实医学文献的首轮对比测试(pymupdf4llm / MinerU / DeepSeek),MinerU Cloud API 作为首个接入引擎:
|
||||
|
||||
| 对比项 | pymupdf4llm | MinerU API (VLM) | DeepSeek LLM |
|
||||
|--------|-------------|------------------|--------------|
|
||||
| 结构化表格检出 | 3 个 (12.5%) | **28 个 (100%)** | 24 个 (85%) |
|
||||
| 合并单元格 | ❌ | **✅ rowspan/colspan** | ⚠️ 文字描述 |
|
||||
| 数值精度 | ✅ | **✅ 100% 保真** | ⚠️ 可能翻译 |
|
||||
| 综合评分 | 2.7/5 | **4.6/5** | 3.4/5 |
|
||||
|
||||
**V2 分层架构(全文 + 表格 分离):**
|
||||
|
||||
| 引擎 | 定位 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **pymupdf4llm** | 全文文本提取 | 标题摘要初筛、PKB 入库、全文检索 |
|
||||
| **PDF 表格提取引擎** | 结构化表格 | 全文复筛、系统综述、Meta 分析 |
|
||||
|
||||
**表格提取引擎候选 (可插拔):**
|
||||
|
||||
| 引擎 | 状态 | 特点 |
|
||||
|------|------|------|
|
||||
| MinerU Cloud API (VLM) | ✅ 已接入 (默认) | 表格结构最完整 |
|
||||
| Qwen3-VL | 📋 待评测 | 多模态理解最强 |
|
||||
| PaddleOCR-VL 1.5 | 📋 待评测 | 医学场景案例多,免费额度最多 |
|
||||
| Qwen-OCR + Qwen-Long | 📋 待评测 | 成本最低 |
|
||||
| Docling (IBM) | 📋 待评测 | MIT 开源,离线部署 |
|
||||
|
||||
详见:[PDF 表格提取引擎设计方案](./03-PDF表格提取引擎设计方案.md)
|
||||
|
||||
### V1 (2026-01-20): PDF 文本提取升级
|
||||
|
||||
| 变更 | 旧方案 | 新方案 |
|
||||
|------|--------|--------|
|
||||
@@ -32,11 +64,6 @@
|
||||
| 多栏布局 | 手动处理 | ✅ 自动重排 |
|
||||
| 依赖复杂度 | 高(GPU) | ✅ 低 |
|
||||
|
||||
**关键决策:**
|
||||
- `pymupdf4llm` 是 PyMuPDF 的上层封装,**自动包含 pymupdf 依赖**
|
||||
- 移除 Nougat 依赖,简化部署
|
||||
- 扫描版 PDF 单独使用 OCR 方案处理
|
||||
|
||||
---
|
||||
|
||||
## 📊 支持格式
|
||||
@@ -75,21 +102,31 @@
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
### 统一处理器架构
|
||||
### V2 双引擎架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DocumentProcessor │
|
||||
│ (统一入口:自动检测文件类型,调用对应处理器) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 文档处理引擎 (V2) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ 全文文本提取 (V1) │ │ PDF 表格提取引擎 (V2 新增) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ pymupdf4llm │ │ 统一抽象层 (可插拔引擎) │ │
|
||||
│ │ ───────────── │ │ ───────────────────── │ │
|
||||
│ │ • PDF → Markdown │ │ 当前: MinerU VLM │ │
|
||||
│ │ • 速度快、免费 │ │ 待测: Qwen3-VL / Paddle │ │
|
||||
│ │ • 不依赖网络 │ │ 待测: Qwen-OCR / Docling │ │
|
||||
│ │ │ │ • 统一 ExtractedTable 输出 │ │
|
||||
│ └─────────────────────┘ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ PDF │ │ Word │ │ PPT │ │ Excel │ │
|
||||
│ │ Processor │ │ Processor │ │ Processor │ │ Processor │ │
|
||||
│ │pymupdf4llm│ │ mammoth │ │python-pptx│ │ pandas │ │
|
||||
│ │ Word │ │ PPT │ │ Excel │ │ CSV │ │
|
||||
│ │ mammoth │ │python-pptx│ │ pandas │ │ pandas │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 输出: 统一 Markdown 格式 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 输出: Markdown 文本 / HTML 结构化表格 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
@@ -186,15 +223,27 @@ rispy>=0.7.0
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [详细设计方案](./01-文档处理引擎设计方案.md) - 完整实现细节
|
||||
- [PDF 表格提取引擎使用指南](./04-PDF表格提取引擎使用指南.md) - **5 秒上手 + 实战场景** ⭐ 推荐
|
||||
- [PDF 表格提取引擎设计方案](./03-PDF表格提取引擎设计方案.md) - 统一抽象 + 多引擎可插拔架构
|
||||
- [详细设计方案](./01-文档处理引擎设计方案.md) - V1 pymupdf4llm 架构
|
||||
- [使用指南](./02-文档处理引擎使用指南.md) - 全文文本提取 API 调用指南
|
||||
- [通用能力层总览](../README.md)
|
||||
- [PKB 知识库](../../03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md)
|
||||
- [Dify 替换计划](../../03-业务模块/PKB-个人知识库/04-开发计划/01-Dify替换为pgvector开发计划.md)
|
||||
|
||||
---
|
||||
|
||||
## 📅 更新日志
|
||||
|
||||
### 2026-02-23 PDF 表格提取引擎升级 (V2)
|
||||
|
||||
- 🆕 **新建 PDF 表格提取引擎 — 统一抽象层,底层引擎可插拔**
|
||||
- 🆕 MinerU Cloud API (VLM) 作为首个接入引擎 (默认)
|
||||
- 🆕 完成 pymupdf4llm / MinerU / DeepSeek 三方对比测试 (8 篇医学文献)
|
||||
- 📊 MinerU 综合评分 4.6/5,作为默认引擎
|
||||
- 📋 后续评测计划:Qwen3-VL / PaddleOCR-VL / Qwen-OCR+Qwen-Long / Docling
|
||||
- 📝 创建 [PDF 表格提取引擎设计方案](./03-PDF表格提取引擎设计方案.md)
|
||||
- 🏗️ 确立分层架构:pymupdf4llm (全文文本) + PDF 表格提取引擎 (结构化表格)
|
||||
|
||||
### 2026-01-20 架构升级
|
||||
|
||||
- 🆕 PDF 处理升级为 `pymupdf4llm`
|
||||
|
||||
290
docs/02-通用能力层/分布式Fan-out任务模式开发指南.md
Normal file
290
docs/02-通用能力层/分布式Fan-out任务模式开发指南.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# 分布式 Fan-out 任务模式开发指南
|
||||
|
||||
> **版本:** v1.0(基于 ASL 工具 3 架构设计经验,尚未经生产验证)
|
||||
> **创建日期:** 2026-02-23
|
||||
> **定位:** 实战 Cookbook,开发时按需查阅
|
||||
> **互补文档:** `系统级异步架构风险剖析与演进技术蓝图.md`(Why)→ 本文(How)
|
||||
> **Postgres-Only 指南:** `Postgres-Only异步任务处理指南.md`(底层规范)
|
||||
> **首个试点:** ASL 工具 3 全文智能提取工作台(`docs/03-业务模块/ASL-AI智能文献/04-开发计划/08-工具3-*.md`)
|
||||
> **状态:** 🟡 设计阶段经验总结,待 ASL 工具 3 M1/M2 实战后升级为 v2.0
|
||||
|
||||
---
|
||||
|
||||
## 一、适用场景判断
|
||||
|
||||
| 维度 | Level 1:单体任务 | Level 2:Fan-out 任务 |
|
||||
|------|-------------------|----------------------|
|
||||
| **触发模式** | 1 触发 → 1 Worker → 结束 | 1 触发 → 1 Manager → N 个 Child Worker |
|
||||
| **典型案例** | DC Tool C 解析 1 个 Excel | ASL 工具 3 批量提取 100 篇文献 |
|
||||
| **失败代价** | 小(重跑 40 秒) | 极大(第 99 篇失败不应导致前 98 篇白做) |
|
||||
| **并发挑战** | 无(单 Worker) | 高(N 个 Child 跨 Pod 竞争同一父任务计数器) |
|
||||
|
||||
**判断公式:** 如果你的任务是"1 次操作处理 N 个独立子项,且 N 可能 > 10",就必须使用 Fan-out 模式。
|
||||
|
||||
---
|
||||
|
||||
## 二、核心架构:Manager + Child + Last Child Wins
|
||||
|
||||
```
|
||||
┌─ API 层 ──────────────────────────────────┐
|
||||
│ POST /tasks → 创建业务记录 → pgBoss.send │
|
||||
│ (module_task_manager) │
|
||||
└────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─ Manager Job ─────────────────────────────┐
|
||||
│ 1. 读取 N 个子项 │
|
||||
│ 2. 快照外部依赖数据(防源头失踪) │
|
||||
│ 3. for each → pgBoss.send(child_queue) │
|
||||
│ 4. 派发完毕 → 退出(Fire-and-forget) │
|
||||
└────────────────────────────────────────────┘
|
||||
↓ (N 个)
|
||||
┌─ Child Job ───────────────────────────────┐
|
||||
│ 1. 乐观锁抢占(updateMany where status │
|
||||
│ = pending → processing) │
|
||||
│ 2. 执行业务逻辑 │
|
||||
│ 3. 事务内:更新子项 + 原子递增父任务计数 │
|
||||
│ 4. 判断 successCount + failedCount >= │
|
||||
│ totalCount → 翻转父任务 completed │
|
||||
│ 5. 错误分级:致命 return / 临时 throw │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、7 项关键设计模式
|
||||
|
||||
### 模式 1:原子递增(禁止 Read-then-Write)
|
||||
|
||||
**问题:** 多个 Child 同时完成时,`count = count + 1` 的读写逻辑导致计数丢失。
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:Read-then-Write 反模式
|
||||
const task = await prisma.task.findUnique({ where: { id } });
|
||||
await prisma.task.update({ data: { successCount: task.successCount + 1 } });
|
||||
|
||||
// ✅ 正确:数据库级原子操作
|
||||
const taskAfterUpdate = await prisma.task.update({
|
||||
where: { id: taskId },
|
||||
data: { successCount: { increment: 1 } },
|
||||
});
|
||||
```
|
||||
|
||||
Prisma 的 `{ increment: 1 }` 编译为 SQL `SET success_count = success_count + 1`,数据库行锁保证原子性。
|
||||
|
||||
### 模式 2:Last Child Wins(终止器)
|
||||
|
||||
**问题:** Manager 派发完就退出,没有人负责把父任务从 `processing` 翻转为 `completed`。
|
||||
|
||||
**解法:** 每个 Child(无论成功还是失败)在原子递增后立即检查:
|
||||
|
||||
```typescript
|
||||
if (taskAfterUpdate.successCount + taskAfterUpdate.failedCount >= taskAfterUpdate.totalCount) {
|
||||
await prisma.task.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'completed', completedAt: new Date() },
|
||||
});
|
||||
// 广播完成事件(如 NOTIFY)
|
||||
}
|
||||
```
|
||||
|
||||
**关键:** 成功路径和失败路径都必须有这段检查。漏掉任何一条路径,任务就可能永远卡在 `processing`。
|
||||
|
||||
### 模式 3:乐观锁抢占(Optimistic Locking)
|
||||
|
||||
**问题:** pg-boss 的 at-least-once 语义意味着同一 Child Job 可能被投递多次。如果用 `findUnique → if (status !== 'pending') return` 做幂等检查,两个 Worker 可能同时读到 `pending` 然后同时处理。
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:Read-then-Write 幂等检查
|
||||
const existing = await prisma.result.findUnique({ where: { id } });
|
||||
if (existing?.status === 'completed') return; // 两个 Worker 可能同时到这里
|
||||
|
||||
// ✅ 正确:原子抢占
|
||||
const lock = await prisma.result.updateMany({
|
||||
where: { id: resultId, status: 'pending' },
|
||||
data: { status: 'processing' },
|
||||
});
|
||||
if (lock.count === 0) return { success: true, note: 'Idempotent skip' };
|
||||
```
|
||||
|
||||
`updateMany` 的 WHERE 条件充当乐观锁,数据库保证只有一个 Worker 能成功更新。
|
||||
|
||||
### 模式 4:错误分级路由
|
||||
|
||||
**问题:** pg-boss 默认对所有失败 Job 进行指数退避重试。但"PDF 损坏"这类永久错误重试 3 次也不会好。
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await doWork();
|
||||
} catch (error) {
|
||||
if (isPermanentError(error)) {
|
||||
// 致命错误:更新业务状态为 error + 原子递增 failedCount
|
||||
await markAsFailed(resultId, taskId, error.message);
|
||||
// ⚠️ 别忘了 Last Child Wins 检查!
|
||||
return { success: false }; // return 而非 throw → pg-boss 视为"成功消费",停止重试
|
||||
}
|
||||
// 临时错误 (429/5xx/网络抖动):throw → pg-boss 指数退避自动重试
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
| 错误类型 | 处理方式 | pg-boss 行为 |
|
||||
|---------|---------|-------------|
|
||||
| 永久错误(4xx、数据不存在、格式损坏) | `return` | 停止重试 |
|
||||
| 临时错误(429、5xx、网络超时) | `throw` | 指数退避重试 |
|
||||
|
||||
### 模式 5:三级限流(teamConcurrency)
|
||||
|
||||
**问题:** 如果不限制 Child 并发,1000 个 Job 被同时拉起 → 1000 个 `await` 挂起的闭包 → Node.js OOM。
|
||||
|
||||
```typescript
|
||||
// 第一级:Child Worker — 控制内存中的并发闭包数量
|
||||
jobQueue.work('module_task_child', { teamConcurrency: 10 }, handler);
|
||||
|
||||
// 第二级:昂贵 API — 保护外部服务
|
||||
jobQueue.work('module_expensive_api', { teamConcurrency: 2 }, handler);
|
||||
|
||||
// 第三级:LLM 调用 — 保护 LLM 并发
|
||||
jobQueue.work('module_llm_call', { teamConcurrency: 5 }, handler);
|
||||
```
|
||||
|
||||
**`teamConcurrency` vs `P-Queue`:**
|
||||
- `P-Queue` 是进程内信号量,多 Pod 下每个 Pod 各自限流 → 全局并发 = 限制值 × Pod 数 → API 429
|
||||
- `teamConcurrency` 是 PostgreSQL 行锁,跨所有 Node.js 实例全局生效
|
||||
- **结论:Fan-out 场景禁止使用 P-Queue,必须用 teamConcurrency**
|
||||
|
||||
### 模式 6:SSE 跨实例广播(NOTIFY/LISTEN)
|
||||
|
||||
**问题:** `sseEmitter.emit()` 基于内存 EventEmitter,用户连 Pod A、Worker 跑 Pod B → Pod A 收不到日志。
|
||||
|
||||
```typescript
|
||||
// Worker 端(发送)
|
||||
await prisma.$executeRawUnsafe(
|
||||
`NOTIFY sse_channel, '${JSON.stringify({ taskId, type: 'log', data: logEntry }).replace(/'/g, "''")}'`
|
||||
);
|
||||
|
||||
// API 端(接收)— Pod 启动时初始化
|
||||
const pgClient = new Client({ connectionString: DATABASE_URL });
|
||||
await pgClient.connect();
|
||||
await pgClient.query('LISTEN sse_channel');
|
||||
pgClient.on('notification', (msg) => {
|
||||
const { taskId, type, data } = JSON.parse(msg.payload);
|
||||
const clients = sseClients.get(taskId);
|
||||
if (clients?.size > 0) {
|
||||
for (const res of clients) {
|
||||
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**约束:**
|
||||
- LISTEN 连接必须独立于连接池(归还后 LISTEN 失效)
|
||||
- NOTIFY payload 上限 8000 bytes
|
||||
- fire-and-forget(无持久化),适合日志流这类"丢了不影响业务"的场景
|
||||
|
||||
### 模式 7:数据一致性快照
|
||||
|
||||
**问题:** Fan-out 任务可能持续数十分钟。期间用户在源模块删改数据 → Child Worker 找不到依赖数据而崩溃。
|
||||
|
||||
**解法:** Manager 派发前一次性快照关键元数据,冻结到子项记录中:
|
||||
|
||||
```typescript
|
||||
// Manager 中:批量快照
|
||||
const pkbDocs = await Promise.all(
|
||||
results.map(r => pkbBridge.getDocumentDetail(r.pkbDocumentId))
|
||||
);
|
||||
const docMap = new Map(pkbDocs.map(d => [d.documentId, d]));
|
||||
|
||||
await prisma.$transaction(
|
||||
results.map(result => {
|
||||
const doc = docMap.get(result.pkbDocumentId);
|
||||
return prisma.result.update({
|
||||
where: { id: result.id },
|
||||
data: {
|
||||
snapshotStorageKey: doc?.storageKey ?? null,
|
||||
snapshotFilename: doc?.filename ?? null,
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
**原则:** 快照轻量元数据(storageKey、filename 等 < 1KB)到数据库。大文件内容不快照,通过错误分级路由兜底。
|
||||
|
||||
---
|
||||
|
||||
## 四、反模式速查表
|
||||
|
||||
| 反模式 | 后果 | 正确做法 |
|
||||
|--------|------|---------|
|
||||
| 内存计数 `count + 1` | 多 Pod 计数丢失 | Prisma `{ increment: 1 }` |
|
||||
| `findUnique → if → update` 幂等 | 并发穿透 | `updateMany({ where: { status: 'pending' } })` |
|
||||
| Manager 等待所有 Child 完成 | Manager 进程挂起,消耗连接 | Fire-and-forget + Last Child Wins |
|
||||
| P-Queue 限流 | 多 Pod 失效 | pg-boss `teamConcurrency` |
|
||||
| 内存 EventEmitter 跨 Pod | SSE 日志断裂 | PostgreSQL NOTIFY/LISTEN |
|
||||
| Job payload 塞大数据 | pg-boss 阻塞 | 仅传 ID(< 1KB),数据存 DB/OSS |
|
||||
| 队列名用点号 | pg-boss 路由截断 | 下划线命名(`module_task_child`) |
|
||||
| 不设 `expireInMinutes` | 僵尸 Job 占据队列名额 | Manager: 60min, Child: 30min |
|
||||
| 成功路径漏检 Last Child Wins | 任务永远卡在 processing | 成功 + 失败路径都检查 |
|
||||
| Child 运行时回查外部模块数据 | 源头删改导致批量崩溃 | Manager 快照元数据到子项记录 |
|
||||
|
||||
---
|
||||
|
||||
## 五、pg-boss 配置速查
|
||||
|
||||
```typescript
|
||||
// Manager Job 派发
|
||||
await pgBoss.send('module_task_manager', { taskId }, {
|
||||
retryLimit: 2,
|
||||
expireInMinutes: 60,
|
||||
singletonKey: `manager-${taskId}`, // 防止同一任务重复派发
|
||||
});
|
||||
|
||||
// Child Job 派发(Manager 内循环)
|
||||
await pgBoss.send('module_task_child', { taskId, itemId }, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 10, // 10 秒后重试
|
||||
retryBackoff: true, // 指数退避(10s, 20s, 40s)
|
||||
expireInMinutes: 30,
|
||||
singletonKey: `child-${itemId}`,
|
||||
});
|
||||
|
||||
// Worker 注册(队列名必须用下划线!)
|
||||
jobQueue.work('module_task_child', { teamConcurrency: 10 }, handler);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、开发检查清单
|
||||
|
||||
在 Code Review 时,逐项核对以下问题:
|
||||
|
||||
- [ ] **原子递增**:父任务计数器是否使用 `{ increment: 1 }`?
|
||||
- [ ] **Last Child Wins**:成功路径和失败路径是否都检查了 `successCount + failedCount >= totalCount`?
|
||||
- [ ] **乐观锁**:Child Worker 是否使用 `updateMany({ where: { status: 'pending' } })` 而非 `findUnique → if`?
|
||||
- [ ] **错误分级**:永久错误是否 `return`(停止重试)?临时错误是否 `throw`(指数退避)?
|
||||
- [ ] **teamConcurrency**:Child 队列是否设置了全局并发限制?是否禁用了 P-Queue?
|
||||
- [ ] **Payload 轻量**:Job data 是否仅传 ID(< 1KB)?
|
||||
- [ ] **过期时间**:是否设置了 `expireInMinutes`?
|
||||
- [ ] **队列命名**:是否使用下划线(`module_task_child`),而非点号?
|
||||
- [ ] **数据快照**:Manager 是否在派发前快照了外部依赖数据?
|
||||
- [ ] **NOTIFY 广播**:SSE 日志推送是否经过 PostgreSQL NOTIFY(如需跨 Pod)?
|
||||
- [ ] **事务保障**:子项状态更新 + 父任务原子递增是否在同一事务中?
|
||||
|
||||
---
|
||||
|
||||
## 七、演进路线
|
||||
|
||||
| 阶段 | 时间 | 内容 |
|
||||
|------|------|------|
|
||||
| v1.0 设计沉淀 | 2026-02 | 基于 ASL 工具 3 架构审查经验编写本指南(当前) |
|
||||
| v1.5 实战验证 | ASL M1 完成后 | 将 M1 开发中遇到的实际问题补充到本文 |
|
||||
| v2.0 基建抽象 | ASL M2 完成后 | 将 Fan-out 通用逻辑抽离为 `common/jobs/FanOutHelper.ts` |
|
||||
| v2.5 全量推广 | 后续模块 | IIT Agent 批量质控、DC 批量 ETL 等模块复用 Fan-out 基建 |
|
||||
|
||||
> **设计原则:** 先在 ASL 工具 3 中"打样",踩完坑后再抽象为平台能力。避免过早抽象导致接口不合理。
|
||||
|
||||
---
|
||||
|
||||
*本文档基于 ASL 工具 3 全文智能提取工作台开发计划(v1.5,经 6 轮架构审查)的设计经验总结。*
|
||||
*待 M1/M2 实战后升级为 v2.0,届时补充真实踩坑记录和性能数据。*
|
||||
75
docs/02-通用能力层/系统级异步架构风险剖析与演进技术蓝图.md
Normal file
75
docs/02-通用能力层/系统级异步架构风险剖析与演进技术蓝图.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# **🎯 系统级异步架构风险剖析与演进技术蓝图 (V2.0 定稿版)**
|
||||
|
||||
**文档性质:** 架构决策与研发执行规范 **面向受众:** 架构师、技术负责人、中高级后端研发 **背景:** 随着 ASL 工具 3 等批量耗时任务的引入,系统正从“单体异步”转向“分布式扇出 (Fan-out)”架构。 **核心目标:** 解决多实例部署下的计数丢失、状态撕裂与事件孤岛风险,建立工业级的分布式任务处理标准。
|
||||
|
||||
## **💡 一、 问题的本质:架构的分水岭**
|
||||
|
||||
在分布式系统设计中,异步任务的复杂度随业务颗粒度呈指数级跃迁。我们必须将任务划分为两个完全不同的等级:
|
||||
|
||||
| 维度 | Level 1:单体任务 (现有标准) | Level 2:分布式扇出 (演进方向) |
|
||||
| :---- | :---- | :---- |
|
||||
| **典型案例** | 工具 C 解析 1 个 Excel | 工具 3 批量提取 100 篇文献 |
|
||||
| **工作流** | 1 个触发 \-\> 1 个 Worker \-\> 结束 | 1 个触发 \-\> 1 个 Manager \-\> N 个子 Worker |
|
||||
| **多实例安全性** | **高**。依靠 pg-boss 行锁。 | **低**。子任务跨机器,必须处理原子性聚合。 |
|
||||
| **容错代价** | **小**。失败重跑 40 秒。 | **极大**。若无扇出,第 99 篇失败会导致前 98 篇全废。 |
|
||||
| **系统影响** | 局部影响。 | 全系统级风险(API 熔断、数据库连接耗尽)。 |
|
||||
|
||||
## **🔍 二、 核心风险深度剖析 (The Risks)**
|
||||
|
||||
在多 SAE 实例(Multi-Pod)部署环境下,若不严格执行 V2.0 规范,将面临以下系统性崩溃风险:
|
||||
|
||||
### **1\. 统计数据的“幻影覆盖” (Race Condition)**
|
||||
|
||||
* **现象:** 当 100 个子任务在不同 Pod 同时完成时,如果采用 count \= count \+ 1 的读写逻辑,多个进程会读到相同的旧值并覆盖写入。
|
||||
* **后果:** 进度条卡死、统计金额错误、任务永远无法触发“完成”回调。
|
||||
|
||||
### **2\. “最后一个人关灯”难题 (The Terminator Problem)**
|
||||
|
||||
* **现象:** 缺乏全局协调逻辑。Manager 派发完任务就结束了,子任务各自为政。
|
||||
* **后果:** 系统不知道“这组任务”什么时候算真正结束,无法自动触发后续的报告生成或通知发送。
|
||||
|
||||
### **3\. SSE 实时日志的“物理隔绝” (Event Silos)**
|
||||
|
||||
* **现象:** 用户的浏览器连接在 Pod A,但执行任务的 Worker 运行在 Pod B。Pod B 产生的日志在 Pod A 的内存里完全不存在。
|
||||
* **后果:** 页面显示“处理中”但日志区一片空白,用户因感知不到进度而频繁刷新,造成更大的后端冲击。
|
||||
|
||||
## **🛠️ 三、 全系统演进执行建议 (The Guidelines)**
|
||||
|
||||
为了消除上述风险,全平台所有异步模块必须强制对齐以下 4 项架构红线:
|
||||
|
||||
### **🚨 规范 1:强制执行数据库级原子操作**
|
||||
|
||||
禁止在异步代码中使用任何内存层面的数学运算来更新数据库。
|
||||
|
||||
* **错误写法:** data: { count: task.count \+ 1 }
|
||||
* **正确标准 (Prisma):** \`\`\`typescript await prisma.task.update({ where: { id: taskId }, data: { successCount: { increment: 1 } } });
|
||||
|
||||
### **🚨 规范 2:引入“Last Child Wins”收口机制**
|
||||
|
||||
在分布式环境下,必须由最后一个完成任务的进程负责“关灯(翻转父任务状态)”。
|
||||
|
||||
* **执行逻辑:** 每个子任务在执行完【原子递增】后,必须同步读取更新后的结果。
|
||||
* **判定公式:** if (updatedTask.successCount \+ updatedTask.failedCount \=== updatedTask.totalCount)
|
||||
* **后续:** 若条件成立,该实例负责将 Task.status 改为 completed 并触发 SSE 完成事件。
|
||||
|
||||
### **🚨 规范 3:从 EventEmitter 转向跨实例消息总线**
|
||||
|
||||
彻底封杀多实例环境下的单机 EventEmitter 实时推送。
|
||||
|
||||
* **Postgres-Only 方案:** 充分利用 PostgreSQL 的 LISTEN/NOTIFY 机制。
|
||||
* **工作流:** 1\. Worker 发送 NOTIFY channel\_name, payload。 2\. 所有 API 节点在启动时 LISTEN 该频道。 3\. 收到通知的 API 节点检查本地内存,若存在对应 taskId 的 SSE 客户端,则执行推送。
|
||||
|
||||
### **🚨 规范 4:极端场景下的“背压限制”与超时阻断**
|
||||
|
||||
* **全局限流:** 针对昂贵的外部 API(如 MinerU),必须使用 pg-boss 的 teamConcurrency 进行数据库级全局限流,严禁使用单机 P-Queue。
|
||||
* **超时阻断:** 所有跨网络请求必须强制设置 timeout(建议 ≤ 90s),防止外部接口假死扣住 pg-boss 队列名额,导致系统死锁。
|
||||
|
||||
## **📅 四、 路线图:如何平滑过渡?**
|
||||
|
||||
1. **实验场 (M1/M2):** 以“ASL 工具 3”作为首个 V2.0 规范试点,沉淀出通用的 FanOutHelper 和 ListenNotifyService。
|
||||
2. **基建化:** 将上述成功代码抽离,封装入 common/jobs 和 common/streaming 能力层。
|
||||
3. **全量覆盖:** 发布《Postgres-Only 异步任务处理指南 v2.0》,要求后续所有涉及“批量处理”的模块(如 IIT Agent、批量报告生成)严格照此执行。
|
||||
|
||||
## **🏁 架构师寄语**
|
||||
|
||||
工具 3 的出现不是增加了复杂度,而是帮我们掀开了分布式环境下一直被掩盖的风险盖子。 **与其在上线后通过熬夜排查“幽灵 Bug”,不如现在多审核一次,在文档阶段就打好地基。** 请研发团队认真研读此蓝图,这套规范将让我们的系统从“能跑通”进化到“金身不坏”。
|
||||
Reference in New Issue
Block a user