阶段一:导入流程 — 文档处理链路(对应课程 day02~05)
当前状态:骨架已就绪(3 节点)→ 新增 3 个文档处理节点
知识标记总览:
| 知识点 | 出现次数 | 扮演的角色 |
|---|---|---|
| LangGraph StateGraph 节点扩展 | 🟢 第 2 次 | 在现有图上追加 3 个节点 |
| MinerU PDF 解析 | 🔴 第 1 次 | PDF→Markdown 转换引擎 |
| VLM 图片摘要(视觉模型) | 🔴 第 1 次 | 用 VL API 理解图片内容 |
| MinIO 对象存储 | 🔴 第 1 次 | 图片的 S3 兼容存储服务 |
| 文档切分策略 | 🟢 第 2 次(Ch16→Ch19) | 按 Markdown 标题层级切分 |
1.1 阶段起始状态:3 个灰色节点(已有骨架)
1.2 本阶段新增文件
shopkeeper_brain/knowledge/processor/import_process/nodes/
├── __init__.py ← 已存在
├── entry_node.py ← 已存在
├── pdf_to_md_node.py ← 🔴 新增:PDF→Markdown
├── md_img_node.py ← 🔴 新增:图片处理
└── document_split_node.py ← 🔴 新增:文档切分main_graph.py 修改:追加 3 个节点的 add_node + 对应的 add_edge。
1.3 核心代码骨架
① PdfToMdNode:PDF→Markdown(🔴 新知识:MinerU)
python
class PdfToMdNode(BaseNode):
"""使用 MinerU 将 PDF 解析为 Markdown"""
name = "pdf_to_md"
def process(self, state: ImportGraphState) -> ImportGraphState:
pdf_path = state.get('pdf_path')
md_dir = Path(state['file_dir']) / "mineru_output"
# MinerU 命令行解析(两种方式任选)
# 方式 1:CLI 命令
# subprocess.run(["mineru", "--pdf", pdf_path, "--output", str(md_dir)])
# 方式 2:Python SDK
from mineru import MinerU
mineru = MinerU(config_path="mineru.json")
result = mineru.parse(pdf_path)
md_path = md_dir / f"{state['file_title']}.md"
state['md_path'] = str(md_path)
return state🔑 MinerU 简介:开源 PDF 解析引擎,比 PyMuPDF / pdfplumber 等库更智能——能识别标题层级、表格、公式、图片。输出结构化的 Markdown。缺点是大模型环境依赖较重(需下载 OCR 模型)。
② MarkDownImageNode:VLM 图片处理(🔴 新知识:VLM + MinIO)
python
class MarkDownImageNode(BaseNode):
"""扫描 MD 中的图片引用,用 VLM 生成摘要,上传 MinIO"""
name = "md_img"
def process(self, state: ImportGraphState) -> ImportGraphState:
md_path = Path(state['md_path'])
md_content = md_path.read_text(encoding='utf-8')
# 1. 正则提取所有图片路径
img_pattern = r'!\[.*?\]\((.*?)\)'
img_paths = re.findall(img_pattern, md_content)
# 2. VLM 生成每张图的摘要(带滑动窗口限流)
summaries = {}
for img_path in img_paths:
# VLM API 调用(与普通 LLM 同格式,但传图片 URL)
summary = self._vlm_describe(img_path) # "包含电路图的接线示意图"
summaries[img_path] = summary
# 3. 替换 MD 引用:原引用 → 新引用(含摘要 + MinIO URL)
for old_path, summary in summaries.items():
minio_url = self._upload_to_minio(old_path)
md_content = md_content.replace(
f'![{old_path}]',
f''
)
# 4. 写回 MD
md_path.write_text(md_content, encoding='utf-8')
state['md_content'] = md_content
return state
def _vlm_describe(self, img_path: str) -> str:
"""调用 VLM 模型生成图片摘要"""
# 请求格式(以 OpenAI-compatible API 为例):
# POST {base_url}/chat/completions
# { "model": "qwen-vl-plus", "messages": [
# {"role": "user", "content": [
# {"type": "image_url", "image_url": {"url": f"file://{img_path}"}},
# {"type": "text", "text": "请用一句话描述这张图片"}
# ]}
# ]}
return "图片摘要内容"
def _upload_to_minio(self, file_path: str) -> str:
"""上传文件到 MinIO,返回可访问的 URL"""
from minio import Minio
client = Minio(
self.config.minio_endpoint,
access_key=self.config.minio_access_key,
secret_key=self.config.minio_secret_key,
secure=self.config.minio_secure,
)
bucket = self.config.minio_bucket
if not client.bucket_exists(bucket):
client.make_bucket(bucket)
object_name = f"images/{Path(file_path).name}"
client.fput_object(bucket, object_name, file_path)
return f"http://{self.config.minio_endpoint}/{bucket}/{object_name}"🔑 设计决策:为什么图片处理要单独做一个节点,而不是合并在 PDF 节点里?
- VLM 调用是 I/O 密集型(网络请求 + 图片传输),PDF 解析是 CPU 密集型(OCR 识别)。两个节点独立可以各自选择不同的重试策略和资源限制。
- VLM 调用有速率限制(requests_per_minute=15),需要独立的滑动窗口队列控制。
③ DocumentSplitNode:文档切分(🟢 第 2 次,Ch16 回顾)
python
class DocumentSplitNode(BaseNode):
"""按 Markdown 一级标题切分文档,短内容合并"""
name = "document_split"
def process(self, state: ImportGraphState) -> ImportGraphState:
md_content = state.get('md_content', '')
# 1. 按一级标题切分(# 标题)
sections = re.split(r'(?=^# )', md_content, flags=re.MULTILINE)
# 2. 构建面包屑路径(标题层级 -> 每个 chunk 保留全局标题路径)
chunks = []
for section in sections:
if not section.strip():
continue
# 提取标题路径
title_path = self._extract_title_path(section)
# 组装 chunk
chunks.append({
"title_path": title_path,
"content": section.strip(),
"metadata": {"file_title": state['file_title']}
})
# 3. 短 chunk 合并(<500 字与相邻合并)
merged = []
for chunk in chunks:
if merged and len(chunk['content']) < 500:
merged[-1]['content'] += "\n" + chunk['content']
else:
merged.append(chunk)
state['chunks'] = merged
return state1.4 设计决策
| 决策 | 选项 A | 选项 B | 选择理由 |
|---|---|---|---|
| PDF 引擎选型 | MinerU | PyMuPDF / pdfplumber | MinerU 能自动识别标题层级和表格结构,输出 Markdown——后续切分直接复用标题结构 |
| VLM 调用方式 | LLM API(OpenAI-compatible) | 本地部署 VLM | API 方式零 GPU 成本,使用滑动窗口限流控制频率 |
| 图片存储方式 | MinIO(S3 兼容) | 本地文件系统 | MinIO 提供 HTTP 可访问的 URL,后续查询前端直接引用 |
| 切分粒度 | 一级标题 | 二级标题 / 固定长度 | 一级标题粒度适中(每个 section 500-2000 字),二级标题太碎 |
| 短内容策略 | 与相邻合并 | 丢弃 | 合并保留信息不丢失 |
1.5 main_graph 追加代码
python
# → 在 create_import_graph() 中追加
from knowledge.processor.import_process.nodes.pdf_to_md_node import PdfToMdNode
from knowledge.processor.import_process.nodes.md_img_node import MarkDownImageNode
from knowledge.processor.import_process.nodes.document_split_node import DocumentSplitNode
def create_import_graph():
builder = StateGraph(ImportGraphState)
# 已有节点
builder.add_node("entry_node", EntryNode())
# 本阶段新增 3 个节点
builder.add_node("pdf_to_md_node", PdfToMdNode())
builder.add_node("md_img_node", MarkDownImageNode())
builder.add_node("document_split_node", DocumentSplitNode())
# 已有边
builder.add_conditional_edges("entry_node", import_router, ...)
# 本阶段新增边
builder.add_edge("entry_node", "pdf_to_md_node")
builder.add_edge("pdf_to_md_node", "md_img_node")
builder.add_edge("md_img_node", "document_split_node")
return builder.compile()1.6 阶段结束时的演进架构图
本阶段已搭建的节点数:4/8(entry + pdf + md + document_split) 本阶段新增的文件:3 个(pdf_to_md_node.py, md_img_node.py, document_split_node.py) 新增的知识:🔴 MinerU 配置 / 🔴 VLM API 调用 / 🔴 MinIO 上传 / 🟢 文档切分 下一阶段将新增:3 个节点(商品名识别 → 批量嵌入 → Milvus 入库)
📂 对应的原始代码快照:
day02/3_code/shopkeeper_brain/→day05/之间的逐日增量