فهرست منبع

接入pgvector 建立多点

wangyunpeng 1 هفته پیش
والد
کامیت
602f8a1837
18فایلهای تغییر یافته به همراه1985 افزوده شده و 114 حذف شده
  1. 40 0
      core/src/main/java/com/tzld/videoVector/common/constant/VectorConstants.java
  2. 60 0
      core/src/main/java/com/tzld/videoVector/config/db/PgVectorDBConfig.java
  3. 19 0
      core/src/main/java/com/tzld/videoVector/config/mybatis/PgVectorMybatisConfig.java
  4. 44 0
      core/src/main/java/com/tzld/videoVector/dao/generator/PgMybatisGeneratorMain.java
  5. 96 0
      core/src/main/java/com/tzld/videoVector/dao/mapper/pgVector/VideoVectorMapper.java
  6. 323 95
      core/src/main/java/com/tzld/videoVector/job/VideoVectorJob.java
  7. 18 1
      core/src/main/java/com/tzld/videoVector/model/entity/VideoMatch.java
  8. 196 0
      core/src/main/java/com/tzld/videoVector/model/po/pgVector/VideoVector.java
  9. 613 0
      core/src/main/java/com/tzld/videoVector/model/po/pgVector/VideoVectorExample.java
  10. 2 1
      core/src/main/java/com/tzld/videoVector/service/VectorStoreService.java
  11. 3 4
      core/src/main/java/com/tzld/videoVector/service/impl/RedisVectorStoreServiceImpl.java
  12. 181 13
      core/src/main/java/com/tzld/videoVector/service/impl/VideoSearchServiceImpl.java
  13. 63 0
      core/src/main/resources/generator/mybatis-pgvector-generator-config.xml
  14. 280 0
      core/src/main/resources/mapper/pgVector/VideoVectorMapper.xml
  15. 14 0
      pom.xml
  16. 11 0
      server/src/main/resources/application-dev.yml
  17. 11 0
      server/src/main/resources/application-prod.yml
  18. 11 0
      server/src/main/resources/application-test.yml

+ 40 - 0
core/src/main/java/com/tzld/videoVector/common/constant/VectorConstants.java

@@ -0,0 +1,40 @@
+package com.tzld.videoVector.common.constant;
+
+/**
+ * 向量化相关常量
+ */
+public interface VectorConstants {
+
+    // ========================== 配置编码 ==========================
+
+    /** 默认配置编码(选题) */
+    String DEFAULT_CONFIG_CODE = "VIDEO_TOPIC";
+
+    /** configCode 传 "ALL" 表示搜索所有启用的向量化配置 */
+    String ALL_CONFIG_CODE = "ALL";
+
+    // ========================== 多点向量化 ==========================
+
+    /**
+     * 多点向量化复合ID因子
+     * 复合ID = videoId * MULTI_POINT_FACTOR + pointIndex
+     * 支持每个视频最多存储100个向量点
+     */
+    long MULTI_POINT_FACTOR = 100L;
+
+    // ========================== Redis Key ==========================
+
+    /** 向量存储 Redis Key 前缀 */
+    String VECTOR_KEY_PREFIX = "video:vector:";
+
+    // ========================== 批处理参数 ==========================
+
+    /** 每页查询数量 */
+    int PAGE_SIZE = 1000;
+
+    /** 审核状态检查批次大小 */
+    int AUDIT_CHECK_BATCH_SIZE = 20;
+
+    /** 超时时间:1小时(毫秒) */
+    long TIMEOUT_MS = 60 * 60 * 1000L;
+}

+ 60 - 0
core/src/main/java/com/tzld/videoVector/config/db/PgVectorDBConfig.java

@@ -0,0 +1,60 @@
+package com.tzld.videoVector.config.db;
+
+import com.zaxxer.hikari.HikariDataSource;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.mybatis.spring.SqlSessionFactoryBean;
+import org.mybatis.spring.boot.autoconfigure.MybatisProperties;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import javax.sql.DataSource;
+
+@Configuration
+@EnableTransactionManagement
+public class PgVectorDBConfig {
+
+    // 1. 配置 pgVector 数据源
+    @Bean(name = "pgVectorDataSource")
+    @ConfigurationProperties(prefix = "spring.datasource.pg-vector")
+    public DataSource pgVectorDataSource() {
+        return new HikariDataSource();
+    }
+
+    // 2. 配置 pgVector 专属 SqlSessionFactory
+    @Bean(name = "pgVectorSqlSessionFactory")
+    public SqlSessionFactory pgVectorSqlSessionFactory(
+            @Qualifier("pgVectorDataSource") DataSource pgVectorDataSource,
+            MybatisProperties properties) throws Exception {
+        SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
+        sessionFactory.setDataSource(pgVectorDataSource);
+
+        // 指定 pgVector 模块的 mapper 文件路径
+        try {
+            org.springframework.core.io.Resource[] mapperResources =
+                new PathMatchingResourcePatternResolver().getResources("classpath:mapper/pgVector/**/*.xml");
+            if (mapperResources != null && mapperResources.length > 0) {
+                sessionFactory.setMapperLocations(mapperResources);
+            }
+        } catch (Exception e) {
+            // 如果目录不存在或为空,不设置 mapper locations,应用仍可正常启动
+        }
+
+        sessionFactory.setTypeAliasesPackage("com.tzld.videoVector");
+        sessionFactory.setConfiguration(properties.getConfiguration());
+        return sessionFactory.getObject();
+    }
+
+    // 3. 配置 pgVector 事务管理器
+    @Bean(name = "pgVectorTransactionManager")
+    public PlatformTransactionManager pgVectorTransactionManager(
+            @Qualifier("pgVectorDataSource") DataSource pgVectorDataSource) {
+        return new DataSourceTransactionManager(pgVectorDataSource);
+    }
+
+}

+ 19 - 0
core/src/main/java/com/tzld/videoVector/config/mybatis/PgVectorMybatisConfig.java

@@ -0,0 +1,19 @@
+package com.tzld.videoVector.config.mybatis;
+
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.mybatis.spring.SqlSessionTemplate;
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@MapperScan(basePackages = "com.tzld.videoVector.dao.mapper.pgVector",
+        sqlSessionFactoryRef = "pgVectorSqlSessionFactory")
+public class PgVectorMybatisConfig {
+
+    @Bean(name = "pgVectorSqlSessionTemplate")
+    public SqlSessionTemplate pgVectorSqlSessionTemplate(@Qualifier("pgVectorSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
+        return new SqlSessionTemplate(sqlSessionFactory);
+    }
+}

+ 44 - 0
core/src/main/java/com/tzld/videoVector/dao/generator/PgMybatisGeneratorMain.java

@@ -0,0 +1,44 @@
+package com.tzld.videoVector.dao.generator;
+
+import org.mybatis.generator.api.MyBatisGenerator;
+import org.mybatis.generator.config.Configuration;
+import org.mybatis.generator.config.xml.ConfigurationParser;
+import org.mybatis.generator.exception.InvalidConfigurationException;
+import org.mybatis.generator.exception.XMLParserException;
+import org.mybatis.generator.internal.DefaultShellCallback;
+
+import java.io.File;
+import java.io.IOException;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * pgVector 数据源 MyBatis Generator 入口
+ * 基于 PostgreSQL 17 的 video_vectors 表生成实体类和 Mapper
+ *
+ * 注意:embedding 列(vector 类型)需在生成后手动补充到实体类和 Mapper XML 中
+ */
+public class PgMybatisGeneratorMain {
+
+    public static void main(String[] args)
+            throws SQLException, IOException, InterruptedException, InvalidConfigurationException, XMLParserException {
+        List<String> warnings = new ArrayList<String>();
+        boolean overwrite = true;
+        File configFile = new File(PgMybatisGeneratorMain.class.getResource("/generator/mybatis-pgvector-generator-config.xml").getFile());
+
+        ConfigurationParser cp = new ConfigurationParser(warnings);
+        Configuration config = cp.parseConfiguration(configFile);
+        DefaultShellCallback callback = new DefaultShellCallback(overwrite);
+        MyBatisGenerator myBatisGenerator = new MyBatisGenerator(config, callback, warnings);
+        myBatisGenerator.generate(null);
+
+        if (!warnings.isEmpty()) {
+            System.out.println("Warnings:");
+            for (String warning : warnings) {
+                System.out.println("  " + warning);
+            }
+        }
+        System.out.println("pgVector generate finish");
+    }
+}

+ 96 - 0
core/src/main/java/com/tzld/videoVector/dao/mapper/pgVector/VideoVectorMapper.java

@@ -0,0 +1,96 @@
+package com.tzld.videoVector.dao.mapper.pgVector;
+
+import com.tzld.videoVector.model.po.pgVector.VideoVector;
+import com.tzld.videoVector.model.po.pgVector.VideoVectorExample;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface VideoVectorMapper {
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    long countByExample(VideoVectorExample example);
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    int deleteByExample(VideoVectorExample example);
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    int deleteByPrimaryKey(Long id);
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    int insert(VideoVector record);
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    int insertSelective(VideoVector record);
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    List<VideoVector> selectByExample(VideoVectorExample example);
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    VideoVector selectByPrimaryKey(Long id);
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    int updateByExampleSelective(@Param("record") VideoVector record, @Param("example") VideoVectorExample example);
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    int updateByExample(@Param("record") VideoVector record, @Param("example") VideoVectorExample example);
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    int updateByPrimaryKeySelective(VideoVector record);
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    int updateByPrimaryKey(VideoVector record);
+}

+ 323 - 95
core/src/main/java/com/tzld/videoVector/job/VideoVectorJob.java

@@ -18,6 +18,7 @@ import com.tzld.videoVector.service.DeconstructService;
 import com.tzld.videoVector.service.EmbeddingService;
 import com.tzld.videoVector.service.VectorStoreService;
 import com.tzld.videoVector.util.OdpsUtil;
+import com.tzld.videoVector.common.constant.VectorConstants;
 import com.xxl.job.core.biz.model.ReturnT;
 import com.xxl.job.core.handler.annotation.XxlJob;
 import lombok.extern.slf4j.Slf4j;
@@ -55,27 +56,6 @@ public class VideoVectorJob {
     @Resource
     private AigcApiService aigcApiService;
 
-    /**
-     * 每页查询数量
-     */
-    private static final int PAGE_SIZE = 1000;
-
-    /**
-     * 审核状态检查批次大小
-     */
-    private static final int AUDIT_CHECK_BATCH_SIZE = 20;
-
-    /**
-     * 超时时间:1小时(毫秒)
-     */
-    private static final long TIMEOUT_MS = 60 * 60 * 1000L;
-
-    /**
-     * 内容类型:长文
-     * @deprecated 已不再按内容类型过滤,加载所有启用的配置
-     */
-    @Deprecated
-    private static final byte CONTENT_TYPE_LONG_ARTICLE = 1;
 
     /**
      * 视频向量化
@@ -99,7 +79,7 @@ public class VideoVectorJob {
 
             // 2. 分页处理前,每次 Job 执行只做一次审核清理
             for (DeconstructVectorConfig config : configs) {
-                checkAndRemoveNotAuditPassedVideos(config.getConfigCode());
+                checkAndRemoveNotAuditPassedVideos(config.getConfigCode(), isMultiPointConfig(config));
             }
             log.info("审核清理完成,开始分页向量化处理");
 
@@ -109,7 +89,7 @@ public class VideoVectorJob {
 
             while (true) {
                 // 2. 分页查询 videoId 列表
-                List<Long> videoIds = queryVideoIdsByPage(pageNum, PAGE_SIZE);
+                List<Long> videoIds = queryVideoIdsByPage(pageNum, VectorConstants.PAGE_SIZE);
                 if (CollectionUtils.isEmpty(videoIds)) {
                     log.info("第 {} 页没有查询到数据,分页查询结束", pageNum);
                     break;
@@ -122,10 +102,23 @@ public class VideoVectorJob {
 
                     // 3.0 审核清理已移至分页外,此处仅进行向量存在性检查
                     // 3.1 查询哪些 videoId 在该配置下已有向量
-                    Set<Long> existingIds = vectorStoreService.existsByIds(configCode, videoIds);
+                    boolean multiPoint = isMultiPointConfig(config);
+                    Set<Long> existingVideoIds;
+                    if (multiPoint) {
+                        // 多点模式:将 videoId 转为复合基准ID(videoId*100)检查存在性
+                        List<Long> baseIds = videoIds.stream()
+                                .map(id -> encodeMultiPointId(id, 0))
+                                .collect(Collectors.toList());
+                        Set<Long> existingBaseIds = vectorStoreService.existsByIds(configCode, baseIds);
+                        existingVideoIds = existingBaseIds.stream()
+                                .map(VideoVectorJob::decodeVideoId)
+                                .collect(Collectors.toSet());
+                    } else {
+                        existingVideoIds = vectorStoreService.existsByIds(configCode, videoIds);
+                    }
                     // 3.2 过滤出需要处理的 videoId(排除已有向量的)
                     List<Long> needProcessIds = videoIds.stream()
-                            .filter(id -> !existingIds.contains(id))
+                            .filter(id -> !existingVideoIds.contains(id))
                             .collect(Collectors.toList());
                     
                     if (needProcessIds.isEmpty()) {
@@ -160,7 +153,7 @@ public class VideoVectorJob {
                                     continue;
                                 }
 
-                                // 根据配置提取文本
+                                // 根据配置提取文本(支持置信度过滤)
                                 List<String> texts = extractTextsFromRawResult(rawResult, config);
                                 if (CollectionUtils.isEmpty(texts)) {
                                     log.debug("videoId={} 配置 {} 未提取到文本,跳过", videoId, configCode);
@@ -168,9 +161,9 @@ public class VideoVectorJob {
                                     continue;
                                 }
 
-                                // 向量化并存储
-                                boolean success = vectorizeAndStore(config, videoId, texts);
-                                if (success) {
+                                // 向量化并存储(多点模式返回成功数>0即为成功)
+                                int storeCount = vectorizeAndStore(config, videoId, texts);
+                                if (storeCount > 0) {
                                     totalSuccessCount++;
                                 } else {
                                     totalFailCount++;
@@ -184,8 +177,8 @@ public class VideoVectorJob {
                     }
                 }
                 // 如果查询到的数据少于 PAGE_SIZE,说明已经是最后一页
-                if (videoIds.size() < PAGE_SIZE) {
-                    log.info("第 {} 页数据量 {} 小于 PAGE_SIZE {},分页查询结束", pageNum, videoIds.size(), PAGE_SIZE);
+                if (videoIds.size() < VectorConstants.PAGE_SIZE) {
+                    log.info("第 {} 页数据量 {} 小于 PAGE_SIZE {},分页查询结束", pageNum, videoIds.size(), VectorConstants.PAGE_SIZE);
                     break;
                 }
                 pageNum++;
@@ -245,31 +238,185 @@ public class VideoVectorJob {
 
     /**
      * 根据配置从 raw_result 中提取文本
+     * 当配置了 extract_rule 时,启用置信度过滤逻辑
      */
     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));
-            
+
+            String extractRule = config.getExtractRule();
+            if (StringUtils.hasText(extractRule)) {
+                // 多点模式:从数组中提取对象,按置信度过滤后取文本字段
+                texts.addAll(extractTextsWithConfidence(json, sourcePath, extractRule));
+            } else {
+                // 单点模式:直接提取文本值(向后兼容)
+                texts.addAll(extractFromJson(json, sourcePath));
+            }
+
         } catch (Exception e) {
             log.error("解析 raw_result 失败: {}", e.getMessage());
         }
-        
+
+        return texts;
+    }
+
+    /**
+     * 带置信度过滤的文本提取
+     * 从数组路径中提取满足置信度条件的文本
+     *
+     * @param json       原始JSON
+     * @param sourcePath 数组路径(如 $.final_normalization_rebuild.keypoint_final.最终关键点列表[*])
+     * @param extractRule 提取规则JSON(如 {"text_field":"关键点","confidence_field":"置信度","confidence_threshold":0.8})
+     * @return 满足置信度条件的文本列表
+     */
+    private List<String> extractTextsWithConfidence(JSONObject json, String sourcePath, String extractRule) {
+        List<String> texts = new ArrayList<>();
+
+        try {
+            JSONObject rule = JSON.parseObject(extractRule);
+            String textField = rule.getString("text_field");
+            String confidenceField = rule.getString("confidence_field");
+            double confidenceThreshold = rule.getDoubleValue("confidence_threshold");
+
+            if (!StringUtils.hasText(textField) || !StringUtils.hasText(confidenceField)) {
+                log.warn("extract_rule 缺少必要字段: text_field={}, confidence_field={}", textField, confidenceField);
+                return texts;
+            }
+
+            // 提取数组项(sourcePath 以 [*] 结尾,提取整个对象列表)
+            List<JSONObject> items = extractArrayItemsFromJson(json, sourcePath);
+
+            for (JSONObject item : items) {
+                if (isConfidenceQualified(item, confidenceField, confidenceThreshold)) {
+                    String text = item.getString(textField);
+                    if (StringUtils.hasText(text)) {
+                        texts.add(text);
+                    }
+                }
+            }
+
+            log.debug("置信度过滤:路径={}, 总数={}, 满足条件={}", sourcePath, items.size(), texts.size());
+
+        } catch (Exception e) {
+            log.error("置信度过滤提取失败: path={}, error={}", sourcePath, e.getMessage());
+        }
+
         return texts;
     }
 
+    /**
+     * 从JSON中提取数组项对象列表
+     * 支持路径以 [*] 结尾,如 $.a.b.c[*]
+     */
+    private List<JSONObject> extractArrayItemsFromJson(JSONObject json, String sourcePath) {
+        List<JSONObject> items = new ArrayList<>();
+
+        if (json == null || !StringUtils.hasText(sourcePath)) {
+            return items;
+        }
+
+        try {
+            if (!sourcePath.startsWith("$.")) {
+                return items;
+            }
+
+            String pathContent = sourcePath.substring(2);
+            // 路径应以 [*] 结尾
+            if (!pathContent.endsWith("[*]")) {
+                log.warn("多点模式下 source_path 应以 [*] 结尾: {}", sourcePath);
+                return items;
+            }
+
+            // 去掉末尾的 [*],找到数组所在的父路径和数组字段名
+            String pathWithoutWildcard = pathContent.substring(0, pathContent.length() - 3);
+            List<String> parts = parseJsonPath(pathWithoutWildcard);
+
+            // 逐层访问到数组字段的父对象
+            Object current = json;
+            for (int i = 0; i < parts.size() - 1; i++) {
+                if (current instanceof JSONObject) {
+                    current = ((JSONObject) current).get(parts.get(i));
+                } else {
+                    return items;
+                }
+            }
+
+            // 获取数组
+            if (current instanceof JSONObject && !parts.isEmpty()) {
+                String arrayKey = parts.get(parts.size() - 1);
+                JSONArray array = ((JSONObject) current).getJSONArray(arrayKey);
+                if (array != null) {
+                    for (int i = 0; i < array.size(); i++) {
+                        JSONObject item = array.getJSONObject(i);
+                        if (item != null) {
+                            items.add(item);
+                        }
+                    }
+                }
+            }
+
+        } catch (Exception e) {
+            log.error("提取数组项失败: path={}, error={}", sourcePath, e.getMessage());
+        }
+
+        return items;
+    }
+
+    /**
+     * 判断置信度是否满足条件
+     * 规则:置信度 == "high"(字符串)或 置信度 > threshold(数值)
+     */
+    private boolean isConfidenceQualified(JSONObject item, String confidenceField, double threshold) {
+        Object value = item.get(confidenceField);
+        if (value == null) {
+            return false;
+        }
+
+        if (value instanceof String) {
+            return "high".equalsIgnoreCase((String) value);
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue() > threshold;
+        }
+        return false;
+    }
+
+    /**
+     * 判断配置是否为多点模式(有 extract_rule 配置)
+     */
+    private static boolean isMultiPointConfig(DeconstructVectorConfig config) {
+        return StringUtils.hasText(config.getExtractRule());
+    }
+
+    /**
+     * 编码多点复合ID
+     * @param videoId 原始视频ID
+     * @param index   点索引(0~99)
+     * @return 复合ID = videoId * 100 + index
+     */
+    private static Long encodeMultiPointId(Long videoId, int index) {
+        return videoId * VectorConstants.MULTI_POINT_FACTOR + index;
+    }
+
+    /**
+     * 从复合ID解码出原始视频ID
+     * @param compositeId 复合ID
+     * @return 原始视频ID = compositeId / 100
+     */
+    private static Long decodeVideoId(Long compositeId) {
+        return compositeId / VectorConstants.MULTI_POINT_FACTOR;
+    }
+
     /**
      * 从JSON中提取文本
      * 支持路径格式:$.final_normalization_rebuild.topic_fusion_result.最终选题.选题
@@ -370,43 +517,69 @@ public class VideoVectorJob {
     }
 
     /**
-     * 向量化并存储
-     * 取第一段有效文本进行向量化,以 configCode 为命名空间存储
-     * 避免多分段时拼接 configCode:i 导致存储键与查询键不一致
+     * 向量化并存储(兼容单点和多点模式)
+     * <p>
+     * 多点模式(extract_rule 非空):对 texts 中每个有效文本分别向量化,
+     * 以复合ID(videoId * 100 + index)存入同一 configCode 命名空间,
+     * 后续搜索时每个点都可被独立匹配。
+     * <p>
+     * 单点模式(extract_rule 为空):仅取第一段有效文本向量化,
+     * 直接以 videoId 存储(向后兼容)。
+     *
+     * @return 成功向量化的数量(单点模式返回0或1)
      */
-    private boolean vectorizeAndStore(DeconstructVectorConfig config, Long videoId, List<String> texts) {
+    private int vectorizeAndStore(DeconstructVectorConfig config, Long videoId, List<String> texts) {
         String configCode = config.getConfigCode();
         Integer maxLength = config.getMaxLength();
-
-        // 取第一段有效文本(VIDEO_TOPIC 等配置通常只有一段)
-        String text = null;
-        for (String t : texts) {
-            if (StringUtils.hasText(t)) {
-                text = t;
-                break;
+        boolean multiPoint = isMultiPointConfig(config);
+
+        if (multiPoint) {
+            // ---- 多点模式:每个文本独立向量化存储 ----
+            int successCount = 0;
+            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, config);
+                if (vector == null || vector.isEmpty()) {
+                    log.warn("videoId={} 配置 {} 第{}个文本向量化失败", videoId, configCode, i);
+                    continue;
+                }
+                Long compositeId = encodeMultiPointId(videoId, i);
+                vectorStoreService.save(configCode, compositeId, vector);
+                log.debug("videoId={} 配置 {} 第{}个点向量化存储成功,compositeId={}", videoId, configCode, i, compositeId);
+                successCount++;
             }
+            return successCount;
+        } else {
+            // ---- 单点模式:仅取第一段有效文本(向后兼容) ----
+            String text = null;
+            for (String t : texts) {
+                if (StringUtils.hasText(t)) {
+                    text = t;
+                    break;
+                }
+            }
+            if (text == null) {
+                log.debug("videoId={} 配置 {} 无有效文本,跳过", videoId, configCode);
+                return 0;
+            }
+            if (maxLength != null && maxLength > 0 && text.length() > maxLength) {
+                text = text.substring(0, maxLength);
+            }
+            List<Float> vector = embeddingService.embed(text, config);
+            if (vector == null || vector.isEmpty()) {
+                log.warn("videoId={} 配置 {} 文本向量化失败", videoId, configCode);
+                return 0;
+            }
+            vectorStoreService.save(configCode, videoId, vector);
+            log.debug("videoId={} 配置 {} 向量化存储成功", videoId, configCode);
+            return 1;
         }
-        if (text == null) {
-            log.debug("videoId={} 配置 {} 无有效文本,跳过", videoId, configCode);
-            return false;
-        }
-
-        // 文本截断
-        if (maxLength != null && maxLength > 0 && text.length() > maxLength) {
-            text = text.substring(0, maxLength);
-        }
-
-        // 向量化
-        List<Float> vector = embeddingService.embed(text, config);
-        if (vector == null || vector.isEmpty()) {
-            log.warn("videoId={} 配置 {} 文本向量化失败", videoId, configCode);
-            return false;
-        }
-
-        // 以 configCode 为命名空间存储
-        vectorStoreService.save(configCode, videoId, vector);
-        log.debug("videoId={} 配置 {} 向量化存储成功", videoId, configCode);
-        return true;
     }
 
     /**
@@ -490,9 +663,21 @@ public class VideoVectorJob {
                 String configCode = config.getConfigCode();
 
                 // 4.1 查询该配置下已有向量的 videoId,排除已处理过的
-                Set<Long> existingIds = vectorStoreService.existsByIds(configCode, allVideoIds);
+                boolean multiPoint = isMultiPointConfig(config);
+                Set<Long> existingVideoIds;
+                if (multiPoint) {
+                    List<Long> baseIds = allVideoIds.stream()
+                            .map(id -> encodeMultiPointId(id, 0))
+                            .collect(Collectors.toList());
+                    Set<Long> existingBaseIds = vectorStoreService.existsByIds(configCode, baseIds);
+                    existingVideoIds = existingBaseIds.stream()
+                            .map(VideoVectorJob::decodeVideoId)
+                            .collect(Collectors.toSet());
+                } else {
+                    existingVideoIds = vectorStoreService.existsByIds(configCode, allVideoIds);
+                }
                 List<Long> needProcessIds = allVideoIds.stream()
-                        .filter(id -> !existingIds.contains(id))
+                        .filter(id -> !existingVideoIds.contains(id))
                         .collect(Collectors.toList());
                 if (needProcessIds.isEmpty()) {
                     log.debug("配置 {} 下所有视频已有向量,跳过", configCode);
@@ -526,17 +711,17 @@ public class VideoVectorJob {
                             continue;
                         }
 
-                        // 从 dataContent 中提取选题文本
-                        List<String> texts = extractTopicFromDataContent(dataContent);
+                        // 从 dataContent 中提取文本(支持置信度过滤)
+                        List<String> texts = extractTextsFromDataContent(dataContent, config);
                         if (CollectionUtils.isEmpty(texts)) {
                             log.debug("videoId={} 配置 {} 未提取到选题文本,跳过", videoId, configCode);
                             totalFailCount++;
                             continue;
                         }
 
-                        // 向量化并写入 Redis
-                        boolean success = vectorizeAndStore(config, videoId, texts);
-                        if (success) {
+                        // 向量化并写入 Redis(多点模式返回成功数>0即为成功)
+                        int storeCount = vectorizeAndStore(config, videoId, texts);
+                        if (storeCount > 0) {
                             totalSuccessCount++;
                         } else {
                             totalFailCount++;
@@ -557,12 +742,34 @@ public class VideoVectorJob {
     }
 
     /**
-     * 从 dataContent 中提取选题文本
-     * 默认复用配置的 sourcePath 提取逻辑
+     * 从 dataContent 中提取文本
+     * 根据配置的 extract_rule 决定是否进行置信度过滤
      *
      * @param dataContent dataContent 解析后的 JSONObject
+     * @param config      向量化配置
      * @return 提取的文本列表
      */
+    private List<String> extractTextsFromDataContent(JSONObject dataContent, DeconstructVectorConfig config) {
+        if (dataContent == null) {
+            return Collections.emptyList();
+        }
+
+        String extractRule = config.getExtractRule();
+        if (StringUtils.hasText(extractRule)) {
+            // 多点模式:使用配置的 sourcePath 中相对路径 + 置信度过滤
+            // AIGC dataContent 的结构与 raw_result 中 final_normalization_rebuild 下的子结构一致
+            return extractTextsWithConfidence(dataContent, config.getSourcePath(), extractRule);
+        } else {
+            // 单点模式:直接提取
+            return extractFromJson(dataContent, config.getSourcePath());
+        }
+    }
+
+    /**
+     * 从 dataContent 中提取选题文本(向后兼容)
+     * @deprecated 请使用 extractTextsFromDataContent(dataContent, config)
+     */
+    @Deprecated
     private List<String> extractTopicFromDataContent(JSONObject dataContent) {
         if (dataContent == null) {
             return Collections.emptyList();
@@ -588,7 +795,7 @@ public class VideoVectorJob {
 
         try {
             // 1. 查询超时的任务(创建时间超过1小时,状态为PENDING或RUNNING)
-            Date timeoutThreshold = new Date(System.currentTimeMillis() - TIMEOUT_MS);
+            Date timeoutThreshold = new Date(System.currentTimeMillis() - VectorConstants.TIMEOUT_MS);
 
             DeconstructContentExample example = new DeconstructContentExample();
             example.createCriteria().andStatusIn(Arrays.asList((byte) 0, (byte) 1))  // PENDING=0, RUNNING=1
@@ -666,41 +873,62 @@ public class VideoVectorJob {
 
     /**
      * 检查并移除审核状态不通过的视频向量
-     * 每批次最多检查20个videoId
      *
      * @param configCode 配置编码
+     * @param multiPoint 是否为多点模式
      */
-    private void checkAndRemoveNotAuditPassedVideos(String configCode) {
+    private void checkAndRemoveNotAuditPassedVideos(String configCode, boolean multiPoint) {
         try {
-            // 获取该配置下所有已有的视频ID
-            Set<Long> allVideoIds = vectorStoreService.getAllVideoIds(configCode);
-            if (allVideoIds == null || allVideoIds.isEmpty()) {
+            // 获取该配置下所有已有的存储ID
+            Set<Long> allStoredIds = vectorStoreService.getAllVideoIds(configCode);
+            if (allStoredIds == null || allStoredIds.isEmpty()) {
                 log.debug("配置 {} 下没有已存储的向量,跳过审核检查", configCode);
                 return;
             }
 
-            log.info("配置 {} 开始检查审核状态,共 {} 个视频", configCode, allVideoIds.size());
+            // 多点模式:存储ID是复合ID,需要还原为真实videoId
+            Set<Long> realVideoIds;
+            if (multiPoint) {
+                realVideoIds = allStoredIds.stream()
+                        .map(VideoVectorJob::decodeVideoId)
+                        .collect(Collectors.toSet());
+            } else {
+                realVideoIds = allStoredIds;
+            }
+
+            log.info("配置 {} 开始检查审核状态,共 {} 个视频(存储条目 {} 个)",
+                    configCode, realVideoIds.size(), allStoredIds.size());
 
             // 分批检查审核状态
-            List<Long> videoIdList = new ArrayList<>(allVideoIds);
+            List<Long> videoIdList = new ArrayList<>(realVideoIds);
             int totalRemoved = 0;
 
-            for (int i = 0; i < videoIdList.size(); i += AUDIT_CHECK_BATCH_SIZE) {
-                int end = Math.min(i + AUDIT_CHECK_BATCH_SIZE, videoIdList.size());
+            for (int i = 0; i < videoIdList.size(); i += VectorConstants.AUDIT_CHECK_BATCH_SIZE) {
+                int end = Math.min(i + VectorConstants.AUDIT_CHECK_BATCH_SIZE, videoIdList.size());
                 Set<Long> batchIds = new HashSet<>(videoIdList.subList(i, end));
 
                 // 获取审核未通过的视频ID
                 Set<Long> notPassedIds = videoApiService.getNotAuditPassedVideoIds(batchIds);
 
                 if (!notPassedIds.isEmpty()) {
-                    // 批量删除审核不通过的视频向量
-                    vectorStoreService.deleteBatch(configCode, notPassedIds);
-                    totalRemoved += notPassedIds.size();
-                    log.info("配置 {} 移除审核不通过的视频 {} 个: {}", configCode, notPassedIds.size(), notPassedIds);
+                    if (multiPoint) {
+                        // 多点模式:找出所有属于未通过视频的复合ID进行删除
+                        Set<Long> compositeIdsToDelete = allStoredIds.stream()
+                                .filter(storedId -> notPassedIds.contains(decodeVideoId(storedId)))
+                                .collect(Collectors.toSet());
+                        vectorStoreService.deleteBatch(configCode, compositeIdsToDelete);
+                        totalRemoved += compositeIdsToDelete.size();
+                        log.info("配置 {} 移除审核不通过的视频 {} 个(向量条目 {} 个): {}",
+                                configCode, notPassedIds.size(), compositeIdsToDelete.size(), notPassedIds);
+                    } else {
+                        vectorStoreService.deleteBatch(configCode, notPassedIds);
+                        totalRemoved += notPassedIds.size();
+                        log.info("配置 {} 移除审核不通过的视频 {} 个: {}", configCode, notPassedIds.size(), notPassedIds);
+                    }
                 }
             }
 
-            log.info("配置 {} 审核检查完成,共移除 {} 个视频向量", configCode, totalRemoved);
+            log.info("配置 {} 审核检查完成,共移除 {} 个向量条目", configCode, totalRemoved);
 
         } catch (Exception e) {
             log.error("配置 {} 检查审核状态失败: {}", configCode, e.getMessage(), e);
@@ -719,7 +947,7 @@ public class VideoVectorJob {
             return Collections.emptyList();
         }
         Set<Long> notPassedIds = new HashSet<>();
-        for (List<Long> batch : Lists.partition(videoIds, AUDIT_CHECK_BATCH_SIZE)) {
+        for (List<Long> batch : Lists.partition(videoIds, VectorConstants.AUDIT_CHECK_BATCH_SIZE)) {
             try {
                 Set<Long> batchNotPassed = videoApiService.getNotAuditPassedVideoIds(new HashSet<>(batch));
                 notPassedIds.addAll(batchNotPassed);

+ 18 - 1
core/src/main/java/com/tzld/videoVector/model/entity/VideoMatch.java

@@ -11,11 +11,20 @@ public class VideoMatch {
     /** 余弦相似度分值(-1 ~ 1,越大越相似) */
     private double score;
 
+    /** 命中的配置编码(用于区分来源) */
+    private String configCode;
+
     public VideoMatch(Long videoId, double score) {
         this.videoId = videoId;
         this.score = score;
     }
 
+    public VideoMatch(Long videoId, double score, String configCode) {
+        this.videoId = videoId;
+        this.score = score;
+        this.configCode = configCode;
+    }
+
     public Long getVideoId() {
         return videoId;
     }
@@ -32,8 +41,16 @@ public class VideoMatch {
         this.score = score;
     }
 
+    public String getConfigCode() {
+        return configCode;
+    }
+
+    public void setConfigCode(String configCode) {
+        this.configCode = configCode;
+    }
+
     @Override
     public String toString() {
-        return "VideoMatch{videoId=" + videoId + ", score=" + score + '}';
+        return "VideoMatch{videoId=" + videoId + ", score=" + score + ", configCode='" + configCode + "'}";
     }
 }

+ 196 - 0
core/src/main/java/com/tzld/videoVector/model/po/pgVector/VideoVector.java

@@ -0,0 +1,196 @@
+package com.tzld.videoVector.model.po.pgVector;
+
+import java.util.Date;
+
+/**
+ *
+ * This class was generated by MyBatis Generator.
+ * This class corresponds to the database table video_vectors
+ */
+public class VideoVector {
+    /**
+     *
+     * This field was generated by MyBatis Generator.
+     * This field corresponds to the database column video_vectors.id
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    private Long id;
+
+    /**
+     *
+     * This field was generated by MyBatis Generator.
+     * This field corresponds to the database column video_vectors.video_id
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    private Long videoId;
+
+    /**
+     *
+     * This field was generated by MyBatis Generator.
+     * This field corresponds to the database column video_vectors.config_code
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    private String configCode;
+
+    /**
+     *
+     * This field was generated by MyBatis Generator.
+     * This field corresponds to the database column video_vectors.created_at
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    private Date createdAt;
+
+    /**
+     *
+     * This field was generated by MyBatis Generator.
+     * This field corresponds to the database column video_vectors.updated_at
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    private Date updatedAt;
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method returns the value of the database column video_vectors.id
+     *
+     * @return the value of video_vectors.id
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public Long getId() {
+        return id;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method sets the value of the database column video_vectors.id
+     *
+     * @param id the value for video_vectors.id
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method returns the value of the database column video_vectors.video_id
+     *
+     * @return the value of video_vectors.video_id
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public Long getVideoId() {
+        return videoId;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method sets the value of the database column video_vectors.video_id
+     *
+     * @param videoId the value for video_vectors.video_id
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public void setVideoId(Long videoId) {
+        this.videoId = videoId;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method returns the value of the database column video_vectors.config_code
+     *
+     * @return the value of video_vectors.config_code
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public String getConfigCode() {
+        return configCode;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method sets the value of the database column video_vectors.config_code
+     *
+     * @param configCode the value for video_vectors.config_code
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public void setConfigCode(String configCode) {
+        this.configCode = configCode;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method returns the value of the database column video_vectors.created_at
+     *
+     * @return the value of video_vectors.created_at
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public Date getCreatedAt() {
+        return createdAt;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method sets the value of the database column video_vectors.created_at
+     *
+     * @param createdAt the value for video_vectors.created_at
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public void setCreatedAt(Date createdAt) {
+        this.createdAt = createdAt;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method returns the value of the database column video_vectors.updated_at
+     *
+     * @return the value of video_vectors.updated_at
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public Date getUpdatedAt() {
+        return updatedAt;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method sets the value of the database column video_vectors.updated_at
+     *
+     * @param updatedAt the value for video_vectors.updated_at
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public void setUpdatedAt(Date updatedAt) {
+        this.updatedAt = updatedAt;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append(getClass().getSimpleName());
+        sb.append(" [");
+        sb.append("Hash = ").append(hashCode());
+        sb.append(", id=").append(id);
+        sb.append(", videoId=").append(videoId);
+        sb.append(", configCode=").append(configCode);
+        sb.append(", createdAt=").append(createdAt);
+        sb.append(", updatedAt=").append(updatedAt);
+        sb.append("]");
+        return sb.toString();
+    }
+}

+ 613 - 0
core/src/main/java/com/tzld/videoVector/model/po/pgVector/VideoVectorExample.java

@@ -0,0 +1,613 @@
+package com.tzld.videoVector.model.po.pgVector;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+public class VideoVectorExample {
+    /**
+     * This field was generated by MyBatis Generator.
+     * This field corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    protected String orderByClause;
+
+    /**
+     * This field was generated by MyBatis Generator.
+     * This field corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    protected boolean distinct;
+
+    /**
+     * This field was generated by MyBatis Generator.
+     * This field corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    protected List<Criteria> oredCriteria;
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public VideoVectorExample() {
+        oredCriteria = new ArrayList<Criteria>();
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public void setOrderByClause(String orderByClause) {
+        this.orderByClause = orderByClause;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public String getOrderByClause() {
+        return orderByClause;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public void setDistinct(boolean distinct) {
+        this.distinct = distinct;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public boolean isDistinct() {
+        return distinct;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public List<Criteria> getOredCriteria() {
+        return oredCriteria;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public void or(Criteria criteria) {
+        oredCriteria.add(criteria);
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public Criteria or() {
+        Criteria criteria = createCriteriaInternal();
+        oredCriteria.add(criteria);
+        return criteria;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public Criteria createCriteria() {
+        Criteria criteria = createCriteriaInternal();
+        if (oredCriteria.size() == 0) {
+            oredCriteria.add(criteria);
+        }
+        return criteria;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    protected Criteria createCriteriaInternal() {
+        Criteria criteria = new Criteria();
+        return criteria;
+    }
+
+    /**
+     * This method was generated by MyBatis Generator.
+     * This method corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public void clear() {
+        oredCriteria.clear();
+        orderByClause = null;
+        distinct = false;
+    }
+
+    /**
+     * This class was generated by MyBatis Generator.
+     * This class corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    protected abstract static class GeneratedCriteria {
+        protected List<Criterion> criteria;
+
+        protected GeneratedCriteria() {
+            super();
+            criteria = new ArrayList<Criterion>();
+        }
+
+        public boolean isValid() {
+            return criteria.size() > 0;
+        }
+
+        public List<Criterion> getAllCriteria() {
+            return criteria;
+        }
+
+        public List<Criterion> getCriteria() {
+            return criteria;
+        }
+
+        protected void addCriterion(String condition) {
+            if (condition == null) {
+                throw new RuntimeException("Value for condition cannot be null");
+            }
+            criteria.add(new Criterion(condition));
+        }
+
+        protected void addCriterion(String condition, Object value, String property) {
+            if (value == null) {
+                throw new RuntimeException("Value for " + property + " cannot be null");
+            }
+            criteria.add(new Criterion(condition, value));
+        }
+
+        protected void addCriterion(String condition, Object value1, Object value2, String property) {
+            if (value1 == null || value2 == null) {
+                throw new RuntimeException("Between values for " + property + " cannot be null");
+            }
+            criteria.add(new Criterion(condition, value1, value2));
+        }
+
+        public Criteria andIdIsNull() {
+            addCriterion("id is null");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdIsNotNull() {
+            addCriterion("id is not null");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdEqualTo(Long value) {
+            addCriterion("id =", value, "id");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdNotEqualTo(Long value) {
+            addCriterion("id <>", value, "id");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdGreaterThan(Long value) {
+            addCriterion("id >", value, "id");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdGreaterThanOrEqualTo(Long value) {
+            addCriterion("id >=", value, "id");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdLessThan(Long value) {
+            addCriterion("id <", value, "id");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdLessThanOrEqualTo(Long value) {
+            addCriterion("id <=", value, "id");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdIn(List<Long> values) {
+            addCriterion("id in", values, "id");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdNotIn(List<Long> values) {
+            addCriterion("id not in", values, "id");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdBetween(Long value1, Long value2) {
+            addCriterion("id between", value1, value2, "id");
+            return (Criteria) this;
+        }
+
+        public Criteria andIdNotBetween(Long value1, Long value2) {
+            addCriterion("id not between", value1, value2, "id");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdIsNull() {
+            addCriterion("video_id is null");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdIsNotNull() {
+            addCriterion("video_id is not null");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdEqualTo(Long value) {
+            addCriterion("video_id =", value, "videoId");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdNotEqualTo(Long value) {
+            addCriterion("video_id <>", value, "videoId");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdGreaterThan(Long value) {
+            addCriterion("video_id >", value, "videoId");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdGreaterThanOrEqualTo(Long value) {
+            addCriterion("video_id >=", value, "videoId");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdLessThan(Long value) {
+            addCriterion("video_id <", value, "videoId");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdLessThanOrEqualTo(Long value) {
+            addCriterion("video_id <=", value, "videoId");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdIn(List<Long> values) {
+            addCriterion("video_id in", values, "videoId");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdNotIn(List<Long> values) {
+            addCriterion("video_id not in", values, "videoId");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdBetween(Long value1, Long value2) {
+            addCriterion("video_id between", value1, value2, "videoId");
+            return (Criteria) this;
+        }
+
+        public Criteria andVideoIdNotBetween(Long value1, Long value2) {
+            addCriterion("video_id not between", value1, value2, "videoId");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeIsNull() {
+            addCriterion("config_code is null");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeIsNotNull() {
+            addCriterion("config_code is not null");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeEqualTo(String value) {
+            addCriterion("config_code =", value, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeNotEqualTo(String value) {
+            addCriterion("config_code <>", value, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeGreaterThan(String value) {
+            addCriterion("config_code >", value, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeGreaterThanOrEqualTo(String value) {
+            addCriterion("config_code >=", value, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeLessThan(String value) {
+            addCriterion("config_code <", value, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeLessThanOrEqualTo(String value) {
+            addCriterion("config_code <=", value, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeLike(String value) {
+            addCriterion("config_code like", value, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeNotLike(String value) {
+            addCriterion("config_code not like", value, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeIn(List<String> values) {
+            addCriterion("config_code in", values, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeNotIn(List<String> values) {
+            addCriterion("config_code not in", values, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeBetween(String value1, String value2) {
+            addCriterion("config_code between", value1, value2, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andConfigCodeNotBetween(String value1, String value2) {
+            addCriterion("config_code not between", value1, value2, "configCode");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtIsNull() {
+            addCriterion("created_at is null");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtIsNotNull() {
+            addCriterion("created_at is not null");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtEqualTo(Date value) {
+            addCriterion("created_at =", value, "createdAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtNotEqualTo(Date value) {
+            addCriterion("created_at <>", value, "createdAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtGreaterThan(Date value) {
+            addCriterion("created_at >", value, "createdAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtGreaterThanOrEqualTo(Date value) {
+            addCriterion("created_at >=", value, "createdAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtLessThan(Date value) {
+            addCriterion("created_at <", value, "createdAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtLessThanOrEqualTo(Date value) {
+            addCriterion("created_at <=", value, "createdAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtIn(List<Date> values) {
+            addCriterion("created_at in", values, "createdAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtNotIn(List<Date> values) {
+            addCriterion("created_at not in", values, "createdAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtBetween(Date value1, Date value2) {
+            addCriterion("created_at between", value1, value2, "createdAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andCreatedAtNotBetween(Date value1, Date value2) {
+            addCriterion("created_at not between", value1, value2, "createdAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtIsNull() {
+            addCriterion("updated_at is null");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtIsNotNull() {
+            addCriterion("updated_at is not null");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtEqualTo(Date value) {
+            addCriterion("updated_at =", value, "updatedAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtNotEqualTo(Date value) {
+            addCriterion("updated_at <>", value, "updatedAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtGreaterThan(Date value) {
+            addCriterion("updated_at >", value, "updatedAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtGreaterThanOrEqualTo(Date value) {
+            addCriterion("updated_at >=", value, "updatedAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtLessThan(Date value) {
+            addCriterion("updated_at <", value, "updatedAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtLessThanOrEqualTo(Date value) {
+            addCriterion("updated_at <=", value, "updatedAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtIn(List<Date> values) {
+            addCriterion("updated_at in", values, "updatedAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtNotIn(List<Date> values) {
+            addCriterion("updated_at not in", values, "updatedAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtBetween(Date value1, Date value2) {
+            addCriterion("updated_at between", value1, value2, "updatedAt");
+            return (Criteria) this;
+        }
+
+        public Criteria andUpdatedAtNotBetween(Date value1, Date value2) {
+            addCriterion("updated_at not between", value1, value2, "updatedAt");
+            return (Criteria) this;
+        }
+    }
+
+    /**
+     * This class was generated by MyBatis Generator.
+     * This class corresponds to the database table video_vectors
+     *
+     * @mbg.generated do_not_delete_during_merge Wed Apr 29 15:13:43 CST 2026
+     */
+    public static class Criteria extends GeneratedCriteria {
+
+        protected Criteria() {
+            super();
+        }
+    }
+
+    /**
+     * This class was generated by MyBatis Generator.
+     * This class corresponds to the database table video_vectors
+     *
+     * @mbg.generated Wed Apr 29 15:13:43 CST 2026
+     */
+    public static class Criterion {
+        private String condition;
+
+        private Object value;
+
+        private Object secondValue;
+
+        private boolean noValue;
+
+        private boolean singleValue;
+
+        private boolean betweenValue;
+
+        private boolean listValue;
+
+        private String typeHandler;
+
+        public String getCondition() {
+            return condition;
+        }
+
+        public Object getValue() {
+            return value;
+        }
+
+        public Object getSecondValue() {
+            return secondValue;
+        }
+
+        public boolean isNoValue() {
+            return noValue;
+        }
+
+        public boolean isSingleValue() {
+            return singleValue;
+        }
+
+        public boolean isBetweenValue() {
+            return betweenValue;
+        }
+
+        public boolean isListValue() {
+            return listValue;
+        }
+
+        public String getTypeHandler() {
+            return typeHandler;
+        }
+
+        protected Criterion(String condition) {
+            super();
+            this.condition = condition;
+            this.typeHandler = null;
+            this.noValue = true;
+        }
+
+        protected Criterion(String condition, Object value, String typeHandler) {
+            super();
+            this.condition = condition;
+            this.value = value;
+            this.typeHandler = typeHandler;
+            if (value instanceof List<?>) {
+                this.listValue = true;
+            } else {
+                this.singleValue = true;
+            }
+        }
+
+        protected Criterion(String condition, Object value) {
+            this(condition, value, null);
+        }
+
+        protected Criterion(String condition, Object value, Object secondValue, String typeHandler) {
+            super();
+            this.condition = condition;
+            this.value = value;
+            this.secondValue = secondValue;
+            this.typeHandler = typeHandler;
+            this.betweenValue = true;
+        }
+
+        protected Criterion(String condition, Object value, Object secondValue) {
+            this(condition, value, secondValue, null);
+        }
+    }
+}

+ 2 - 1
core/src/main/java/com/tzld/videoVector/service/VectorStoreService.java

@@ -1,5 +1,6 @@
 package com.tzld.videoVector.service;
 
+import com.tzld.videoVector.common.constant.VectorConstants;
 import com.tzld.videoVector.model.entity.VideoMatch;
 
 import java.util.Collection;
@@ -14,7 +15,7 @@ import java.util.Set;
 public interface VectorStoreService {
 
     /** 默认配置 */
-    String DEFAULT_CONFIG_CODE = "VIDEO_TOPIC";
+    String DEFAULT_CONFIG_CODE = VectorConstants.DEFAULT_CONFIG_CODE;
 
     /**
      * 保存视频向量(默认配置)

+ 3 - 4
core/src/main/java/com/tzld/videoVector/service/impl/RedisVectorStoreServiceImpl.java

@@ -3,6 +3,7 @@ package com.tzld.videoVector.service.impl;
 import com.alibaba.fastjson.JSONArray;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
+import com.tzld.videoVector.common.constant.VectorConstants;
 import com.tzld.videoVector.dao.mapper.videoVector.deconstruct.DeconstructVectorConfigMapper;
 import com.tzld.videoVector.model.entity.VideoMatch;
 import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructVectorConfig;
@@ -41,8 +42,6 @@ import java.util.concurrent.locks.ReentrantReadWriteLock;
 @Service
 public class RedisVectorStoreServiceImpl implements VectorStoreService {
 
-    /** 向量 Key 前缀 */
-    private static final String VECTOR_KEY_PREFIX = "video:vector:";
 
     /** 本地缓存:configCode -> (videoId -> 归一化向量) */
     private final Cache<String, Map<Long, float[]>> vectorCache = CacheBuilder.newBuilder()
@@ -585,7 +584,7 @@ public class RedisVectorStoreServiceImpl implements VectorStoreService {
      * 格式: video:vector:{configCode}:{videoId}
      */
     private String buildKey(String configCode, Long videoId) {
-        return VECTOR_KEY_PREFIX + configCode + ":" + videoId;
+        return VectorConstants.VECTOR_KEY_PREFIX + configCode + ":" + videoId;
     }
 
     /**
@@ -593,7 +592,7 @@ public class RedisVectorStoreServiceImpl implements VectorStoreService {
      * 格式: video:vector:{configCode}:ids
      */
     private String buildIdsKey(String configCode) {
-        return VECTOR_KEY_PREFIX + configCode + ":ids";
+        return VectorConstants.VECTOR_KEY_PREFIX + configCode + ":ids";
     }
 
     /**

+ 181 - 13
core/src/main/java/com/tzld/videoVector/service/impl/VideoSearchServiceImpl.java

@@ -17,6 +17,7 @@ import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructContentV
 import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructContentVectorExample;
 import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructVectorConfig;
 import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructVectorConfigExample;
+import com.tzld.videoVector.common.constant.VectorConstants;
 import com.tzld.videoVector.service.*;
 import com.tzld.videoVector.util.Md5Util;
 import lombok.extern.slf4j.Slf4j;
@@ -28,7 +29,7 @@ import java.util.*;
 import java.util.concurrent.*;
 import java.util.stream.Collectors;
 
-import static com.tzld.videoVector.service.VectorStoreService.DEFAULT_CONFIG_CODE;
+import static com.tzld.videoVector.common.constant.VectorConstants.*;
 
 @Slf4j
 @Service
@@ -448,6 +449,9 @@ public class VideoSearchServiceImpl implements VideoSearchService {
         });
     }
 
+    /**
+     * 异步向量化内容,使用线程池执行,避免阻塞接口响应
+     */
     @Override
     public List<Object> matchTopNVideo(MatchTopNVideoParam param) {
         if (param == null) {
@@ -462,39 +466,203 @@ public class VideoSearchServiceImpl implements VideoSearchService {
         }
 
         // 若提供了 channelContentId,且内容已解构完成但向量表缺少对应 configCode 的向量,则异步触发向量化
-        if (StringUtils.hasText(param.getChannelContentId())) {
+        if (StringUtils.hasText(param.getChannelContentId()) && !VectorConstants.ALL_CONFIG_CODE.equalsIgnoreCase(configCode)) {
             triggerVectorizeIfNeeded(param.getChannelContentId(), configCode);
         }
 
-        // 确定查询向量:直接传入 > channelContentId历史向量 > text_hash历史embedding > 文本向量化
+        // 确定要搜索的配置列表
+        List<DeconstructVectorConfig> searchConfigs;
+        if (VectorConstants.ALL_CONFIG_CODE.equalsIgnoreCase(configCode)) {
+            searchConfigs = getEnabledConfigs();
+            if (searchConfigs.isEmpty()) {
+                log.warn("未找到任何启用的向量化配置");
+                return Collections.emptyList();
+            }
+            log.info("all 模式,加载 {} 个启用的向量化配置", searchConfigs.size());
+        } else {
+            DeconstructVectorConfig singleConfig = getVectorConfigByCode(configCode);
+            searchConfigs = singleConfig != null
+                    ? Collections.singletonList(singleConfig)
+                    : Collections.emptyList();
+            // 兼容:如果查不到配置记录,仍按原始逻辑用 configCode 搜索(不做多点解码)
+            if (searchConfigs.isEmpty()) {
+                log.warn("未找到 configCode={} 的配置记录,降级为原始搜索", configCode);
+                return matchTopNVideoLegacy(param, configCode, topN);
+            }
+        }
+
+        // 对每个配置分别搜索,各自取 topN 后汇总返回
+        List<Object> result = new ArrayList<>();
+        int candidateSize = topN * 3;
+
+        // 缓存 embeddingModel -> queryVector,避免相同模型重复 embedding
+        Map<String, List<Float>> embeddingCache = new HashMap<>();
+
+        for (DeconstructVectorConfig config : searchConfigs) {
+            String cfgCode = config.getConfigCode();
+            try {
+                // 解析查询向量(按配置的 embeddingModel 分组缓存)
+                List<Float> queryVector = resolveQueryVectorForConfig(param, config, embeddingCache);
+                if (queryVector == null || queryVector.isEmpty()) {
+                    log.warn("配置 {} 无法获取查询向量,跳过", cfgCode);
+                    continue;
+                }
+
+                log.info("配置 {} 开始搜索 Top-{},向量维度: {}", cfgCode, candidateSize, queryVector.size());
+                List<VideoMatch> matches = vectorStoreService.searchTopN(cfgCode, queryVector, candidateSize);
+
+                if (matches == null || matches.isEmpty()) {
+                    log.debug("配置 {} 无匹配结果", cfgCode);
+                    continue;
+                }
+
+                // 多点模式:复合ID解码为真实videoId,同一videoId去重保留最高分
+                boolean multiPoint = isMultiPointConfig(config);
+                if (multiPoint) {
+                    matches = decodeAndDeduplicateMultiPointMatches(matches, cfgCode);
+                } else {
+                    // 单点模式:直接设置 configCode
+                    for (VideoMatch m : matches) {
+                        m.setConfigCode(cfgCode);
+                    }
+                }
+
+                // 每个配置独立进行审核过滤并取各自的 topN
+                List<VideoMatch> filteredMatches = filterByAuditStatus(matches, topN);
+
+                // 转化为返回格式
+                for (VideoMatch match : filteredMatches) {
+                    JSONObject item = new JSONObject();
+                    item.put("configCode", cfgCode);
+                    item.put("videoId", match.getVideoId());
+                    item.put("score", match.getScore());
+                    result.add(item);
+                }
+
+                log.info("配置 {} 搜索完成,返回 {} 条结果", cfgCode, filteredMatches.size());
+
+            } catch (Exception e) {
+                log.error("配置 {} 搜索失败: {}", cfgCode, e.getMessage(), e);
+            }
+        }
+
+        log.info("匹配完成,configCode: {},共返回 {} 条结果", param.getConfigCode(), result.size());
+        return result;
+    }
+
+    /**
+     * 降级的原始匹配逻辑(configCode 在数据库中无记录时使用)
+     */
+    private List<Object> matchTopNVideoLegacy(MatchTopNVideoParam param, String configCode, int topN) {
         List<Float> queryVector = resolveQueryVector(param);
         if (queryVector == null || queryVector.isEmpty()) {
             log.error("matchTopNVideo 无法获取查询向量,param={}", param);
             return Collections.emptyList();
         }
-
-        log.info("开始匹配 Top-{} 视频,configCode: {},向量维度: {}", topN, configCode, queryVector.size());
-
-        // configCode 已在上方确保非空,直接使用它搜索
         int candidateSize = topN * 3;
         List<VideoMatch> matches = vectorStoreService.searchTopN(configCode, queryVector, candidateSize);
-
-        // 过滤审核状态不通过的视频
         List<VideoMatch> filteredMatches = filterByAuditStatus(matches, topN);
-
-        // 转化为返回格式
         List<Object> result = new ArrayList<>(filteredMatches.size());
         for (VideoMatch match : filteredMatches) {
             JSONObject item = new JSONObject();
             item.put("videoId", match.getVideoId());
             item.put("score", match.getScore());
+            item.put("configCode", configCode);
             result.add(item);
         }
-
-        log.info("匹配完成,configCode: {},返回 {} 条结果", configCode, result.size());
         return result;
     }
 
+    /**
+     * 根据具体配置解析查询向量
+     * 相同 embeddingModel 的配置共享同一个查询向量,避免重复 embedding
+     */
+    private List<Float> resolveQueryVectorForConfig(MatchTopNVideoParam param,
+                                                    DeconstructVectorConfig config,
+                                                    Map<String, List<Float>> embeddingCache) {
+        // 1. 直接传入的 queryVector 优先(不依赖 embeddingModel)
+        if (param.getQueryVector() != null && !param.getQueryVector().isEmpty()) {
+            return param.getQueryVector();
+        }
+
+        // 2. queryText 向量化:按 embeddingModel 缓存
+        if (StringUtils.hasText(param.getQueryText())) {
+            String embeddingModel = config.getEmbeddingModel();
+            String cacheKey = embeddingModel != null ? embeddingModel : "__default__";
+
+            if (embeddingCache.containsKey(cacheKey)) {
+                return embeddingCache.get(cacheKey);
+            }
+
+            // 先查 text_hash 缓存
+            String textHash = Md5Util.encoderByMd5(param.getQueryText());
+            if (StringUtils.hasText(textHash)) {
+                List<Float> cached = getVectorByTextHash(textHash, config.getConfigCode());
+                if (cached != null && !cached.isEmpty()) {
+                    embeddingCache.put(cacheKey, cached);
+                    return cached;
+                }
+            }
+
+            // 调用 embedding API
+            List<Float> vector = embeddingService.embed(param.getQueryText(), config);
+            if (vector != null && !vector.isEmpty()) {
+                embeddingCache.put(cacheKey, vector);
+                return vector;
+            }
+            log.warn("配置 {} embedding 失败", config.getConfigCode());
+            return null;
+        }
+
+        // 3. channelContentId 历史向量
+        if (StringUtils.hasText(param.getChannelContentId())) {
+            return getVectorByChannelContentId(param.getChannelContentId(), config.getConfigCode());
+        }
+
+        return null;
+    }
+
+    /**
+     * 判断配置是否为多点模式(有 extract_rule 配置)
+     */
+    private static boolean isMultiPointConfig(DeconstructVectorConfig config) {
+        return config != null && StringUtils.hasText(config.getExtractRule());
+    }
+
+    /**
+     * 多点模式下将复合ID解码为真实videoId,并对同一videoId去重(保留最高分)
+     */
+    private List<VideoMatch> decodeAndDeduplicateMultiPointMatches(List<VideoMatch> matches, String configCode) {
+        // compositeId -> realVideoId,同一videoId保留最高分
+        Map<Long, VideoMatch> deduped = new LinkedHashMap<>();
+
+        for (VideoMatch match : matches) {
+            Long realVideoId = match.getVideoId() / VectorConstants.MULTI_POINT_FACTOR;
+            VideoMatch existing = deduped.get(realVideoId);
+            if (existing == null || match.getScore() > existing.getScore()) {
+                deduped.put(realVideoId, new VideoMatch(realVideoId, match.getScore(), configCode));
+            }
+        }
+
+        return new ArrayList<>(deduped.values());
+    }
+
+    /**
+     * 获取所有启用的向量化配置
+     */
+    private List<DeconstructVectorConfig> getEnabledConfigs() {
+        try {
+            DeconstructVectorConfigExample example = new DeconstructVectorConfigExample();
+            example.createCriteria().andEnabledEqualTo((byte) 1);
+            example.setOrderByClause("priority ASC");
+            List<DeconstructVectorConfig> configs = deconstructVectorConfigMapper.selectByExample(example);
+            return configs != null ? configs : Collections.emptyList();
+        } catch (Exception e) {
+            log.error("查询启用配置失败: {}", e.getMessage(), e);
+            return Collections.emptyList();
+        }
+    }
+
     /**
      * 解析查询向量,优先级:直接传入 > channelContentId历史向量 > text_hash历史embedding > 文本向量化
      * 任意一级命中则直接返回,不再继续降级

+ 63 - 0
core/src/main/resources/generator/mybatis-pgvector-generator-config.xml

@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE generatorConfiguration
+        PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
+        "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
+<!-- pgVector 数据源生成器配置 -->
+<generatorConfiguration>
+    <context id="pgVector" defaultModelType="flat" targetRuntime="MyBatis3">
+        <property name="autoDelimitKeywords" value="true"/>
+        <!-- 生成的Java文件的编码 -->
+        <property name="javaFileEncoding" value="UTF-8"/>
+        <!-- 格式化java代码 -->
+        <property name="javaFormatter" value="org.mybatis.generator.api.dom.DefaultJavaFormatter"/>
+        <!-- 格式化XML代码 -->
+        <property name="xmlFormatter" value="org.mybatis.generator.api.dom.DefaultXmlFormatter"/>
+        <!-- PostgreSQL 使用双引号作为标识符分隔符 -->
+        <property name="beginningDelimiter" value="&quot;"/>
+        <property name="endingDelimiter" value="&quot;"/>
+
+        <plugin type="org.mybatis.generator.plugins.ToStringPlugin" />
+        <plugin type="org.mybatis.generator.plugins.UnmergeableXmlMappersPlugin" />
+
+        <commentGenerator>
+            <property name="addRemarkComments" value="true"/>
+        </commentGenerator>
+
+        <!-- PostgreSQL 17 连接配置 -->
+        <jdbcConnection driverClass="org.postgresql.Driver"
+                        connectionURL="jdbc:postgresql://pgm-bp1p0qr1gim87wnibo.pg.rds.aliyuncs.com/vector?currentSchema=public"
+                        userId="vector" password="vector123456@">
+            <!-- PostgreSQL 需要指定 schema -->
+            <property name="nullCatalogMeansCurrent" value="true"/>
+        </jdbcConnection>
+
+        <javaTypeResolver type="org.mybatis.generator.internal.types.JavaTypeResolverDefaultImpl">
+            <property name="forceBigDecimals" value="false"/>
+        </javaTypeResolver>
+
+        <!-- PO 实体类输出路径 -->
+        <javaModelGenerator targetPackage="com.tzld.videoVector.model.po.pgVector" targetProject="core/src/main/java">
+            <property name="constructorBased" value="false"/>
+            <property name="enableSubPackages" value="true"/>
+            <property name="immutable" value="false"/>
+        </javaModelGenerator>
+
+        <!-- Mapper XML 输出路径 -->
+        <sqlMapGenerator targetPackage="mapper.pgVector" targetProject="core/src/main/resources">
+            <property name="enableSubPackages" value="true"/>
+        </sqlMapGenerator>
+
+        <!-- Mapper 接口输出路径 -->
+        <javaClientGenerator targetPackage="com.tzld.videoVector.dao.mapper.pgVector" type="XMLMAPPER" targetProject="core/src/main/java">
+            <property name="enableSubPackages" value="true"/>
+        </javaClientGenerator>
+
+        <!-- 向量主表(embedding 列类型 MBG 不识别,生成后需手动调整) -->
+        <table tableName="video_vectors" domainObjectName="VideoVector" alias="">
+            <generatedKey column="id" sqlStatement="JDBC" identity="true"/>
+            <!-- pgvector 的 vector 类型 MBG 无法自动映射,忽略后手动补充 -->
+            <ignoreColumn column="embedding"/>
+        </table>
+    </context>
+
+</generatorConfiguration>

+ 280 - 0
core/src/main/resources/mapper/pgVector/VideoVectorMapper.xml

@@ -0,0 +1,280 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.tzld.videoVector.dao.mapper.pgVector.VideoVectorMapper">
+  <resultMap id="BaseResultMap" type="com.tzld.videoVector.model.po.pgVector.VideoVector">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    <id column="id" jdbcType="BIGINT" property="id" />
+    <result column="video_id" jdbcType="BIGINT" property="videoId" />
+    <result column="config_code" jdbcType="VARCHAR" property="configCode" />
+    <result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
+    <result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
+  </resultMap>
+  <sql id="Example_Where_Clause">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    <where>
+      <foreach collection="oredCriteria" item="criteria" separator="or">
+        <if test="criteria.valid">
+          <trim prefix="(" prefixOverrides="and" suffix=")">
+            <foreach collection="criteria.criteria" item="criterion">
+              <choose>
+                <when test="criterion.noValue">
+                  and ${criterion.condition}
+                </when>
+                <when test="criterion.singleValue">
+                  and ${criterion.condition} #{criterion.value}
+                </when>
+                <when test="criterion.betweenValue">
+                  and ${criterion.condition} #{criterion.value} and #{criterion.secondValue}
+                </when>
+                <when test="criterion.listValue">
+                  and ${criterion.condition}
+                  <foreach close=")" collection="criterion.value" item="listItem" open="(" separator=",">
+                    #{listItem}
+                  </foreach>
+                </when>
+              </choose>
+            </foreach>
+          </trim>
+        </if>
+      </foreach>
+    </where>
+  </sql>
+  <sql id="Update_By_Example_Where_Clause">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    <where>
+      <foreach collection="example.oredCriteria" item="criteria" separator="or">
+        <if test="criteria.valid">
+          <trim prefix="(" prefixOverrides="and" suffix=")">
+            <foreach collection="criteria.criteria" item="criterion">
+              <choose>
+                <when test="criterion.noValue">
+                  and ${criterion.condition}
+                </when>
+                <when test="criterion.singleValue">
+                  and ${criterion.condition} #{criterion.value}
+                </when>
+                <when test="criterion.betweenValue">
+                  and ${criterion.condition} #{criterion.value} and #{criterion.secondValue}
+                </when>
+                <when test="criterion.listValue">
+                  and ${criterion.condition}
+                  <foreach close=")" collection="criterion.value" item="listItem" open="(" separator=",">
+                    #{listItem}
+                  </foreach>
+                </when>
+              </choose>
+            </foreach>
+          </trim>
+        </if>
+      </foreach>
+    </where>
+  </sql>
+  <sql id="Base_Column_List">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    id, video_id, config_code, created_at, updated_at
+  </sql>
+  <select id="selectByExample" parameterType="com.tzld.videoVector.model.po.pgVector.VideoVectorExample" resultMap="BaseResultMap">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    select
+    <if test="distinct">
+      distinct
+    </if>
+    <include refid="Base_Column_List" />
+    from video_vectors
+    <if test="_parameter != null">
+      <include refid="Example_Where_Clause" />
+    </if>
+    <if test="orderByClause != null">
+      order by ${orderByClause}
+    </if>
+  </select>
+  <select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="BaseResultMap">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    select 
+    <include refid="Base_Column_List" />
+    from video_vectors
+    where id = #{id,jdbcType=BIGINT}
+  </select>
+  <delete id="deleteByPrimaryKey" parameterType="java.lang.Long">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    delete from video_vectors
+    where id = #{id,jdbcType=BIGINT}
+  </delete>
+  <delete id="deleteByExample" parameterType="com.tzld.videoVector.model.po.pgVector.VideoVectorExample">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    delete from video_vectors
+    <if test="_parameter != null">
+      <include refid="Example_Where_Clause" />
+    </if>
+  </delete>
+  <insert id="insert" keyColumn="id" keyProperty="id" parameterType="com.tzld.videoVector.model.po.pgVector.VideoVector" useGeneratedKeys="true">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    insert into video_vectors (video_id, config_code, created_at, 
+      updated_at)
+    values (#{videoId,jdbcType=BIGINT}, #{configCode,jdbcType=VARCHAR}, #{createdAt,jdbcType=TIMESTAMP}, 
+      #{updatedAt,jdbcType=TIMESTAMP})
+  </insert>
+  <insert id="insertSelective" keyColumn="id" keyProperty="id" parameterType="com.tzld.videoVector.model.po.pgVector.VideoVector" useGeneratedKeys="true">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    insert into video_vectors
+    <trim prefix="(" suffix=")" suffixOverrides=",">
+      <if test="videoId != null">
+        video_id,
+      </if>
+      <if test="configCode != null">
+        config_code,
+      </if>
+      <if test="createdAt != null">
+        created_at,
+      </if>
+      <if test="updatedAt != null">
+        updated_at,
+      </if>
+    </trim>
+    <trim prefix="values (" suffix=")" suffixOverrides=",">
+      <if test="videoId != null">
+        #{videoId,jdbcType=BIGINT},
+      </if>
+      <if test="configCode != null">
+        #{configCode,jdbcType=VARCHAR},
+      </if>
+      <if test="createdAt != null">
+        #{createdAt,jdbcType=TIMESTAMP},
+      </if>
+      <if test="updatedAt != null">
+        #{updatedAt,jdbcType=TIMESTAMP},
+      </if>
+    </trim>
+  </insert>
+  <select id="countByExample" parameterType="com.tzld.videoVector.model.po.pgVector.VideoVectorExample" resultType="java.lang.Long">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    select count(*) from video_vectors
+    <if test="_parameter != null">
+      <include refid="Example_Where_Clause" />
+    </if>
+  </select>
+  <update id="updateByExampleSelective" parameterType="map">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    update video_vectors
+    <set>
+      <if test="record.id != null">
+        id = #{record.id,jdbcType=BIGINT},
+      </if>
+      <if test="record.videoId != null">
+        video_id = #{record.videoId,jdbcType=BIGINT},
+      </if>
+      <if test="record.configCode != null">
+        config_code = #{record.configCode,jdbcType=VARCHAR},
+      </if>
+      <if test="record.createdAt != null">
+        created_at = #{record.createdAt,jdbcType=TIMESTAMP},
+      </if>
+      <if test="record.updatedAt != null">
+        updated_at = #{record.updatedAt,jdbcType=TIMESTAMP},
+      </if>
+    </set>
+    <if test="_parameter != null">
+      <include refid="Update_By_Example_Where_Clause" />
+    </if>
+  </update>
+  <update id="updateByExample" parameterType="map">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    update video_vectors
+    set id = #{record.id,jdbcType=BIGINT},
+      video_id = #{record.videoId,jdbcType=BIGINT},
+      config_code = #{record.configCode,jdbcType=VARCHAR},
+      created_at = #{record.createdAt,jdbcType=TIMESTAMP},
+      updated_at = #{record.updatedAt,jdbcType=TIMESTAMP}
+    <if test="_parameter != null">
+      <include refid="Update_By_Example_Where_Clause" />
+    </if>
+  </update>
+  <update id="updateByPrimaryKeySelective" parameterType="com.tzld.videoVector.model.po.pgVector.VideoVector">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    update video_vectors
+    <set>
+      <if test="videoId != null">
+        video_id = #{videoId,jdbcType=BIGINT},
+      </if>
+      <if test="configCode != null">
+        config_code = #{configCode,jdbcType=VARCHAR},
+      </if>
+      <if test="createdAt != null">
+        created_at = #{createdAt,jdbcType=TIMESTAMP},
+      </if>
+      <if test="updatedAt != null">
+        updated_at = #{updatedAt,jdbcType=TIMESTAMP},
+      </if>
+    </set>
+    where id = #{id,jdbcType=BIGINT}
+  </update>
+  <update id="updateByPrimaryKey" parameterType="com.tzld.videoVector.model.po.pgVector.VideoVector">
+    <!--
+      WARNING - @mbg.generated
+      This element is automatically generated by MyBatis Generator, do not modify.
+      This element was generated on Wed Apr 29 15:13:43 CST 2026.
+    -->
+    update video_vectors
+    set video_id = #{videoId,jdbcType=BIGINT},
+      config_code = #{configCode,jdbcType=VARCHAR},
+      created_at = #{createdAt,jdbcType=TIMESTAMP},
+      updated_at = #{updatedAt,jdbcType=TIMESTAMP}
+    where id = #{id,jdbcType=BIGINT}
+  </update>
+</mapper>

+ 14 - 0
pom.xml

@@ -289,6 +289,20 @@
             <version>0.9.9</version>
         </dependency>
 
+        <!-- PostgreSQL JDBC 驱动 -->
+        <dependency>
+            <groupId>org.postgresql</groupId>
+            <artifactId>postgresql</artifactId>
+            <version>42.7.3</version>
+        </dependency>
+
+        <!-- pgvector Java 客户端 -->
+        <dependency>
+            <groupId>com.pgvector</groupId>
+            <artifactId>pgvector</artifactId>
+            <version>0.1.6</version>
+        </dependency>
+
 
     </dependencies>
 

+ 11 - 0
server/src/main/resources/application-dev.yml

@@ -13,6 +13,17 @@ spring:
         minimum-idle: 10
         maximum-pool-size: 20
         connection-test-query: SELECT 1
+    pg-vector:
+      driver-class-name: org.postgresql.Driver
+      jdbc-url: jdbc:postgresql://pgm-bp1p0qr1gim87wnibo.pg.rds.aliyuncs.com/vector?currentSchema=public
+      username: vector
+      password: vector123456@
+      type: com.zaxxer.hikari.HikariDataSource
+      hikari:
+        minimum-idle: 5
+        maximum-pool-size: 20
+        connection-test-query: SELECT 1
+        connection-init-sql: SET hnsw.ef_search = 100
 
   redis:
     host: r-bp1zg8fw8db0vxdo2mpd.redis.rds.aliyuncs.com

+ 11 - 0
server/src/main/resources/application-prod.yml

@@ -13,6 +13,17 @@ spring:
         minimum-idle: 10
         maximum-pool-size: 20
         connection-test-query: SELECT 1
+    pg-vector:
+      driver-class-name: org.postgresql.Driver
+      jdbc-url: jdbc:postgresql://pgm-bp1p0qr1gim87wni.pg.rds.aliyuncs.com/vector?currentSchema=public
+      username: vector
+      password: vector123456@
+      type: com.zaxxer.hikari.HikariDataSource
+      hikari:
+        minimum-idle: 5
+        maximum-pool-size: 20
+        connection-test-query: SELECT 1
+        connection-init-sql: SET hnsw.ef_search = 100
 
   redis:
     host: r-bp1zg8fw8db0vxdo2mpd.redis.rds.aliyuncs.com

+ 11 - 0
server/src/main/resources/application-test.yml

@@ -13,6 +13,17 @@ spring:
         minimum-idle: 10
         maximum-pool-size: 20
         connection-test-query: SELECT 1
+    pg-vector:
+      driver-class-name: org.postgresql.Driver
+      jdbc-url: jdbc:postgresql://pgm-bp1p0qr1gim87wni.pg.rds.aliyuncs.com/vector?currentSchema=public
+      username: vector
+      password: vector123456@
+      type: com.zaxxer.hikari.HikariDataSource
+      hikari:
+        minimum-idle: 5
+        maximum-pool-size: 20
+        connection-test-query: SELECT 1
+        connection-init-sql: SET hnsw.ef_search = 100
 
   redis:
     host: r-bp1zg8fw8db0vxdo2mpd.redis.rds.aliyuncs.com