企业实战:Markdown图片检索

目录

  • [1 图片处理 (node_md_img)](#1 图片处理 (node_md_img))
  • [2 步骤分解](#2 步骤分解)
  • [3 基于LangChain大模型的配置和类](#3 基于LangChain大模型的配置和类)
  • [4 工具类支持: MinIO 客户端](#4 工具类支持: MinIO 客户端)
    • [4.1 导入与配置](#4.1 导入与配置)
    • [4.2 主流程定义](#4.2 主流程定义)
    • [4.3 步骤 1: 获取内容与路径](#4.3 步骤 1: 获取内容与路径)
    • [4.4 步骤 2: 图片扫描](#4.4 步骤 2: 图片扫描)
    • [4.5 步骤 3: 图片摘要](#4.5 步骤 3: 图片摘要)
    • [4.6 步骤 4: 上传与替换](#4.6 步骤 4: 上传与替换)
    • [4.7 步骤 5: 备份文件](#4.7 步骤 5: 备份文件)
    • [4.8 单元测试](#4.8 单元测试)

1 图片处理 (node_md_img)

文件 : app/import_process/agent/nodes/node_md_img.py

相关工具类位置 : app/clients/minio_utils.py, app/utils/task_utils.py

RAG多模态知识库核心增强模块,核心目标:将Markdown中本地图片,转化为「可云端访问、可文本检索、可模型理解」的标准化多模态知识单元,解决传统RAG图片不可检索、环境切换失效的痛点,支撑多模态RAG精准问答。

实现思路:

  1. 图文解耦与云端持久化:本地图片批量上传至MinIO,实现存储与计算分离,保障跨环境可访问、高可用,支撑大规模知识库扩展。
  2. 视觉语义增强:引入VLM多模态模型,结合图片上下文,生成精准中文语义描述,让图片具备文本检索能力,填补传统RAG视觉知识盲区(区别于单纯OCR识别)。
  3. 企业级稳定性保障:内置API速率控制(令牌桶算法)、格式校验、旧资源清理、异常捕获,确保大批量图片处理稳定无崩溃。
  4. 全流程自动化:从扫描、语义生成、上传、替换到保存,全程无人工干预,不改变原有MD格式,无缝对接后续节点。

2 步骤分解

  1. Step 1:初始化校验:读取MD路径与内容,校验文件合法性,定位同级images文件夹。
  2. Step 2:图片扫描与上下文匹配:筛选支持格式的图片,校验MD引用关系,截取图片前后各100字符上下文。
  3. Step 3:VLM语义生成:调用千文Qwen3-VL-Flash,Base64编码图片+上下文构造请求,生成规范语义描述。
  4. Step 4:上传与替换:清理MinIO旧资源,批量上传图片生成在线URL,替换MD本地路径并填充alt语义。
  5. Step 5:保存与状态更新:生成「原文件名_new.md」备份,更新流程状态,完成闭环。

视觉模型选择: https://rank.opencompass.org.cn/leaderboard-multimodal

模型 核心劣势 核心优势
千文Qwen3-VL-Flash(选型) 复杂实景图理解精度略逊GPT-4V 中文技术场景适配优、轻量化部署、推理快(≤500ms)、开源私有化、LangChain适配好
GPT-4V 成本高、有隐私风险、中文适配弱、依赖外网 视觉理解精度最高,支持复杂场景
通义千问VL 模型体量大、推理慢、部署门槛高 中文适配强、精度较高
文心一格VL 技术类图片理解弱、架构适配一般 中文适配好、擅长实景/艺术图
LLaVA/MiniGPT-4(开源) 精度有限、维护成本高、推理慢 开源免费、可私有化

3 基于LangChain大模型的配置和类

添加全局配置 .env文件

ini 复制代码
# api_key申请地址:https://bailian.console.aliyun.com/cn-beijing/?spm=5176.29597918.J_SEsSjsNv72yRuRFS2VknO.2.4d877b08ThdGtP&tab=model#/api-key
# 大模型的文档地址:https://bailian.console.aliyun.com/cn-beijing/?spm=5176.29597918.J_SEsSjsNv72yRuRFS2VknO.2.4d877b08ThdGtP&tab=doc#/doc
# Qwen3系列Flash模型,实现思考模式和非思考模式的有效融合,可在对话中切换模式。复杂推理类任务性能优秀,指令遵循、文本理解等能力显著提高。支持1M上下文长度,按照上下文长度进行阶梯计费。
LLM_DEFAULT_MODEL=qwen-flash
#Qwen3系列小尺寸视觉理解模型,实现思考模式和非思考模式的有效融合,效果优于开源版Qwen3-VL-30B-A3B,响应速度快。全面升级图像/视频理解,支持长视频长文档等超长上下文、
#空间感知与万物识别;具备视觉2D/3D定位能力,胜任复杂现实任务。
VL_MODEL=qwen3-vl-flash
OPENAI_API_KEY=your key
OPENAI_API_BASE=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_DEFAULT_TEMPERATURE=0.1

定义配置类,读取配置文件

位置:app/config/lm_config.py 【通用配置放在app下】

python 复制代码
# 导入核心依赖:数据类、环境变量读取、路径处理
from dataclasses import dataclass
import os
from dotenv import load_dotenv

# 提前加载.env配置文件(必须在读取环境变量前执行,确保os.getenv能获取到值)
# 若.env不在项目根目录,可指定路径:load_dotenv(dotenv_path=Path(__file__).parent / ".env")
load_dotenv()


# 定义minerU服务配置
@dataclass
class LLMConfig:
    base_url: str
    api_key : str
    lv_model: str
    llm_model: str
    llm_temperature: float

lm_config = LLMConfig(
    base_url=os.getenv("OPENAI_API_BASE"),
    api_key=os.getenv("OPENAI_API_KEY"),
    lv_model=os.getenv("VL_MODEL"),
    llm_model=os.getenv("LLM_DEFAULT_MODEL"),
    llm_temperature=float(os.getenv("LLM_DEFAULT_TEMPERATURE"))
)

首先引入必要的库,并加载环境变量配置。

定义大模型工具类

位置:app/lm/llm_utils.py

python 复制代码
# 环境配置与依赖导入
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.exceptions import LangChainException
from typing import Optional

# 项目内部依赖
from app.conf.lm_config import lm_config
from app.core.logger import logger

# 全局缓存:键为(模型名, JSON输出模式)元组,值为ChatOpenAI实例
# 作用:避免重复初始化客户端,提升性能,统一实例管理
_llm_client_cache = {}


def get_llm_client(model: Optional[str] = None, json_mode: bool = False) -> ChatOpenAI:
    """
    获取带全局缓存的LangChain ChatOpenAI客户端实例
    适配OpenAI/千问/即梦AI等**OpenAI兼容API**,支持自定义模型和JSON标准化输出
    核心特性:缓存机制+配置统一加载+异常精准捕获+国产模型参数适配

    :param model: 模型名称,优先级:传入参数 > 配置文件lm_config.llm_model > 内置默认qwen3-32b
    :param json_mode: 是否开启JSON输出模式,开启后返回标准json_object格式(适配结构化数据解析)
    :return: 初始化完成的ChatOpenAI实例(优先从全局缓存获取,未命中则新建并缓存)
    :raise ValueError: 缺失API密钥/基础地址等核心配置
    :raise Exception: 模型初始化失败(LangChain封装层异常)
    """
    # 1. 确定目标模型(优先级递减,保证模型名非空)
    target_model = model or lm_config.llm_model or "qwen3-32b"
    # 缓存键:模型名+JSON模式,唯一标识不同配置的客户端
    cache_key = (target_model, json_mode)

    # 2. 缓存命中:直接返回已初始化的实例,避免重复创建
    if cache_key in _llm_client_cache:
        logger.debug(f"[LLM客户端] 缓存命中,直接返回实例:模型={target_model},JSON模式={json_mode}")
        return _llm_client_cache[cache_key]

    # 3. 核心配置校验:拦截缺失的API关键配置,提前抛出明确异常
    if not lm_config.api_key:
        raise ValueError("[LLM客户端] 配置缺失:请在.env中配置OPENAI_API_KEY(大模型API密钥)")
    if not lm_config.base_url:
        raise ValueError("[LLM客户端] 配置缺失:请在.env中配置OPENAI_API_BASE(API接口基础地址)")
    logger.info(f"[LLM客户端] 开始初始化新实例:模型={target_model},JSON模式={json_mode}")

    # 4. 配置参数组装:区分「国产模型私有参数」和「OpenAI通用参数」
    # extra_body:千问/即梦等国产模型专属私有参数(LangChain透传至API)
    extra_body = {"enable_thinking": False}  # 千问专属:关闭思考链输出,减少冗余内容
    # model_kwargs:OpenAI通用参数,所有兼容API均支持
    model_kwargs = {}
    if json_mode:
        # 开启JSON标准输出模式,强制模型返回可解析的json_object
        model_kwargs["response_format"] = {"type": "json_object"}
        logger.debug(f"[LLM客户端] 已开启JSON输出模式,模型将返回标准JSON结构")

    # 5. 客户端初始化:捕获LangChain封装层异常,抛出更友好的提示
    try:
        llm_client = ChatOpenAI(
            model=target_model,  # 目标模型名
            temperature=lm_config.llm_temperature or 0.1,  # 低温度保证输出确定性(0~1)
            api_key=lm_config.api_key,  # API密钥
            base_url=lm_config.base_url,  # API基础地址(适配国产模型代理地址)
            extra_body=extra_body,  # 国产模型私有参数透传
            model_kwargs=model_kwargs,  # OpenAI通用参数
        )
    except LangChainException as e:
        raise Exception(f"[LLM客户端] 模型【{target_model}】初始化失败(LangChain层):{str(e)}") from e

    # 6. 新实例存入全局缓存,供后续调用复用
    _llm_client_cache[cache_key] = llm_client
    logger.info(f"[LLM客户端] 实例初始化成功并缓存:模型={target_model},JSON模式={json_mode}")

    return llm_client


# 测试示例:验证客户端创建、缓存机制及日志输出
if __name__ == "__main__":
    logger.info("===== 开始执行LLM客户端工具测试 =====")
    try:
        # 测试1:默认配置(默认模型+普通模式)
        client1 = get_llm_client()
        logger.info("✅ 测试1通过:默认配置客户端创建成功")

        # 测试2:指定多模态模型(qwen-vl-plus)+ 普通模式
        client2 = get_llm_client(model="qwen-vl-plus")
        logger.info("✅ 测试2通过:指定多模态模型客户端创建成功")

        # 测试3:同一模型+模式,验证缓存命中
        client3 = get_llm_client(model="qwen-vl-plus")
        logger.info(f"✅ 测试3通过:缓存机制验证成功,client2与client3为同一实例:{client2 is client3}")

        # 测试4:开启JSON输出模式
        client4 = get_llm_client(model="qwen3-32b", json_mode=True)
        logger.info("✅ 测试4通过:JSON输出模式客户端创建成功")

    except Exception as e:
        logger.error(f"❌ LLM客户端工具测试失败:{str(e)}", exc_info=True)
    finally:
        logger.info("===== LLM客户端工具测试结束 =====")

4 工具类支持: MinIO 客户端

封装 MinIO 客户端的初始化过程,支持从环境变量读取配置。

特别之处在于,初始化时会自动检查并创建默认的 Bucket(如果不存在),减少手动运维成本。

采用模块级变量 minio_client 作为单例,避免重复建立连接。

步骤1:使用Docker启动Minio

cmd 复制代码
docker run -d --name minio \
    -p 9000:9000 -p 9001:9001 \
    -e "MINIO_ROOT_USER=minioadmin" \
    -e "MINIO_ROOT_PASSWORD=minioadmin" \
    -v $(pwd)/volumes/minio/data:/data \
    quay.io/minio/minio server /data --console-address ":9001"
    
docker stop minio
docker start minio
# 实时查看日志(按 Ctrl+C 退出)
docker logs -f minio

MinIO 在 2025 年 5 月之后的社区版 中,完全移除了 Web 控制台(9001 端口)的权限管理入口(包括 Identity、Policies 等菜单),仅保留「文件 / 桶的基础浏览上传功能」,这是官方对社区版的功能精简(商业版仍保留完整控制台权限功能)。

步骤2:编写minio使用工具类

Java代码回忆:

java 复制代码
public class App {
    public static void main(String[] args) throws IOException, NoSuchAlgorithmException, InvalidKeyException {

        try {
            //构造MinIO Client (登录)
            MinioClient minioClient = MinioClient.builder()
                    .endpoint("http://192.168.10.101:9000")
                    .credentials("minioadmin", "minioadmin")
                    .build();
            
            //创建hello-minio桶
            boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket("hello-minio").build());
            if (!found) {
                //创建hello-minio桶
                minioClient.makeBucket(MakeBucketArgs.builder().bucket("hello-minio").build());
                //设置hello-minio桶的访问权限
                String policy = """
                        {
                          "Statement" : [ {
                            "Action" : "s3:GetObject",
                            "Effect" : "Allow",
                            "Principal" : "*",
                            "Resource" : "arn:aws:s3:::hello-minio/*"
                          } ],
                          "Version" : "2012-10-17"
                        }""";
                minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket("hello-minio").config(policy).build());
            } else {
                System.out.println("Bucket 'hello-minio' already exists.");
            }

            //上传图片
            minioClient.uploadObject(
                    UploadObjectArgs.builder()
                            .bucket("hello-minio")
                            .object("公寓-外观.jpg")
                            .filename("D:\\workspace\\hello-minio\\src\\main\\resources\\公寓-外观.jpg")
                            .build());
            System.out.println("上传成功");
        } catch (MinioException e) {
            System.out.println("Error occurred: " + e);
        }
    }
}

添加minio全局配置 .env文件

ini 复制代码
#minio客户端
MINIO_ENDPOINT=http://47.94.86.115:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET_NAME=knowledge-base-files
MINIO_IMG_DIR=/upload-images

定义配置类,读取配置文件

位置:app/config/minio_config.py

python 复制代码
# 导入核心依赖:数据类、环境变量读取、路径处理
from dataclasses import dataclass
import os
from dotenv import load_dotenv

# 提前加载.env配置文件(确保os.getenv能获取到MinIO相关配置)
load_dotenv()


# 定义MinIO对象存储服务配置(与LLMConfig风格一致,字段对应.env配置项)
@dataclass
class MinIOConfig:
    endpoint: str    # MinIO服务地址(含http/https和端口)
    access_key: str  # MinIO访问密钥(对应MINIO_ACCESS_KEY)
    secret_key: str  # MinIO秘钥(对应MINIO_SECRET_KEY)
    bucket_name: str # MinIO默认存储桶名(知识库文件专用)
    minio_img_dir: str #Minio存储图片的文件夹


# 实例化MinIO配置对象,自动从.env读取配置并绑定
minio_config = MinIOConfig(
    endpoint=os.getenv("MINIO_ENDPOINT"),
    access_key=os.getenv("MINIO_ACCESS_KEY"),
    secret_key=os.getenv("MINIO_SECRET_KEY"),
    bucket_name=os.getenv("MINIO_BUCKET_NAME"),
    minio_img_dir=os.getenv("MINIO_IMG_DIR")
)

工具类文件 : app/clients/minio_utils.py

python 复制代码
# 导入Python内置模块
import os
import json

# 导入MinIO官方Python SDK核心类(用于MinIO对象存储的客户端操作)
from minio import Minio

# 导入项目内部配置与日志工具
from app.conf.minio_config import minio_config  # MinIO相关配置(端点、密钥、桶名等)
from app.core.logger import logger            # 项目统一日志工具

# 全局MinIO客户端实例(单例模式,避免重复创建连接,提升性能)
_minio_client = None


# 1. 定义MinIO客户端连接创建函数(私有函数,仅内部调用)
def _create_minio_client() -> Minio:
    """
    创建并返回MinIO客户端连接
    核心作用:读取配置文件中的MinIO参数,初始化客户端连接
    :return: 初始化完成的MinIO客户端对象
    """
    return Minio(
        endpoint=minio_config.endpoint,        # MinIO服务端点(IP:端口)
        access_key=minio_config.access_key,    # MinIO访问密钥
        secret_key=minio_config.secret_key,    # MinIO秘密密钥
        secure=minio_config.minio_secure       # 是否启用HTTPS(True/False)
    )


# 2. 定义桶访问策略生成函数(私有函数,仅内部调用)
def _set_bucket_policy(bucket_name: str) -> str:
    """
    生成MinIO桶的访问策略字符串(JSON格式)
    核心策略:允许所有用户(Principal: "*")对桶内所有对象执行读取操作(s3:GetObject)
    适配场景:图片上传后需公开访问(如MD中图片在线URL)
    :param bucket_name: 目标桶名
    :return: 序列化后的JSON格式访问策略字符串
    """
    # 策略模板(遵循AWS S3策略规范,MinIO兼容该规范)
    policy = {
        "Version": "2012-10-17",  # 策略版本(固定值,兼容S3标准)
        "Statement": [
            {
                "Effect": "Allow",  # 策略效果:允许访问
                "Principal": {"AWS": ["*"]},  # 授权对象:所有用户
                "Action": ["s3:GetObject"],  # 授权操作:读取桶内对象
                "Resource": [f"arn:aws:s3:::{bucket_name}/*"],  # 授权范围:桶内所有对象
            }
        ],
    }
    # 将字典策略序列化为JSON字符串,供MinIO设置使用
    return json.dumps(policy)


# 3. 定义桶初始化函数(私有函数,仅内部调用)
def _create_bucket_ready(client: Minio):
    """
    检查MinIO桶是否存在,不存在则创建,并设置访问策略
    核心作用:确保图片上传所需的桶已就绪,避免上传失败
    :param client: 已初始化的MinIO客户端对象
    """
    bucket_name = minio_config.bucket_name  # 从配置中获取目标桶名
    # 检查桶是否存在
    if not client.bucket_exists(bucket_name):
        client.make_bucket(bucket_name)  # 不存在则创建桶
        # 为新桶设置访问策略(允许公开读取,适配图片在线访问需求)
        client.set_bucket_policy(bucket_name, _set_bucket_policy(bucket_name))
        logger.info(f"MinIO桶 {bucket_name} 已创建,并设置访问策略")
    else:
        # 桶已存在,仅打印日志,不重复操作
        logger.info(f"MinIO桶 {bucket_name} 已存在,无需重复创建")


def get_minio_client() -> Minio:
    """
    获取全局MinIO客户端(懒加载模式,无锁版本,适配单线程场景)
    核心逻辑:
    1. 首次调用:初始化客户端 + 检查/创建桶 + 设置策略,将客户端实例赋值给全局变量
    2. 后续调用:直接复用全局客户端实例,避免重复创建连接(提升性能、节省资源)
    :return: 全局唯一的MinIO客户端对象
    """
    # 声明使用全局变量(修改全局变量需显式声明)
    global _minio_client

    # 懒加载:仅在客户端未初始化时执行创建逻辑
    if _minio_client is None:
        logger.info("开始初始化MinIO客户端(首次调用,执行懒加载)")
        client = _create_minio_client()          # 创建客户端连接
        _create_bucket_ready(client)             # 检查并初始化桶
        _minio_client = client                   # 赋值给全局变量,供后续复用
        logger.info("MinIO客户端初始化完成,已就绪可使用")

    # 复用全局客户端实例,直接返回
    return _minio_client

4.1 导入与配置

首先引入必要的库,并加载环境变量配置。

python 复制代码
import os
import re
import base64
from pathlib import Path
from typing import Dict, List, Tuple
from collections import deque

# 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, add_done_task
from langchain_core.output_parsers import StrOutputParser
# LLM客户端工具类(核心复用,替换原生OpenAI调用)
from app.lm.lm_utils import get_llm_client
# LangChain多模态依赖(消息构造+异常捕获)
from langchain.messages import HumanMessage
# 项目配置
from app.conf.minio_config import minio_config
from app.conf.lm_config import lm_config
# 项目日志工具(统一使用)
from app.core.logger import logger, node_log, step_log
# 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"}

def is_supported_image(filename: str) -> bool:
    """
    判断文件是否为MinIO支持的图片格式(后缀不区分大小写)
    :param filename: 文件名(含后缀)
    :return: 支持返回True,否则False
    """
    return os.path.splitext(filename)[1].lower() in IMAGE_EXTENSIONS

4.2 主流程定义

node_md_img 是本节点的入口函数,它定义了整个图片处理的流水线。我们先定义好步骤,具体的实现细节在后续部分展开。

python 复制代码
"""
    主要目标: 就是将md中的图片进行单独处理,图片转成对应的语义文本,方便后续进行切片搜索!
    主要动作: 图片 -> 图片服务器 -> (上文)图片内容(下文) -> 传递到视觉模型 -> 生成图片总结
             -> 替换md原有的图片显示 ![图片总结](图片的minio网络地址) -> state 修改md_content / md_path 新内容 -> 结束
    技术总结: minio reg正则 多模态模型 提示词
    实现步骤: 
          1. 进行任务和日志处理 
          2. 进行核心参数校验 [校验md_path/md_content/返回images的文件夹地址]
          3. 查找md中使用的图片和上下文 [传入md_content和images文件夹,返回进行模型访问准备 [(图片名,图片地址,(上文,下文))]]
          4. 进行图片内容总结和处理[调用多模态模型,总结图片内容,最终返回 图片名/总结]
          5. 上传图片到minio服务器,替换图片的本地地址和描述!返回替换后的md_content内容
          6. 备份新的md内容,改为原名称 _new.md
          7. 进行md_path和md_content内容更新(state)
          8. 返回目标结果即可
"""
@node_log("node_md_img")
def node_md_img(state: ImportGraphState) -> ImportGraphState:
    """
    节点: 图片处理 (node_md_img)
    为什么叫这个名字: 处理 Markdown 中的图片资源 (Image)。
    """
    # 1. 进行任务和日志处理
    add_running_task(state['task_id'],'node_md_img')
    # 2. 进行核心参数校验 [校验md_path/md_content/返回images的文件夹地址]
    md_content,md_path_obj,images_dir_obj = step_1_get_content(state)
    # 3. 查找md中使用的图片和上下文 [传入md_content和images文件夹,返回进行模型访问准备 [(图片名,图片地址,(上文,下文))]]
    image_targets = step_2_scan_images(md_content, images_dir_obj)
    # 4. 进行图片内容总结和处理[调用多模态模型,总结图片内容,最终返回 图片名/总结]
    image_summaries = step_3_image_summary(image_targets,md_path_obj.stem)
    # 5. 上传图片到minio服务器,替换图片的本地地址和描述!返回替换后的md_content内容
    new_md_content = step_4_upload_images_replace(image_summaries, image_targets , md_content, md_path_obj.stem)
    # 6. 备份新的md内容,改为原名称 _new.md
    new_md_file_path_str = step_5_backup_md_file(md_path_obj, new_md_content)
    # 7. 进行md_path和md_content内容更新(state)
    state['md_path'] = new_md_file_path_str
    state['md_content'] = new_md_content
    # 8. 返回目标结果即可
    add_done_task(state['task_id'], 'node_md_img')
    return state

4.3 步骤 1: 获取内容与路径

这一步负责从 state 中获取文件路径,读取文件内容,并确定图片存放的目录。

python 复制代码
@step_log("step_1_get_content")
def step_1_get_content(state) -> Tuple[str, Path, Path]:
    """
    提取和校验内容,并且返回图片的地址
    :param state:
    :return: 返回md内容,md地址,images地址
    """
    # 1.获取md地址 md_path
    md_file_path = state.get("md_path")
    if not md_file_path:
        raise ValueError("md_path参数错误,请检查输入参数!")
    md_file_obj = Path(md_file_path)
    if not md_file_obj.exists():
        raise FileNotFoundError(f"md_path参数错误,请检查输入参数! {md_file_path}")

    # 2.获取读取md_content
    if not state['md_content']:
        state['md_content'] = md_file_obj.read_text(encoding="utf-8")

    # 3.拼接图片存储地址
    images_dir_obj = md_file_obj.parent / "images"

    return state['md_content'], md_file_obj, images_dir_obj

4.4 步骤 2: 图片扫描

这一步扫描 Markdown 文件中引用的本地图片,并检查文件是否存在。同时提取图片的上下文(前后文),用于后续生成摘要。

python 复制代码
@step_log("step_2_scan_images")
def step_2_scan_images(md_content: str, images_dir_obj: Path) -> List[Tuple[str, str, Tuple[str, str]]]:
    """
    扫描 MD 文档中的图片,匹配本地图片文件,并截取图片上下文(前后100字符)
    :param md_content: Markdown 原文内容
    :param images_dir_obj: 图片所在目录(Path 对象)
    :return: 列表 -> [(图片文件名, 图片完整路径, (上文, 下文))]
    """
    # 存储最终处理好的图片信息
    image_targets = []
    # 使用 pathlib 遍历目录(现代、安全、自带完整路径)
    for image_file in images_dir_obj.iterdir():
        img_name = image_file.name  # 图片文件名(如:test.png)
        # 过滤非图片格式
        if not is_supported_image(img_name):
            logger.warning(f"跳过非图片文件:{img_name}")
            continue
        # 正则匹配 MD 中的图片语法:![...](...图片名...)
        # re.escape 处理文件名中带特殊字符(如括号、点)导致正则爆炸的问题
        pattern = re.compile(r"!\[.*?\]\(.*?" + re.escape(img_name) + ".*?\)")
        items = list(pattern.finditer(md_content))
        # 没有匹配到 → 跳过
        if not items:
            logger.warning(f"图片 {img_name} 未在 MD 中引用,跳过")
            continue
        # 获取图片在 MD 中的位置
        start, end = items[0].span()
        # 截取上下文(前后各100字符)
        pre_text = md_content[max(start - 100, 0): start]
        post_text = md_content[end: min(end + 100, len(md_content))]
        context = (pre_text, post_text)
        # 组装结果:文件名、图片完整路径、上下文
        # str(image_file) = 直接获取绝对路径(Path 自带,无需拼接)
        image_targets.append((img_name, str(image_file), context))
    return image_targets

为避免与其他re方法混淆,整理关键方法差异,现在场景用finditer是最优选择:

方法 核心作用 返回值 适用场景
compile 预编译正则,提升效率 Pattern 对象 多次调用同一正则时(推荐)
finditer 迭代查找所有非重叠匹配项 Match 迭代器 大文本 / 需获取所有匹配结果(你的场景)
findall 查找所有匹配项,返回字符串列表 列表(str, ... 简单场景,仅需匹配字符串
search 查找第一个匹配项 单个 Match 对象 / None 仅需第一个匹配结果

明确 2 个基础概念

  1. .* 贪婪匹配.* 表示匹配任意字符(.)任意次数(*,0 次或多次)贪婪特性会让它尽可能多的匹配字符,直到字符串末尾,再回头验证是否符合正则整体规则;
  2. .*? 非贪婪匹配 :在*后加?就变成了非贪婪模式,会让它尽可能少的匹配字符,只要满足正则整体规则,就立刻停止匹配,不会继续向后延伸。

匹配 Markdown 图片语法:!\[.*?\]\(.*?图片名.*?\),对应的 Markdown 图片格式是 ![图片描述](图片路径),比如一段包含多张图片的 Markdown 内容:

复制代码
这是第一张图![风景](a.jpg),这是第二张图![人物](b.jpg),这是第三张图![动物](c.jpg)

用贪婪匹配 .*(会出现匹配过度 ,完全不符合需求),如果把你的正则写成贪婪模式(去掉?):!\[.*\]\(.*a.jpg.*\),匹配结果会是:

复制代码
![风景](a.jpg),这是第二张图![人物](b.jpg),这是第三张图![动物](c.jpg)

贪婪的.*会「贪心」的尽可能多匹配:

  • 第一个.*匹配从[开始,一直到最后一个] (而不是第一个]);
  • 第二个 .* 匹配从(开始,一直到最后一个)

最终把从第一张图开始到最后一张图结束的所有内容都匹配成了「一个结果」,这就是匹配过度,完全无法精准获取单个图片标签。

假设有字符串:ab123ab456ab,正则匹配a.*b(贪婪)和a.*?b(非贪婪):

  • 贪婪a.*b:匹配结果是ab123ab456ab(从第一个 a 到最后一个 b,尽可能多匹配);
  • 非贪婪a.*?b:匹配结果是ab(从第一个 a 到第一个b,尽可能少匹配)。

4.5 步骤 3: 图片摘要

提取提示词,建议将所有的提示词提取放在外部,统一管理。我们存储的文件是根路径下 prompts文件夹

文件:prompts/image_summary.prompt

复制代码
这是"{root_folder}"文件中的一张图片,图片上文部分为"{image_content[0]}",
下文部分为"{image_content[1]}",请用中文简要总结这张图片的内容,用于 Markdown 图片标题,控制在50字以内。

加载提示词和格式化工具类

文件:app/core/load_prompt.py

python 复制代码
from pathlib import Path
from app.utils.path_util import PROJECT_ROOT
from app.core.logger import logger  # 可选,加日志更友好

def load_prompt(name: str, **kwargs) -> str:
    """
    加载提示词并渲染变量占位符
    :param name: 提示词文件名(不带.prompt后缀,如image_summary)
    :param **kwargs: 需渲染的变量键值对(如root_folder="测试文件", image_content=("上文内容", "下文内容"))
    :return: 渲染后的最终提示词字符串
    """
    # 1. 拼接提示词路径(你的原有逻辑,完全保留)
    prompt_path = PROJECT_ROOT / 'prompts' / f'{name}.prompt'

    # 2. 校验文件是否存在(可选,避免文件不存在直接报错)
    if not prompt_path.exists():
        raise FileNotFoundError(f"提示词文件不存在:{prompt_path.absolute()}")

    # 3. 读取纯文本提示词(你的原有逻辑)
    raw_prompt = prompt_path.read_text(encoding='utf-8')

    # 4. 核心:如果传了参数,渲染占位符;没传参,直接返回原文本
    if kwargs:
        rendered_prompt = raw_prompt.format(**kwargs)
        logger.debug(f"提示词渲染成功,替换变量:{list(kwargs.keys())}")
        return rendered_prompt
    return raw_prompt



if __name__ == '__main__':
    # 测试:传入参数渲染占位符(和业务代码中实际使用方式一致)
    root_folder = "hl3070使用说明书"  # 要替换的文件名称
    image_content = ("这是图片的上文内容", "这是图片的下文内容")  # 要替换的上下文
    # 调用时传入所有需要渲染的变量(键名必须和.prompt中的占位符完全一致)
    final_prompt = load_prompt(
        name='image_summary',
        root_folder=root_folder,  # 对应{root_folder}
        image_content=image_content  # 对应{image_content[0]}、{image_content[1]}
    )
    print("✅ 渲染后的最终提示词:")
    print(final_prompt)

这一步调用多模态大模型(如 GPT-4o 或 Qwen-VL)来生成图片的中文摘要,作为 Markdown 图片的 Alt Text。为了避免触发 API 速率限制,我们实现了简单的令牌桶算法。

python 复制代码
@step_log("step_3_generate_img_summaries")
def step_3_image_summary(image_targets, stem) -> Dict[str, str]:
    """
    总结图片,生成图片名.png - 对应的图片描述内容
    :param image_targets: 图片信息 [(文件名,文件地址,(上文,下文))]
    :param stem: 文件名 -> 提示词需要
    :return: 图片总结
    """
    # 1. 定义总结字典
    summaries = {}
    # 2. 定义任务队列 -> 模型访问队列 -> 限制访问次数
    # - 在 node_md_img.py 里 request_times = deque() 定义在函数内部。
    # - 所以每次调用 step_3_generate_img_summaries() 都会创建一个 新的队列 。
    # - 它只能限制"这一次函数调用里的图片循环速率", 不是全局限流 。
    # 也就是说:
    # - 单次任务内:有效(同一批图片会被限速)
    # - 多次请求/多任务并发:彼此不共享队列,不会互相限速
    # 如果你要"全局限制",要改成共享状态,比如:
    # - 模块级全局 deque (仅单进程有效)
    # - Redis 限流(多进程/多实例推荐,企业常用)
    # - 网关层限流(如 Nginx/API Gateway)
    requests_limiter = deque()

    for image_file,image_path,context in image_targets:
        # 访问限速问题(我们模型的限速标准 1分钟 可以访问10  限制并发访问次数..)
        # 具体要根据模型的配置 https://help.aliyun.com/zh/model-studio/rate-limit?spm=a2c4g.11186623.help-menu-2400256.d_0_0_3.29c5d355nLkkXf&scm=20140722.H_2840182._.OR_help-T_cn~zh-V_1
        apply_api_rate_limit(requests_limiter,max_requests=100)
        # 获取多模态模型对象
        vm_model = get_llm_client(model=lm_config.lv_model)
        # 准备提示词
        prompt = load_prompt(name="image_summary", root_folder=stem, image_content=context)
        # 将图片转成base64字符串
        # path.read_text()	读取文本(txt/md)	str 字符串
        # path.write_text()	写入文本	str 字符串
        # path.read_bytes()	读取二进制(图片 / 视频)	bytes 字节
        # path.write_bytes()	写入二进制(保存文件)	bytes 字节
        if isinstance(image_path, str):
            image_path = Path(image_path)
        # # 转成字符串 base64.b64encode(Path.read_bytes()) -> 转成base64字节数据格式 .decode() 转成字符串
        image_base64 = base64.b64encode(image_path.read_bytes()).decode("utf-8")

        message = HumanMessage(
            content=[
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{image_base64}"
                    }
                },
                {
                    "type": "text",
                    "text": prompt
                }
            ]
        )

        # 调用模型并获取结果
        chain = vm_model | StrOutputParser()
        summary = chain.invoke([message])
        summaries[image_file] = summary

    return summaries

4.6 步骤 4: 上传与替换

这一步将图片上传到 MinIO 对象存储,并将 Markdown 中的本地图片路径替换为 MinIO 的 URL,同时填入生成的摘要。

python 复制代码
@step_log("step_4_upload_images")
def step_4_upload_images_replace(image_summaries, image_targets, md_content, stem) -> str:
    """
    将图片上传到minio服务器!
    同时替代原md内容中的图片地址和描述内容!
    确保任何位置可以进行访问图片和现实
    :param image_summaries: 图片 和 总结
    :param image_targets: 图片名称 地址 和上下文
    :param md_content: 原md内容
    :param stem: md文件名
    :return: 替换后的md_content
    """
    # 1. 获取minio客户端对象
    minio_client = get_minio_client()
    # 2. 先清空原有md在minio中所在的图片
    # minio / 存储桶 / 文件名 / 图片
    object_list = minio_client.list_objects(
        bucket_name=minio_config.bucket_name,
        # 注意:{minio_config.minio_img_dir[1:]}  一定要去掉一个 /
        prefix=f"{minio_config.minio_img_dir[1:]}/{stem}",
        recursive=True
    )
    # 转化成minio的删除对象
    #
    delete_object_list = [
        DeleteObject(obj.object_name)
        for obj in object_list
    ]
    # 调用方法进行删除即可
    errors = minio_client.remove_objects(
        bucket_name=minio_config.bucket_name,
        delete_object_list=delete_object_list
    )
    for error in errors:
        logger.warning(f"删除图片失败: {error}")

    # 3.上传图片到minio服务器
    # 定义一个字段存储每张图片的信息 {图片名.minio_url地址}
    images_urls = {}
    for image_file,image_path,_ in image_targets:
        # 联网操作最好进行报错保护,避免直接单张失败直接异常
        try:
            minio_client.fput_object(
                bucket_name=minio_config.bucket_name,
                object_name=f"{minio_config.minio_img_dir}/{stem}/{image_file}",
                file_path=image_path,
                content_type="image/jpeg"
            )
            # 拼接完整路径 图片地址 = 协议 + 端点 + 桶名 + 对象名  http://47.94.86.115:9000/ 桶名 / 对象名
            images_urls[
                image_file] = f"http://{minio_config.endpoint}/{minio_config.bucket_name}/{minio_config.minio_img_dir}/{stem}/{image_file}"
            logger.debug(f"完成图片:{image_file}上传,URL:{images_urls[image_file]}")
        except Exception as e:
            logger.exception(f"上传图片失败:{image_file},失败原因:{e}")
            logger.debug("继续尝试上传下一张图片!")
    #4.拼接替换的完整资料 {image_file,(summary,url)}
    images_infos = {}
    for image_file,summary in image_summaries.items():
        images_infos[image_file] = (summary,images_urls[image_file])

    #5.进行md_content内容替换
    if images_infos:
        for image_file,(summary,url) in images_infos.items():
            # 定义正则
            # ![](/xxx/xx/image_file) -> ![无所谓](无所谓image_file无所谓)
            # 正则必须加固!否则图片名带特殊符号直接炸
            # 如果图片名叫:image(1).png image[2].png
            # 里面的 () . [] 都是正则特殊符号,直接报错!
            # 加固 re.escape (图片名) 作用:把图片名里的特殊符号自动转义,正则不会炸!
            rep = re.compile(r"!\[.*?\]\(.*?"+ re.escape(image_file) +".*?\)")
            # 进行替换
            # 方法	作用	结果	你用它来干嘛
            # findall	查找所有匹配	返回匹配到的文本列表	找图片、找内容
            # finditer	查找所有匹配	返回带位置的匹配对象	截取上下文、取位置
            # sub	    查找 + 替换	返回替换后的新文本	改内容、换链接
            # md_content = rep.sub(f"![{summary}]({url})", md_content)
            # - 每匹配到一次,就执行这个函数
            # - 函数返回什么字符串,就原样拿去替换
            # - re 不再去解析里面的 \T 、 \1 这些东西
            md_content = rep.sub(lambda _: f"![{summary}]({url})", md_content)
    logger.debug(f"完成新旧md内容替换,最新内容:{md_content[:200]}")
    return md_content

4.7 步骤 5: 备份文件

最后,我们将处理后的 Markdown 内容保存为一个新的文件,通常命名为 *_new.md

python 复制代码
@step_log("step_5_backup_md_file")
def step_5_backup_md_file(md_path_obj, new_md_content) -> str:
    """
    完成新的md的磁盘备份,并且返回新的地址!
    新的命名规则: 原名称_new.md
    :param md_path_obj:
    :param new_md_content:
    :return: 返回新地址
    """
    #   c:/xxx/xxx/xxx/xxxx/erdaye.md
    #   -》 c:/xxx/xxx/xxx/xxxx/erdaye _new.md
    # new_md_path_obj = md_path_obj.with_stem(f"{md_path_obj.stem}_new")
    new_md_path_obj = md_path_obj.parent / (md_path_obj.stem + "_new" + md_path_obj.suffix)

    new_md_path_obj.write_text(new_md_content,encoding="utf-8")

    return str(new_md_path_obj)

4.8 单元测试

您可以在 node_md_img.py 文件底部直接运行以下测试代码:

python 复制代码
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}")
相关推荐
C++、Java和Python的菜鸟1 小时前
第3章 从0开始用若依
java·开发语言
Json____1 小时前
五金制品行业-企业官网源码
java·大数据·数据库·企业站·wwwoop.com
夜雪一千1 小时前
Python 如何实现 SHA 加密?SHA1 / SHA256 / SHA512 实战教程
开发语言·python
alexhilton2 小时前
千万别误用Android Skills
android·kotlin·android jetpack
有点。2 小时前
C++二叉树(一)
开发语言·数据结构·c++
民乐团扒谱机2 小时前
【微实验】谐波乘积谱(HPS)算法深度解析:原理、数学与代码实现
开发语言·人工智能·python·算法·语音识别·音乐
报错小能手2 小时前
Go 语言结构 基础语法
开发语言·后端·golang
Lazionr2 小时前
stack与queue:底层实现与容器适配器
开发语言·数据结构·c++
剩下了什么2 小时前
go语言 Ctx:「错误三:在 Context 中存储可变值」
开发语言·后端·golang