Parcourir la source

支持按配置编码管理视频向量及搜索功能

wangyunpeng il y a 8 heures
Parent
commit
8bf3ee94bb

+ 302 - 92
core/src/main/java/com/tzld/videoVector/job/VideoVectorJob.java

@@ -1,13 +1,20 @@
 package com.tzld.videoVector.job;
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.aliyun.odps.data.Record;
 import com.tzld.videoVector.dao.mapper.videoVector.deconstruct.DeconstructContentMapper;
+import com.tzld.videoVector.dao.mapper.videoVector.deconstruct.DeconstructVectorConfigMapper;
 import com.tzld.videoVector.model.entity.DeconstructResult;
 import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructContent;
 import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructContentExample;
-import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructContentVector;
 import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructVectorConfig;
+import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructVectorConfigExample;
 import com.tzld.videoVector.service.DeconstructService;
-import com.tzld.videoVector.service.VectorizeService;
+import com.tzld.videoVector.service.EmbeddingService;
+import com.tzld.videoVector.service.VectorStoreService;
+import com.tzld.videoVector.util.OdpsUtil;
 import com.xxl.job.core.biz.model.ReturnT;
 import com.xxl.job.core.handler.annotation.XxlJob;
 import lombok.extern.slf4j.Slf4j;
@@ -16,10 +23,7 @@ import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
 import javax.annotation.Resource;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.List;
-import java.util.Set;
+import java.util.*;
 import java.util.stream.Collectors;
 
 
@@ -30,11 +34,17 @@ public class VideoVectorJob {
     @Resource
     private DeconstructContentMapper deconstructContentMapper;
 
+    @Resource
+    private DeconstructVectorConfigMapper vectorConfigMapper;
+
     @Resource
     private DeconstructService deconstructService;
 
     @Resource
-    private VectorizeService vectorizeService;
+    private VectorStoreService vectorStoreService;
+
+    @Resource
+    private EmbeddingService embeddingService;
 
     /**
      * 每页查询数量
@@ -46,6 +56,11 @@ public class VideoVectorJob {
      */
     private static final long TIMEOUT_MS = 60 * 60 * 1000L;
 
+    /**
+     * 内容类型:视频
+     */
+    private static final byte CONTENT_TYPE_VIDEO = 3;
+
     /**
      * 视频向量化
      * 根据配置对解构内容进行向量化
@@ -57,88 +72,93 @@ public class VideoVectorJob {
     public ReturnT<String> vectorVideoJob(String param) {
         log.info("开始执行视频向量化任务, param: {}", param);
 
-        int totalSuccessCount = 0;
-        int totalFailCount = 0;
-        int totalSkipCount = 0;
-        int pageNum = 0;
-
         try {
-            // 1. 获取向量配置(视频类型 content_type=3)
-            List<DeconstructVectorConfig> configs = vectorizeService.getVectorConfigs(null, 3);
+            // 1. 获取所有启用的向量化配置(content_type=3 视频)
+            List<DeconstructVectorConfig> configs = getEnabledConfigs(CONTENT_TYPE_VIDEO);
             if (CollectionUtils.isEmpty(configs)) {
-                log.warn("未找到视频类型的向量配置");
+                log.warn("未找到启用的向量化配置");
                 return ReturnT.SUCCESS;
             }
-            log.info("加载 {} 个向量配置", configs.size());
+            log.info("加载 {} 个向量化配置", configs.size());
+
+            int totalSuccessCount = 0;
+            int totalFailCount = 0;
+            int pageNum = 0;
 
             while (true) {
-                // 2. 分页查询解构成功的内容
-                List<DeconstructContent> contents = querySuccessContentsByPage(pageNum, PAGE_SIZE, (byte) 3);
-                if (CollectionUtils.isEmpty(contents)) {
+                // 2. 分页查询 videoId 列表
+                List<Long> videoIds = queryVideoIdsByPage(pageNum, PAGE_SIZE);
+                if (videoIds == null || videoIds.isEmpty()) {
                     log.info("第 {} 页没有查询到数据,分页查询结束", pageNum);
                     break;
                 }
-                log.info("第 {} 页查询到 {} 条解构内容", pageNum, contents.size());
-
-                // 3. 逐个处理内容
-                for (DeconstructContent content : contents) {
-                    try {
-                        // 3.1 查询已有向量
-                        List<DeconstructContentVector> existingVectors = vectorizeService.getVectorsByContentId(content.getId());
-                        Set<String> existingFields = existingVectors.stream()
-                                .map(DeconstructContentVector::getSourceField)
-                                .collect(Collectors.toSet());
-
-                        // 3.2 遍历配置,对缺失的向量进行补充
-                        boolean hasNewVector = false;
-                        for (DeconstructVectorConfig config : configs) {
-                            String sourceField = config.getSourceField();
-
-                            // 检查是否已有该字段的向量
-                            if (existingFields.contains(sourceField)) {
-                                log.debug("contentId={} 已有 {} 字段向量,跳过", content.getId(), sourceField);
+                log.info("第 {} 页查询到 {} 个 videoId", pageNum, videoIds.size());
+
+                // 3. 批量查询视频详情(包含 raw_result)
+                Map<Long, String> videoRawResults = batchQueryVideoRawResults(videoIds);
+
+                // 4. 对每个配置进行处理
+                for (DeconstructVectorConfig config : configs) {
+                    String configCode = config.getConfigCode();
+                    
+                    // 4.1 查询哪些 videoId 在该配置下已有向量
+                    Set<Long> existingIds = vectorStoreService.existsByIds(configCode, videoIds);
+                    
+                    // 4.2 过滤出需要处理的 videoId
+                    List<Long> needProcessIds = videoIds.stream()
+                            .filter(id -> !existingIds.contains(id) && videoRawResults.containsKey(id))
+                            .collect(Collectors.toList());
+                    
+                    if (needProcessIds.isEmpty()) {
+                        log.debug("配置 {} 下所有视频已有向量,跳过", configCode);
+                        continue;
+                    }
+                    
+                    log.info("配置 {} 需要处理 {} 个视频", configCode, needProcessIds.size());
+
+                    // 4.3 逐个处理
+                    for (Long videoId : needProcessIds) {
+                        try {
+                            String rawResult = videoRawResults.get(videoId);
+                            if (!StringUtils.hasText(rawResult)) {
+                                log.debug("videoId={} raw_result 为空,跳过", videoId);
+                                totalFailCount++;
                                 continue;
                             }
 
-                            // 检查原始数据是否有该字段内容
-                            if (!hasSourceContent(content, config)) {
-                                log.debug("contentId={} 无 {} 字段原始数据,跳过", content.getId(), sourceField);
+                            // 根据配置提取文本
+                            List<String> texts = extractTextsFromRawResult(rawResult, config);
+                            if (CollectionUtils.isEmpty(texts)) {
+                                log.debug("videoId={} 配置 {} 未提取到文本,跳过", videoId, configCode);
+                                totalFailCount++;
                                 continue;
                             }
 
-                            // 执行向量化
-                            log.info("contentId={} 开始向量化字段 {}", content.getId(), sourceField);
-                            List<DeconstructContentVector> newVectors = vectorizeService.vectorizeByConfig(content, config);
-                            if (!CollectionUtils.isEmpty(newVectors)) {
-                                vectorizeService.batchSaveVectors(newVectors);
-                                hasNewVector = true;
+                            // 向量化并存储
+                            boolean success = vectorizeAndStore(config, videoId, texts);
+                            if (success) {
                                 totalSuccessCount++;
-                                log.info("contentId={} 字段 {} 向量化完成,生成 {} 条向量",
-                                        content.getId(), sourceField, newVectors.size());
+                            } else {
+                                totalFailCount++;
                             }
-                        }
 
-                        if (!hasNewVector) {
-                            totalSkipCount++;
+                        } catch (Exception e) {
+                            log.error("处理 videoId={} 配置 {} 时发生异常: {}", videoId, configCode, e.getMessage(), e);
+                            totalFailCount++;
                         }
-
-                    } catch (Exception e) {
-                        log.error("处理 contentId={} 时发生异常: {}", content.getId(), e.getMessage(), e);
-                        totalFailCount++;
                     }
                 }
 
                 // 如果查询到的数据少于 PAGE_SIZE,说明已经是最后一页
-                if (contents.size() < PAGE_SIZE) {
-                    log.info("第 {} 页数据量 {} 小于 PAGE_SIZE {},分页查询结束", pageNum, contents.size(), PAGE_SIZE);
+                if (videoIds.size() < PAGE_SIZE) {
+                    log.info("第 {} 页数据量 {} 小于 PAGE_SIZE {},分页查询结束", pageNum, videoIds.size(), PAGE_SIZE);
                     break;
                 }
 
                 pageNum++;
             }
 
-            log.info("视频向量化任务完成,总成功: {}, 总失败: {}, 总跳过: {}, 总页数: {}",
-                    totalSuccessCount, totalFailCount, totalSkipCount, pageNum + 1);
+            log.info("视频向量化任务完成,总成功: {}, 总失败: {}, 总页数: {}", totalSuccessCount, totalFailCount, pageNum + 1);
             return ReturnT.SUCCESS;
 
         } catch (Exception e) {
@@ -148,45 +168,235 @@ public class VideoVectorJob {
     }
 
     /**
-     * 分页查询解构成功的内容
-     *
-     * @param pageNum     页码
-     * @param pageSize    每页数量
-     * @param contentType 内容类型
-     * @return 内容列表
+     * 获取启用的向量化配置
+     */
+    private List<DeconstructVectorConfig> getEnabledConfigs(byte contentType) {
+        DeconstructVectorConfigExample example = new DeconstructVectorConfigExample();
+        example.createCriteria()
+                .andEnabledEqualTo((byte) 1)
+                .andContentTypeEqualTo(contentType);
+        example.setOrderByClause("priority ASC");
+        return vectorConfigMapper.selectByExample(example);
+    }
+
+    /**
+     * 批量查询视频的 raw_result
      */
-    private List<DeconstructContent> querySuccessContentsByPage(int pageNum, int pageSize, Byte contentType) {
-        DeconstructContentExample example = new DeconstructContentExample();
-        DeconstructContentExample.Criteria criteria = example.createCriteria();
-        criteria.andStatusEqualTo((byte) 2); // SUCCESS
-        if (contentType != null) {
-            criteria.andContentTypeEqualTo(contentType);
+    private Map<Long, String> batchQueryVideoRawResults(List<Long> videoIds) {
+        if (CollectionUtils.isEmpty(videoIds)) {
+            return Collections.emptyMap();
         }
-        example.setOrderByClause("id ASC LIMIT " + (pageNum * pageSize) + ", " + pageSize);
-        return deconstructContentMapper.selectByExampleWithBLOBs(example);
+        
+        // 构建IN查询
+        String idsStr = videoIds.stream()
+                .map(String::valueOf)
+                .collect(Collectors.joining(","));
+        
+        String sql = String.format(
+                "SELECT video_id, raw_result " +
+                        "FROM videoods.content_profile " +
+                        "WHERE status = 3 AND is_deleted = 0 AND video_id IN (%s)",
+                idsStr);
+        
+        List<Record> records = OdpsUtil.getOdpsData(sql);
+        if (records == null || records.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        
+        Map<Long, String> result = new HashMap<>();
+        for (Record record : records) {
+            Long videoId = record.getBigint("video_id");
+            String rawResult = record.getString("raw_result");
+            if (videoId != null && rawResult != null) {
+                result.put(videoId, rawResult);
+            }
+        }
+        return result;
     }
 
     /**
-     * 检查内容是否有配置所需的原始数据
-     *
-     * @param content 内容
-     * @param config  配置
-     * @return 是否有原始数据
+     * 根据配置从 raw_result 中提取文本
+     */
+    private List<String> extractTextsFromRawResult(String rawResult, DeconstructVectorConfig config) {
+        List<String> texts = new ArrayList<>();
+        
+        try {
+            JSONObject json = JSON.parseObject(rawResult);
+            if (json == null) {
+                return texts;
+            }
+            
+            String sourcePath = config.getSourcePath();
+            if (!StringUtils.hasText(sourcePath)) {
+                return texts;
+            }
+            
+            // 解析路径并提取
+            texts.addAll(extractFromJson(json, sourcePath));
+            
+        } catch (Exception e) {
+            log.error("解析 raw_result 失败: {}", e.getMessage());
+        }
+        
+        return texts;
+    }
+
+    /**
+     * 从JSON中提取文本
+     * 支持路径格式:$.final_normalization_rebuild.topic_fusion_result.最终选题.选题
+     */
+    private List<String> extractFromJson(JSONObject json, String path) {
+        List<String> results = new ArrayList<>();
+        
+        if (json == null || !StringUtils.hasText(path)) {
+            return results;
+        }
+        
+        try {
+            // 路径处理
+            if (path.startsWith("$.")) {
+                String pathContent = path.substring(2);
+                List<String> parts = parseJsonPath(pathContent);
+                Object current = json;
+
+                for (int i = 0; i < parts.size(); i++) {
+                    String part = parts.get(i);
+                    if (current == null) {
+                        break;
+                    }
+
+                    // 处理数组路径(如 topics[*])
+                    if (part.endsWith("[*]")) {
+                        String arrayKey = part.substring(0, part.length() - 3);
+                        if (current instanceof JSONObject) {
+                            JSONArray array = ((JSONObject) current).getJSONArray(arrayKey);
+                            if (array != null) {
+                                List<String> remainingParts = parts.subList(i + 1, parts.size());
+                                String remainingPath = String.join(".", remainingParts);
+                                for (int j = 0; j < array.size(); j++) {
+                                    Object item = array.get(j);
+                                    if (remainingParts.isEmpty()) {
+                                        if (item instanceof String) {
+                                            results.add((String) item);
+                                        }
+                                    } else {
+                                        results.addAll(extractFromJson(
+                                                JSON.parseObject(JSON.toJSONString(item)), "$." + remainingPath));
+                                    }
+                                }
+                            }
+                        }
+                        return results;
+                    } else {
+                        if (current instanceof JSONObject) {
+                            current = ((JSONObject) current).get(part);
+                        } else {
+                            break;
+                        }
+                    }
+                }
+
+                if (current instanceof String) {
+                    results.add((String) current);
+                } else if (current instanceof JSONArray) {
+                    JSONArray array = (JSONArray) current;
+                    for (int i = 0; i < array.size(); i++) {
+                        Object item = array.get(i);
+                        if (item instanceof String) {
+                            results.add((String) item);
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.error("JSON提取失败,path={}, error={}", path, e.getMessage());
+        }
+
+        return results;
+    }
+
+    /**
+     * 解析 JSONPath 路径
+     */
+    private List<String> parseJsonPath(String pathContent) {
+        List<String> parts = new ArrayList<>();
+        StringBuilder current = new StringBuilder();
+        
+        for (int i = 0; i < pathContent.length(); i++) {
+            char c = pathContent.charAt(i);
+            if (c == '.') {
+                if (current.length() > 0) {
+                    parts.add(current.toString());
+                    current = new StringBuilder();
+                }
+            } else {
+                current.append(c);
+            }
+        }
+        if (current.length() > 0) {
+            parts.add(current.toString());
+        }
+        
+        return parts;
+    }
+
+    /**
+     * 向量化并存储
+     */
+    private boolean vectorizeAndStore(DeconstructVectorConfig config, Long videoId, List<String> texts) {
+        String configCode = config.getConfigCode();
+        Integer maxLength = config.getMaxLength();
+        
+        for (int i = 0; i < texts.size(); i++) {
+            String text = texts.get(i);
+            if (!StringUtils.hasText(text)) {
+                continue;
+            }
+            
+            // 文本截断
+            if (maxLength != null && maxLength > 0 && text.length() > maxLength) {
+                text = text.substring(0, maxLength);
+            }
+            
+            // 向量化
+            List<Float> vector = embeddingService.embed(text);
+            if (vector == null || vector.isEmpty()) {
+                log.warn("videoId={} 配置 {} 文本向量化失败", videoId, configCode);
+                continue;
+            }
+            
+            // 存储(如果有多个分段,使用 configCode:segmentIndex 作为key的一部分)
+            String storeConfigCode = texts.size() > 1 ? configCode + ":" + i : configCode;
+            vectorStoreService.save(storeConfigCode, videoId, vector);
+            log.debug("videoId={} 配置 {} 向量化存储成功", videoId, storeConfigCode);
+        }
+        
+        return true;
+    }
+
+    /**
+     * 分页查询 videoId 列表
+     * @param pageNum 页码(从0开始)
+     * @param pageSize 每页数量
+     * @return videoId 列表
      */
-    private boolean hasSourceContent(DeconstructContent content, DeconstructVectorConfig config) {
-        String sourceField = config.getSourceField();
-
-        switch (sourceField) {
-            case "title":
-                return StringUtils.hasText(content.getTitle());
-            case "body_text":
-                return StringUtils.hasText(content.getBodyText());
-            case "result_json":
-                return StringUtils.hasText(content.getResultJson()) && StringUtils.hasText(config.getSourcePath());
-            default:
-                // 其他字段从 result_json 中提取
-                return StringUtils.hasText(content.getResultJson()) && StringUtils.hasText(config.getSourcePath());
+    private List<Long> queryVideoIdsByPage(int pageNum, int pageSize) {
+        int offset = pageNum * pageSize;
+        String sql = String.format(
+                "SELECT video_id " +
+                        "FROM videoods.content_profile " +
+                        "WHERE status = 3 AND is_deleted = 0 " +
+                        "ORDER BY video_id " +
+                        "LIMIT %d, %d",
+                offset, pageSize);
+        List<Record> records = OdpsUtil.getOdpsData(sql);
+        if (records == null || records.isEmpty()) {
+            return new ArrayList<>();
         }
+        return records.stream()
+                .map(record -> record.getBigint("video_id"))
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
     }
 
     /**

+ 3 - 0
core/src/main/java/com/tzld/videoVector/model/param/MatchTopNVideoParam.java

@@ -7,6 +7,9 @@ import java.util.List;
 @Data
 public class MatchTopNVideoParam {
 
+    /** 配置编码,用于指定搜索哪个向量配置(如不指定则使用默认配置) */
+    private String configCode;
+
     /** 查询文本,将被向量化后进行检索 */
     private String queryText;
 

+ 75 - 8
core/src/main/java/com/tzld/videoVector/service/VectorStoreService.java

@@ -9,62 +9,129 @@ import java.util.Set;
 
 /**
  * 向量存储服务接口(Redis 实现)
+ * 支持按配置编码(configCode)存储多类型向量
  */
 public interface VectorStoreService {
 
+    /** 默认配置 */
+    String DEFAULT_CONFIG_CODE = "VIDEO_TOPIC";
+
     /**
-     * 保存视频向量
+     * 保存视频向量(默认配置)
      * @param videoId 视频ID
      * @param vector  向量数据
      */
     void save(Long videoId, List<Float> vector);
 
     /**
-     * 判断某个 videoId 的向量是否已存在
+     * 保存视频向量(指定配置)
+     * @param configCode 配置编码
+     * @param videoId    视频ID
+     * @param vector     向量数据
+     */
+    void save(String configCode, Long videoId, List<Float> vector);
+
+    /**
+     * 判断某个 videoId 的向量是否已存在(默认配置)
      * @param videoId 视频ID
      * @return 是否存在
      */
     boolean exists(Long videoId);
 
     /**
-     * 批量判断 videoId 是否已存在
+     * 判断某个 videoId 在指定配置下的向量是否已存在
+     * @param configCode 配置编码
+     * @param videoId    视频ID
+     * @return 是否存在
+     */
+    boolean exists(String configCode, Long videoId);
+
+    /**
+     * 批量判断 videoId 是否已存在(默认配置)
      * @param videoIds 视频ID列表
      * @return 已存在的 videoId 集合
      */
     Set<Long> existsByIds(Collection<Long> videoIds);
 
     /**
-     * 获取指定 videoId 的向量
+     * 批量判断 videoId 在指定配置下是否已存在
+     * @param configCode 配置编码
+     * @param videoIds   视频ID列表
+     * @return 已存在的 videoId 集合
+     */
+    Set<Long> existsByIds(String configCode, Collection<Long> videoIds);
+
+    /**
+     * 获取指定 videoId 的向量(默认配置)
      * @param videoId 视频ID
      * @return 向量数据,不存在返回 null
      */
     List<Float> getVector(Long videoId);
 
     /**
-     * 批量获取向量
+     * 获取指定 videoId 在指定配置下的向量
+     * @param configCode 配置编码
+     * @param videoId    视频ID
+     * @return 向量数据,不存在返回 null
+     */
+    List<Float> getVector(String configCode, Long videoId);
+
+    /**
+     * 批量获取向量(默认配置)
      * @param videoIds 视频ID列表
      * @return videoId -> vector 映射
      */
     Map<Long, List<Float>> getVectors(Collection<Long> videoIds);
 
     /**
-     * 获取所有已存储的 videoId
+     * 批量获取向量(指定配置)
+     * @param configCode 配置编码
+     * @param videoIds   视频ID列表
+     * @return videoId -> vector 映射
+     */
+    Map<Long, List<Float>> getVectors(String configCode, Collection<Long> videoIds);
+
+    /**
+     * 获取所有已存储的 videoId(默认配置)
      * @return videoId 集合
      */
     Set<Long> getAllVideoIds();
 
     /**
-     * 删除指定视频向量
+     * 获取指定配置下已存储的所有 videoId
+     * @param configCode 配置编码
+     * @return videoId 集合
+     */
+    Set<Long> getAllVideoIds(String configCode);
+
+    /**
+     * 删除指定视频向量(默认配置)
      * @param videoId 视频ID
      */
     void delete(Long videoId);
 
     /**
-     * 在所有向量中搜索 Top-N 最相似的视频
+     * 删除指定视频向量(指定配置)
+     * @param configCode 配置编码
+     * @param videoId    视频ID
+     */
+    void delete(String configCode, Long videoId);
+
+    /**
+     * 在所有向量中搜索 Top-N 最相似的视频(默认配置)
      * @param queryVector 查询向量
      * @param topN        返回数量
      * @return 按相似度降序排列的结果列表
      */
     List<VideoMatch> searchTopN(List<Float> queryVector, int topN);
 
+    /**
+     * 在指定配置的向量中搜索 Top-N 最相似的视频
+     * @param configCode  配置编码
+     * @param queryVector 查询向量
+     * @param topN        返回数量
+     * @return 按相似度降序排列的结果列表
+     */
+    List<VideoMatch> searchTopN(String configCode, List<Float> queryVector, int topN);
+
 }

+ 100 - 25
core/src/main/java/com/tzld/videoVector/service/impl/RedisVectorStoreServiceImpl.java

@@ -16,20 +16,17 @@ import java.util.stream.Collectors;
  *
  * <p>存储结构:
  * <ul>
- *   <li>向量数据:Key = {@code video:vector:{videoId}},Value = JSON 数组字符串</li>
- *   <li>ID 索引:Key = {@code video:vector:ids},类型 = Redis Set</li>
+ *   <li>向量数据:Key = {@code video:vector:{configCode}:{videoId}},Value = JSON 数组字符串</li>
+ *   <li>ID 索引:Key = {@code video:vector:{configCode}:ids},类型 = Redis Set</li>
  * </ul>
  */
 @Slf4j
 @Service
 public class RedisVectorStoreServiceImpl implements VectorStoreService {
 
-    /** 单条向量 Key 前缀 */
+    /** 向量 Key 前缀 */
     private static final String VECTOR_KEY_PREFIX = "video:vector:";
 
-    /** 所有 videoId 的索引集合 Key */
-    private static final String IDS_SET_KEY = "video:vector:ids";
-
     @Autowired
     private RedisTemplate<String, String> redisTemplate;
 
@@ -37,30 +34,55 @@ public class RedisVectorStoreServiceImpl implements VectorStoreService {
 
     @Override
     public void save(Long videoId, List<Float> vector) {
+        save(DEFAULT_CONFIG_CODE, videoId, vector);
+    }
+
+    @Override
+    public void save(String configCode, Long videoId, List<Float> vector) {
         if (videoId == null || vector == null || vector.isEmpty()) {
-            log.warn("save 参数非法,videoId={}", videoId);
+            log.warn("save 参数非法,configCode={}, videoId={}", configCode, videoId);
             return;
         }
-        String key = buildKey(videoId);
+        if (configCode == null || configCode.isEmpty()) {
+            configCode = DEFAULT_CONFIG_CODE;
+        }
+        String key = buildKey(configCode, videoId);
         String value = JSONArray.toJSONString(vector);
         redisTemplate.opsForValue().set(key, value);
-        redisTemplate.opsForSet().add(IDS_SET_KEY, videoId.toString());
-        log.debug("保存向量成功,videoId={}, 维度={}", videoId, vector.size());
+        redisTemplate.opsForSet().add(buildIdsKey(configCode), videoId.toString());
+        log.debug("保存向量成功,configCode={}, videoId={}, 维度={}", configCode, videoId, vector.size());
     }
 
     @Override
     public boolean exists(Long videoId) {
+        return exists(DEFAULT_CONFIG_CODE, videoId);
+    }
+
+    @Override
+    public boolean exists(String configCode, Long videoId) {
         if (videoId == null) return false;
-        return redisTemplate.hasKey(buildKey(videoId));
+        if (configCode == null || configCode.isEmpty()) {
+            configCode = DEFAULT_CONFIG_CODE;
+        }
+        return redisTemplate.hasKey(buildKey(configCode, videoId));
     }
 
     @Override
     public Set<Long> existsByIds(Collection<Long> videoIds) {
+        return existsByIds(DEFAULT_CONFIG_CODE, videoIds);
+    }
+
+    @Override
+    public Set<Long> existsByIds(String configCode, Collection<Long> videoIds) {
         if (videoIds == null || videoIds.isEmpty()) return Collections.emptySet();
+        if (configCode == null || configCode.isEmpty()) {
+            configCode = DEFAULT_CONFIG_CODE;
+        }
 
         // Pipeline 批量判断 Key 是否存在
+        final String finalConfigCode = configCode;
         List<String> keys = videoIds.stream()
-                .map(this::buildKey)
+                .map(id -> buildKey(finalConfigCode, id))
                 .collect(Collectors.toList());
 
         List<Boolean> results = redisTemplate.executePipelined(
@@ -86,16 +108,33 @@ public class RedisVectorStoreServiceImpl implements VectorStoreService {
 
     @Override
     public List<Float> getVector(Long videoId) {
+        return getVector(DEFAULT_CONFIG_CODE, videoId);
+    }
+
+    @Override
+    public List<Float> getVector(String configCode, Long videoId) {
         if (videoId == null) return null;
-        String value = redisTemplate.opsForValue().get(buildKey(videoId));
+        if (configCode == null || configCode.isEmpty()) {
+            configCode = DEFAULT_CONFIG_CODE;
+        }
+        String value = redisTemplate.opsForValue().get(buildKey(configCode, videoId));
         return parseVector(value);
     }
 
     @Override
     public Map<Long, List<Float>> getVectors(Collection<Long> videoIds) {
+        return getVectors(DEFAULT_CONFIG_CODE, videoIds);
+    }
+
+    @Override
+    public Map<Long, List<Float>> getVectors(String configCode, Collection<Long> videoIds) {
         if (videoIds == null || videoIds.isEmpty()) return Collections.emptyMap();
+        if (configCode == null || configCode.isEmpty()) {
+            configCode = DEFAULT_CONFIG_CODE;
+        }
         List<Long> idList = new ArrayList<>(videoIds);
-        List<String> keys = idList.stream().map(this::buildKey).collect(Collectors.toList());
+        final String finalConfigCode = configCode;
+        List<String> keys = idList.stream().map(id -> buildKey(finalConfigCode, id)).collect(Collectors.toList());
         List<String> values = redisTemplate.opsForValue().multiGet(keys);
 
         Map<Long, List<Float>> result = new HashMap<>();
@@ -111,7 +150,15 @@ public class RedisVectorStoreServiceImpl implements VectorStoreService {
 
     @Override
     public Set<Long> getAllVideoIds() {
-        Set<String> members = redisTemplate.opsForSet().members(IDS_SET_KEY);
+        return getAllVideoIds(DEFAULT_CONFIG_CODE);
+    }
+
+    @Override
+    public Set<Long> getAllVideoIds(String configCode) {
+        if (configCode == null || configCode.isEmpty()) {
+            configCode = DEFAULT_CONFIG_CODE;
+        }
+        Set<String> members = redisTemplate.opsForSet().members(buildIdsKey(configCode));
         if (members == null) return Collections.emptySet();
         return members.stream()
                 .map(Long::parseLong)
@@ -120,30 +167,46 @@ public class RedisVectorStoreServiceImpl implements VectorStoreService {
 
     @Override
     public void delete(Long videoId) {
+        delete(DEFAULT_CONFIG_CODE, videoId);
+    }
+
+    @Override
+    public void delete(String configCode, Long videoId) {
         if (videoId == null) return;
-        redisTemplate.delete(buildKey(videoId));
-        redisTemplate.opsForSet().remove(IDS_SET_KEY, videoId.toString());
-        log.debug("删除向量成功,videoId={}", videoId);
+        if (configCode == null || configCode.isEmpty()) {
+            configCode = DEFAULT_CONFIG_CODE;
+        }
+        redisTemplate.delete(buildKey(configCode, videoId));
+        redisTemplate.opsForSet().remove(buildIdsKey(configCode), videoId.toString());
+        log.debug("删除向量成功,configCode={}, videoId={}", configCode, videoId);
     }
 
     // ---------------------------------------------------------------- 搜索
 
     @Override
     public List<VideoMatch> searchTopN(List<Float> queryVector, int topN) {
+        return searchTopN(DEFAULT_CONFIG_CODE, queryVector, topN);
+    }
+
+    @Override
+    public List<VideoMatch> searchTopN(String configCode, List<Float> queryVector, int topN) {
         if (queryVector == null || queryVector.isEmpty() || topN <= 0) {
             return Collections.emptyList();
         }
+        if (configCode == null || configCode.isEmpty()) {
+            configCode = DEFAULT_CONFIG_CODE;
+        }
 
-        Set<Long> allIds = getAllVideoIds();
+        Set<Long> allIds = getAllVideoIds(configCode);
         if (allIds.isEmpty()) {
-            log.info("向量库为空,无法搜索");
+            log.info("向量库为空,configCode={},无法搜索", configCode);
             return Collections.emptyList();
         }
 
-        log.info("开始向量搜索,库中共 {} 条记录,topN={}", allIds.size(), topN);
+        log.info("开始向量搜索,configCode={},库中共 {} 条记录,topN={}", configCode, allIds.size(), topN);
 
         // 批量获取所有向量
-        Map<Long, List<Float>> allVectors = getVectors(allIds);
+        Map<Long, List<Float>> allVectors = getVectors(configCode, allIds);
 
         // 对查询向量做 L2 归一化,加速余弦相似度计算
         float[] qNorm = l2Normalize(queryVector);
@@ -160,14 +223,26 @@ public class RedisVectorStoreServiceImpl implements VectorStoreService {
         matches.sort((a, b) -> Double.compare(b.getScore(), a.getScore()));
         List<VideoMatch> topMatches = matches.subList(0, Math.min(topN, matches.size()));
 
-        log.info("向量搜索完成,返回 {} 条结果", topMatches.size());
+        log.info("向量搜索完成,configCode={},返回 {} 条结果", configCode, topMatches.size());
         return topMatches;
     }
 
     // ---------------------------------------------------------------- 工具方法
 
-    private String buildKey(Long videoId) {
-        return VECTOR_KEY_PREFIX + videoId;
+    /**
+     * 构建向量存储 Key
+     * 格式: video:vector:{configCode}:{videoId}
+     */
+    private String buildKey(String configCode, Long videoId) {
+        return VECTOR_KEY_PREFIX + configCode + ":" + videoId;
+    }
+
+    /**
+     * 构建 ID 索引集合 Key
+     * 格式: video:vector:{configCode}:ids
+     */
+    private String buildIdsKey(String configCode) {
+        return VECTOR_KEY_PREFIX + configCode + ":ids";
     }
 
     private List<Float> parseVector(String value) {

+ 10 - 4
core/src/main/java/com/tzld/videoVector/service/impl/VideoSearchServiceImpl.java

@@ -340,6 +340,7 @@ public class VideoSearchServiceImpl implements VideoSearchService {
         }
 
         int topN = param.getTopN() != null && param.getTopN() > 0 ? param.getTopN() : 10;
+        String configCode = param.getConfigCode();
 
         // 确定查询向量:直接传入 or 文本向量化
         List<Float> queryVector = param.getQueryVector();
@@ -356,10 +357,15 @@ public class VideoSearchServiceImpl implements VideoSearchService {
             }
         }
 
-        log.info("开始匹配 Top-{} 视频,向量维度: {}", topN, queryVector.size());
+        log.info("开始匹配 Top-{} 视频,configCode: {},向量维度: {}", topN, configCode, queryVector.size());
 
-        // 在 Redis 中搜索
-        List<VideoMatch> matches = vectorStoreService.searchTopN(queryVector, topN);
+        // 在 Redis 中搜索(支持按 configCode 搜索)
+        List<VideoMatch> matches;
+        if (configCode != null && !configCode.isEmpty()) {
+            matches = vectorStoreService.searchTopN(configCode, queryVector, topN);
+        } else {
+            matches = vectorStoreService.searchTopN(queryVector, topN);
+        }
 
         // 转化为返回格式
         List<Object> result = new ArrayList<>(matches.size());
@@ -370,7 +376,7 @@ public class VideoSearchServiceImpl implements VideoSearchService {
             result.add(item);
         }
 
-        log.info("匹配完成,返回 {} 条结果", result.size());
+        log.info("匹配完成,configCode: {},返回 {} 条结果", configCode, result.size());
         return result;
     }
 }