RAG项目案例--01离线数据治理流水线

【 阶段一:离线海量数据治理流水线 】

 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_enabledis_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 提取工具(如 PyPDFpdfplumber)遇到多栏排版或跨页表格时,会将文字错行交叉提取,导致表格数据变成一堆毫无逻辑的垃圾字符。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远程引用
    替换规则:![原描述](本地路径) → ![图片摘要](MinIO访问URL)
    :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"![{summary}]({new_url})", md_content)
        md_content = pattern.sub( f"![{summary}]({new_url})", 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_titlefile_titlepart 等元数据,使其具备完美的独立可解释性。(确保被切开的两个子块之间有 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 14iPhone 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_vectorsparse_vector,使得你的知识库在线上运行时能够完美调用主流的 “混合检索 + 标量过滤” 架构。

  • 极高的高可用与防灾能力:集成了自动建表机制,即使你的 Milvus 数据库遭遇断电重建、全库清空,离线导入流水线只要一跑,也能全自动从零恢复表的结构和索引,实现了零人工运维干预。

  • 彻底隔离的业务租户属性:利用 item_name 进行 Delete-Before-Insert 幂等设计,天然对多款不同的产品、设备手册进行了逻辑上的业务隔离,对频繁更新手册的复杂生产场景提供了极度稳健的底座支持。

🚀 五、 待重构与演进调整

上述仅仅为流程架构介绍,若要支撑日均万级文档吞吐的高并发生产环境,建议对 3 个隐患进行重构:

  1. 全面向异步 I/O 架构演进(提升高并发吞吐)

    • 现状:目前 main_graph.py 中的所有节点全部是普通的同步阻塞函数。当节点在轮询 MinerU 任务、请求大模型、上传 MinIO 时,当前线程会被锁死。

    • 重构方案:建议将节点升级为 async def,内部采用 httpx.AsyncClient 和支持异步的存储客户端,编译图后调用 kb_import_app.ainvoke()。这样,单台服务器的并发吞吐能力可以提升数倍。

  2. 大文件下的 VLM 图片串行循环超时风险

    • 现状:在 node_md_img.py 中,代码使用 for target in targets: 串行、同步调用大模型解析一本手册中的所有图片。如果一本设备手册有 100 张高清结构图,这个节点会持续运行数分钟,直接引发上层 API 网关超时熔断。

    • 重构方案:针对单本手册内的图片数组,引入 asyncio.gather 或线程池,并发并行发起多路大模型 VLM 请求;同时增设图片总数上限熔断。

  3. Milvus 大表频繁标量硬删除的性能衰退

    • 现状:目前幂等性依赖 milvus_client.delete(..., filter=expr)。在 Milvus 底层,基于标量表达式的 delete 属于硬删除(Soft Delete 后版本控制链挂载),频繁大批量操作会导致读放大,影响在线检索性能。

    • 重构方案:当数据达到千万级时,建议引入 Milvus 分区(Partition)隔离机制。将每个大项目/大设备直接划分为独立的物理 Partition,更新时直接 drop_partition,或者在在线检索时带上逻辑版本号 version==2 过滤,由后台离线脚本定期执行物理 compaction 压缩清理。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值