RAG知识库新增文搜图+图搜图检索能力

9 阅读11分钟

RAG知识库新增文搜图+图搜图检索能力

升级说明 & 能力价值

基于现有的图片向量化基础版能力,无缝扩展两大核心检索能力,形成「文本+PDF+图片」三位一体的全模态检索体系,所有能力复用同一套技术栈,无任何新增组件依赖:

  1. 文搜图 (IMAGE_TEXT_SEARCH):输入文本关键词 → 检索知识库中语义相似的图片(如:输入「产品部署流程图」→ 返回所有相关流程图图片)
  2. 图搜图 (IMAGE_IMAGE_SEARCH):上传一张图片 → 检索知识库中视觉+语义相似的图片(如:上传一张产品LOGO → 返回所有同款/相似LOGO图片)

核心升级优势

  1. 100%无缝兼容:所有新增逻辑复用原有混合检索内核,向量检索/关键词加权/LLM重排序/智能去重逻辑完全不变,无重构成本
  2. 完美适配ReAct智能体:新增2个动作枚举,直接接入ReAct的「思考-行动」循环,大模型可自主决策调用「文搜图/图搜图」能力
  3. 全链路复用现有工具:复用图片预处理、向量化、MinIO存储、ES检索、Rerank重排序所有能力,无重复开发
  4. 精细化过滤能力:支持按「图片分类、格式、业务标签」自定义过滤,精准检索目标图片
  5. 生产级健壮性:新增动作执行自动重试机制+异常兜底,单环节异常不影响整体服务可用性
  6. 向量维度统一:图片向量与文本/PDF向量维度一致,无兼容性问题,检索结果精准度拉满

完整能力矩阵(本次升级后)

至此RAG知识库已具备全场景检索能力,覆盖所有主流业务需求,形成完整闭环:

  1. 基础能力:文本搜文本、文本搜PDF文档、PDF切片检索
  2. 新增能力:文本搜图片、图片搜图片
  3. 增强能力:多轮上下文感知检索、批量检索、ReAct智能体动态决策检索

1. 动作枚举类 ActionType 扩展

/**
 * ReAct智能体 动作类型枚举【完整版】
 * 覆盖所有工具能力:知识库检索/文件下载/直接回答/重试/结束 + 新增【文搜图/图搜图】
 * 新增能力无缝接入,零侵入改造,大模型可自主决策调用对应检索能力
 */
public enum ActionType {
    // 知识库混合查询(核心动作:文本/PDF检索)
    KNOWLEDGE_SEARCH,
    // MinIO 文件下载/预览(复用现有文件服务)
    FILE_DOWNLOAD,
    // 直接回答用户问题(无需调用工具)
    DIRECT_ANSWER,
    // 重试上一步失败的动作
    RETRY,
    // 结束任务,返回最终答案
    FINISH,
    // ==========  新增核心能力 ==========
    /** 文搜图:文本关键词检索相似图片(如:搜索「产品logo」返回相关图片) */
    IMAGE_TEXT_SEARCH,
    /** 图搜图:上传图片检索相似图片(如:上传一张图片返回同款/相似图片) */
    IMAGE_IMAGE_SEARCH
}

2. 动作参数模型 ActionParams 补充核心字段

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;

/**
 * ReAct智能体 动作参数模型【完整版,适配图片检索】
 * 补充图搜图必备的图片文件字段,兼容所有动作入参,无侵入修改
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ActionParams {
    // 知识库检索/文搜图 专用:检索文本/关键词
    private String searchQuery;
    // 全场景检索专用:过滤条件(如:图片分类、格式、文件类型,可选)
    private Map<String, Object> filter;
    // MinIO文件操作专用:文件存储路径/对象名
    private String objectName;
    // 直接回答/结束任务专用:回答内容
    private String answer;
    // ==========  新增:图搜图专用核心字段 ==========
    // 图片检索专用:前端上传的图片文件(图搜图时必传)
    private MultipartFile imageFile;
}

混合检索服务 HybridSearchService

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.util.*;
import java.util.stream.Collectors;

/**
 * RAG混合检索核心服务【完整版】
 * 核心能力:文本检索/文档检索 + 新增【文搜图+图搜图】+ 上下文感知检索 + 批量检索
 * 核心特性:向量检索+关键词加权+同文件去重+LLM二次重排序,全模态检索能力全覆盖,无缝兼容ReAct智能体
 */
@Service
@Slf4j
public class HybridSearchService {

    private final ElasticsearchClient client;
    private final EmbeddingModel embeddingModel;

    @Resource
    private RerankService rerankService;
    @Resource
    private ImagePreProcessUtil imagePreProcessUtil;
    @Resource
    private ImageVectorizationService imageVectorizationService;

    @Autowired
    public HybridSearchService(ElasticsearchClient client, EmbeddingModel embeddingModel) {
        this.client = client;
        this.embeddingModel = embeddingModel;
    }

    // ========== 核心检索配置(生产级推荐:后续抽离至yml配置文件) ==========
    private static final int K = 20;                // 粗召回候选数:保证召回率
    private static final int FINAL_K = 8;           // 最终返回数:兼顾精准度和上下文长度
    private final Float SIMILARITY_THRESHOLD = 0.6f;// 相似度阈值:过滤低相关内容,提升精准度
    private static final String VECTOR_FIELD = "vector"; // 向量检索核心字段
    private static final String CONTENT_FIELD = "content";// 关键词检索核心字段
    private static final String IMAGE_FILE_TYPE = "IMAGE";// 图片类型标识,用于过滤图片文档

    // ==========  核心方法:文本+PDF混合检索  ==========
    public List<DocFragment> hybridSearch(String index, String queryText) throws Exception {
        if (StringUtils.isBlank(index) || StringUtils.isBlank(queryText)) {
            log.warn("混合检索入参为空,index:{}, queryText:{}", index, queryText);
            return Collections.emptyList();
        }
        log.info("开始混合检索,索引:{},查询文本:{}", index, queryText);

        float[] queryVector = embeddingModel.embed(queryText);
        List<Float> floatList = Arrays.stream(queryVector).boxed().collect(Collectors.toList());

        SearchResponse<DocFragment> response = client.search(
                s -> s.index(index)
                        .knn(k -> k
                                .field(VECTOR_FIELD)
                                .queryVector(floatList)
                                .k(K)
                                .numCandidates(100)
                        )
                        .query(q -> q.functionScore(fs -> fs
                                .query(qb -> qb.match(m->m.field(CONTENT_FIELD).query(queryText)))
                                .functions(fn -> fn.filter(fil -> fil.match(m -> m.field(CONTENT_FIELD).query(queryText))).weight(0.3))
                                .boostMode(FunctionBoostMode.Sum)
                        ))
                        .size(K),
                DocFragment.class
        );

        if (Objects.isNull(response) || CollectionUtils.isEmpty(response.hits().hits())) {
            log.info("混合检索无结果,索引:{},查询文本:{}", index, queryText);
            return Collections.emptyList();
        }

        List<DocFragment> deduplicateFragments = response.hits().hits().stream()
                .collect(Collectors.groupingBy(hit -> hit.source().getId()))
                .values().stream()
                .map(group -> group.stream().max(Comparator.comparing(Hit::score)).get())
                .sorted(Comparator.comparing(Hit::score, Comparator.reverseOrder()))
                .limit(FINAL_K)
                .map(Hit::source)
                .collect(Collectors.toList());

        List<DocFragment> finalResult = rerankService.rerank(deduplicateFragments, queryText);
        log.info("混合检索完成,去重前{}条,去重后{}条,最终{}条",
                response.hits().hits().size(), deduplicateFragments.size(), finalResult.size());
        return finalResult;
    }

    // ==========  上下文感知检索  ==========
    public List<DocFragment> hybridSearchWithContext(String index, String queryText, String contextText) throws Exception {
        String searchText = StringUtils.isBlank(contextText) ? queryText : contextText + "," + queryText;
        log.info("上下文感知检索,拼接后检索文本:{}", searchText);
        return this.hybridSearch(index, searchText);
    }

    // ==========  批量混合检索  ==========
    public Map<String, List<DocFragment>> batchHybridSearch(String index, List<String> queryTexts) throws Exception {
        Map<String, List<DocFragment>> resultMap = new HashMap<>(queryTexts.size());
        if (CollectionUtils.isEmpty(queryTexts) || StringUtils.isBlank(index)) {
            return resultMap;
        }
        log.info("开始批量混合检索,索引:{},批量查询数量:{}", index, queryTexts.size());
        for (String queryText : queryTexts) {
            if (StringUtils.isNotBlank(queryText)) {
                resultMap.put(queryText, this.hybridSearch(index, queryText));
            }
        }
        return resultMap;
    }

    // ==========  新增核心方法【文搜图 - 生产级】:文本关键词检索相似图片  ==========
    /**
     * 文搜图核心能力:输入文本关键词 → 检索知识库中语义相似的图片
     * 核心逻辑:文本生成向量 + ES向量检索(图片) + BM25关键词检索(图片名称/分类) + 自定义过滤 + LLM重排序
     * @param index ES索引名
     * @param queryText 检索文本(如:产品logo、部署流程图、功能截图)
     * @param filterParams 精细化过滤条件:{"category":"产品图片", "imageFormat":"PNG", "business":"运营"}
     * @return 按相似度排序的高相关图片列表
     */
    public List<DocFragment> textSearchImage(String index,String queryText, Map<String, Object> filterParams) throws Exception {
        // 1. 入参空值校验,快速返回
        if (StringUtils.isBlank(index) || StringUtils.isBlank(queryText)) {
            log.warn("文搜图入参为空,index:{}, queryText:{}", index, queryText);
            return Collections.emptyList();
        }
        log.info("开始文搜图检索,索引:{},检索文本:{},过滤条件:{}", index, queryText, filterParams);

        // 2. 文本生成检索向量,复用文本向量化能力,向量维度一致
        float[] queryVector = embeddingModel.embed(queryText);
        List<Float> floatList = Arrays.stream(queryVector).boxed().collect(Collectors.toList());

        // 3. 构建ES精准查询:只检索图片类型 + 向量检索+关键词加权 + 自定义过滤
        BoolQuery.Builder boolQuery = QueryBuilders.bool();
        // 核心过滤:只返回图片类型的文档,不干扰文本/PDF检索结果
        boolQuery.filter(f -> f.term(t -> t.field("fileType").value(IMAGE_FILE_TYPE)));
        // 混合加权:向量相似度70%权重 + 图片名称/分类关键词30%权重,兼顾语义与精准命中
        boolQuery.must(q -> q.functionScore(fs -> fs
                .query(k -> k.knn(knn -> knn
                        .field(VECTOR_FIELD)
                        .queryVector(floatList)
                        .k(K)
                        .numCandidates(100)
                ))
                .functions(fn -> fn.filter(fil -> fil.match(m -> m.field(CONTENT_FIELD).query(queryText))).weight(0.3))
                .boostMode(FunctionBoostMode.Sum)
        ));

        // 4. 拼接自定义过滤条件,支持多维度精准筛选图片
        if (filterParams != null && !filterParams.isEmpty()) {
            filterParams.forEach((field, value) -> {
                boolQuery.filter(f -> f.term(t -> t.field(field).value(value.toString())));
            });
        }

        // 5. 执行ES检索,只返回图片核心字段,提升检索性能
        SearchResponse<DocFragment> response = client.search(
                s -> s.index(index)
                        .query(boolQuery.build()._toQuery())
                        .size(K)
                        .fields(f->f.field("id"),f->f.field("fileName"),f->f.field("vector"),f->f.field("imageFormat"),f->f.field("category")),
                DocFragment.class
        );

        // 6. 复用同文件去重+相似度排序+截取+LLM重排序
        List<DocFragment> result = buildImageSearchResult(response);
        log.info("文搜图检索完成,最终返回相似图片数量:{}", result.size());
        // LLM二次语义重排序,基于文本关键词对图片做精准度排序
        return rerankService.rerank(result, queryText);
    }

    // ==========  新增核心方法【图搜图 - 生产级】:图片检索相似图片  ==========
    /**
     * 图搜图核心能力:上传一张图片 → 检索知识库中视觉+语义相似的图片
     * 核心逻辑:图片预处理 → Base64编码 → 生成图片向量 → ES向量检索(图片) + 自定义过滤 + LLM重排序
     * @param index ES索引名
     * @param imageFile 前端上传的检索图片
     * @param filterParams 精细化过滤条件:{"category":"技术截图", "imageFormat":"JPG"}
     * @return 按相似度排序的高相似图片列表
     */
    public List<DocFragment> imageSearchImage(String index, MultipartFile imageFile, Map<String, Object> filterParams) throws Exception {
        // 1. 入参空值校验,快速返回
        if (StringUtils.isBlank(index) || imageFile == null || imageFile.isEmpty()) {
            log.warn("图搜图入参为空,index:{}, imageFile:{}", index, imageFile);
            return Collections.emptyList();
        }
        log.info("开始图搜图检索,索引:{},图片名称:{},过滤条件:{}", index, imageFile.getOriginalFilename(), filterParams);

        // 2. 图片标准化预处理 + 生成图片向量,复用图片向量化能力,向量维度一致
        InputStream preProcessStream = imagePreProcessUtil.preProcess(imageFile);
        String fileMd5 = imageVectorizationService.getFileMd5(imageFile.getBytes());
        float [] queryVector = imageVectorizationService.embedImage(preProcessStream, fileMd5);
        List<Float> floatList = Arrays.stream(queryVector).boxed().collect(Collectors.toList());

        // 3. 复用文搜图的查询逻辑,完全一致:只检索图片 + 向量检索 + 自定义过滤
        BoolQuery.Builder boolQuery = QueryBuilders.bool();
        boolQuery.filter(f -> f.term(t -> t.field("fileType").value(IMAGE_FILE_TYPE)));
        boolQuery.must(q -> q.functionScore(fs -> fs
                .query(k -> k.knn(knn -> knn
                        .field(VECTOR_FIELD)
                        .queryVector(floatList)
                        .k(K)
                        .numCandidates(100)
                ))
                .boostMode(FunctionBoostMode.Sum)
        ));

        // 4. 拼接自定义过滤条件
        if (filterParams != null && !filterParams.isEmpty()) {
            filterParams.forEach((field, value) -> {
                boolQuery.filter(f -> f.term(t -> t.field(field).value(value.toString())));
            });
        }

        // 5. 执行ES检索,返回图片核心字段
        SearchResponse<DocFragment> response = client.search(
                s -> s.index(index)
                        .query(boolQuery.build()._toQuery())
                        .size(K)
                        .fields(f->f.field("id"),f->f.field("fileName"),f->f.field("vector"),f->f.field("imageFormat"),f->f.field("category")),
                DocFragment.class
        );

        // 6. 复用结果处理逻辑,无冗余代码
        List<DocFragment> result = buildImageSearchResult(response);
        log.info("图搜图检索完成,最终返回相似图片数量:{}", result.size());
        // LLM重排序:基于图片名称做语义排序,提升结果精准度
        return rerankService.rerank(result, imageFile.getOriginalFilename());
    }

    // ========== 私有工具方法:图片检索结果统一处理,复用逻辑,减少冗余 ==========
    private List<DocFragment> buildImageSearchResult(SearchResponse<DocFragment> response) {
        if (Objects.isNull(response) || CollectionUtils.isEmpty(response.hits().hits())) {
            return Collections.emptyList();
        }
        // 同文件去重 + 相似度倒序 + 截取最终数量
        return response.hits().hits().stream()
                .collect(Collectors.groupingBy(hit -> hit.source().getId()))
                .values().stream()
                .map(group -> group.stream().max(Comparator.comparing(Hit::score)).get())
                .sorted(Comparator.comparing(Hit::score, Comparator.reverseOrder()))
                .limit(FINAL_K)
                .map(Hit::source)
                .collect(Collectors.toList());
    }
}

ReAct动作执行器 ReActActionExecutor

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * ReAct智能体 动作执行器【完整版】
 * 核心能力:执行所有标准化动作,包含原有能力 + 新增【文搜图+图搜图】
 * 核心特性:自动重试机制+异常兜底+全量空值校验+标准化返回结果,无缝兼容ReAct思考-行动循环
 */
@Component
@Slf4j
@RequiredArgsConstructor
public class ReActActionExecutor {
    // 注入现有核心服务,全链路复用
    private final HybridSearchService hybridSearchService;
    private final MinioUtils minioUtils;
    @Resource
    private ChatClientConfig.ChatClientFactory chatClientFactory;

    // ========== 生产级常量配置 ==========
    private static final int MAX_RETRY_COUNT = 2;  // 最大重试次数,防止无限重试
    private static final String ES_INDEX = "ai_vector_index"; // ES向量索引名,统一配置
    private static final String EMPTY_RESULT = "未检索到相关图片内容"; // 空结果友好提示

    /**
     * 核心执行方法:调度所有动作类型,包含新增的图片检索动作
     * @param actionType 动作类型(含文搜图/图搜图)
     * @param params 动作参数(含图片文件/检索文本/过滤条件)
     * @return 标准化执行结果,供ReAct思考器做下一步决策
     */
    public String execute(ActionType actionType, ActionParams params) {
        int retryCount = 0;
        // 核心重试逻辑:异常时重试,最多2次,保障服务可用性
        while (retryCount < MAX_RETRY_COUNT) {
            try {
                return switch (actionTable) {
                    case KNOWLEDGE_SEARCH -> executeKnowledgeSearch(params);
                    case FILE_DOWNLOAD -> executeFileDownload(params);
                    case DIRECT_ANSWER -> executeDirectAnswer(params);
                    case RETRY -> "已执行重试动作,重新调用上一步工具";
                    case FINISH -> StringUtils.isNotBlank(params.getAnswer()) ? params.getAnswer() : "任务已完成";
                    
                    // ==========  新增核心动作:文搜图 执行逻辑 ==========
                    case IMAGE_TEXT_SEARCH -> {
                        String searchQuery = params.getSearchQuery();
                        Map<String, Object> filter = params.getFilter();
                        // 空值校验
                        if (StringUtils.isBlank(searchQuery)) {
                            yield "文搜图失败:检索文本不能为空";
                        }
                        // 执行文搜图检索
                        List<DocFragment> images = hybridSearchService.textSearchImage(ES_INDEX, searchQuery, filter);
                        if (images.isEmpty()) yield EMPTY_RESULT;
                        // 拼接标准化结果:图片名称+预览链接,便于大模型生成回答
                        yield images.stream()
                                .map(f -> String.format("【相似图片】%s(分类:%s)→ 预览链接:%s", 
                                        f.getFileName(), f.getCategory(), minioUtils.getFilePreviewUrl(f.getId())))
                                .collect(Collectors.joining("\n\n"));
                    }
                    
                    // ==========  新增核心动作:图搜图 执行逻辑 ==========
                    case IMAGE_IMAGE_SEARCH -> {
                        MultipartFile imageFile = params.getImageFile();
                        Map<String, Object> filter = params.getFilter();
                        // 空值校验
                        if (imageFile == null || imageFile.isEmpty()) {
                            yield "图搜图失败:上传的图片不能为空";
                        }
                        // 执行图搜图检索
                        List<DocFragment> images = hybridSearchService.imageSearchImage(ES_INDEX, imageFile, filter);
                        if (images.isEmpty()) yield EMPTY_RESULT;
                        // 拼接标准化结果:图片名称+预览链接,信息完整
                        yield images.stream()
                                .map(f -> String.format("【高度相似图片】%s(格式:%s)→ 预览链接:%s", 
                                        f.getFileName(), f.getImageFormat(), minioUtils.getFilePreviewUrl(f.getId())))
                                .collect(Collectors.joining("\n\n"));
                    }
                };
            } catch (Exception e) {
                retryCount++;
                log.error("动作执行失败:{},开始第{}次重试", actionType, retryCount, e);
                // 重试耗尽,返回最终异常提示
                if (retryCount >= MAX_RETRY_COUNT) {
                    return String.format("【%s】调用失败,原因:%s,已重试%d次,无法完成检索", 
                            actionType.name(), e.getMessage(), MAX_RETRY_COUNT);
                }
            }
        }
        return "工具调用超时,已为你整理相关检索信息,请参考";
    }

    // ========== 知识库检索  ==========
    private String executeKnowledgeSearch(ActionParams params) throws Exception {
        String searchQuery = params.getSearchQuery();
        List<DocFragment> fragments = hybridSearchService.hybridSearch(ES_INDEX, searchQuery);
        return fragments.stream()
                .map(DocFragment::getContent)
                .collect(Collectors.joining("\n\n"));
    }

    // ========== MinIO文件下载  ==========
    private String executeFileDownload(ActionParams params) {
        String objectName = params.getObjectName();
        String previewUrl = minioUtils.getFilePreviewUrl(objectName);
        return String.format("文件下载成功 → 预览链接:%s", previewUrl);
    }

    // ========== 直接回答  ==========
    private String executeDirectAnswer(ActionParams params) {
        return chatClientFactory.getDefaultChatClient().prompt()
                .user(params.getAnswer())
                .call()
                .content();
    }
}

生产落地关键注意事项

性能优化建议

  1. 图片向量生成可开启Redis缓存(按图片MD5缓存向量),避免相同图片重复生成向量,提升图搜图效率
  2. 批量图片检索场景可做异步处理,避免同步调用导致的接口超时
  3. ES检索的K值(粗召回数)可根据图片数量调整,图片量越大,K值可适当调大(如30),保证召回率

功能扩展建议

  1. 新增「图片批量检索」能力,适配批量图片查询场景,复用现有批量检索逻辑即可
  2. 新增「图文混合检索」能力,输入文本+上传图片,同时检索文本和图片结果,满足复杂检索需求
  3. 图片检索结果新增「相似度分值」展示,便于前端排序/展示,提升用户体验