【 阶段一:离线海量数据治理流水线 】
LangGraph 框架下,架构最核心的工程思想是“状态跟着图走,节点完全无状态(Stateless)”,以此来实现高并发、易扩展以及分布式容灾能力。:
整体架构拓扑图:
==================================================================================================
【输入层】 [ 用户上传物理文件 / 本地触发任务 (携带 task_id) ]
│ (PDF 或 Markdown 文件)
▼
==================================================================================================
【编排控制层】 【 节点:node_entry 】
(LangGraph) (参数初始化与文件类型防御校验)
│
┌────────────┴────────────┐
(如果是 PDF 格式) (如果是 MD 格式)
▼ ▼
【 节点:node_pdf_to_md 】 │
(异步提交 MinerU 算力集群) │
│ │
└────────────┬────────────┘
│ (高纯度原始 Markdown 文本)
▼
【 节点:node_md_img 】
(正则榨取本地图片 ──> VLM 视觉理解)
│
▼
==================================================================================================
【数据清洗/切片层】 【 节点:node_document_split 】
(双阶层级自治切片算法:宏观切分 + 短块合并)
│
▼
==================================================================================================
【知识增强/NLP层】 【 节点:node_item_name_recognition 】
(LLM 强约束实体抽取 ──> 全量切片“血统注入”)
│
▼
==================================================================================================
【深度向量表征层】 【 节点:node_bge_embedding 】
(BGE-M3 双通道编码:Dense 语义 + Sparse 词频)
│
▼
==================================================================================================
【持久化存储层】 【 节点:node_import_milvus 】
(幂等性清理旧资产 ──> 批量流式写入双通道索引表)
│
▼
【输出状态富化】 [ 完备的 state["chunks"] 列表,携带 chunk_id 与多模态网络路径 ]
==================================================================================================
🎨 一、 核心骨架:图拓扑编排与全局状态治理
1. 统一状态定义 (state.py)
from typing import TypedDict
import copy
from app.core.logger import logger
class ImportGraphState(TypedDict):
"""
图的状态定义,包含所有节点产生和消费的数据字段。
TypedDict 让我们在代码中能有自动补全和类型检查。
使用字典式访问(如state["session_id"]、state.get("embedding_chunks"))
"""
task_id: str # 任务唯一ID,用于追踪日志
# --- 流程控制标记 ---
is_md_read_enabled: bool # 是否启用 Markdown 读取路径
is_pdf_read_enabled: bool # 是否启用 PDF 读取路径
# --- 切块相关 ---
is_normal_split_enabled: bool
is_silicon_flow_api_enabled: bool
is_advanced_split_enabled: bool
is_vllm_enabled: bool
# --- 路径相关 ---
local_dir: str # 当前工作目录或输出目录
local_file_path: str # 原始输入文件路径
file_title: str # 文件标题(文件名去后缀)
pdf_path: str # PDF 文件路径 (如果输入是PDF)
md_path: str # Markdown 文件路径 (转换后或直接输入的)
split_path: str # 分块后的文件路径
embeddings_path: str # 向量数据库文件路径
# --- 内容数据 ---
md_content: str # Markdown 的全文内容
chunks: list # 切片后的文本列表,包含 metadata
item_name: str # 识别出的主体名称 (如: "万用表"),用于增强检索
# --- 数据库相关 ---
embeddings_content: list # 包含向量数据的列表,准备写入 Milvus
# 建议定一个初始化对象,方便后续使用
# 定义图状态的默认初始值
graph_default_state: ImportGraphState = {
"task_id":"",
"is_pdf_read_enabled": False,
"is_md_read_enabled": False,
"is_normal_split_enabled": True,
"is_silicon_flow_api_enabled": True,
"is_advanced_split_enabled": False,
"is_vllm_enabled": False,
"local_dir": "",
"local_file_path": "",
"pdf_path": "",
"md_path": "",
"file_title": "",
"split_path": "",
"embeddings_path": "",
"md_content": "",
"chunks": [],
"item_name": "",
"embeddings_content": []
}
def create_default_state(**overrides) -> ImportGraphState:
"""
创建默认状态,支持覆盖
Args:
**overrides: 要覆盖的字段(关键字参数解包)
Returns:
新的状态实例
Examples:
state = create_default_state(task_id="task_001", local_file_path="doc.pdf")
"""
# 默认状态
state = copy.deepcopy(graph_default_state)
# 用 overrides 覆盖默认值
state.update(overrides)
# 返回创建好的状态字典实例
return state
def get_default_state() -> ImportGraphState:
"""
返回一个新的状态实例,避免全局变量污染
"""
return copy.deepcopy(graph_default_state)
if __name__ == "__main__":
"""
测试
"""
# 创建默认状态
state = create_default_state(local_file_path="万用表RS-12的使用.pdf")
logger.info(state)
在工业级 RAG 业务中,最忌讳节点之间通过全局变量或直接调用的方式传递数据。项目通过 ImportGraphState(TypedDict) 完美治理了这个问题:
-
解耦共享:它定义了流转全程的全量状态字段。所有节点的入参都是这个状态对象,节点返回的键值对会自动合并回状态,实现了“数据随图流转”。
-
生命周期变量分类:
-
控制道岔:
is_md_read_enabled、is_pdf_read_enabled,用于动态分流,目的是实现“多源异构输入”的入口标准化与柔性架构, -
内容载体:
md_content(清洗后的高纯度 Markdown)、chunks(带元数据的切片列表)。 -
业务特征:
item_name(识别出的商品/设备主体,用于强特征增强)。
-
2. node_entry 入口校验与参数初始化
import os
import sys
from os.path import splitext
from app.core.logger import logger
from app.import_process.agent.state import ImportGraphState, create_default_state
from app.utils.format_utils import format_state
from app.utils.task_utils import add_running_task, add_done_task
def node_entry(state: ImportGraphState) -> ImportGraphState:
"""
LangGraph知识库导入工作流 - 入口节点
核心职责:初始化参数校验 | 自动判断文件类型(PDF/MD) | 设置解析开关 | 提取业务标识
入参:ImportGraphState - 必须包含 local_file_path(文件路径)、task_id(任务ID)
出参:ImportGraphState - 新增/更新 is_pdf_read_enabled/is_md_read_enabled/pdf_path/md_path/file_title
执行链路:__start__ → 本节点 → route_after_entry(条件路由) → 对应解析节点/流程终止
"""
# 动态获取函数名避免硬编码
func_name = sys._getframe().f_code.co_name
# 节点启动日志,打印当前工作流状态
logger.debug(f"【{func_name}】节点启动,\n当前工作流状态:{format_state(state)}")
# 开始:记录节点运行状态
add_running_task(state["task_id"], func_name)
# 1. 核心参数提取与非空校验
document_path = state.get("local_file_path", "")
if not document_path:
logger.error(f"【{func_name}】核心参数缺失:工作流状态中未配置local_file_path,文件路径为空")
return state
# 2. 根据文件后缀判断类型,设置对应解析开关
if document_path.endswith(".pdf"):
logger.info(f"【{func_name}】文件类型校验通过:{document_path} → PDF格式,开启PDF解析流程")
state["is_pdf_read_enabled"] = True
state["pdf_path"] = document_path
elif document_path.endswith(".md"):
logger.info(f"【{func_name}】文件类型校验通过:{document_path} → MD格式,开启MD解析流程")
state["is_md_read_enabled"] = True
state["md_path"] = document_path
else:
logger.warning(f"【{func_name}】文件类型校验失败:{document_path} → 不支持的格式,仅支持.pdf/.md")
# 3. 提取文件无后缀纯名称,作为全局业务标识
file_name = os.path.basename(document_path)
state["file_title"] = splitext(file_name)[0]
logger.info(f"【{func_name}】文件业务标识提取完成:file_title = {state['file_title']}")
# 结束:记录节点运行状态
add_done_task(state["task_id"], func_name)
# 节点完成日志,打印当前工作流状态
logger.debug(f"【{func_name}】节点执行完成,\n更新后工作流状态:{format_state(state)}")
return state
if __name__ == '__main__':
# 单元测试:覆盖不支持类型、MD、PDF三种场景
logger.info("===== 开始node_entry节点单元测试 =====")
# 测试1: 不支持的TXT文件
test_state1 = create_default_state(
task_id="test_task_001",
local_file_path="联想海豚用户手册.txt"
)
node_entry(test_state1)
# 测试2: MD文件
test_state2 = create_default_state(
task_id="test_task_002",
local_file_path="小米用户手册.md"
)
node_entry(test_state2)
# 测试3: PDF文件
test_state3 = create_default_state(
task_id="test_task_003",
local_file_path="万用表的使用.pdf"
)
node_entry(test_state3)
logger.info("===== 结束node_entry节点单元测试 =====")
-
目的:将多源头(前端上传、定时脚本、第三方API)的入参进行标准化对齐,为整个图结构提供清晰、确定的道岔依据。
-
优势:
-
零脏任务算力浪费:通过前置非空校验和类型嗅探,在毫秒级将损坏的任务、格式不支持的任务(如
.txt、.exe)拦截并快速失败响应,保护后续昂贵的 GPU/大模型算力免受垃圾流量的压榨。 -
架构解耦与极强的横向扩展性:采用 LangGraph 的状态驱动(State-Driven)分流,上层业务无论传什么,底层只有这一个入口。如果未来需要支持 Word 或 Excel 导入,只需在此节点扩展后缀判断,并横向挂载新节点,已有通路的稳定代码完全不需变动。
-
3. 拓扑图构建与条件路由 (main_graph.py)
# 加载环境变量:从 .env 文件读取配置(如Milvus地址、KG服务地址、BGE模型路径等)
from dotenv import load_dotenv
# 导入LangGraph核心依赖:StateGraph(状态图)、START/END(内置起始/结束节点常量)
from langgraph.graph import StateGraph, END, START
from app.core.logger import logger
# 导入自定义状态类:统一管理工作流全程的所有数据(各节点共享/修改)
from app.import_process.agent.state import ImportGraphState, create_default_state
# 导入所有自定义业务节点:每个节点对应知识库导入的一个具体步骤
from app.import_process.agent.nodes.node_entry import node_entry # 入口节点:初始化参数、校验输入
from app.import_process.agent.nodes.node_pdf_to_md import node_pdf_to_md # PDF转MD:解析PDF文件为markdown格式
from app.import_process.agent.nodes.node_md_img import node_md_img # MD图片处理:提取/下载markdown中的图片、修复图片路径
from app.import_process.agent.nodes.node_document_split import node_document_split # 文档分块:将长文档切分为符合模型要求的小片段
from app.import_process.agent.nodes.node_item_name_recognition import node_item_name_recognition # 项目名识别:从分块中提取核心项目名称(业务定制化)
from app.import_process.agent.nodes.node_bge_embedding import node_bge_embedding # BGE向量化:将文本分块转换为向量表示(适配Milvus向量库)
from app.import_process.agent.nodes.node_import_milvus import node_import_milvus # 导入Milvus:将向量数据写入Milvus向量数据库
# 初始化环境变量:必须在配置读取前执行,确保后续节点能获取到环境变量中的配置信息
load_dotenv()
# ===================== 1. 初始化LangGraph状态图 =====================
# 核心:StateGraph是LangGraph的核心类,用于构建有状态的工作流
# 参数ImportGraphState:自定义TypedDict类型,定义了工作流的**全量状态字段**
# 作用:所有节点的入参都是该状态对象,节点返回的键值对会自动合并回状态,实现节点间数据共享
workflow = StateGraph(ImportGraphState)
# ===================== 2. 注册所有业务节点 =====================
# 语法:add_node("节点唯一标识", 节点函数)
# 要求:节点函数必须接收「状态对象」作为入参,返回字典(用于更新状态)
# 所有节点按「知识库导入流程」先后顺序注册,节点标识与函数名保持一致,便于维护
workflow.add_node("node_entry", node_entry) # 流程入口:参数初始化、输入校验
workflow.add_node("node_pdf_to_md", node_pdf_to_md) # PDF转MD:非MD格式文件的前置处理
workflow.add_node("node_md_img", node_md_img) # MD图片处理:保证文档中图片的可访问性
workflow.add_node("node_document_split", node_document_split) # 文档分块:解决大文本无法向量化/推理的问题
workflow.add_node("node_item_name_recognition", node_item_name_recognition) # 项目名识别:业务定制化步骤,提取核心业务标识
workflow.add_node("node_bge_embedding", node_bge_embedding) # BGE向量化:文本→向量,为Milvus存储做准备
workflow.add_node("node_import_milvus", node_import_milvus) # 向量入库:将向量数据持久化到Milvus
# ===================== 3. 设置工作流入口节点 =====================
# 语法:set_entry_point("节点标识") → 推荐写法,直接指定流程起始节点
# 等效写法:workflow.add_edge(START, "node_entry")(START是LangGraph内置起始常量)
# 作用:指定工作流执行的第一个节点,替代手动添加START到目标节点的边,代码更简洁
workflow.set_entry_point("node_entry")
# ===================== 4. 定义条件路由函数(入口节点后的分支逻辑) =====================
# 核心:根据状态中的配置项,动态决定后续执行路径,实现「PDF导入」/「MD直接导入」分支
# 要求:接收状态对象为入参,返回「目标节点标识」或END(内置结束常量)
def route_after_entry(state: ImportGraphState) -> str:
"""
入口节点后的条件路由逻辑
:param state: 工作流全量状态对象,包含所有配置项和中间结果
:return: 目标节点标识/END,LangGraph会自动跳转到对应节点
"""
# 分支1:开启MD直接导入 → 跳过PDF转MD,直接执行MD图片处理
if state.get("is_md_read_enabled"):
return "node_md_img"
# 分支2:开启PDF导入 → 执行PDF转MD,再走后续流程
elif state.get("is_pdf_read_enabled"):
return "node_pdf_to_md"
# 分支3:未开启任何导入配置 → 直接终止工作流(END是LangGraph内置结束常量)
else:
return END
# 注册条件边:将入口节点与路由函数绑定
# 语法:add_conditional_edges("源节点标识", 路由函数)
# 作用:源节点执行完成后,调用路由函数,根据返回值动态跳转到目标节点
workflow.add_conditional_edges(
"node_entry",
route_after_entry,
{
"node_md_img": "node_md_img",
"node_pdf_to_md": "node_pdf_to_md",
END: END
}
)
# ===================== 5. 注册静态顺序边(分支合并后的统一流程) =====================
# 核心:所有分支最终合并为「固定顺序执行流程」,从MD图片处理到知识图谱入库,一步到底
# 语法:add_edge("源节点标识", "目标节点标识/END") → 静态边,固定路由关系,无分支逻辑
workflow.add_edge("node_pdf_to_md", "node_md_img") # PDF转MD完成 → MD图片处理
workflow.add_edge("node_md_img", "node_document_split") # MD处理完成 → 文档分块
workflow.add_edge("node_document_split", "node_item_name_recognition") # 分块完成 → 项目名识别
workflow.add_edge("node_item_name_recognition", "node_bge_embedding") # 项目名识别完成 → BGE向量化
workflow.add_edge("node_bge_embedding", "node_import_milvus") # 向量化完成 → 导入Milvus向量库
workflow.add_edge("node_import_milvus", END) # Milvus入库完成 → 工作流执行结束(END是内置结束节点)
# ===================== 6. 编译工作流为可执行对象 =====================
# 语法:compile() → 将StateGraph构建的流程编译为LangGraph的可执行应用
# 作用:生成可调用的kb_import_app,通过invoke()方法触发工作流执行
# 特性:编译后可重复调用,支持传入不同的初始状态,实现多任务执行
kb_import_app = workflow.compile()
main_graph.py 是整个流水线的“指挥官”,采用了“前置动态分流,后置串行收敛”的经典拓扑:
-
条件边(Conditional Edge):利用
workflow.add_conditional_edges绑定route_after_entry路由函数。当node_entry完成初始化后,图引擎会动态判断:如果是.md路由到node_md_img节点;如果是.pdf路由到node_pdf_to_md节点;否则走END终止流程。 -
静态边(Static Edge):分支执行完毕后,通过
add_edge将数据流强行收敛到统一的标准化数据清洗、切片、向量化和入库通路中。
🛠️ 二、 数据前置加工:突破传统 RAG 的“语义坍塌”与“多模态割裂”
1. PDF 高保真重构 (node_pdf_to_md.py)
# 系统库
import os
import sys
import time
import requests
import zipfile
import shutil
from pathlib import Path
# 项目内部库
from app.import_process.agent.state import ImportGraphState, create_default_state
from app.utils.format_utils import format_state
from app.utils.task_utils import add_running_task, add_done_task
from app.conf.mineru_config import mineru_config
from app.core.logger import logger # 统一日志工具
# MinerU配置(缓存配置信息)
MINERU_BASE_URL = mineru_config.base_url
MINERU_API_TOKEN = mineru_config.api_key
def step_1_validate_paths(state):
"""
步骤1:校验PDF文件路径和输出目录
核心职责:参数非空校验 | PDF文件有效性校验 | 输出目录自动创建
返回:合法的PDF文件Path对象、输出目录Path对象
异常:ValueError(参数缺失)、FileNotFoundError(文件无效)
"""
log_prefix = "[step_1_validate_paths] "
pdf_path = state.get("pdf_path", "").strip()
local_dir = state.get("local_dir", "").strip()
# 参数非空校验
if not pdf_path:
raise ValueError(f"{log_prefix}工作流状态缺失有效参数:pdf_path,当前值:{repr(pdf_path)}")
if not local_dir:
raise ValueError(f"{log_prefix}工作流状态缺失有效参数:local_dir,当前值:{repr(local_dir)}")
# 转换为Path对象统一处理路径
pdf_path_obj = Path(pdf_path)
output_dir_obj = Path(local_dir)
# PDF文件有效性校验(存在且为文件,非目录)
if not pdf_path_obj.exists():
raise FileNotFoundError(f"{log_prefix}PDF文件不存在,绝对路径:{pdf_path_obj.absolute()}")
if not pdf_path_obj.is_file():
raise FileNotFoundError(f"{log_prefix}指定路径非文件(是目录),绝对路径:{pdf_path_obj.absolute()}")
# 确保输出目录存在,不存在则递归创建
if not output_dir_obj.exists():
logger.info(f"{log_prefix}输出目录不存在,自动创建:{output_dir_obj.absolute()}")
output_dir_obj.mkdir(parents=True, exist_ok=True)
return pdf_path_obj, output_dir_obj
def step_2_upload_and_poll(pdf_path_obj: Path, output_dir_obj: Path):
"""
步骤2:上传PDF至MinerU并轮询解析任务状态
核心流程:配置校验 → 获取上传链接 → 文件上传(含重试) → 任务轮询(直至完成/失败/超时)
参数:pdf_path_obj-已校验的PDF Path对象;output_dir_obj-输出目录Path对象
返回:解析结果ZIP包下载链接full_zip_url
异常:ValueError(配置缺失)、RuntimeError(请求/上传失败)、TimeoutError(任务超时)
"""
# 前置配置校验,拦截无效配置
if not MINERU_BASE_URL or not MINERU_API_TOKEN:
raise ValueError("MinerU配置缺失:请在.env中正确配置MINERU_BASE_URL和MINERU_API_TOKEN")
logger.info(f"[配置校验] MinerU基础配置加载成功,开始处理文件:{pdf_path_obj.name}")
# 构造请求头(符合HTTP规范,Bearer鉴权)
request_headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {MINERU_API_TOKEN}"
}
# 1. 调用批量接口,获取上传Signed URL和任务batch_id
url_get_upload = f"{MINERU_BASE_URL}/file-urls/batch"
req_data = {
"files": [{"name": pdf_path_obj.name}],
"model_version": "vlm" # 官方推荐解析模型
}
logger.debug(f"[获取上传链接] 调用接口:{url_get_upload},请求参数:{req_data}")
resp = requests.post(url=url_get_upload, headers=request_headers, json=req_data, timeout=30)
# 响应校验:先验HTTP状态,再验业务返回码
if resp.status_code != 200:
raise RuntimeError(f"[获取上传链接] 网络请求失败,状态码:{resp.status_code},响应内容:{resp.text}")
resp_data = resp.json()
if resp_data["code"] != 0:
raise RuntimeError(f"[获取上传链接] API业务错误,返回数据:{resp_data}")
# 提取核心数据:上传链接和任务唯一标识
signed_url = resp_data["data"]["file_urls"][0]
batch_id = resp_data["data"]["batch_id"]
logger.info(f"[获取上传链接] 成功,batch_id:{batch_id},上传链接已生成")
# 2. 读取PDF二进制数据,准备上传
logger.info(f"[文件上传] 开始读取PDF文件:{pdf_path_obj.name}")
with open(pdf_path_obj, "rb") as f:
file_data = f.read()
# 创建Session(复用TCP连接,禁用代理避免签名验证失败)
upload_session = requests.Session()
upload_session.trust_env = False
try:
# 首次上传:自动识别文件类型
put_resp = upload_session.put(url=signed_url, data=file_data, timeout=60)
# 重试逻辑:首次失败则强制指定PDF的Content-Type
if put_resp.status_code != 200:
logger.warning(f"[文件上传] 首次上传失败(状态码:{put_resp.status_code}),强制指定PDF类型重试")
pdf_headers = {"Content-Type": "application/pdf"}
put_resp = upload_session.put(url=signed_url, data=file_data, headers=pdf_headers, timeout=60)
# 重试仍失败则抛出异常
if put_resp.status_code != 200:
raise RuntimeError(f"[文件上传] 重试后仍失败,状态码:{put_resp.status_code},响应内容:{put_resp.text}")
logger.info(f"[文件上传] 成功,文件{pdf_path_obj.name}已存入云存储")
except Exception as e:
raise RuntimeError(f"[文件上传] 网络异常导致上传失败,错误信息:{str(e)}")
finally:
# 无论成败,关闭Session释放网络连接,避免资源泄漏
upload_session.close()
# 3. 根据batch_id轮询任务状态,直至完成/失败/超时
poll_url = f"{MINERU_BASE_URL}/extract-results/batch/{batch_id}"
start_time = time.time()
timeout_seconds = 600 # 最大超时时间10分钟(适配600页内PDF)
poll_interval = 3 # 轮询间隔3秒(平衡查询频率和服务端压力)
logger.info(f"[任务轮询] 开始监控任务状态,batch_id:{batch_id},最大超时:{timeout_seconds}s")
while True:
# 超时检查:超过最大时间直接终止轮询
elapsed_time = time.time() - start_time
if elapsed_time > timeout_seconds:
raise TimeoutError(f"[任务轮询] 超时!任务处理超{int(timeout_seconds)}秒,batch_id:{batch_id}")
# 发起轮询请求,短超时10秒,异常则重试
try:
poll_resp = requests.get(url=poll_url, headers=request_headers, timeout=10)
except Exception as e:
logger.warning(f"[任务轮询] 网络请求异常,{poll_interval}秒后重试:{str(e)}")
time.sleep(poll_interval)
continue
# 处理HTTP响应错误:5xx服务端繁忙则重试,其他错误直接抛出
if poll_resp.status_code != 200:
if 500 <= poll_resp.status_code < 600:
logger.warning(f"[任务轮询] 服务端繁忙(状态码:{poll_resp.status_code}),{poll_interval}秒后重试")
time.sleep(poll_interval)
continue
else:
raise RuntimeError(f"[任务轮询] HTTP请求失败,状态码:{poll_resp.status_code},响应内容:{poll_resp.text}")
# 解析轮询结果,校验业务状态
poll_data = poll_resp.json()
if poll_data["code"] != 0:
raise RuntimeError(f"[任务轮询] API业务错误,返回数据:{poll_data}")
extract_results = poll_data["data"]["extract_result"]
# 结果暂空,继续轮询
if not extract_results:
logger.debug(f"[任务轮询] 结果暂为空,已耗时{int(elapsed_time)}s,继续等待")
time.sleep(poll_interval)
continue
# 解析任务状态,分支处理
result_item = extract_results[0]
state_status = result_item["state"]
# 状态1:任务完成,提取ZIP下载链接
if state_status == "done":
logger.info(f"[任务轮询] 解析任务完成!总耗时:{int(elapsed_time)}s,batch_id:{batch_id}")
full_zip_url = result_item.get("full_zip_url")
if not full_zip_url:
raise RuntimeError("[任务轮询] 任务完成但未返回ZIP包下载链接,batch_id:{batch_id}")
logger.info(f"[任务轮询] 结果ZIP包下载链接:{full_zip_url}...")
return full_zip_url
# 状态2:任务失败,提取错误信息抛出
elif state_status == "failed":
err_msg = result_item.get("err_msg", "未知错误,无具体信息")
raise RuntimeError(f"[任务轮询] 解析任务失败,batch_id:{batch_id},错误信息:{err_msg}")
# 状态3:处理中,实时打印进度(覆盖当前行)
else:
logger.debug(
f"[任务轮询] 处理中(已耗时{int(elapsed_time)}s),状态:{state_status} | 刷新间隔{poll_interval}s",
end="\r"
)
time.sleep(poll_interval)
def step_3_download_and_extract(zip_url: str, output_dir_obj: Path, pdf_stem: str) -> str:
"""
步骤3:下载MinerU解析结果ZIP包并解压,提取目标MD文件(重命名统一规范)
核心流程:下载ZIP → 清理旧目录并解压 → 查找MD文件(按优先级) → 重命名统一为PDF同名
参数:zip_url-ZIP包下载链接;output_dir_obj-输出目录Path;pdf_stem-PDF无后缀纯名称
返回:最终MD文件的字符串格式绝对路径
异常:RuntimeError(下载失败)、FileNotFoundError(无MD文件)
"""
logger.info(f"===== 开始处理[{pdf_stem}]的MinerU解析结果 =====")
# 1. 下载解析结果ZIP包,120秒超时适配大文件
logger.info(f"[步骤1/4] 开始下载ZIP包,链接:{zip_url}...")
resp = requests.get(zip_url, timeout=120)
if resp.status_code != 200:
raise RuntimeError(f"[步骤1/4] ZIP包下载失败,HTTP状态码:{resp.status_code}")
# 拼接ZIP包保存路径,按PDF名称唯一命名
zip_save_path = output_dir_obj / f"{pdf_stem}_result.zip"
with open(zip_save_path, "wb") as f:
f.write(resp.content)
logger.info(f"[步骤1/4] ZIP包下载成功,保存路径:{zip_save_path}")
# 2. 清理旧解压目录并解压ZIP包(避免旧文件干扰,为每个PDF创建专属目录)
logger.info(f"[步骤2/4] 开始解压ZIP包...")
extract_target_dir = output_dir_obj / pdf_stem
# 清理旧目录,异常则警告不终止
if extract_target_dir.exists():
try:
# 递归删除整个目录树,包括目录本身及其所有子目录和文件。
shutil.rmtree(extract_target_dir)
logger.info(f"[步骤2/4] 已清理旧的解压目录:{extract_target_dir}")
except Exception as e:
logger.warning(f"[步骤2/4] 清理旧目录失败,可能不影响新文件解压:{str(e)}")
# 重新创建解压目录
extract_target_dir.mkdir(parents=True, exist_ok=True)
# 核心解压操作,保留原目录结构
with zipfile.ZipFile(zip_save_path, 'r') as zip_file_obj:
zip_file_obj.extractall(extract_target_dir)
logger.info(f"[步骤2/4] ZIP包解压完成,解压目录:{extract_target_dir}")
# 3. 递归查找解压目录下所有MD文件(适配子目录结构)
logger.info(f"[步骤3/4] 开始查找解压目录中的MD文件...")
md_file_list = list(extract_target_dir.rglob("*.md"))
if not md_file_list:
raise FileNotFoundError(f"[步骤3/4] 解压目录中未找到任何.md格式文件:{extract_target_dir}")
logger.info(f"[步骤3/4] 共找到{len(md_file_list)}个MD文件,按优先级匹配目标文件")
# 4. 按优先级匹配目标MD文件(同名→full.md→第一个,兜底避免流程中断)
target_md_file = None
# 优先级1:与PDF纯名称完全同名的MD文件
for md_file in md_file_list:
if md_file.stem == pdf_stem:
target_md_file = md_file
logger.info(f"[步骤4/4] 匹配到优先级1目标:与PDF同名的MD文件 {target_md_file.name}")
break
# 优先级2:MinerU默认生成的full.md(不区分大小写)
if not target_md_file:
for md_file in md_file_list:
if md_file.name.lower() == "full.md":
target_md_file = md_file
logger.info(f"[步骤4/4] 匹配到优先级2目标:MinerU默认文件 {target_md_file.name}")
break
# 优先级3:兜底取第一个MD文件
if not target_md_file:
target_md_file = md_file_list[0]
logger.info(f"[步骤4/4] 未匹配到前两级目标,兜底取第一个MD文件 {target_md_file.name}")
# 重命名MD文件:统一为PDF纯名称,便于后续流程处理(仅不同名时执行)
if target_md_file.stem != pdf_stem:
logger.info(f"[步骤4/4] 开始重命名MD文件,统一为PDF同名:{pdf_stem}.md")
new_md_path = target_md_file.with_name(f"{pdf_stem}.md")
try:
# 将磁盘上的文件进行重命名
target_md_file.rename(new_md_path)
# 更新变量引用
target_md_file = new_md_path
logger.info(f"[步骤4/4] MD文件重命名成功:{pdf_stem}.md")
except OSError as e:
logger.warning(f"[步骤4/4] MD文件重命名失败,将使用原文件名继续流程:{str(e)}")
# 转换为字符串绝对路径返回,适配后续仅支持字符串路径的函数
final_md_path = str(target_md_file.absolute())
logger.info(f"===== [{pdf_stem}]解析结果处理完成,最终MD文件路径:{final_md_path} =====")
return final_md_path
def node_pdf_to_md(state: ImportGraphState) -> ImportGraphState:
"""
LangGraph工作流节点:PDF转MD核心处理节点
核心流程:路径校验 → MinerU上传解析 → 结果下载解压 → 读取MD内容并更新工作流状态
参数:state-工作流状态对象,需包含pdf_path/local_dir/task_id
返回:更新后的工作流状态,新增md_path/md_content
"""
# 动态获取函数名避免硬编码
func_name = sys._getframe().f_code.co_name
# 节点启动日志,打印当前工作流状态
logger.debug(f"【{func_name}】节点启动,\n当前工作流状态:{format_state(state)}")
# 开始:记录节点运行状态
add_running_task(state["task_id"], func_name)
try:
# 步骤1:校验PDF路径和输出目录
pdf_path_obj, output_dir_obj = step_1_validate_paths(state)
# 步骤2:上传PDF至MinerU并轮询解析结果
zip_url = step_2_upload_and_poll(pdf_path_obj, output_dir_obj)
# 步骤3:下载ZIP包并提取MD文件
md_path = step_3_download_and_extract(zip_url, output_dir_obj, pdf_path_obj.stem)
# 更新工作流状态:记录MD文件路径和内容
state["md_path"] = md_path
logger.info(f"【{func_name}】MD文件生成成功,路径:{md_path}")
# 读取MD文件内容,捕获异常仅警告不终止
try:
with open(md_path, "r", encoding="utf-8") as f:
state["md_content"] = f.read()
logger.debug(f"【{func_name}】MD文件内容读取成功,内容长度:{len(state['md_content'])}字符")
except Exception as e:
logger.error(f"【{func_name}】读取MD文件内容失败:{str(e)}")
logger.info(f"【{func_name}】节点执行完成,更新后工作流状态键:{list(state.keys())}")
except Exception as e:
# 异常日志分级,精准提示配置问题
logger.error(f"【{func_name}】PDF转MD流程执行失败:{str(e)}", exc_info=True)
raise # 抛出异常,终止工作流
finally:
# 结束:记录节点运行状态
add_done_task(state["task_id"], func_name)
# 节点完成日志,打印当前工作流状态
logger.debug(f"【{func_name}】节点执行完成,\n更新后工作流状态:{format_state(state)}")
return state
if __name__ == "__main__":
# 单元测试:验证PDF转MD全流程
logger.info("===== 开始node_pdf_to_md节点单元测试 =====")
from app.utils.path_util import PROJECT_ROOT
logger.info(f"测试获取根地址:{PROJECT_ROOT}")
test_pdf_name = os.path.join("doc", "hak180产品安全手册.pdf")
test_pdf_path = os.path.join(PROJECT_ROOT, test_pdf_name)
# 构造测试状态
test_state = create_default_state(
task_id="test_pdf2md_task_001",
pdf_path=test_pdf_path,
local_dir=os.path.join(PROJECT_ROOT, "output")
)
node_pdf_to_md(test_state)
logger.info("===== 结束node_pdf_to_md节点单元测试 =====")
传统 RAG 直接提取 PDF 纯文本会导致表格乱码、标题层级丢失(语义坍塌)。
-
工程实现:该节点对接了 MinerU 分布式解析集群(PDF -->重构建为结构化的markdown)。首先在
step_1_validate_paths触发强健的物理路径与非空校验,接着通过step_3_poll_mineru_task采用自适应时间步长轮询机制(可使用webhook的方式)获取异步解析结果。最终通过zipfile解压,将带有排版树、复杂表格和格式的干净 Markdown 写入state["md_content"]。 -
完美攻克“表格乱码”绝症:传统 PDF 提取工具(如
PyPDF、pdfplumber)遇到多栏排版或跨页表格时,会将文字错行交叉提取,导致表格数据变成一堆毫无逻辑的垃圾字符。MinerU 引入了视觉版面分析(Layout Analysis),能将复杂的物理表格完美还原为 Markdown 表格语法,让大模型在后续检索后能 100% 读懂多指标财报、设备参数表等强逻辑数据。 -
保留长尾文档的纵向深度:保留了类似
#、##的章节树状层级,使得后续切片阶段能够感知内容的隶属关系,让知识库有了“上下级”的灵魂。
2. 图像多模态视觉打标与回填 (node_md_img.py)
import os
import re
import sys
import base64
from pathlib import Path
from typing import Dict, List, Tuple
from collections import deque
# MinIO相关依赖
from minio import Minio
from minio.deleteobjects import DeleteObject
# 【核心改造1:移除原生OpenAI,导入LangChain工具类和多模态消息模块】
from app.clients.minio_utils import get_minio_client
from app.import_process.agent.state import ImportGraphState
from app.utils.task_utils import add_running_task
# LLM客户端工具类(核心复用,替换原生OpenAI调用)
from app.lm.lm_utils import get_llm_client
# LangChain多模态依赖(消息构造+异常捕获)
from langchain.messages import HumanMessage
from langchain_core.exceptions import LangChainException
# 项目配置
from app.conf.minio_config import minio_config
from app.conf.lm_config import lm_config
# 项目日志工具(统一使用)
from app.core.logger import logger
# api访问限速工具
from app.utils.rate_limit_utils import apply_api_rate_limit
# 提示词加载工具
from app.core.load_prompt import load_prompt
# MinIO支持的图片格式集合(小写后缀,统一匹配标准)
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
# 步骤1:初始化MD核心数据,获取内容、文件路径、图片文件夹路径
def step_1_get_content(state: ImportGraphState) -> Tuple[str, Path, Path]:
"""
从全局状态中提取并初始化MD处理所需核心数据
:param state: 导入流程全局状态对象
:return: 三元组(MD文件内容, MD文件路径对象, 图片文件夹路径对象)
:raise FileNotFoundError: 当状态中无有效MD文件路径时抛出
"""
md_file_path = state["md_path"]
# 校验MD文件路径有效性
if not md_file_path:
raise FileNotFoundError(f"全局状态中无有效MD文件路径:{state['md_path']}")
path_obj = Path(md_file_path)
# 优先使用状态中已存在的MD内容,无则从文件读取
if not state["md_content"]:
with open(path_obj, "r", encoding="utf-8") as f:
md_content = f.read()
logger.debug(f"从文件读取MD内容完成,文件大小:{len(md_content)} 字符")
else:
md_content = state["md_content"]
logger.debug(f"从全局状态获取MD内容完成,内容大小:{len(md_content)} 字符")
# 图片文件夹固定为MD文件同级的images目录
images_dir = path_obj.parent / "images"
return md_content, path_obj, images_dir
def is_supported_image(filename: str) -> bool:
"""
判断文件是否为MinIO支持的图片格式(后缀不区分大小写)
:param filename: 文件名(含后缀)
:return: 支持返回True,否则False
"""
return os.path.splitext(filename)[1].lower() in IMAGE_EXTENSIONS
def find_image_in_md(md_content: str, image_filename: str, context_len: int = 100) -> List[Tuple[str, str]]:
"""
查找MD内容中指定图片的所有引用位置,并返回每个位置的上下文文本
:param md_content: MD文件完整内容
:param image_filename: 图片文件名(含后缀)
:param context_len: 上下文截取长度,默认前后各100字符
:return: 上下文列表,每个元素为(上文, 下文)元组,无匹配则返回空列表
"""
pattern = re.compile(r"!\[.*?\]\(.*?" + re.escape(image_filename) + r".*?\)")
results = []
for m in pattern.finditer(md_content):
start, end = m.span()
pre_text = md_content[max(0, start - context_len):start]
post_text = md_content[end:min(len(md_content), end + context_len)]
# 打印图片上下文,便于调试
logger.debug(f"图片[{image_filename}]匹配到引用,上文:{pre_text.strip()}")
logger.debug(f"图片[{image_filename}]匹配到引用,下文:{post_text.strip()}")
results.append((pre_text, post_text))
if not results:
logger.debug(f"MD内容中未找到图片[{image_filename}]的引用")
return results
# 步骤2:扫描图片文件夹,筛选MD中实际引用的支持格式图片
def step_2_scan_images(md_content: str, images_dir: Path) -> List[Tuple[str, str, Tuple[str, str]]]:
"""
扫描图片文件夹,过滤出「支持格式+MD中实际引用」的图片,组装处理元数据
:param md_content: MD文件完整内容
:param images_dir: 图片文件夹路径对象
:return: 待处理图片列表,每个元素为(图片文件名, 图片完整路径, 图片上下文)元组
"""
targets = []
# 遍历图片文件夹所有文件
# 遍历图片文件夹所有文件
for image_file in os.listdir(images_dir):
# 过滤非支持格式的图片
if not is_supported_image(image_file):
logger.debug(f"图片格式不支持,跳过:{image_file}")
continue
# 组装图片完整路径
img_path = str(images_dir / image_file)
# 查找图片在MD中的引用上下文
context_list = find_image_in_md(md_content, image_file)
# 过滤MD中未引用的图片
if not context_list:
logger.warning(f"图片未在MD中引用,跳过处理:{image_file}")
continue
# 组装待处理图片元数据,取第一个匹配的上下文
targets.append((image_file, img_path, context_list[0]))
logger.info(f"图片加入待处理列表:{image_file}")
logger.info(f"图片扫描完成,共筛选出待处理图片:{len(targets)} 张")
return targets
def encode_image_to_base64(image_path: str) -> str:
"""
将本地图片文件编码为Base64字符串(用于多模态大模型输入)
:param image_path: 图片本地完整路径
:return: 图片的Base64编码字符串(UTF-8解码)
"""
with open(image_path, "rb") as img_file:
base64_str = base64.b64encode(img_file.read()).decode("utf-8")
logger.debug(f"图片Base64编码完成,文件:{image_path},编码后长度:{len(base64_str)}")
return base64_str
def summarize_image(image_path: str, root_folder: str, image_content: Tuple[str, str]) -> str:
"""
调用多模态大模型生成图片内容摘要(适配LangChain工具类,复用项目统一LLM客户端)
生成的摘要用于Markdown图片标题,严格控制50字以内中文描述
:param image_path: 图片本地完整路径
:param root_folder: 文档所属文件夹/主名,为大模型提供上下文
:param image_content: 图片在MD中的上下文元组,格式(上文文本, 下文文本)
:return: 图片内容摘要(异常时返回默认值"图片描述")
"""
# 将图片编码为Base64,适配多模态大模型输入要求
base64_image = encode_image_to_base64(image_path)
try:
# 1. 获取项目统一LLM客户端(自动缓存,传入多模态模型名)
lvm_client = get_llm_client(model=lm_config.lv_model)
# 加载并渲染提示词(核心:传入所有占位符对应的变量)
prompt_text = load_prompt(
name="image_summary", # 提示词文件名(不带.prompt)
root_folder=root_folder, # 对应{root_folder}
image_content=image_content # 对应{image_content[0]}、{image_content[1]}
)
# 2. 构造LangChain标准多模态HumanMessage(兼容千问/OpenAI等视觉模型)
messages = [
HumanMessage(
content=[
# 文本提示词:携带上下文,限定摘要规则
{
"type": "text",
"text": prompt_text
},
# 多模态核心:Base64编码图片数据
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
)
]
# 3. LangChain标准调用:invoke方法(工具类已封装超时/重试等参数)
response = lvm_client.invoke(messages)
# 4. 解析响应(LangChain统一返回content字段,统一格式无需多层解析)
summary = response.content.strip().replace("\n", "")
logger.info(f"图片摘要生成成功:{image_path},摘要:{summary}")
return summary
except LangChainException as e:
logger.error(f"图片摘要生成失败(LangChain框架异常):{image_path},错误信息:{str(e)}")
return "图片描述"
except Exception as e:
logger.error(f"图片摘要生成失败(系统异常):{image_path},错误信息:{str(e)}")
return "图片描述"
def step_3_generate_summaries(doc_stem: str, targets: List[Tuple[str, str, Tuple[str, str]]],
requests_per_minute: int = 9) -> Dict[str, str]:
"""
步骤3:批量为待处理图片生成内容摘要,带API速率限制防止触发大模型限流
:param doc_stem: 文档文件名(不含后缀),作为大模型prompt上下文
:param targets: 待处理图片列表,元素为(图片文件名, 图片完整路径, 图片上下文)
:param requests_per_minute: 每分钟最大API请求数,默认9次(按大模型限制调整)
:return: 图片摘要字典,键:图片文件名,值:图片内容摘要
"""
summaries = {}
request_times = deque() # 外部初始化请求时间队列,跨循环复用
for img_file, image_path, context in targets:
# 直接调用抽离的公共工具方法,参数和原逻辑完全一致
apply_api_rate_limit(request_times, requests_per_minute, window_seconds=60)
logger.debug(f"开始生成图片摘要:{image_path}")
summaries[img_file] = summarize_image(image_path, root_folder=doc_stem, image_content=context)
logger.info(f"图片摘要批量生成完成,共处理{len(summaries)}张图片")
return summaries
def clean_minio_directory(minio_client: Minio, prefix: str) -> None:
"""
幂等性清理MinIO指定目录下的所有旧文件,防止重名文件内容混淆和垃圾文件堆积
幂等性:多次调用结果一致,无文件时不报错
:param minio_client: 初始化完成的MinIO客户端对象
:param prefix: MinIO目录前缀(要清理的目录路径)
"""
try:
# 列出指定前缀下的所有对象(递归遍历子目录)
objects_to_delete = minio_client.list_objects(
bucket_name=minio_config.bucket_name,
prefix=prefix,
recursive=True
)
# 构造删除对象列表
delete_list = [DeleteObject(obj.object_name) for obj in objects_to_delete]
if delete_list:
logger.info(f"开始清理MinIO旧文件,待删除文件数:{len(delete_list)},目录:{prefix}")
# 批量删除对象
errors = minio_client.remove_objects(minio_config.bucket_name, delete_list)
# 遍历删除错误信息,记录异常
for error in errors:
logger.error(f"MinIO文件删除失败:{error}")
else:
logger.debug(f"MinIO目录无旧文件,无需清理:{prefix}")
except Exception as e:
logger.error(f"MinIO目录清理失败:{prefix},错误信息:{str(e)}")
def upload_images_batch(minio_client: Minio, upload_dir: str, targets: List[Tuple[str, str, Tuple[str, str]]]) -> Dict[
str, str]:
"""
批量上传待处理图片至MinIO,返回图片文件名与访问URL的映射关系
:param minio_client: 初始化完成的MinIO客户端对象
:param upload_dir: MinIO上传根目录
:param targets: 待处理图片列表,元素为(图片文件名, 图片完整路径, 图片上下文)
:return: 图片URL字典,键:图片文件名,值:MinIO访问URL
"""
urls = {}
for img_file, img_path, _ in targets:
# 构造MinIO对象名称
object_name = f"{upload_dir}/{img_file}"
logger.debug(f"构造MinIO对象名称完成:{object_name}")
# 上传单张图片并获取URL
"""
:= 是 Python 3.8+ 引入的海象运算符(Walrus Operator),核心作用是 **「表达式内赋值 + 结果判断」一体化 **:
在执行判断、循环等逻辑的同一个表达式中,完成变量赋值和赋值结果的使用 / 判断,替代传统「先赋值、后判断」的两行代码,让逻辑更简洁。
"""
if img_url := upload_to_minio(minio_client, img_path, object_name):
urls[img_file] = img_url
logger.info(f"图片批量上传完成,成功上传{len(urls)}/{len(targets)}张图片")
return urls
def upload_to_minio(minio_client: Minio, local_path: str, object_name: str) -> str | None:
"""
将单张本地图片上传至MinIO对象存储,并返回公网可访问URL
:param minio_client: 初始化完成的MinIO客户端对象
:param local_path: 图片本地完整路径
:param object_name: MinIO中要存储的对象名称(带目录)
:return: 图片MinIO访问URL(上传失败返回None)
"""
try:
logger.info(f"开始上传图片至MinIO:本地路径={local_path},MinIO对象名={object_name}")
# 上传本地文件至MinIO(fput_object:文件流上传,适合大文件)
minio_client.fput_object(
bucket_name=minio_config.bucket_name, # MinIO存储桶名(从配置读取)
object_name=object_name, # MinIO对象名称
file_path=local_path, # 本地文件路径
# 自动推断图片Content-Type(如image/png、image/jpeg)
# 入参:文件路径字符串(可带目录,如/a/b/test.jpg、demo.tar.gz);
# 返回值:元组(root, ext),其中:
# root:文件主名(含目录,去掉最后一个后缀的完整部分);
# ext:文件后缀(以.开头,仅包含最后一个扩展名,如.jpg、.gz,无后缀则为空字符串"");
# 关键规则:仅识别 ** 最后一个.** 作为后缀分隔符,多后缀文件仅拆分最后一个(如test.tar.gz拆分为("test.tar", ".gz"))。
content_type=f"image/{os.path.splitext(local_path)[1][1:]}"
)
# 处理路径特殊字符,避免URL解析错误
# 假设原始 object_name 是:图片\logo.png
# 替换后变成:图片%5Clogo.png
# 这个字符串是URL 合法格式,所有服务器 / 浏览器都能正确识别;
# MinIO 接收到 %5C 后,会自动解析回 \,保证对象名的正确性;
# 后续通过 URL 访问时,%5C 会被正确解码,不会出现路径错误。
object_name = object_name.replace("\\", "%5C")
# 根据配置选择HTTP/HTTPS协议
protocol = "https" if minio_config.minio_secure else "http"
# 构造MinIO基础访问URL
base_url = f"{protocol}://{minio_config.endpoint}/{minio_config.bucket_name}"
# 拼接完整图片访问URL base_url 后面带 / 中间直接两个字符串拼接即可
img_url = f"{base_url}{object_name}"
logger.info(f"图片上传成功,访问URL:{img_url}")
return img_url
except Exception as e:
logger.error(f"图片上传MinIO失败:{local_path},错误信息:{str(e)}")
return None
def merge_summary_and_url(/service/summaries: Dict[str, str], urls: Dict[str, str]) -> Dict[str, Tuple[str, str]]:
"""
合并图片摘要字典和URL字典,过滤掉上传失败无URL的图片
:param summaries: 图片摘要字典,键:图片文件名,值:内容摘要
:param urls: 图片URL字典,键:图片文件名,值:MinIO访问URL
:return: 合并后的图片信息字典,键:图片文件名,值:(摘要, URL)元组
"""
image_info = {}
# 遍历摘要字典,仅保留有对应URL的图片
for image_file, summary in summaries.items():
if url := urls.get(image_file):
image_info[image_file] = (summary, url)
logger.info(f"图片摘要与URL合并完成,有效图片信息{len(image_info)}条")
return image_info
def process_md_file(md_content: str, image_info: Dict[str, Tuple[str, str]]) -> str:
"""
核心功能:替换MD内容中的本地图片引用为MinIO远程引用
替换规则: → 
:param md_content: 原始MD文件内容
:param image_info: 合并后的图片信息字典,键:图片文件名,值:(摘要, URL)
:return: 替换后的新MD内容
"""
for img_filename, (summary, new_url) in image_info.items():
# 正则匹配MD图片标签,忽略大小写,兼容不同路径写法
# 正则规则:
pattern = re.compile(
r"!\[.*?\]\(.*?" + re.escape(img_filename) + r".*?\)",
re.IGNORECASE
)
# 替换匹配内容:使用新摘要作为图片描述,新URL作为图片路径
# - 如果你的 summary 和 new_url 是完全可控的纯文本(不含反斜杠) :这两种写法确实 一模一样 。
# - 如果你想写出“防御性代码”(Defensive Code),防止未来某天被特殊字符坑 :请坚持使用 Lambda 写法 。它是最稳健、最安全的做法。
# md_content = pattern.sub(lambda m: f"", md_content)
md_content = pattern.sub( f"", md_content)
logger.debug(f"完成MD图片引用替换:{img_filename} → {new_url}")
logger.info(f"MD文件图片引用替换完成,共替换{len(image_info)}处图片引用")
logger.debug(f"替换后MD内容:{md_content[:500]}..." if len(md_content) > 500 else f"替换后MD内容:{md_content}")
return md_content
def step_4_upload_and_replace(minio_client: Minio, doc_stem: str, targets: List[Tuple[str, str, Tuple[str, str]]],
summaries: Dict[str, str], md_content: str) -> str:
"""
步骤4:核心流程-图片上传MinIO + 合并摘要&URL + 替换MD图片引用
完整流程:清理MinIO旧目录 → 批量上传新图片 → 合并摘要和URL → 替换MD内容
:param minio_client: 初始化完成的MinIO客户端对象
:param doc_stem: 文档文件名(不含后缀),作为MinIO上传子目录名(按文档隔离)
:param targets: 待处理图片列表,元素为(图片文件名, 图片完整路径, 图片上下文)
:param summaries: 图片摘要字典,键:图片文件名,值:内容摘要
:param md_content: 原始MD文件内容
:return: 图片引用替换后的新MD内容
"""
# 构造MinIO上传目录:配置根目录 + 文档主名(去除空格,避免路径问题)
minio_img_dir = minio_config.minio_img_dir
upload_dir = f"{minio_img_dir}/{doc_stem}".replace(" ", "")
# 步骤1:清理该文档对应的MinIO旧目录,保证幂等性
clean_minio_directory(minio_client, upload_dir)
# 步骤2:批量上传图片至MinIO,获取URL映射
urls = upload_images_batch(minio_client, upload_dir, targets)
# 步骤3:合并图片摘要和URL,过滤上传失败的图片
# Dict[str, Tuple[str, str]] 键:图片文件名,值:(摘要, URL)元组
image_info = merge_summary_and_url(https://blog.csdn.net/summaries,%20urls)
# 步骤4:替换MD内容中的本地图片引用为MinIO远程引用
if image_info:
md_content = process_md_file(md_content, image_info)
return md_content
def step_5_backup_new_md_file(origin_md_path: str, md_content: str) -> str:
"""
步骤5:将处理后的MD内容保存为新文件(原文件不变,避免数据丢失)
新文件命名规则:原文件名 + _new.md(如test.md → test_new.md)
:param origin_md_path: 原始MD文件完整路径
:param md_content: 处理后的新MD内容
:return: 新MD文件的完整路径
"""
# 构造新文件路径:替换原后缀为 _new.md
new_md_file_name = os.path.splitext(origin_md_path)[0] + "_new.md"
# 写入新MD内容(覆盖写入,若文件已存在则更新)
with open(new_md_file_name, "w", encoding="utf-8") as f:
f.write(md_content)
logger.info(f"处理后MD文件已保存,新文件路径:{new_md_file_name}")
return new_md_file_name
def node_md_img(state: ImportGraphState) -> ImportGraphState:
"""
MD文件图片处理核心节点 - 五步法完成图片全流程处理
核心流程:
1. 初始化获取MD内容、文件路径、图片文件夹路径
2. 扫描图片文件夹,筛选MD中实际引用的支持格式图片
3. 调用多模态大模型为图片生成内容摘要
4. 将图片上传至MinIO,替换MD中本地图片路径为MinIO访问URL,并填充图片摘要
5. 备份原MD文件,保存处理后的新MD文件并更新状态
:param state: 导入流程全局状态对象,包含task_id、md_path、md_content等核心参数
:return: 更新后的全局状态对象(md_content/md_path为处理后新值)
"""
# 记录当前运行任务,用于任务监控和状态追踪
add_running_task(state["task_id"], sys._getframe().f_code.co_name)
# 步骤1:初始化数据,获取MD核心信息
md_content, path_obj, images_dir = step_1_get_content(state)
state["md_content"] = md_content
# 无图片文件夹,直接跳过所有图片处理逻辑
if not images_dir.exists():
logger.info(f"图片文件夹不存在,跳过图片处理:{images_dir.absolute()}")
return state
# 初始化MinIO客户端,失败则终止流程
minio_client = get_minio_client()
if not minio_client:
logger.warning("MinIO客户端初始化失败,已跳过图片处理全流程")
return state
# 步骤2:扫描并筛选MD中引用的支持格式图片
# (image_file, img_path, context_list[0])
targets = step_2_scan_images(md_content, images_dir)
if not targets:
logger.info("未检测到MD中引用的支持格式图片,跳过后续处理")
return state
# 步骤3:调用多模态大模型生成图片摘要(修复原代码传参错误:使用文件主名而非MD内容)
summaries = step_3_generate_summaries(path_obj.stem, targets)
# 步骤4:上传图片至MinIO,替换MD图片路径并填充摘要
new_md_content = step_4_upload_and_replace(minio_client, path_obj.stem, targets, summaries, md_content)
state["md_content"] = new_md_content
# 步骤5:备份并保存新MD文件,更新状态中的文件路径
new_md_file_name = step_5_backup_new_md_file(state['md_path'], new_md_content)
state["md_path"] = new_md_file_name
logger.info(f"MD图片处理完成,新文件已保存:{new_md_file_name}")
return state
if __name__ == "__main__":
"""本地测试入口:单独运行该文件时,执行MD图片处理全流程测试"""
from app.utils.path_util import PROJECT_ROOT
logger.info(f"本地测试 - 项目根目录:{PROJECT_ROOT}")
# 测试MD文件路径(需手动将测试文件放入对应目录)
test_md_name = os.path.join(r"output\hak180产品安全手册", "hak180产品安全手册.md")
test_md_path = os.path.join(PROJECT_ROOT, test_md_name)
# 校验测试文件是否存在
if not os.path.exists(test_md_path):
logger.error(f"本地测试 - 测试文件不存在:{test_md_path}")
logger.info("请检查文件路径,或手动将测试MD文件放入项目根目录的output目录下")
else:
# 构造测试状态对象,模拟流程入参
test_state = {
"md_path": test_md_path,
"task_id": "test_task_123456",
"md_content": ""
}
logger.info("开始本地测试 - MD图片处理全流程")
# 执行核心处理流程
result_state = node_md_img(test_state)
logger.info(f"本地测试完成 - 处理结果状态:{result_state}")
生产环境中,PDF 内嵌的图表、设备构造图(如设备结构图、接线图、流程图)通常包含核心指标,丢弃它们会导致知识库残缺。该节点通过抽取图片 -> 视觉大模型(VLM)生成文本摘要 -> 上传分布式对象存储 -> 原地回填摘要与新路径的闭环操作,完美解决了传统 RAG 无法检索图片信息的痛点。
【 状态输入 (ImportGraphState) 】
(携带 md_path 与 md_content)
│
▼
【 步骤 1:初始化与防御校验 】(验证文件及环境有效性)
│
▼
【 步骤 2:提取图片相对路径 】(正则扫描解析 ![]() 标签)
│
▼
【 步骤 3:多模态大模型视觉理解 】(并行/串行生成图片 OCR + 语义摘要)
│
▼
【 步骤 4:MinIO 离线存储与路径替换 】(上传图片,并在 MD 源码中回填摘要)
│
▼
【 步骤 5:备份并保存新 MD 文件 】(生成优化后的新 .md 文件)
│
▼
【 状态输出 (ImportGraphState) 】
(富化后的新 md_content 与新 md_path)
-
技术:
-
视觉摘要化:使用正则
!\[.*?\]\((.*?)\)榨取出 Markdown 中所有的内嵌图片。调用大模型工具类get_llm_client()构建多模态HumanMessage投喂给 VLM 视觉大模型。(通过将图片转为 Base64 流塞入大模型,VLM 会输出对这张图的详细文字描述(如:“这是一张 HAK180 万用表的正面按键布局图,上方为 LCD 屏幕,下方包含红、黑、黄三个接线插孔...”)。让原本“不可检索”的图片资产瞬间变得“可被文本检索命中”。) -
知识级转换:VLM 对图表进行 OCR 提取和内容摘要生成。本地图片被推送到企业级对象存储 MinIO 中,同时使用 `` 语法将视觉知识转换为纯文本直接回填至 Markdown 源码中。这样在后续检索时,图表内容就能通过文本相似度被精准召回!(本地图片是不可能直接塞进 Milvus 的,所以需要把它上传到中央存储 MinIO。图片传完后,代码做了一个非常绝妙的改动:把原先的本地图片路径换成了 MinIO 的网络 URL,并且在图片标签正下方追加了 ``。后续节点切片(Split)时,这段通过 HTML 注释隐藏起来的图表摘要文本会和上下文顺理成章地切进同一个 Chunk。下游向量化编码后,用户哪怕搜纯文字,也能直接打中这段摘要,从而把这张 MinIO 的图片 URL 随着大模型回答一起吐给前端展示!)
-
优势:
-
变“视觉多模态”为“高召回文本”:通过 VLM 摘要回填机制,完美消灭了 RAG 知识库盲区,让系统具备了检索图片、图表并精准抛出图片证据的能力。
-
轻量化解耦设计:文档内的二进制图片全部被剥离到 MinIO 中,Markdown 源码和后续的向量数据库只需要维护轻量的字符串 URL,极大地节约了内存和数据库检索压力。
-
完全无缝对接下游:因为它是直接修改
md_content字符串文本,下游的node_document_split(切片节点)完全不需要做任何复杂的多模态特殊改造,直接按照普通的普通文本切分即可,架构复用率极高!
-
-
✂️ 三、 切片与业务语义富化:打造“最小可解释单元”
1. 语义边界自治切片 (node_document_split.py)
import re
import json
import os
import sys
# 统一类型注解,避免混用any/Any
from typing import List, Dict, Any, Tuple
# LangChain文本分割器(标注核心用途,便于理解)
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 项目内部工具/状态/日志导入(保持原有路径)
from app.utils.task_utils import add_running_task
from app.import_process.agent.state import ImportGraphState
from app.core.logger import logger # 项目统一日志工具,核心替换print
# --- 配置参数 (Configuration) ---
# 单个Chunk最大字符长度:超过则触发二次切分(适配大模型上下文窗口)
DEFAULT_MAX_CONTENT_LENGTH = 2000
# 短Chunk合并阈值:同父标题的短Chunk会被合并,减少碎片化
MIN_CONTENT_LENGTH = 500
def step_1_get_inputs(state: ImportGraphState) -> Tuple[Any, str, int]:
"""
【步骤1】获取并预处理输入数据
功能:从状态字典中提取MD内容/文件标题/最大长度,做基础标准化
:param state: 项目状态字典(ImportGraphState),包含md_content等核心键
:return: 标准化后的MD内容/文件标题/单个Chunk最大长度(无内容则返回None,None,None)
"""
# 从状态中提取MD原始内容
content = state.get("md_content")
# 空内容兜底:无MD内容则直接返回,终止后续处理
if not content:
logger.warning("状态字典中无有效MD内容,终止文档切分")
return None, None, None
# 基础标准化:统一换行符,避免Windows/Linux换行符差异导致的后续处理异常
# 原始混合换行:"# HL3070说明书\r\n## 产品概述\nHL3070是扫描枪\r\n\r\n### 操作步骤"
# 统一后:"# HL3070说明书\n## 产品概述\nHL3070是扫描枪\n\n### 操作步骤"
content = content.replace("\r\n", "\n").replace("\r", "\n")
# 提取文件标题:有则用,无则默认"Unknown File"
file_title = state.get("file_title", "Unknown File")
# 提取最大Chunk长度:有则用状态中的配置,无则用全局默认值
max_len = DEFAULT_MAX_CONTENT_LENGTH
logger.info(f"步骤1:输入数据加载完成,文件标题:{file_title},最大Chunk长度:{max_len}")
return content, file_title, max_len
def step_2_split_by_titles(content: str, file_title: str) -> Tuple[List[Dict[str, Any]], int, int]:
"""
【步骤2】按Markdown标题初次切分(核心:按#分级切分,跳过代码块内标题)
LangChain前置预处理:将整份MD按标题拆分为独立章节,为后续精细化切分做基础
:param content: 标准化后的MD完整内容(字符串)
:param file_title: 所属文件标题,用于标记章节归属
:return: 切分后的章节列表/有效标题数量/原始文本总行数
"""
# 正则匹配Markdown 1-6级标题(核心规则,适配缩进/标准格式)
# ^\s*:行首允许0/多个空格/Tab(兼容缩进的标题)
# #{1,6}:匹配1-6个#(对应MD1-6级标题)
# \s+:#后必须有至少1个空格(区分#是标题还是普通文本)
# .+:标题文字至少1个字符(避免空标题)
title_pattern = r'^\s*#{1,6}\s+.+'
# 将MD内容按换行符拆分为行列表,逐行处理
lines = content.split("\n")
sections = [] # 最终切分的章节列表
current_title = "" # 当前章节标题
current_lines = [] # 当前章节的行缓存
title_count = 0 # 有效标题数量(非代码块内)
in_code_block = False # 代码块标记:避免误判代码块内的#为标题
def _flush_section():
"""内部辅助函数:将当前缓存的章节写入sections,空缓存则跳过"""
if not current_lines:
return
sections.append({
"title": current_title,
# 每段之间使用 \n换行区分
"content": "\n".join(current_lines),
"file_title": file_title,
})
# 逐行遍历,识别标题并切分章节
for line in lines:
stripped_line = line.strip()
# 识别代码块边界(```/~~~):进入/退出代码块时翻转状态
if stripped_line.startswith("```") or stripped_line.startswith("~~~"):
in_code_block = not in_code_block
current_lines.append(line)
continue
# 判断是否为有效标题:非代码块内 + 匹配标题正则
is_valid_title = (not in_code_block) and re.match(title_pattern, line)
if is_valid_title:
# 遇到新标题:先将上一个章节写入结果,再初始化新章节
_flush_section()
current_title = line.strip() # 清理标题前后空格
current_lines = [current_title] # 新章节从标题开始
title_count += 1
logger.debug(f"识别到MD标题:{current_title}")
else:
# 普通行:追加到当前章节的行缓存
current_lines.append(line)
# 处理最后一个章节:循环结束后,将最后一个缓存的章节写入结果
_flush_section()
logger.info(f"步骤2:MD标题切分完成,识别到{title_count}个有效标题,原始文本共{len(lines)}行")
return sections, title_count, len(lines)
def step_3_handle_no_title(content: str, sections: List[Dict[str, Any]], title_count: int, file_title: str) -> List[Dict[str, Any]]:
"""
【步骤3】无标题兜底处理
功能:若MD中未识别到任何标题,将全文作为一个整体处理,避免后续逻辑异常
:param content: 标准化后的MD完整内容
:param sections: 步骤2切分后的章节列表
:param title_count: 步骤2识别的有效标题数量
:param file_title: 所属文件标题
:return: 兜底后的章节列表
"""
if title_count == 0:
# 无标题情况:替换为单章节,标题为"无标题"
logger.warning(f"步骤3:未识别到任何MD标题,将全文作为单个章节处理,文件:{file_title}")
return [{"title": "无标题", "content": content, "file_title": file_title}]
# 有标题情况:直接返回步骤2的结果
logger.debug(f"步骤3:检测到{title_count}个有效标题,无需兜底处理")
return sections
def _split_long_section(section: Dict[str, Any], max_length: int = DEFAULT_MAX_CONTENT_LENGTH) -> List[Dict[str, Any]]:
"""
【辅助函数】超长章节二次切分(核心适配LangChain分割器)
功能:单个章节内容超限时,按「段落→句子→空格」从粗到细切分,保留语义
切分规则:1.先按空行(段落) 2.再按换行 3.最后按中英文标点/空格
:param section: 原始章节字典,必须包含content键,可选title/file_title等
:param max_length: 单个Chunk最大字符长度,默认使用全局配置
:return: 切分后的子章节列表,每个子章节带父标题/序号等元信息
"""
# 内容空值兜底:无内容直接返回原章节
content = section.get("content", "") or ""
# 长度未超限,无需切分,直接返回原章节(列表格式保持统一)
if len(content) <= max_length:
return [section]
# 标准化预处理:统一换行符,避免不同系统(\r\n/\n)导致的切分异常
content = content.replace("\r\n", "\n").replace("\r", "\n")
# 提取章节标题,用于组装子Chunk前缀(保留标题上下文)
title = section.get("title", "") or ""
# 标题前缀:带空行分隔,与正文区分开
prefix = f"{title}\n\n" if title else ""
# 计算正文可用长度:总长度 - 标题前缀长度(避免标题占满Chunk额度)
available_len = max_length - len(prefix)
# 极端情况:标题长度超过阈值,无法切分,返回原章节
if available_len <= 0:
logger.warning(f"章节标题过长,无法切分:{title[:20]}...")
return [section]
# 清理正文重复标题:避免原章节中正文开头重复标题,导致子Chunk内容冗余
body = content
if title and body.lstrip().startswith(title):
body = body[body.find(title) + len(title):].lstrip()
# 初始化LangChain递归分割器(核心工具:按优先级分隔符切分,保留语义)
# separators:分割符优先级(从粗到细),优先按大语义单元切分,最后才硬拆
splitter = RecursiveCharacterTextSplitter(
chunk_size=available_len, # 正文部分最大长度(已扣除标题)
chunk_overlap=0, # 无重叠:按标题切分后语义完整,无需重叠
# 分割符优先级:空行(段落)→换行→中文标点→英文标点→空格,最后硬拆
separators=["\n\n", "\n", "。", "!", "?", ";", ".", "!", "?", ";", " "],
)
# 切分正文并组装子章节(带完整元信息,便于溯源)
sub_sections = []
for idx, chunk in enumerate(splitter.split_text(body), start=1):
# 清理空内容:跳过切分后的空字符串
text = chunk.strip()
if not text:
continue
# 组装子Chunk完整内容:标题前缀 + 切分后的正文
full_text = (prefix + text).strip()
# 子章节元信息:保留父级关联,添加序号,便于后续检索/溯源
sub_sections.append({
"title": f"{title}-{idx}" if title else f"chunk-{idx}", # 子Chunk标题(带序号)
"content": full_text, # 切分后的完整内容
"parent_title": title, # 父章节标题(用于后续合并)
"part": idx, # 子Chunk序号
"file_title": section.get("file_title"), # 所属文件标题
})
logger.debug(f"超长章节切分完成:{title} → 生成{len(sub_sections)}个子Chunk")
return sub_sections
def _merge_short_sections(sections: List[Dict[str, Any]], min_length: int = MIN_CONTENT_LENGTH) -> List[Dict[str, Any]]:
"""
【辅助函数】过短章节合并(减少碎片化,提升检索效果)
核心规则:仅合并「同父标题」且「当前块长度不足阈值」的相邻Chunk,避免跨章节合并
:param sections: 待合并的Chunk列表(通常是_split_long_section切分后的结果)
:param min_length: 最小长度阈值,低于此值的Chunk会被合并
:return: 合并后的Chunk列表,长度适中,保留元信息
"""
# 边界处理:空列表直接返回,避免后续索引报错
if not sections:
logger.debug("待合并Chunk列表为空,直接返回")
return []
merged_sections = [] # 最终合并结果
current_chunk = None # 迭代累加器:保存当前待合并的Chunk
for sec in sections:
# 初始化:第一个Chunk直接作为当前待合并块
if current_chunk is None:
current_chunk = sec
continue
# 合并条件:1.当前块长度不足阈值 2.与下一块同父标题(同属一个原章节)
is_current_short = len(current_chunk["content"]) < min_length
is_same_parent = current_chunk.get("parent_title") == sec.get("parent_title")
if is_current_short and is_same_parent:
# 合并前清理:去掉下一块开头重复的父标题,避免内容冗余
parent_title = sec.get("parent_title", "")
next_content = sec["content"]
if parent_title and next_content.startswith(parent_title):
next_content = next_content[len(parent_title):].lstrip()
# 合并内容:空行分隔,保证格式整洁
current_chunk["content"] += "\n\n" + next_content
# 更新子Chunk序号:保留最新序号,便于溯源
if "part" in sec:
current_chunk["part"] = sec["part"]
logger.debug(f"合并短Chunk:{current_chunk.get('parent_title')} → 累计长度{len(current_chunk['content'])}")
else:
# 不满足合并条件:将当前块加入结果,切换为新的待合并块
merged_sections.append(current_chunk)
current_chunk = sec
# 循环结束后,将最后一个待合并块加入结果
if current_chunk is not None:
merged_sections.append(current_chunk)
logger.debug(f"短Chunk合并完成:原{len(sections)}个 → 合并后{len(merged_sections)}个")
return merged_sections
def step_4_refine_chunks(sections: List[Dict[str, Any]], max_len: int) -> List[Dict[str, Any]]:
"""
【步骤4】Chunk精细化处理(核心:长切短合,适配大模型/检索)
执行流程:1.切分超长章节 2.合并过短章节 3.父标题兜底(适配Milvus向量库schema)
:param sections: 步骤3处理后的章节列表
:param max_len: 单个Chunk最大字符长度
:return: 长度适中、低碎片化的最终Chunk列表
"""
# 边界处理:最大长度无效(空/≤0),直接返回原章节,避免切分异常
if not max_len or max_len <= 0:
logger.warning(f"步骤4:Chunk最大长度配置无效({max_len}),跳过精细化处理")
return sections
# 阶段1:切分超长章节 → 所有章节长度控制在max_len内
refined_split = []
for sec in sections:
# 对每个章节执行超长切分,结果平铺加入列表(避免嵌套)
# extend 的作用就是: 把另一个列表(或可迭代对象)里的“元素”,一个个拆出来,直接追加到当前列表的尾部
refined_split.extend(_split_long_section(sec, max_len))
logger.info(f"步骤4-1:超长章节切分完成,共生成{len(refined_split)}个初始子Chunk")
# 阶段2:合并过短章节 → 减少碎片化,提升后续检索/大模型调用效果
final_sections = _merge_short_sections(refined_split)
logger.info(f"步骤4-2:过短章节合并完成,最终得到{len(final_sections)}个Chunk")
# 阶段3:父标题兜底 → 适配Milvus向量库schema(parent_title为必填字段)
# 兜底规则:无parent_title则用自身title,title也无则填空字符串
for sec in final_sections:
if not isinstance(sec, dict):
continue
# 补全缺失的part字段(默认0),适配Milvus schema
if "part" not in sec:
sec["part"] = 0
if not sec.get("parent_title"):
sec["parent_title"] = sec.get("title") or ""
logger.debug(f"步骤4-3:父标题兜底完成,所有Chunk均包含parent_title字段")
return final_sections
def step_5_print_stats(lines_count: int, sections: List[Dict[str, Any]]) -> None:
"""
【步骤5】输出文档切分统计信息(日志记录,便于监控/调试)
:param lines_count: MD原始文本总行数
:param sections: 最终处理后的Chunk列表
"""
chunk_num = len(sections)
# 输出核心统计信息:原始行数/最终Chunk数/首个Chunk预览
logger.info("-" * 50 + " 文档切分统计信息 " + "-" * 50)
logger.info(f"MD原始文本总行数:{lines_count}")
logger.info(f"最终生成Chunk数量:{chunk_num}")
if sections:
first_title = sections[0].get("title", "无标题")
logger.info(f"首个Chunk标题预览:{first_title}")
logger.info("-" * 110)
def step_6_backup(state: ImportGraphState, sections: List[Dict[str, Any]]) -> None:
"""
【步骤6】Chunk结果本地JSON备份(便于调试/问题排查,保留处理结果)
:param state: 项目状态字典,需包含local_dir(备份目录)
:param sections: 最终处理后的Chunk列表
"""
# 提取备份目录:无则直接返回,不执行备份
local_dir = state.get("local_dir")
if not local_dir:
logger.warning("步骤6:未配置备份目录(local_dir),跳过Chunk结果备份")
return
try:
# 创建备份目录:已存在则不报错(exist_ok=True)
os.makedirs(local_dir, exist_ok=True)
# 拼接备份文件路径:local_dir + chunks.json(固定文件名,便于查找)
backup_path = os.path.join(local_dir, "chunks.json")
# 写入JSON文件:保留中文/格式化缩进,便于人工查看
with open(backup_path, "w", encoding="utf-8") as f:
"""
sections是Python 嵌套数据结构(List[Dict[str, Any]],列表里装字典,字典里可能嵌套字符串 / 数字等),而普通文件写入
(如f.write(sections))仅支持写入字符串,直接写 Python 数据结构会报错。
json.dump的核心作用就是:将 Python 原生数据结构(列表、字典、字符串、数字等)直接序列化并写入 JSON 文件,无需手动转换为字符串,
同时保证数据格式规范、可跨语言 / 跨场景读取,完美适配「Chunk 列表备份」的需求。
"""
json.dump(
sections,
f,
#开启 True:"title": "\u4e00\u7ea7\u6807\u9898"(乱码,无法直接看);
#开启 False:"title": "一级标题"(正常中文,人工可直接阅读)。
ensure_ascii=False, # 保留中文,不转义为\u编码
indent=2 # 格式化缩进,便于阅读
)
logger.info(f"步骤6:Chunk结果备份成功,备份文件路径:{backup_path}")
except Exception as e:
# 备份失败仅记录日志,不终止主流程
logger.error(f"步骤6:Chunk结果备份失败,错误信息:{str(e)}", exc_info=False)
def node_document_split(state: ImportGraphState) -> ImportGraphState:
"""
【核心节点】文档切分主节点(node_document_split)
整体流程:加载输入→按MD标题初切→无标题兜底→长切短合→统计输出→结果备份
核心目的:将长MD文档切分为长度适中的Chunk,适配大模型上下文窗口和向量检索
后续扩展点:可在各步骤间新增Chunk元信息补充、自定义切分规则、向量入库前置处理等
:param state: 项目状态字典(ImportGraphState),必须包含md_content/task_id;可选local_dir/max_content_length/file_title
:return: 更新后的状态字典,新增chunks键(存储最终处理后的Chunk列表,每个Chunk为含title/content/parent_title的字典)
"""
# 初始化当前节点信息,用于任务监控和日志溯源
node_name = sys._getframe().f_code.co_name
logger.info(f">>> 开始执行核心节点:【文档切分】{node_name}")
# 将当前节点加入运行中任务,更新全局任务状态
add_running_task(state["task_id"], node_name)
try:
# ===================================== 步骤1:加载并标准化输入数据 =====================================
# 作用:从状态字典提取MD内容/文件标题/Chunk最大长度,统一换行符消除系统差异,做空值兜底
# 输出:标准化后的md_content、文件标题、单个Chunk最大长度;无有效MD内容则直接终止节点执行
content, file_title, max_len = step_1_get_inputs(state)
if content is None:
logger.info(f">>> 节点执行终止:{node_name}(无有效MD内容)")
return state
# ===================================== 步骤2:按MD标题进行初次切分 =====================================
# 作用:基于Markdown标题(#/##/###)切分文档为独立章节,自动跳过代码块内的伪标题,保证章节语义完整
# 输出:初切后的章节列表、识别到的有效标题数量、MD原始文本总行数(为后续统计/日志使用)
sections, title_count, lines_count = step_2_split_by_titles(content, file_title)
# ===================================== 步骤3:无标题场景兜底处理 =====================================
# 作用:解决MD文档无任何标题的边界情况,避免后续切分逻辑异常
# 输出:有标题则返回步骤2的章节列表;无标题则将全文封装为单个「无标题」章节,保证数据格式统一
sections = step_3_handle_no_title(content, sections, title_count, file_title)
# ===================================== 步骤4:Chunk精细化处理(长切短合) =====================================
# 作用:核心切分逻辑,先将超长章节按「段落→句子」二次切分,再合并同父标题的过短章节,减少碎片化
# 额外处理:对所有Chunk做parent_title兜底,适配Milvus向量库必填字段要求
# 输出:长度适中、语义完整、低碎片化的最终Chunk列表(可直接用于向量入库/大模型调用)
sections = step_4_refine_chunks(sections, max_len)
# ===================================== 步骤5:输出文档切分统计信息 =====================================
# 作用:打印核心统计数据,便于监控切分效果、调试问题(原始行数/最终Chunk数/首个Chunk预览)
# 输出:无返回值,仅通过logger输出标准化统计日志
step_5_print_stats(lines_count, sections)
# ===================================== 步骤6:Chunk结果本地JSON备份 + 状态更新 =====================================
# 作用1:将最终Chunk列表备份到local_dir目录的chunks.json,便于后续问题排查、数据复用
# 作用2:将Chunk列表写入状态字典,传递给下一个节点(如向量入库、大模型摘要等)
# 输出:状态字典新增chunks键;无local_dir则跳过备份,不影响主流程
state["chunks"] = sections
step_6_backup(state, sections)
# 节点执行完成日志
logger.info(f">>> 核心节点执行完成:【文档切分】{node_name},已生成{len(sections)}个有效Chunk,结果已写入状态字典")
except Exception as e:
# 全局异常捕获:保证节点执行失败不崩溃整个流程,记录详细错误日志便于排查
logger.error(f">>> 核心节点执行失败:【文档切分】{node_name},错误信息:{str(e)}", exc_info=True)
# 返回更新后的状态字典,传递Chunk结果到下游节点
return state
if __name__ == '__main__':
"""
单元测试:联合node_md_img(图片处理节点)进行集成测试
测试条件:1.已配置.env(MinIO/大模型环境) 2.存在测试MD文件 3.能导入node_md_img
测试流程:先运行图片处理→再运行文档切分,验证端到端流程
"""
"""本地测试入口:单独运行该文件时,执行MD图片处理全流程测试"""
from app.utils.path_util import PROJECT_ROOT
from app.import_process.agent.nodes.node_md_img import node_md_img
logger.info(f"本地测试 - 项目根目录:{PROJECT_ROOT}")
# 测试MD文件路径(需手动将测试文件放入对应目录)
test_md_name = os.path.join(r"output\hak180产品安全手册", "hak180产品安全手册.md")
test_md_path = os.path.join(PROJECT_ROOT, test_md_name)
# 校验测试文件是否存在
if not os.path.exists(test_md_path):
logger.error(f"本地测试 - 测试文件不存在:{test_md_path}")
logger.info("请检查文件路径,或手动将测试MD文件放入项目根目录的output目录下")
else:
# 构造测试状态对象,模拟流程入参
test_state = {
"md_path": test_md_path,
"task_id": "test_task_123456",
"md_content": "",
"file_title": "hak180产品安全手册",
"local_dir":os.path.join(PROJECT_ROOT, "output"),
}
logger.info("开始本地测试 - MD图片处理全流程")
# 执行核心处理流程
result_state = node_md_img(test_state)
logger.info(f"本地测试完成 - 处理结果状态:{result_state}")
logger.info("\n=== 开始执行文档切分节点集成测试 ===")
logger.info(">> 开始运行当前节点:node_document_split(文档切分)")
final_state = node_document_split(result_state)
final_chunks = final_state.get("chunks", [])
logger.info(f"✅ 测试成功:最终生成{len(final_chunks)}个有效Chunk{final_chunks}")
传统 RAG 通常采用按字符数硬切(如每 500 字切一刀)的方式,这会导致长句子、核心公式或紧密相连的表格在物理上被无情劈成两半,进库后语义直接破碎,导致大模型回答时缺乏完整的证据链。。本项目实现了双阶复合切片算法:
它优先沿着 Markdown 的标题树进行宏观切分,接着对极短的文本块进行智能同域合并,对超长文本块进行递归平滑二次切分。
状态流转与核心数据结构:
【 状态输入 (ImportGraphState) 】
(携带完整的 md_content 和 file_title)
│
▼
【 step_1_get_inputs 】(防御性非空拦截与参数标准化)
│
▼
【 step_2_core_split_logic 】(双阶复合切片主骨架)
│
├───► 1. _split_by_markdown_headers (按 # 标题宏观切开)
│
├───► 2. _merge_short_chunks (按 MIN_CONTENT_LENGTH=500 向上就近合并)
│
└───► 3. 二次切分降维 (长块触发 RecursiveCharacterTextSplitter)
│
▼
【 step_3_build_state_chunks 】(元数据规范化封装,注入 parent_title 等)
│
▼
【 状态输出 (ImportGraphState) 】
(全量富化后的 state["chunks"] 列表)
-
粗粒度切分:优先通过
_split_by_markdown_headers沿着 Markdown 的#标题层级切开,保证同一主题的自然段高度凝聚。 -
短块自动同域合并:为了防止表格小结或短句由于信息稀释在向量空间中沉底,代码设计了
MIN_CONTENT_LENGTH = 500的硬阈值。如果片段过短,自动向上寻找属于同一个父级标题的临近短块进行合并。( 在1024 维的高维语义向量空间中,文本字数越少,其特征信息稀释越严重。一个只有 20 个字的短句进库后,由于特征过弱会直接沉底,永远无法被检索出来。通过 500 字的智能向上合并机制,既保证了切片的“分量”,又避免了知识碎片化。) -
长块精细化二次切分:若块超过
DEFAULT_MAX_CONTENT_LENGTH = 2000,调用 LangChain 的RecursiveCharacterTextSplitter沿着\n\n,\n进行平滑二次切分,并确保各子块显式继承parent_title、file_title和part等元数据,使其具备完美的独立可解释性。(确保被切开的两个子块之间有 200 字的语义交叉缓冲区,完美防止衔接处的语义在切缝中丢失)
-
优势:
-
消灭长尾噪声与特征稀释:利用
MIN_CONTENT_LENGTH = 500强行聚拢合并孤立短块,保障了未来检索时各切片的语义丰满度。 -
上下文平滑重叠(Overlap):在长块二次切分时引入 200 字的语义滑窗,彻底解决了边界字句被腰斩、信息不连续的问题。
-
完美对齐下游业务:由于它在元数据中规范化注入了标题和文件名,下游的商品名提取节点(
node_item_name_recognition)只需无脑抽取第 0 个 Chunk 就能获得全书的总纲,极大地简化了系统的开发开销。
-
2. 定制化业务主体提取 (node_item_name_recognition.py)
# 导入基础库:系统、路径、类型注解(类型注解提升代码可读性和可维护性)
import os
import sys
from typing import List, Dict, Any, Tuple
# 导入Milvus客户端(向量数据库核心操作)、数据类型枚举(定义集合Schema)
from pymilvus import MilvusClient, DataType
# 导入LangChain消息类(标准化大模型对话消息格式)
from langchain_core.messages import SystemMessage, HumanMessage
# 导入自定义模块:
# 1. 流程状态载体:ImportGraphState为LangGraph流程的统一状态管理对象
from app.import_process.agent.state import ImportGraphState
# 2. Milvus工具:获取单例Milvus客户端,实现连接复用
from app.clients.milvus_utils import get_milvus_client
# 3. 大模型工具:获取大模型客户端,统一模型调用入口
from app.lm.lm_utils import get_llm_client
# 4. 向量工具:BGE-M3模型实例、向量生成方法(稠密+稀疏向量)
from app.lm.embedding_utils import get_bge_m3_ef, generate_embeddings
# 5. 稀疏向量工具:归一化处理,保证向量长度为1,提升检索准确性
from app.utils.normalize_sparse_vector import normalize_sparse_vector
# 6. 任务工具:更新任务运行状态,用于任务监控和管理
from app.utils.task_utils import add_running_task
# 7. 日志工具:项目统一日志入口,分级输出(info/warning/error)
from app.core.logger import logger
# 8. 提示词工具:加载本地prompt模板,实现提示词与代码解耦
from app.core.load_prompt import load_prompt
from app.utils.escape_milvus_string_utils import escape_milvus_string
# --- 配置参数 (Configuration) ---
# 大模型识别商品名称的上下文切片数:取前5个切片,避免上下文过长导致大模型输入超限
DEFAULT_ITEM_NAME_CHUNK_K = 5
# 单个切片内容截断长度:防止单切片内容过长,占满大模型上下文
SINGLE_CHUNK_CONTENT_MAX_LEN = 800
# 大模型上下文总字符数上限:适配主流大模型输入限制,默认2500
CONTEXT_TOTAL_MAX_CHARS = 2500
def step_1_get_inputs(state: ImportGraphState) -> Tuple[str, List[Dict]]:
"""
步骤 1: 接收并校验流程输入(商品名称识别的前置数据处理)
核心作用:
1. 从流程状态中提取文件标题、文本切片核心数据
2. 做多层空值兜底,避免后续流程因空值报错
3. 基础数据类型校验,保证下游流程输入有效性
依赖的状态数据(上游节点产出):
- state["file_title"]: 上游提取的文件标题(优先使用)
- state["file_name"]: 原始文件名(file_title为空时兜底)
- state["chunks"]: 文本切片列表(每个切片为字典,含title/content等字段)
返回值:
Tuple[str, List[Dict]]: (处理后的文件标题, 校验后的文本切片列表)
"""
# 多层兜底获取文件标题:优先file_title → 其次file_name → 空字符串
file_title = state.get("file_title", "") or state.get("file_name", "")
# 获取文本切片列表:空值时返回空列表,避免后续遍历报错
chunks = state.get("chunks") or []
# 二次兜底:file_title仍为空时,尝试从第一个有效切片中提取
if not file_title:
if chunks and isinstance(chunks[0], dict):
file_title = chunks[0].get("file_title", "")
logger.warning("state中无有效file_title,已从第一个切片中提取兜底标题")
# 空值日志提示:文件标题为空时不中断流程,仅记录警告
if not file_title:
logger.warning("state中缺少file_title和file_name,后续大模型识别可能精度下降")
# 数据类型校验:确保chunks为有效非空列表,否则返回空列表
if not isinstance(chunks, list) or not chunks:
logger.warning("state中chunks为空或非列表类型,无法进行商品名称识别")
return file_title, []
logger.info(f"步骤1:输入校验完成,获取到{len(chunks)}个有效文本切片")
return file_title, chunks
def step_2_build_context(chunks: List[Dict], k: int = DEFAULT_ITEM_NAME_CHUNK_K, max_chars: int = CONTEXT_TOTAL_MAX_CHARS) -> str:
"""
步骤 2: 构造大模型商品名称识别的标准化上下文
核心作用:
1. 限制切片数量:仅取前k个切片,避免上下文过长
2. 限制字符长度:单切片+总上下文双重字符限制,适配大模型输入上限
3. 格式化内容:带序号的结构化格式,提升大模型识别精度
4. 过滤无效切片:跳过空内容/非字典类型切片,保证上下文有效性
参数说明:
chunks: 文本切片列表(每个元素为字典,需包含"title"和"content"键)
k: 最大取片数,默认5个(可通过配置调整)
max_chars: 上下文总字符数上限,默认2500(适配大模型输入限制)
返回值:
str: 格式化后的上下文字符串(直接传给大模型,空切片时返回空字符串)
"""
# 空切片直接返回空字符串,无需后续处理
if not chunks:
return ""
# 存储格式化后的切片片段,保证上下文结构化
parts: List[str] = []
# 统计已拼接字符数,用于控制总长度不超限
total_chars = 0
# 遍历前k个切片,避免上下文过长
for idx, chunk in enumerate(chunks[:k]):
# 跳过非字典类型切片,防止键取值报错
if not isinstance(chunk, dict):
logger.debug(f"第{idx+1}个切片非字典类型,已过滤")
continue
# 提取切片标题和内容,去首尾空格,过滤无效字符
chunk_title = chunk.get("title", "").strip()
chunk_content = chunk.get("content", "").strip()
# 标题和内容均为空,跳过该无效切片
if not (chunk_title or chunk_content):
logger.debug(f"第{idx+1}个切片为空白内容,已过滤")
continue
# 单切片内容截断:防止单个切片内容过长占满上下文
if len(chunk_content) > SINGLE_CHUNK_CONTENT_MAX_LEN:
chunk_content = chunk_content[:SINGLE_CHUNK_CONTENT_MAX_LEN]
logger.debug(f"第{idx+1}个切片内容过长,已截断至{SINGLE_CHUNK_CONTENT_MAX_LEN}字符")
# 结构化格式化切片:带序号+标题+内容,提升大模型识别效率
piece = f"【切片{idx + 1}】\n标题:{chunk_title} \n内容:{chunk_content}"
parts.append(piece)
# 累计字符数,包含分隔符
total_chars += len(piece)
# 总字符数超限时立即停止拼接,避免大模型输入超限
if total_chars > max_chars:
logger.info(f"上下文总字符数即将超限({max_chars}),已停止拼接后续切片")
break
# 用空行分隔切片片段,拼接为最终上下文,最后一次去重空格
context = "\n\n".join(parts).strip()
# 最终二次截断,确保绝对不超限
final_context = context[:max_chars]
logger.info(f"步骤2:上下文构建完成,最终长度{len(final_context)}字符")
return final_context
def step_3_call_llm(file_title: str, context: str) -> str:
"""
步骤 3: 调用大模型实现商品名称/型号精准识别
核心逻辑:
1. 上下文为空 → 直接返回file_title(兜底,无需调用大模型)
2. 上下文非空 → 加载标准化prompt模板,构建大模型对话消息
3. 调用大模型后对返回结果做清洗,过滤无效字符
4. 大模型返回空/调用异常 → 均返回file_title兜底,保证流程不中断
核心特性:
- 提示词解耦:通过load_prompt加载本地模板,无需硬编码
- 格式兼容:兼容不同LLM客户端返回格式,防止属性报错
- 异常兜底:全异常捕获,大模型服务不可用时不影响主流程
参数:
file_title: 处理后的文件标题(异常/空值时的兜底值)
context: 步骤2构建的结构化切片上下文(大模型识别的核心依据)
返回值:
str: 清洗后的商品名称(异常/空值时返回原始file_title)
"""
logger.info("开始执行步骤3:调用大模型识别商品名称")
# 上下文为空时,直接返回文件标题,跳过大模型调用
if not context:
logger.warning("上下文为空,跳过大模型调用,直接使用文件标题作为商品名称")
return file_title
try:
# 加载商品名称识别prompt模板,动态传入文件标题和上下文
human_prompt = load_prompt("item_name_recognition", file_title=file_title, context=context)
# 加载系统提示词,定义大模型角色(商品识别专家,仅返回纯结果)
system_prompt = load_prompt("product_recognition_system")
logger.debug(f"大模型调用提示词构建完成,系统提示词长度{len(system_prompt)},人类提示词长度{len(human_prompt)}")
# 获取大模型客户端:json_mode=False,要求返回纯文本而非JSON格式
llm = get_llm_client(json_mode=False)
if not llm:
logger.error("大模型客户端获取失败,使用文件标题兜底")
return file_title
# 标准化构建大模型对话消息:SystemMessage定义角色 + HumanMessage传递业务请求
messages = [
SystemMessage(content=system_prompt),
HumanMessage(content=human_prompt)
]
# 调用大模型并获取返回结果
resp = llm.invoke(messages)
# 兼容不同LLM客户端返回格式:优先取content字段,无则返回空字符串
item_name = getattr(resp, "content", "").strip()
# 清洗返回结果:过滤空格、换行、回车、制表符等无效字符
item_name = item_name.replace(" ", "").replace("\n", "").replace("\r", "").replace("\t", "")
# 清洗后结果为空,使用文件标题兜底
if not item_name:
logger.warning("大模型返回空内容,使用文件标题作为商品名称兜底")
return file_title
logger.info(f"步骤3:大模型识别商品名称成功,结果为:{item_name}")
return item_name
# 捕获所有异常:大模型调用超时、网络错误、格式错误等,均不中断主流程
except Exception as e:
logger.error(f"步骤3:大模型调用失败,原因:{str(e)}", exc_info=True)
# 异常时返回文件标题兜底,保证流程继续执行
return file_title
def step_4_update_chunks(state: ImportGraphState, chunks: List[Dict], item_name: str):
"""
步骤 4: 回填商品名称到流程状态和所有文本切片
核心作用:
1. 全局状态更新:将item_name存入state,供下游所有节点直接使用
2. 切片数据补全:为每个切片添加item_name字段,保证数据一致性
3. 状态同步:更新state中的chunks,确保切片修改全局生效
设计思路:
所有切片关联同一商品名称,保证后续向量入库、检索时的维度一致性
参数:
state: 流程状态对象(ImportGraphState),全局数据载体
chunks: 校验后的文本切片列表(步骤1输出)
item_name: 步骤3识别并清洗后的商品名称
"""
# 将商品名称存入全局状态,供下游节点调用
state["item_name"] = item_name
# 遍历所有切片,为每个切片添加商品名字段,保证数据全链路一致
for chunk in chunks:
chunk["item_name"] = item_name
# 同步更新state中的切片列表,确保修改全局生效
state["chunks"] = chunks
logger.info(f"步骤4:商品名称回填完成,共为{len(chunks)}个切片添加item_name字段,值为:{item_name}")
def step_5_generate_vectors(item_name: str) -> Tuple[Any, Any]:
"""
步骤 5: 为商品名称生成BGE-M3稠密+稀疏双向量(Milvus向量检索核心)
核心说明:
- 稠密向量(dense_vector):BGE-M3固定1024维,记录文本深层语义信息
- 稀疏向量(sparse_vector):变长键值对,记录文本关键词/特征位置信息
依赖工具:
generate_embeddings:封装BGE-M3模型,批量生成双向量,兼容单条/批量输入
参数:
item_name: 步骤3识别的商品名称(非空,空值时直接返回空向量)
返回值:
Tuple[Any, Any]: (稠密向量列表, 稀疏向量字典),空值/异常时返回(None, None)
"""
logger.info(f"开始执行步骤5:为商品名称[{item_name}]生成BGE-M3双向量")
# 商品名称为空,直接返回空向量,跳过模型调用
if not item_name:
logger.warning("商品名称为空,跳过向量生成,返回空向量")
return None, None
try:
# 调用向量生成工具:传入列表支持批量生成,单条数据仍用列表保证格式统一
vector_result = generate_embeddings([item_name])
# 向量生成结果非空,才进行后续解析
if vector_result and "dense" in vector_result and "sparse" in vector_result:
# 稠密向量解析:取批量结果第一个,为Python列表(Milvus存储要求)
dense_vector = vector_result["dense"][0]
# 稀疏向量解析:取批量结果第一个,CSR矩阵解析为字典格式
sparse_vector = vector_result["sparse"][0]
logger.info("步骤5:BGE-M3稠密+稀疏向量生成成功")
else:
logger.warning("步骤5:向量生成工具返回空结果,无法提取双向量")
dense_vector, sparse_vector = None, None
# 捕获所有异常:模型加载失败、向量生成超时、格式错误等
except Exception as e:
logger.error(f"步骤5:向量生成失败,原因:{str(e)}", exc_info=True)
dense_vector, sparse_vector = None, None
return dense_vector, sparse_vector
def step_6_save_to_milvus(state: ImportGraphState, file_title: str, item_name: str, dense_vector, sparse_vector):
"""
步骤 6: 将商品名称、文件标题、双向量持久化到Milvus向量数据库
核心逻辑:
1. 配置校验:检查Milvus连接地址和集合名配置,缺失则跳过
2. 客户端获取:获取单例Milvus客户端,连接失败则跳过
3. 集合初始化:无集合则创建(定义Schema+索引),有集合则直接使用(保留原有配置)
4. 幂等性处理:删除同名商品数据,避免重复存储
5. 数据插入:构造符合Schema的数据,非空向量才添加
6. 集合加载:插入后强制加载集合,确保数据立即可查/Attu可见
参数:
state: 流程状态对象,用于最终状态同步
file_title: 处理后的文件标题
item_name: 识别后的商品名称(主键去重依据)
dense_vector: 步骤5生成的稠密向量(1024维列表)
sparse_vector: 步骤5生成的稀疏向量(字典格式)
"""
# 从环境变量读取Milvus核心配置,与MilvusConfig配置类保持一致
milvus_uri = os.environ.get("MILVUS_URL")
collection_name = os.environ.get("ITEM_NAME_COLLECTION")
# 配置缺失校验:任一配置为空则跳过Milvus存储,记录警告
if not all([milvus_uri, collection_name]):
logger.warning("Milvus配置缺失(MILVUS_URL/ITEM_NAME_COLLECTION),跳过数据保存")
return
logger.info(f"开始执行步骤6:将商品名称[{item_name}]保存到Milvus集合[{collection_name}]")
try:
# 获取Milvus单例客户端,连接失败则直接返回
client = get_milvus_client()
if not client:
logger.error("无法获取Milvus客户端(连接失败),跳过数据保存")
return
# 集合初始化:不存在则创建(定义Schema+索引),存在则直接使用
if not client.has_collection(collection_name=collection_name):
logger.info(f"Milvus集合[{collection_name}]不存在,开始创建Schema和索引")
# 创建集合Schema:自增主键+动态字段,适配灵活的数据存储
schema = client.create_schema(auto_id=True, enable_dynamic_field=True)
# 添加自增主键字段:INT64类型,唯一标识每条数据
schema.add_field(
field_name="pk",
datatype=DataType.INT64,
is_primary=True,
auto_id=True
)
# 添加文件标题字段:VARCHAR类型,最大长度65535,适配长标题
schema.add_field(
field_name="file_title",
datatype=DataType.VARCHAR,
max_length=65535
)
# 添加商品名字段:VARCHAR类型,最大长度65535,去重依据
schema.add_field(
field_name="item_name",
datatype=DataType.VARCHAR,
max_length=65535
)
# 添加稠密向量字段:FLOAT_VECTOR,1024维(BGE-M3固定维度)
schema.add_field(
field_name="dense_vector",
datatype=DataType.FLOAT_VECTOR,
dim=1024
)
# 添加稀疏向量字段:SPARSE_FLOAT_VECTOR,变长
schema.add_field(
field_name="sparse_vector",
datatype=DataType.SPARSE_FLOAT_VECTOR
)
# 构建索引参数:为向量字段创建索引,提升检索性能
index_params = client.prepare_index_params()
# 优化版稠密向量索引:HNSW + COSINE (恢复最佳性能配置)
index_params.add_index(
field_name="dense_vector",
index_name="dense_vector_index",
# HNSW (Hierarchical Navigable Small World) 是目前性能最好、最常用的基于图的索引,检索速度极快,精度极高。
index_type="HNSW",
# 使用 COSINE 作为稠密向量相似度计算方式
metric_type="COSINE",
# M: 图中每个节点的最大连接数(常用16-64)
# efConstruction: 构建索引时的搜索范围(越大建索引越慢,但精度越高,常用100-200)
params={"M": 16, "efConstruction": 200}
)
# 稀疏向量索引:专用SPARSE_INVERTED_INDEX+IP,关闭量化保证精度
index_params.add_index(
field_name="sparse_vector",
index_name="sparse_vector_index",
# 稀疏倒排索引 专门为稀疏向量(比如文本的 TF-IDF 向量、关键词权重向量,特点是大部分元素为 0,只有少数维度有值)设计的倒排索引,是稀疏向量检索的标配索引类型。
index_type="SPARSE_INVERTED_INDEX",
# IP(内积,Inner Product)如果向量是 “文本语义向量 + 关键词权重”,长度代表文本与主题的关联强度,此时用 IP 能同时体现 “语义匹配度” 和 “关联强度”。
metric_type="IP",
#DAAT_MAXSCORE 是稀疏检索的高效算法,quantization="none" 保证稀疏向量权重无损失;normalize=是否归一化。
params = {"inverted_index_algo": "DAAT_MAXSCORE", "normalize": True, "quantization": "none"}
)
# 创建集合:Schema + 索引参数
client.create_collection(collection_name=collection_name, schema=schema, index_params=index_params)
logger.info(f"Milvus集合[{collection_name}]创建成功,包含Schema和向量索引")
# 幂等性处理:删除同名商品数据,避免重复存储(核心:先加载集合才能删除)
clean_item_name = (item_name or "").strip()
if clean_item_name:
client.load_collection(collection_name=collection_name)
# 商品名称转义,防止特殊字符导致过滤表达式解析失败
safe_item_name = escape_milvus_string(clean_item_name)
filter_expr = f'item_name=="{safe_item_name}"'
# 执行删除操作
client.delete(collection_name=collection_name, filter=filter_expr)
logger.info(f"Milvus幂等性处理完成,已删除集合中[{clean_item_name}]的历史数据")
# 构造插入Milvus的数据:基础字段+非空向量字段
data = {
"file_title": file_title,
"item_name": item_name
}
# 稠密向量非空才添加,避免空值入库报错
if dense_vector is not None:
data["dense_vector"] = dense_vector
# 稀疏向量非空则归一化后添加,保证检索准确性
if sparse_vector is not None:
data["sparse_vector"] = normalize_sparse_vector(sparse_vector)
# 插入数据:列表格式支持批量插入,单条数据保持格式统一
client.insert(collection_name=collection_name, data=[data])
# 插入后强制加载集合,确保数据立即可查、Attu可视化界面可见
client.load_collection(collection_name=collection_name)
# 最终同步商品名称到全局状态
state["item_name"] = item_name
logger.info(f"步骤6:商品名称[{item_name}]成功存入Milvus集合[{collection_name}],数据:{list(data.keys())}")
# 捕获所有Milvus操作异常:连接中断、入库失败、索引错误等,不中断主流程
except Exception as e:
logger.error(f"步骤6:数据存入Milvus失败,原因:{str(e)}", exc_info=True)
def node_item_name_recognition(state: ImportGraphState) -> ImportGraphState:
"""
【核心节点】商品主体名称识别(node_item_name_recognition)
整体流程:提取输入→构建上下文→大模型识别→回填数据→生成向量→存入Milvus
核心目的:利用大模型从文档切片中精准识别商品/主体名称,并生成双路向量(稠密+稀疏)存入数据库
后续扩展点:支持多主体识别、增加商品属性提取、对接其他向量库等
:param state: 项目状态字典(ImportGraphState),必须包含chunks/file_title/task_id
:return: 更新后的状态字典,新增item_name键,且chunks列表中每个元素新增item_name字段
"""
# 初始化当前节点信息,用于任务监控和日志溯源
node_name = sys._getframe().f_code.co_name
logger.info(f">>> 开始执行核心节点:【商品名称识别】{node_name}")
# 将当前节点加入运行中任务,更新全局任务状态
add_running_task(state.get("task_id", ""), node_name)
try:
# ===================================== 步骤1:提取并校验输入数据 =====================================
# 作用:从状态字典提取文件标题和切片列表,校验数据完整性
# 输出:文件标题、切片列表;若无切片则抛出异常或终止
file_title, chunks = step_1_get_inputs(state)
if not chunks:
logger.warning(f">>> 节点执行警告:{node_name}(无有效切片数据),跳过识别")
return state
# ===================================== 步骤2:构建大模型识别上下文 =====================================
# 作用:截取前N个切片的内容,拼接成大模型可阅读的上下文,用于辅助识别
# 输出:拼接后的上下文字符串
context = step_2_build_context(chunks)
# ===================================== 步骤3:调用大模型识别商品名称 =====================================
# 作用:构造Prompt,调用LLM从上下文和标题中提取最核心的商品名称
# 输出:识别出的商品名称字符串(如 "iPhone 15 Pro")
item_name = step_3_call_llm(file_title, context)
# ===================================== 步骤4:回填商品名称到状态和切片 =====================================
# 作用:将识别结果写入状态字典,并同步更新到每一个Chunk对象的元数据中
# 输出:状态字典新增item_name,chunks列表被就地修改
step_4_update_chunks(state, chunks, item_name)
# ===================================== 步骤5:生成双路向量(稠密+稀疏) =====================================
# 作用:调用BGE-M3模型,为商品名称生成稠密语义向量和稀疏关键词向量
# 输出:dense_vector(List[float])、sparse_vector(Dict[int, float])
dense_vector, sparse_vector = step_5_generate_vectors(item_name)
# ===================================== 步骤6:存入Milvus向量数据库 =====================================
# 作用:将商品名称及其双路向量存入Milvus的 item_names 集合,用于后续检索
# 输出:无返回值,数据已持久化
step_6_save_to_milvus(state, file_title, item_name, dense_vector, sparse_vector)
# 节点执行完成日志
logger.info(f">>> 核心节点执行完成:【商品名称识别】{node_name},识别结果:{item_name},已存入Milvus")
except Exception as e:
# 全局异常捕获:保证节点执行失败不崩溃整个流程,记录详细错误日志便于排查
logger.error(f">>> 核心节点执行失败:【商品名称识别】{node_name},错误信息:{str(e)}", exc_info=True)
# 可选:失败时设置默认值或标记状态
state["item_name"] = "未知商品"
# 返回更新后的状态(供下游节点使用)
return state
# ===================== 本地测试方法(直接运行调试,无需启动LangGraph) =====================
def test_node_item_name_recognition():
"""
商品名称识别节点本地测试方法
功能:模拟LangGraph流程输入,独立测试node_item_name_recognition节点全链路逻辑
适用场景:本地开发、调试、单节点功能验证,无需启动整个LangGraph流程
测试前准备:
1. 确保项目环境变量配置完成(MILVUS_URL/ITEM_NAME_COLLECTION等)
2. 确保大模型、Milvus、BGE-M3服务均可正常访问
3. 确保prompt模板(item_name_recognition/product_recognition_system)已存在
使用方法:
直接运行该函数:if __name__ == "__main__": test_node_item_name_recognition()
"""
logger.info("=== 开始执行商品名称识别节点本地测试 ===")
try:
# 1. 构造模拟的ImportGraphState状态(模拟上游节点产出数据)
mock_state = ImportGraphState({
"task_id": "test_task_123456", # 测试任务ID
"file_title": "华为Mate60 Pro手机使用说明书", # 模拟文件标题
"file_name": "华为Mate60Pro说明书.pdf", # 模拟原始文件名(兜底用)
# 模拟文本切片列表(上游切片节点产出,含title/content字段)
"chunks": [
{
"title": "产品简介",
"content": "华为Mate60 Pro是华为公司2023年发布的旗舰智能手机,搭载麒麟9000S芯片,支持卫星通话功能,屏幕尺寸6.82英寸,分辨率2700×1224。"
},
{
"title": "拍照功能",
"content": "华为Mate60 Pro后置5000万像素超光变摄像头+1200万像素超广角摄像头+4800万像素长焦摄像头,支持5倍光学变焦,100倍数字变焦。"
},
{
"title": "电池参数",
"content": "电池容量5000mAh,支持88W有线超级快充,50W无线超级快充,反向无线充电功能。"
}
]
})
# 2. 调用商品名称识别核心节点
result_state = node_item_name_recognition(mock_state)
# 3. 打印测试结果(调试用)
logger.info("=== 商品名称识别节点本地测试完成 ===")
logger.info(f"测试任务ID:{result_state.get('task_id')}")
logger.info(f"最终识别商品名称:{result_state.get('item_name')}")
logger.info(f"切片数量:{len(result_state.get('chunks', []))}")
logger.info(f"第一个切片商品名称:{result_state.get('chunks', [{}])[0].get('item_name')}")
# 4. 验证Milvus存储(可选)
milvus_client = get_milvus_client()
collection_name = os.environ.get("ITEM_NAME_COLLECTION")
if milvus_client and collection_name:
milvus_client.load_collection(collection_name)
# 检索测试结果
item_name = result_state.get('item_name')
safe_name = escape_milvus_string(item_name)
res = milvus_client.query(
collection_name=collection_name,
filter=f'item_name=="{safe_name}"',
output_fields=["file_title", "item_name"]
)
logger.info(f"Milvus中检索到的数据:{res}")
except Exception as e:
logger.error(f"商品名称识别节点本地测试失败,原因:{str(e)}", exc_info=True)
# 测试方法运行入口:直接执行该文件即可触发测试
if __name__ == "__main__":
# 执行本地测试
test_node_item_name_recognition()
利用 LLM(大模型)从文档开篇内容中,精准提炼出整本手册所指向的唯一商品/项目/设备主体名称(item_name),然后采用“血统注入”机制,批量将该标量标签回填到上游切分好的每一个 Chunk 字典中,最后建立商品实体的免运维 Milvus 字典表。
【 状态输入 (ImportGraphState) 】
(携带上游切分好的 state["chunks"] 列表)
│
▼
【 步骤 1:前置物理防御校验与提取 】(抽取前 N 个切片作为上下文,主题/书名/标题等总结性)
│
▼
【 步骤 2:LLM 强约束实体名称抽取 】(大模型分析出唯一主体:item_name)
│
▼
【 步骤 3:全量切片标签“血统注入” 】(遍历 chunks 列表,强行追加 item_name 标量)
│
▼
【 步骤 4:Milvus 实体字典表幂等持久化 】(自动创建商品表并存入其稀疏/稠密向量)
│
▼
【 状态输出 (ImportGraphState) 】
(富化了 item_name 的 state["chunks"] 列表及全局标记)“它”指代不明)。
在智能客服或企业 RAG 系统中,用户经常会提出指代不明的问题(如“它的续航是多少?”、“如何配置这个设备?”)。如果直接去向量数据库检索,因为切片(Chunk)中可能也全是代词,会导致检索严重漂移或漏召回。
-
工程破解:该节点专门抓取文档的首个或核心切片,通过 LLM 的 SystemPrompt 强制提取出手册指代的具体商品/项目/设备型号主体名称(
item_name,如“HAK180产品安全手册”)。随后这个item_name被批量追加、强行注入到每一个下游子 Chunk 的字典中,为混合检索中的标量条件过滤(Scalar Filtering)和字面硬匹配铸牢了数据底座。
🛢️ 四、 编码入库:稠密/稀疏双通道构建与幂等性防线
1. 双通道混合向量化 (node_bge_embedding.py)
import sys
import os
from typing import Any, List, Dict
from app.import_process.agent.state import ImportGraphState
from app.lm.embedding_utils import get_bge_m3_ef, generate_embeddings
from app.utils.task_utils import add_running_task,add_done_task
from app.core.logger import logger
# ==========================================
# BGE-M3向量化核心节点
# 核心能力:将文本切片转换为稠密/稀疏双向量,为Milvus向量检索提供数据基础
# 依赖模型:BAAI/bge-m3(多语言、多粒度,同时支持语义/关键词检索)
# 向量说明:
# 1. 稠密向量:1024维固定长度,记录文本深层语义信息,用于语义相似度匹配
# 2. 稀疏向量:变长键值对,记录文本关键词/特征位置,用于关键词精准匹配
# 核心设计:
# - 单例模型:避免重复加载模型,节省显存/时间
# - 批量处理:分批生成向量,防止大批次导致的显存溢出
# - 文本增强:拼接商品名+切片内容,强化核心特征,提升检索准确性
# ==========================================
def node_bge_embedding(state: ImportGraphState) -> ImportGraphState:
"""
LangGraph核心节点:BGE-M3文本向量化处理
主流程(串行执行,全流程异常隔离):
1. 输入校验:验证chunks有效性,核心数据缺失则终止当前节点
2. 模型初始化:获取BGE-M3单例模型实例,避免重复加载
3. 批量向量化:分批拼接文本、生成双向量,为切片绑定向量字段
4. 状态更新:将带向量的chunks更新回全局状态,供下游Milvus入库节点使用
参数:
state: ImportGraphState - 流程全局状态对象,包含上游传入的chunks、task_id等数据
返回:
ImportGraphState - 更新后的状态对象,chunks字段新增dense_vector/sparse_vector
异常处理:
节点内所有异常均捕获,不终止整体LangGraph流程,仅记录错误日志
"""
# 获取当前节点名称,用于日志和任务状态记录
current_node = sys._getframe().f_code.co_name
logger.info(f">>> 开始执行LangGraph节点:{current_node}")
# 标记任务运行状态,用于任务监控/前端进度展示
add_running_task(state.get("task_id", ""), current_node)
logger.info("--- BGE-M3 文本向量化处理启动 ---")
try:
# 步骤1:输入数据校验,核心chunks无效则抛出异常
texts_to_embed = step_1_validate_input(state)
# 步骤2:初始化BGE-M3模型(单例模式,仅加载一次)
bge_m3_ef = step_2_init_model()
# 步骤3:批量生成双向量,为切片绑定向量字段
output_data = step_3_generate_embeddings(texts_to_embed, bge_m3_ef)
# 步骤4:更新全局状态,将带向量的chunks回传下游
state['chunks'] = output_data
logger.info(f"--- BGE-M3 向量化处理完成,共处理 {len(output_data)} 条文本切片 ---")
add_done_task(state.get("task_id", ""), current_node)
except Exception as e:
# 捕获节点所有异常,记录错误堆栈,不中断整体流程
logger.error(f"BGE-M3向量化节点执行失败:{str(e)}", exc_info=True)
# 返回更新后的状态对象,传递至下游节点
return state
def step_1_validate_input(state: ImportGraphState) -> List[Dict[str, Any]]:
"""
向量化前置步骤1:输入数据有效性校验
核心作用:
1. 从全局状态提取待向量化的chunks切片列表
2. 严格校验chunks类型和非空性,无有效数据则终止向量化
参数:
state: ImportGraphState - 流程全局状态对象
返回:
List[Dict[str, Any]] - 校验通过的文本切片列表
异常:
若chunks非列表/为空,抛出ValueError,终止当前向量化流程
"""
# 从状态中提取切片数据
texts_to_embed = state.get("chunks")
# 校验:必须是非空列表,否则无法进行向量化
if not isinstance(texts_to_embed, list) or not texts_to_embed:
logger.error("向量化输入校验失败:chunks字段为空或非有效列表")
raise ValueError("错误: 无有效文本切片数据,无法执行向量化处理")
logger.info(f"向量化输入校验通过,待处理文本切片数量:{len(texts_to_embed)}")
return texts_to_embed
def step_2_init_model():
"""
向量化步骤2:初始化BGE-M3模型实例(单例模式)
核心作用:
1. 调用单例函数get_bge_m3_ef,确保模型全局仅加载一次
2. 校验模型实例有效性,加载失败则抛出明确异常
返回:
Any - 有效BGE-M3模型实例(embedding function)
异常:
模型加载失败(路径错误/显存不足/依赖缺失)时,抛出ValueError并提示配置问题
"""
try:
# 获取单例模型实例,避免重复加载浪费资源
ef = get_bge_m3_ef()
# 校验模型实例是否有效
if ef is None:
raise ValueError("BGE-M3模型实例为None:pymilvus.model模块未找到或模型加载失败")
logger.info("BGE-M3模型实例初始化成功(单例模式)")
return ef
except Exception as e:
# 包装异常信息,明确错误原因和排查方向
error_msg = f"BGE-M3模型初始化失败:{e},请检查模型路径/环境变量配置是否正确"
logger.error(error_msg)
raise ValueError(error_msg)
def step_3_generate_embeddings(texts_to_embed: List[Dict[str, Any]], bge_m3_ef: Any) -> List[Dict[str, Any]]:
"""
向量化核心步骤3:批量生成稠密/稀疏双向量
核心逻辑(分批执行,每批独立异常处理):
1. 文本拼接:item_name(商品名)+ 换行 + content(切片内容),强化核心特征
2. 批量调用:传入拼接后的文本,生成批量双向量
3. 向量绑定:为每个切片复制原数据,新增dense_vector/sparse_vector字段
4. 异常兜底:单批次失败则保留原切片数据,继续处理下一批次
参数:
texts_to_embed: List[Dict[str, Any]] - 校验通过的文本切片列表,含item_name/content字段
bge_m3_ef: Any - 步骤2初始化的BGE-M3模型实例
返回:
List[Dict[str, Any]] - 带向量字段的文本切片列表,异常批次保留原数据
关键配置:
batch_size: 每批处理5条,可根据服务器显存大小调整(显存大则调大,反之调小)
"""
# 初始化结果列表,存储带向量的切片数据
output_data = []
# 批次大小配置:平衡显存占用和处理效率,建议根据实际环境调整
batch_size = 5
# 按批次遍历,避免一次性处理过多数据导致显存溢出(OOM)
total = len(texts_to_embed)
for i in range(0, total, batch_size):
# 截取当前批次的切片,最后一批自动适配剩余数量【每次获取5个】
batch_texts = texts_to_embed[i:i + batch_size]
# 计算当前批次的起止索引,用于日志展示(方便看从1开始,也不获取下标,没有影响)
start_idx, end_idx = i + 1, min(i + len(batch_texts), total)
try:
# 构造模型输入文本:拼接商品名+切片内容,增强核心特征
input_texts = []
for doc in batch_texts:
item_name = doc["item_name"]
content = doc["content"]
# 有商品名则拼接(换行分隔提升模型识别效率),无则直接使用内容
# 几乎所有的 Embedding 模型(尤其是基于 BERT 架构的),对前 128 个 token 的注意力是最集中的。越往后的词,对最终向量方向的拉扯力越弱。
# **“核心词前置”**的原则
# 方案 1:用强标点代替换行(最简单、最推荐)
# 优化前:苹果手机\n性能很好...
# 优化后:苹果手机。性能很好...
# 方案2:加一点“微量”的语义胶水(适合属性明确的场景)
text = f"商品:{item_name},介绍:{content}" if item_name else content
# Embedding 模型是个强迫症,你给它喂中文,就用全套中文标点伺候;给它喂英文,就用全套英文标点。保持 语境纯粹 ,生成的向量质量最高!
input_texts.append(text)
# 调用封装函数生成批量向量,返回格式:{"dense": [稠密向量列表], "sparse": [稀疏向量列表]}
docs_embeddings = generate_embeddings(input_texts)
if not docs_embeddings:
logger.warning(f"第{start_idx}-{end_idx}条切片:向量生成返回空,保留原数据")
output_data.extend(batch_texts)
continue
# 为当前批次每个切片绑定对应向量,复制原数据避免修改上游源数据
for j, doc in enumerate(batch_texts):
item = doc.copy()
item["dense_vector"] = docs_embeddings["dense"][j] # 绑定稠密向量
item["sparse_vector"] = docs_embeddings["sparse"][j] # 绑定稀疏向量(已归一化)
output_data.append(item)
logger.info(f"第{start_idx}-{end_idx}条切片:双向量生成成功")
except Exception as e:
# 捕获单批次所有异常,记录错误堆栈,不终止整体批量处理
logger.error(
f"第{start_idx}-{end_idx}条切片:向量生成失败,保留原数据 | 错误原因:{str(e)}",
exc_info=True
)
# 异常批次保留原切片数据,保证数据完整性,后续可人工排查
output_data.extend(batch_texts)
continue
return output_data
# ==========================================
# 本地单元测试入口
# 功能:独立验证向量化节点全链路逻辑,无需启动整个LangGraph流程
# 适用场景:本地开发、调试、模型有效性验证
# ==========================================
if __name__ == '__main__':
# 加载环境变量:定位项目根目录下的.env,读取模型路径/设备等配置
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(current_dir))
# 构造模拟测试状态:模拟上游节点输出的chunks数据,贴合真实业务场景
test_state = ImportGraphState({
"task_id": "test_task_embedding_001", # 测试任务ID
"chunks": [ # 模拟带item_name的文本切片(上游商品名称识别节点产出)
{
"content": "这是一个测试文档的内容,用于验证向量化是否成功。",
"title": "测试文档标题",
"item_name": "测试项目",
"file_title": "测试文件.pdf"
},
{
"content": "这是第二个测试文档的内容,用于验证批量处理逻辑。",
"title": "测试文档标题2",
"item_name": "测试项目",
"file_title": "测试文件.pdf"
}
]
})
# 执行本地测试
logger.info("=== BGE-M3向量化节点本地单元测试启动 ===")
try:
# 调用核心节点函数
result_state = node_bge_embedding(test_state)
# 提取测试结果
result_chunks = result_state.get("chunks", [])
# 打印测试结果统计
logger.info(f"=== 向量化节点本地测试完成 ===")
logger.info(f"测试任务ID:{test_state.get('task_id')}")
logger.info(f"待处理切片数:2 | 实际处理切片数:{len(result_chunks)}")
logger.info(f"向量维度:{result_chunks}")
# 验证向量生成结果(打印向量字段是否存在)
for idx, chunk in enumerate(result_chunks):
has_dense = "dense_vector" in chunk
has_sparse = "sparse_vector" in chunk
logger.info(
f"第{idx + 1}条切片:稠密向量生成{'' if has_dense else '未'}成功 | 稀疏向量生成{'' if has_sparse else '未'}成功")
except Exception as e:
logger.error(f"=== 向量化节点本地测试失败 ===" f"错误原因:{str(e)}", exc_info=True)
# 新手友好提示:给出核心排查方向
logger.warning("排查提示:请检查BGE-M3模型路径、显存是否充足、环境变量配置是否正确")
在 RAG 系统中,大模型本身是无法直接对几万篇文档进行快速文字匹配的。本节点通过调用多语言、多粒度的 BAAI/bge-m3 统一嵌入模型,将上游切分好并带有业务标签的文本切片(Chunks),同时编码为稠密向量(Dense Vector)和稀疏向量(Sparse Vector),为下游 Milvus 的高性能混合检索(Hybrid Search)铸造了最坚实的数学底座。
【 状态输入 (ImportGraphState) 】
(携带富化了 item_name 的 chunks 列表)
│
▼
【 步骤 1:物理边界校验与防爆检查 】(拦截空数据,防止显存溢出--向量化是一项极度消耗 GPU 算力的重工业操作,减少脏数据等)
│
▼
【 步骤 2:文本增强拼接 (Text Enhancement) 】(强行注入:商品名称 + 切片正文 --- 解决代词的飘移)
│
▼
【 步骤 3:BGE-M3 双通道向量化生成 】(批量化多线程并发调用模型)
│
├──► 通道 A:稠密向量生成 (1024维固定长度语义捕捉)
└──► 通道 B:稀疏向量生成 (变长关键词特征权重捕捉)
│
▼
【 步骤 4:成果物回填与归一化处理 】(将双向量精准追加回每一个 chunk 字典)
│
▼
【 状态输出 (ImportGraphState) 】
(每个 chunk 均富化了 dense_vector 和 sparse_vector 字段)
-
Hybrid(混合)双数学引擎矩阵:稠密通道(Dense):负责捕捉“泛化语义理解”。用户搜“屏幕黑屏”,它能理解意思,泛化捞出包含“主板断电”、“显示器无法通电”的切片。稀疏通道(Sparse):负责捕捉“字面精准匹配”。大模型和稠密向量极易把
iPhone 14和iPhone 15混淆(因为语义太像了),而稀疏向量精准记录了数字和特定型号的特征位置与权重,能保证绝对不吐出错误的型号数据。 -
特征文本增强:代码在对 Chunk 进行 Embedding 前,做了一步极其漂亮的工程增强:
text_to_embed = f"商品/项目名称: {item_name}\n内容: {chunk_content}"。这在向量空间中为该设备的所有切片施加了一个强引力,让检索相关实体时凝聚力暴增。 -
并行与批控安全:采用
get_bge_m3_ef()单例加载模型,规避了 LangGraph 多任务时显存重复加载导致的 OOM。通过 Batch 机制分批生成dense_vector(1024 维语义向量) 和sparse_vector(特征项词频稀疏向量),完美匹配了大厂主流的混合检索(Hybrid Search)要求。
2. 向量数据库持久化与极致幂等设计 (node_import_milvus.py)
读取上游富化后的切片与双通道向量,自动检测并动态创建 Milvus 结构,并在批量插入前执行全量旧数据的精准清理,确保知识资产的绝对纯净。
【 状态输入 (ImportGraphState) 】
(携带富化了双向量及 item_name 的 chunks)
│
▼
【 步骤 1:前置安全防御与数据校准 】(验证 chunks 是否为空、向量维度是否对齐)
│
▼
【 步骤 2:免运维全自动 Schema 建表 】(判断 Collection 是否存在 -> 动态建表并挂载双索引)
│
▼
【 步骤 3:分布式金融级幂等清理 】(根据唯一 item_name 执行 Delete-Before-Insert 擦除)
│
▼
【 步骤 4:高性能批量流式插入 】(组装 Payload,批量 Insert 进 Milvus 矩阵)
│
▼
【 步骤 5:主键回填与持久化落地 】(将 Milvus 自增的 chunk_id 回填状态树,落盘备份)
│
▼
【 状态输出 (ImportGraphState) 】
(返回最终安全持久化后的完整工作流状态)
入库节点的核心风险在于因网络闪断、系统重试导致数据重复写入、旧版本残留。
-
Schema 自动进化:自动检测 Milvus 集合状态,无需手动建表。自动创建包含自增主键、1024 维 FloatVector(稠密)、SparseFloatVector(稀疏)以及
item_name标量索引的复杂 Schema。 -
专家级幂等性治理(覆盖式写入)
safe_item_name = escape_milvus_string(item_name) expr = f'item_name == "{safe_item_name}"' # 核心行:在批量写入前,先利用标量表达式把 Milvus 中该设备的历史陈旧切片一网打尽 milvus_client.delete(collection_name=CHUNKS_COLLECTION_NAME, filter=expr)在执行
milvus_client.insert前,先执行基于item_name过滤的delete清理。这意味着无论这个大数据量的工作流因为意外重跑多少次,数据库中永远只会保持最新、最干净的唯一一份知识资产。 -
混合检索的双引擎底座:在建表阶段无缝支持
dense_vector和sparse_vector,使得你的知识库在线上运行时能够完美调用主流的 “混合检索 + 标量过滤” 架构。 -
极高的高可用与防灾能力:集成了自动建表机制,即使你的 Milvus 数据库遭遇断电重建、全库清空,离线导入流水线只要一跑,也能全自动从零恢复表的结构和索引,实现了零人工运维干预。
-
彻底隔离的业务租户属性:利用
item_name进行Delete-Before-Insert幂等设计,天然对多款不同的产品、设备手册进行了逻辑上的业务隔离,对频繁更新手册的复杂生产场景提供了极度稳健的底座支持。
🚀 五、 待重构与演进调整
上述仅仅为流程架构介绍,若要支撑日均万级文档吞吐的高并发生产环境,建议对 3 个隐患进行重构:
-
全面向异步 I/O 架构演进(提升高并发吞吐)
-
现状:目前
main_graph.py中的所有节点全部是普通的同步阻塞函数。当节点在轮询 MinerU 任务、请求大模型、上传 MinIO 时,当前线程会被锁死。 -
重构方案:建议将节点升级为
async def,内部采用httpx.AsyncClient和支持异步的存储客户端,编译图后调用kb_import_app.ainvoke()。这样,单台服务器的并发吞吐能力可以提升数倍。
-
-
大文件下的 VLM 图片串行循环超时风险
-
现状:在
node_md_img.py中,代码使用for target in targets:串行、同步调用大模型解析一本手册中的所有图片。如果一本设备手册有 100 张高清结构图,这个节点会持续运行数分钟,直接引发上层 API 网关超时熔断。 -
重构方案:针对单本手册内的图片数组,引入
asyncio.gather或线程池,并发并行发起多路大模型 VLM 请求;同时增设图片总数上限熔断。
-
-
Milvus 大表频繁标量硬删除的性能衰退
-
现状:目前幂等性依赖
milvus_client.delete(..., filter=expr)。在 Milvus 底层,基于标量表达式的delete属于硬删除(Soft Delete 后版本控制链挂载),频繁大批量操作会导致读放大,影响在线检索性能。 -
重构方案:当数据达到千万级时,建议引入 Milvus 分区(Partition)隔离机制。将每个大项目/大设备直接划分为独立的物理 Partition,更新时直接
drop_partition,或者在在线检索时带上逻辑版本号version==2过滤,由后台离线脚本定期执行物理compaction压缩清理。
-
354

被折叠的 条评论
为什么被折叠?



