Ver Fonte

Merge branch 'feature/luojunhui/20260521-add-material-data' of Server/video-vector-server into master

luojunhui há 1 semana atrás
pai
commit
1fb6ddb700
20 ficheiros alterados com 2760 adições e 277 exclusões
  1. 11 0
      core/src/main/java/com/tzld/videoVector/common/constant/VectorConstants.java
  2. 25 0
      core/src/main/java/com/tzld/videoVector/dao/mapper/pgVector/ext/MaterialDeconstructResultMapperExt.java
  3. 52 0
      core/src/main/java/com/tzld/videoVector/dao/mapper/pgVector/ext/MaterialVectorMapperExt.java
  4. 773 44
      core/src/main/java/com/tzld/videoVector/job/MaterialVectorJob.java
  5. 96 0
      core/src/main/java/com/tzld/videoVector/model/entity/MaterialMatch.java
  6. 10 4
      core/src/main/java/com/tzld/videoVector/model/param/recall/MatchByTextParam.java
  7. 140 0
      core/src/main/java/com/tzld/videoVector/model/po/pgVector/MaterialDeconstructResult.java
  8. 201 0
      core/src/main/java/com/tzld/videoVector/model/po/pgVector/MaterialVector.java
  9. 39 0
      core/src/main/java/com/tzld/videoVector/model/vo/recall/MaterialDetailVO.java
  10. 6 6
      core/src/main/java/com/tzld/videoVector/model/vo/recall/RecallResultVO.java
  11. 31 22
      core/src/main/java/com/tzld/videoVector/model/vo/recall/VideoMatchEnrichedVO.java
  12. 61 0
      core/src/main/java/com/tzld/videoVector/service/MaterialVectorStoreService.java
  13. 284 0
      core/src/main/java/com/tzld/videoVector/service/impl/PgMaterialVectorStoreServiceImpl.java
  14. 6 1
      core/src/main/java/com/tzld/videoVector/service/impl/PgVectorStoreServiceImpl.java
  15. 653 193
      core/src/main/java/com/tzld/videoVector/service/recall/impl/VectorRecallTestServiceImpl.java
  16. 56 0
      core/src/main/resources/mapper/pgVector/ext/MaterialDeconstructResultMapperExt.xml
  17. 153 0
      core/src/main/resources/mapper/pgVector/ext/MaterialVectorMapperExt.xml
  18. 152 0
      server/src/main/java/com/tzld/videoVector/MaterialEmbeddingTestRunner.java
  19. 2 1
      server/src/main/java/com/tzld/videoVector/controller/VectorRecallTestController.java
  20. 9 6
      server/src/main/java/com/tzld/videoVector/controller/XxlJobController.java

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

@@ -66,4 +66,15 @@ public interface VectorConstants {
 
     /** ODPS SQL IN 子句单批最大 ID 数(防止 SQL 超长) */
     int ODPS_IN_BATCH_SIZE = 1000;
+
+    // ========================== 召回参数 ==========================
+
+    /**
+     * 多点向量召回候选倍数:单素材有多个点向量命中时,需要在应用层按 materialId 去重。
+     * 实际拉取数 = max(topN * MULTI_POINT_RECALL_CANDIDATE_FACTOR, MULTI_POINT_RECALL_MIN_CANDIDATES)
+     */
+    int MULTI_POINT_RECALL_CANDIDATE_FACTOR = 3;
+
+    /** 多点向量召回候选最小数(避免极端小 topN 时候选不足) */
+    int MULTI_POINT_RECALL_MIN_CANDIDATES = 30;
 }

+ 25 - 0
core/src/main/java/com/tzld/videoVector/dao/mapper/pgVector/ext/MaterialDeconstructResultMapperExt.java

@@ -0,0 +1,25 @@
+package com.tzld.videoVector.dao.mapper.pgVector.ext;
+
+import com.tzld.videoVector.model.po.pgVector.MaterialDeconstructResult;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * MaterialDeconstructResult 自定义 Mapper(对称 VideoDeconstructResultMapperExt)
+ * 存储素材解构结果,目前来源仅 aigc_deconstruct
+ */
+public interface MaterialDeconstructResultMapperExt {
+
+    List<String> selectExistingMaterialIds(@Param("source") String source,
+                                           @Param("materialIds") List<String> materialIds);
+
+    int batchInsertIgnore(@Param("list") List<MaterialDeconstructResult> list);
+
+    List<String> selectMaterialIdsBySourcePaged(@Param("source") String source,
+                                                @Param("offset") int offset,
+                                                @Param("limit") int limit);
+
+    List<MaterialDeconstructResult> selectResultsByMaterialIds(@Param("source") String source,
+                                                               @Param("materialIds") List<String> materialIds);
+}

+ 52 - 0
core/src/main/java/com/tzld/videoVector/dao/mapper/pgVector/ext/MaterialVectorMapperExt.java

@@ -0,0 +1,52 @@
+package com.tzld.videoVector.dao.mapper.pgVector.ext;
+
+import com.tzld.videoVector.model.po.pgVector.MaterialVector;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * MaterialVector 自定义查询 Mapper(与 MBG 生成的基础 Mapper 分离)
+ * 主要用于向量检索和批量操作
+ */
+public interface MaterialVectorMapperExt {
+
+    void upsertVector(@Param("materialId") String materialId,
+                      @Param("configCode") String configCode,
+                      @Param("pointIndex") Integer pointIndex,
+                      @Param("embedding") String embedding,
+                      @Param("text") String text,
+                      @Param("textHash") String textHash,
+                      @Param("sourceType") Short sourceType);
+
+    int existsByMaterialIdAndConfigCode(@Param("materialId") String materialId,
+                                        @Param("configCode") String configCode);
+
+    List<String> selectExistingMaterialIds(@Param("materialIds") List<String> materialIds,
+                                           @Param("configCode") String configCode);
+
+    List<String> selectAllMaterialIds(@Param("configCode") String configCode);
+
+    List<MaterialVector> searchTopN(@Param("configCode") String configCode,
+                                    @Param("queryVector") String queryVector,
+                                    @Param("topN") Integer topN);
+
+    List<MaterialVector> searchTopNBySource(@Param("configCode") String configCode,
+                                            @Param("queryVector") String queryVector,
+                                            @Param("topN") Integer topN,
+                                            @Param("sourceType") Short sourceType);
+
+    MaterialVector selectByTextHashAndConfigCode(@Param("textHash") String textHash,
+                                                 @Param("configCode") String configCode);
+
+    int deleteByMaterialIdAndConfigCode(@Param("materialId") String materialId,
+                                        @Param("configCode") String configCode);
+
+    int deleteBatchByMaterialIds(@Param("materialIds") List<String> materialIds,
+                                 @Param("configCode") String configCode);
+
+    /** 删除指定素材在 configCode 下 point_index >= minPointIndex 的旧向量(多点模式清理用) */
+    int deleteAbovePointIndex(@Param("materialId") String materialId,
+                              @Param("configCode") String configCode,
+                              @Param("minPointIndex") int minPointIndex);
+}

+ 773 - 44
core/src/main/java/com/tzld/videoVector/job/MaterialVectorJob.java

@@ -1,96 +1,825 @@
 package com.tzld.videoVector.job;
 
-import com.tzld.videoVector.dao.mapper.pgVector.DeconstructContentMapper;
-import com.tzld.videoVector.model.po.pgVector.DeconstructContent;
-import com.tzld.videoVector.model.po.pgVector.DeconstructContentExample;
-import com.tzld.videoVector.service.VectorizeService;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.ctrip.framework.apollo.spring.annotation.ApolloJsonValue;
+import com.google.common.collect.Lists;
+import com.tzld.videoVector.api.AigcApiService;
+import com.tzld.videoVector.common.constant.VectorConstants;
+import com.tzld.videoVector.dao.mapper.pgVector.DeconstructVectorConfigMapper;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialDeconstructResultMapperExt;
+import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfig;
+import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfigExample;
+import com.tzld.videoVector.model.po.pgVector.MaterialDeconstructResult;
+import com.tzld.videoVector.service.EmbeddingService;
+import com.tzld.videoVector.service.MaterialVectorStoreService;
+import com.tzld.videoVector.util.Md5Util;
+import com.tzld.videoVector.util.VectorUtils;
 import com.xxl.job.core.biz.model.ReturnT;
 import com.xxl.job.core.handler.annotation.XxlJob;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
 import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
 
 /**
- * 素材批量向量化定时任务
+ * 素材向量化定时任务(参考 VideoVectorJob#aigcVideoVectorJob)
  * <p>
- * 扫描 deconstruct_content 中解构完成(status=2)的记录,
- * 调用 VectorizeService.vectorizeContent() 进行向量化(内部自动幂等)。
+ * 数据流:
+ * <ul>
+ *   <li>{@link #syncMaterialDeconstructJob(String)}:从 AIGC API 拉取素材解构结果,写入 material_deconstruct_result</li>
+ *   <li>{@link #vectorMaterialJob(String)}:扫描 material_deconstruct_result,按配置提取文本并向量化,写入 material_vectors</li>
+ *   <li>{@link #materialJob(String)}:编排前两步串行执行</li>
+ * </ul>
  * <p>
- * 复用 deconstruct_vector_config 中 biz_type=NULL 的通用配置,与视频解构配置共享。
+ * 与 VideoVectorJob#aigcVideoVectorJob 对称:
+ * <ul>
+ *   <li>videoId → materialId(字符串:内部数字 / 外部文件 MD5)</li>
+ *   <li>video_deconstruct_result → material_deconstruct_result</li>
+ *   <li>video_vectors → material_vectors(带 sourceType 1=外部合作 / 2=内部素材)</li>
+ *   <li>aigc.deconstruct.task.ids → aigc.material.task.ids</li>
+ *   <li>跳过审核过滤(素材无审核流程)</li>
+ * </ul>
  */
 @Slf4j
 @Component
 public class MaterialVectorJob {
 
+    private static final String SOURCE_AIGC = "aigc_deconstruct";
+
+    @Resource
+    private DeconstructVectorConfigMapper vectorConfigMapper;
+
+    @Resource
+    private MaterialDeconstructResultMapperExt materialDeconstructResultMapperExt;
+
+    @Resource
+    private MaterialVectorStoreService materialVectorStoreService;
+
     @Resource
-    private DeconstructContentMapper deconstructContentMapper;
+    private EmbeddingService embeddingService;
 
     @Resource
-    private VectorizeService vectorizeService;
+    private AigcApiService aigcApiService;
+
+    /**
+     * 素材专用 AIGC 任务配置
+     * <p>
+     * 格式:{"67": 1, "69": 2}
+     * <ul>
+     *   <li>key: AIGC 任务 ID(String 形式,因 JSON map key 必须是字符串)</li>
+     *   <li>value: 素材来源 sourceType — 1=外部合作,2=内部素材</li>
+     * </ul>
+     * <p>
+     * 例如:taskId=67 拉到的素材标记为 sourceType=1(外部合作),taskId=69 拉到的素材标记为 sourceType=2(内部素材)。
+     */
+    @ApolloJsonValue("${aigc.material.task.source.map:{}}")
+    private Map<String, Short> aigcMaterialTaskSourceMap;
+
+    /**
+     * 兜底 sourceType(当 task 在 map 中没匹配到时使用)
+     */
+    @Value("${aigc.material.source.type.default:2}")
+    private short defaultSourceType;
+
+    // ====================================================================
+    // 入口 1:同步素材解构结果
+    // ====================================================================
+
+    /**
+     * 同步素材解构结果
+     * <p>
+     * 从 AIGC API 拉取 dataContent,写入 material_deconstruct_result
+     */
+    @XxlJob("syncMaterialDeconstructJob")
+    public ReturnT<String> syncMaterialDeconstructJob(String param) {
+        log.info("开始执行素材解构同步任务, param: {}", param);
+        try {
+            AtomicInteger insertCount = new AtomicInteger(0);
+            AtomicInteger skipCount = new AtomicInteger(0);
+            syncAigcMaterialSource(insertCount, skipCount);
+            log.info("素材解构同步完成 新增={}, 已存在跳过={}", insertCount.get(), skipCount.get());
+            return ReturnT.SUCCESS;
+        } catch (Exception e) {
+            log.error("素材解构同步任务失败: {}", e.getMessage(), e);
+            return new ReturnT<>(ReturnT.FAIL_CODE, "任务执行失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 从 AIGC API 拉取素材解构结果,写入 material_deconstruct_result
+     * 对称 VideoVectorJob.syncAigcDeconstructSource
+     * <p>
+     * 按 aigc.material.task.source.map 配置遍历每个 taskId,
+     * 同一批拉到的素材统一带上 task 对应的 sourceType。
+     */
+    private void syncAigcMaterialSource(AtomicInteger insertCount, AtomicInteger skipCount) {
+        if (aigcMaterialTaskSourceMap == null || aigcMaterialTaskSourceMap.isEmpty()) {
+            log.info("aigc.material.task.source.map 为空,跳过同步");
+            return;
+        }
+        log.info("aigc.material.task.source.map: {}", aigcMaterialTaskSourceMap);
+
+        // 1. 收集 (materialId, taskInstanceId) 和 (materialId, sourceType) 映射
+        // 按 taskId 自然序遍历保证确定性;同一 materialId 在多个 task 中出现时,taskId 较大的覆盖前者
+        Map<String, Long> materialIdToTaskInstanceId = new HashMap<>();
+        Map<String, Short> materialIdToSourceType = new HashMap<>();
+
+        List<Map.Entry<String, Short>> sortedEntries = new ArrayList<>(aigcMaterialTaskSourceMap.entrySet());
+        sortedEntries.sort(Comparator.comparingInt(e -> Integer.parseInt(e.getKey())));
+        for (Map.Entry<String, Short> entry : sortedEntries) {
+            Integer taskId;
+            try {
+                taskId = Integer.parseInt(entry.getKey());
+            } catch (NumberFormatException e) {
+                log.error("跳过非法 taskId key: {}", entry.getKey());
+                continue;
+            }
+            Short sourceType = entry.getValue() != null ? entry.getValue() : defaultSourceType;
+
+            List<AigcApiService.AigcTaskInput> taskInputList = aigcApiService.getTaskInputList(taskId);
+            if (CollectionUtils.isEmpty(taskInputList)) {
+                continue;
+            }
+            for (AigcApiService.AigcTaskInput input : taskInputList) {
+                String materialId = normalizeMaterialId(input.getBizUniqueId());
+                if (materialId == null) {
+                    log.info("跳过空 bizUniqueId, taskId={}", taskId);
+                    continue;
+                }
+                materialIdToTaskInstanceId.put(materialId, input.getTaskInstanceId());
+                Short prevSourceType = materialIdToSourceType.put(materialId, sourceType);
+                if (prevSourceType != null && !prevSourceType.equals(sourceType)) {
+                    log.warn("materialId={} 在 taskId={}(sourceType={}) 中 sourceType 被覆盖,原值={}",
+                            materialId, taskId, sourceType, prevSourceType);
+                }
+            }
+            log.info("taskId={} sourceType={} 拉到 {} 条素材", taskId, sourceType, taskInputList.size());
+        }
+
+        if (materialIdToTaskInstanceId.isEmpty()) {
+            log.info("AIGC 任务无有效素材数据");
+            return;
+        }
+        log.info("AIGC 任务汇总后共 {} 个唯一素材", materialIdToTaskInstanceId.size());
+
+        // 2. 分批检查已存在,过滤后调 detail 接口
+        List<String> allMaterialIds = new ArrayList<>(materialIdToTaskInstanceId.keySet());
+        for (List<String> batchIds : Lists.partition(allMaterialIds, VectorConstants.ODPS_IN_BATCH_SIZE)) {
+            Set<String> existingIds = new HashSet<>(
+                    materialDeconstructResultMapperExt.selectExistingMaterialIds(SOURCE_AIGC, batchIds));
+            skipCount.addAndGet(existingIds.size());
+
+            List<String> needSyncIds = batchIds.stream()
+                    .filter(id -> !existingIds.contains(id))
+                    .collect(Collectors.toList());
+
+            if (needSyncIds.isEmpty()) {
+                continue;
+            }
+
+            // 并发调 detail 接口
+            ExecutorService executor = Executors.newFixedThreadPool(VectorConstants.AIGC_DETAIL_PARALLELISM);
+            try {
+                List<Future<?>> futures = new ArrayList<>();
+                List<MaterialDeconstructResult> batch = Collections.synchronizedList(new ArrayList<>());
+
+                for (String materialId : needSyncIds) {
+                    futures.add(executor.submit(() -> {
+                        try {
+                            Long taskInstanceId = materialIdToTaskInstanceId.get(materialId);
+                            if (taskInstanceId == null) return;
+                            JSONObject dataContent = aigcApiService.getTaskCallbackDetail(taskInstanceId);
+                            if (dataContent != null) {
+                                MaterialDeconstructResult r = new MaterialDeconstructResult();
+                                r.setMaterialId(materialId);
+                                r.setSource(SOURCE_AIGC);
+                                r.setResult(dataContent.toJSONString());
+                                r.setSourceType(materialIdToSourceType.getOrDefault(materialId, defaultSourceType));
+                                batch.add(r);
+                            }
+                        } catch (Exception e) {
+                            log.error("同步 materialId={} 失败: {}", materialId, e.getMessage());
+                        }
+                    }));
+                }
+                awaitAndShutdown(futures, executor, 30, "素材同步");
+
+                if (!batch.isEmpty()) {
+                    for (List<MaterialDeconstructResult> subBatch : Lists.partition(batch, 200)) {
+                        insertCount.addAndGet(materialDeconstructResultMapperExt.batchInsertIgnore(subBatch));
+                    }
+                }
+            } finally {
+                executor.shutdownNow();
+            }
+        }
+    }
+
+    // ====================================================================
+    // 入口 2:素材向量化
+    // ====================================================================
 
     /**
-     * 素材批量向量化
-     * 扫描解构完成的素材,逐条调用 VectorizeService 进行向量化
+     * 素材向量化任务
+     * <p>
+     * 从 material_deconstruct_result 读取 dataContent,按 source_field='aigc_deconstruct' 配置向量化
      */
     @XxlJob("vectorMaterialJob")
     public ReturnT<String> vectorMaterialJob(String param) {
-        log.info("开始执行素材批量向量化任务, param: {}", param);
+        log.info("开始执行素材向量化任务, param: {}", param);
+        Integer maxMaterialCount = parseMaxCount(param);
+        return doVectorize(maxMaterialCount);
+    }
 
+    /**
+     * 单次执行素材向量化
+     *
+     * @param maxMaterialCount 本次最多处理素材数;null 或 <=0 表示不限制
+     */
+    private ReturnT<String> doVectorize(Integer maxMaterialCount) {
         try {
-            int totalSuccess = 0;
-            int totalFail = 0;
+            // 1. 加载素材专用向量配置
+            List<DeconstructVectorConfig> configs = getEnabledConfigsBySourceField(SOURCE_AIGC);
+            if (CollectionUtils.isEmpty(configs)) {
+                log.info("未找到 source_field={} 的向量化配置", SOURCE_AIGC);
+                return ReturnT.SUCCESS;
+            }
+            log.info("加载 {} 个素材向量化配置: {}", configs.size(),
+                    configs.stream().map(DeconstructVectorConfig::getConfigCode).collect(Collectors.toList()));
+
+            AtomicInteger totalSuccessCount = new AtomicInteger(0);
+            AtomicInteger totalFailCount = new AtomicInteger(0);
+            AtomicInteger totalProcessed = new AtomicInteger(0);
             int pageNum = 0;
-            int pageSize = 100;
 
             while (true) {
-                // 分页查询解构完成的内容(status=2)
-                List<DeconstructContent> contents = queryDeconstructedContents(pageNum, pageSize);
-                if (CollectionUtils.isEmpty(contents)) {
-                    log.info("第 {} 页无数据,分页结束", pageNum);
+                int offset = pageNum * VectorConstants.PAGE_SIZE;
+                int limit = VectorConstants.PAGE_SIZE;
+                if (maxMaterialCount != null && maxMaterialCount > 0) {
+                    int remaining = maxMaterialCount - totalProcessed.get();
+                    if (remaining <= 0) break;
+                    limit = Math.min(limit, remaining);
+                }
+
+                List<String> materialIds = materialDeconstructResultMapperExt
+                        .selectMaterialIdsBySourcePaged(SOURCE_AIGC, offset, limit);
+                if (CollectionUtils.isEmpty(materialIds)) {
+                    log.info("第 {} 页没有查询到数据,分页查询结束", pageNum);
                     break;
                 }
-                log.info("第 {} 页查询到 {} 条解构完成的内容", pageNum, contents.size());
-
-                for (DeconstructContent content : contents) {
-                    try {
-                        // VectorizeService 内部自动幂等:已有向量的 configCode 会跳过
-                        vectorizeService.vectorizeContent(content);
-                        totalSuccess++;
-                    } catch (Exception e) {
-                        log.error("素材向量化失败,contentId={}, error={}",
-                                content.getId(), e.getMessage(), e);
-                        totalFail++;
+                log.info("第 {} 页查询到 {} 个 materialId", pageNum, materialIds.size());
+
+                // 2. 一次性拉取并解析本页所有素材的 result,多个 config 共享解析结果
+                Map<String, ParsedMaterial> parsedById = loadParsedMaterials(materialIds);
+
+                // 3. 对每个配置并发处理
+                ExecutorService configExecutor = Executors.newFixedThreadPool(configs.size());
+                try {
+                    List<Future<?>> configFutures = new ArrayList<>();
+                    for (DeconstructVectorConfig config : configs) {
+                        configFutures.add(configExecutor.submit(() ->
+                                processConfigForMaterial(config, materialIds, parsedById, totalSuccessCount, totalFailCount)
+                        ));
                     }
+                    awaitAndShutdown(configFutures, configExecutor, 30, "素材向量化配置并发");
+                } finally {
+                    configExecutor.shutdownNow();
+                }
+
+                totalProcessed.addAndGet(materialIds.size());
+
+                if (maxMaterialCount != null && maxMaterialCount > 0
+                        && totalProcessed.get() >= maxMaterialCount) {
+                    log.info("已达到 maxMaterialCount={} 限制,结束扫描", maxMaterialCount);
+                    break;
                 }
 
-                if (contents.size() < pageSize) {
-                    log.info("第 {} 页数据量 {} 小于 pageSize {},分页结束",
-                            pageNum, contents.size(), pageSize);
+                if (materialIds.size() < limit) {
+                    log.info("第 {} 页数据量 {} 小于 limit {},分页结束", pageNum, materialIds.size(), limit);
                     break;
                 }
                 pageNum++;
             }
 
-            log.info("素材批量向量化完成,成功: {}, 失败: {}, 总页数: {}",
-                    totalSuccess, totalFail, pageNum + 1);
+            log.info("素材向量化任务完成 总处理素材={}, 成功={}, 失败={}",
+                    totalProcessed.get(), totalSuccessCount.get(), totalFailCount.get());
             return ReturnT.SUCCESS;
+        } catch (Exception e) {
+            log.error("素材向量化任务失败: {}", e.getMessage(), e);
+            return new ReturnT<>(ReturnT.FAIL_CODE, "任务执行失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 一次性拉取并解析本页所有素材的解构结果,避免每个 config 重复 JSON 解析。
+     */
+    private Map<String, ParsedMaterial> loadParsedMaterials(List<String> materialIds) {
+        List<MaterialDeconstructResult> results = materialDeconstructResultMapperExt
+                .selectResultsByMaterialIds(SOURCE_AIGC, materialIds);
+        Map<String, ParsedMaterial> map = new HashMap<>(materialIds.size());
+        for (MaterialDeconstructResult r : results) {
+            if (r == null || !StringUtils.hasText(r.getResult())) continue;
+            JSONObject dataContent;
+            try {
+                dataContent = JSON.parseObject(r.getResult());
+            } catch (Exception e) {
+                log.error("materialId={} result JSON 解析失败: {}", r.getMaterialId(), e.getMessage());
+                continue;
+            }
+            if (dataContent == null) continue;
+            Short sourceType = r.getSourceType() != null ? r.getSourceType() : defaultSourceType;
+            map.put(r.getMaterialId(), new ParsedMaterial(dataContent, sourceType));
+        }
+        return map;
+    }
+
+    /**
+     * 处理单个配置下的素材向量化
+     * <p>
+     * 计数粒度:素材级。multiPoint 模式下,单素材只要有至少一个点向量化成功就计入 success。
+     */
+    private void processConfigForMaterial(DeconstructVectorConfig config, List<String> materialIds,
+                                          Map<String, ParsedMaterial> parsedById,
+                                          AtomicInteger totalSuccessCount, AtomicInteger totalFailCount) {
+        String configCode = config.getConfigCode();
+        try {
+            Set<String> existingIds = materialVectorStoreService.existsByIds(configCode, materialIds);
+            List<String> needProcessIds = materialIds.stream()
+                    .filter(id -> !existingIds.contains(id))
+                    .collect(Collectors.toList());
+            if (needProcessIds.isEmpty()) {
+                log.info("配置 {} 下所有素材已有向量,跳过", configCode);
+                return;
+            }
+            log.info("配置 {} 需要处理 {} 个素材", configCode, needProcessIds.size());
+
+            for (String materialId : needProcessIds) {
+                ParsedMaterial parsed = parsedById.get(materialId);
+                if (parsed == null) {
+                    log.info("materialId={} 配置 {} 无解构结果,跳过", materialId, configCode);
+                    totalFailCount.incrementAndGet();
+                    continue;
+                }
+                try {
+                    List<String> texts = extractTextsFromDataContent(parsed.dataContent, config);
+                    if (CollectionUtils.isEmpty(texts)) {
+                        log.info("materialId={} 配置 {} 未提取到文本,跳过", materialId, configCode);
+                        totalFailCount.incrementAndGet();
+                        continue;
+                    }
+                    boolean ok = vectorizeAndStoreMaterial(config, materialId, texts, parsed.sourceType);
+                    if (ok) {
+                        totalSuccessCount.incrementAndGet();
+                    } else {
+                        totalFailCount.incrementAndGet();
+                    }
+                } catch (Exception e) {
+                    log.error("处理 materialId={} 配置 {} 时发生异常: {}", materialId, configCode, e.getMessage(), e);
+                    totalFailCount.incrementAndGet();
+                }
+            }
+        } catch (Exception e) {
+            log.error("配置 {} 处理异常: {}", configCode, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 单素材向量化 + 存储。
+     *
+     * @return true 表示本素材至少有一条点成功入库;false 表示全部失败 / 无有效文本
+     */
+    private boolean vectorizeAndStoreMaterial(DeconstructVectorConfig config, String materialId,
+                                              List<String> texts, Short sourceType) {
+        String configCode = config.getConfigCode();
+        Integer maxLength = config.getMaxLength();
+        boolean multiPoint = VectorUtils.isMultiPointConfig(config);
+
+        if (multiPoint) {
+            // 1) 先压缩掉空文本,pointIndex 用紧凑下标
+            // 2) 全部 embed 成功后再统一 save,避免出现"部分点写入、existsByIds 误判已完成"的中间态
+            //    (existsByIds 仅按 materialId 判存,留下"洞"后下一轮会跳过整个素材)
+            List<String> validTexts = new ArrayList<>(texts.size());
+            for (String raw : texts) {
+                if (StringUtils.hasText(raw)) validTexts.add(raw);
+            }
+            if (validTexts.isEmpty()) {
+                log.info("materialId={} 配置 {} 无有效文本", materialId, configCode);
+                return false;
+            }
+            List<List<Float>> vectors = new ArrayList<>(validTexts.size());
+            List<String> truncated = new ArrayList<>(validTexts.size());
+            for (int i = 0; i < validTexts.size(); i++) {
+                String text = validTexts.get(i);
+                if (maxLength != null && maxLength > 0 && text.length() > maxLength) {
+                    text = text.substring(0, maxLength);
+                }
+                List<Float> vector = getOrEmbed(text, config);
+                if (vector == null || vector.isEmpty()) {
+                    // 整素材本轮放弃,留待下次重跑(不会留下洞)
+                    log.error("materialId={} 配置 {} 第{}个文本向量化失败,本素材本轮放弃",
+                            materialId, configCode, i);
+                    return false;
+                }
+                vectors.add(vector);
+                truncated.add(text);
+            }
+            for (int i = 0; i < vectors.size(); i++) {
+                if (!materialVectorStoreService.save(configCode, materialId, i, vectors.get(i), truncated.get(i), sourceType)) {
+                    log.error("materialId={} 配置 {} 第{}个点 save 返回 false", materialId, configCode, i);
+                    return false;
+                }
+            }
+            // 清理不再需要的旧点(例如上次 5 个点,本次只有 3 个)
+            materialVectorStoreService.deleteAbovePointIndex(configCode, materialId, vectors.size());
+            log.debug("materialId={} 配置 {} 多点向量化存储成功,共 {} 个点", materialId, configCode, vectors.size());
+            return true;
+        } else {
+            String text = null;
+            for (String t : texts) {
+                if (StringUtils.hasText(t)) {
+                    text = t;
+                    break;
+                }
+            }
+            if (text == null) {
+                log.info("materialId={} 配置 {} 无有效文本,跳过", materialId, configCode);
+                return false;
+            }
+            if (maxLength != null && maxLength > 0 && text.length() > maxLength) {
+                text = text.substring(0, maxLength);
+            }
+            List<Float> vector = getOrEmbed(text, config);
+            if (vector == null || vector.isEmpty()) {
+                log.error("materialId={} 配置 {} 文本向量化失败", materialId, configCode);
+                return false;
+            }
+            if (!materialVectorStoreService.save(configCode, materialId, vector, text, sourceType)) {
+                log.error("materialId={} 配置 {} save 返回 false", materialId, configCode);
+                return false;
+            }
+            log.debug("materialId={} 配置 {} 向量化存储成功", materialId, configCode);
+            return true;
+        }
+    }
+
+    /**
+     * 优先通过 text_hash 复用已有 embedding,未命中则调用 embedding API
+     * 复用素材自身向量库的 text_hash 缓存
+     */
+    private List<Float> getOrEmbed(String text, DeconstructVectorConfig config) {
+        String configCode = config.getConfigCode();
+        String textHash = Md5Util.encoderByMd5(text);
+        if (StringUtils.hasText(textHash)) {
+            List<Float> cached = materialVectorStoreService.getVectorByTextHash(textHash, configCode);
+            if (cached != null && !cached.isEmpty()) {
+                log.debug("命中 text_hash 缓存(material),hash={}, configCode={}", textHash, configCode);
+                return cached;
+            }
+        }
+        return embeddingService.embed(text, config);
+    }
+
+    // ====================================================================
+    // 入口 3:编排
+    // ====================================================================
+
+    /**
+     * 素材完整链路:先同步解构结果,再向量化
+     */
+    @XxlJob("materialJob")
+    public ReturnT<String> materialJob(String param) {
+        log.info("开始执行素材完整链路, param: {}", param);
+        ReturnT<String> syncResult = syncMaterialDeconstructJob(param);
+        if (syncResult.getCode() != ReturnT.SUCCESS_CODE) {
+            log.error("素材同步阶段失败: {}", syncResult.getMsg());
+            return syncResult;
+        }
+        return vectorMaterialJob(param);
+    }
+
+    // ====================================================================
+    // 离线测试入口
+    // ====================================================================
+
+    /**
+     * 单次执行入口,供 MaterialEmbeddingTestRunner 调用
+     *
+     * @param maxMaterialCount 本次执行最多处理素材数;null 或 <=0 表示不限制
+     */
+    public ReturnT<String> runOnce(Integer maxMaterialCount) {
+        log.info("runOnce 开始, maxMaterialCount={}", maxMaterialCount);
+        ReturnT<String> syncResult = syncMaterialDeconstructJob(null);
+        if (syncResult.getCode() != ReturnT.SUCCESS_CODE) {
+            log.error("[runOnce] sync 失败: {}", syncResult.getMsg());
+            return syncResult;
+        }
+        return doVectorize(maxMaterialCount);
+    }
+
+    // ====================================================================
+    // TODO: 与 VideoVectorJob 的提取逻辑统一抽取到 VectorUtils / ExtractionUtils,避免两边各自维护
+    // ====================================================================
+
+    /**
+     * 从 dataContent 中提取文本(与 VideoVectorJob 完全对称)
+     */
+    private List<String> extractTextsFromDataContent(JSONObject dataContent, DeconstructVectorConfig config) {
+        if (dataContent == null) {
+            return Collections.emptyList();
+        }
+        String extractRule = config.getExtractRule();
+        if (StringUtils.hasText(extractRule)) {
+            try {
+                JSONObject rule = JSON.parseObject(extractRule);
+                if ("point_decomposition".equals(rule.getString("type"))) {
+                    return extractTextsFromPointDecomposition(dataContent, rule);
+                }
+            } catch (Exception e) {
+                // 不是 JSON 或无 type 字段,走原有逻辑
+            }
+            return extractTextsWithConfidence(dataContent, config.getSourcePath(), extractRule);
+        } else {
+            return VectorUtils.extractFromJson(dataContent, config.getSourcePath());
+        }
+    }
+
+    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.error("extract_rule 缺少必要字段: text_field={}, confidence_field={}", textField, confidenceField);
+                return texts;
+            }
+            if (sourcePath.endsWith("[*]")) {
+                List<JSONObject> items = VectorUtils.extractArrayItemsFromJson(json, sourcePath);
+                for (JSONObject item : items) {
+                    if (isConfidenceQualified(item, confidenceField, confidenceThreshold)) {
+                        String text = item.getString(textField);
+                        if (StringUtils.hasText(text)) {
+                            texts.add(text);
+                        }
+                    }
+                }
+            } else {
+                List<String> pathValues = VectorUtils.extractFromJson(json, sourcePath);
+                if (!pathValues.isEmpty()) {
+                    JSONObject targetObj = navigateToObject(json, sourcePath);
+                    if (targetObj != null && isConfidenceQualified(targetObj, confidenceField, confidenceThreshold)) {
+                        String text = targetObj.getString(textField);
+                        if (StringUtils.hasText(text)) {
+                            texts.add(text);
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.error("置信度过滤提取失败: path={}, error={}", sourcePath, e.getMessage());
+        }
+        return texts;
+    }
+
+    private List<String> extractTextsFromPointDecomposition(JSONObject dataContent, JSONObject rule) {
+        List<String> texts = new ArrayList<>();
+        try {
+            String pointArrayPath = rule.getString("point_array_path");
+            String finalResultPath = rule.getString("final_result_path");
+            String pointNameField = rule.getString("point_name_field");
+            String confidenceField = rule.getString("confidence_field");
+            double confidenceThreshold = rule.getDoubleValue("confidence_threshold");
+            String target = rule.getString("target");
+            String contributionPath = rule.getString("contribution_path");
+            double contributionThreshold = rule.getDoubleValue("contribution_threshold");
+
+            List<JSONObject> finalPoints = VectorUtils.extractArrayItemsFromJson(dataContent, finalResultPath + "[*]");
+            List<String> qualifiedPointNames = new ArrayList<>();
+            for (JSONObject fp : finalPoints) {
+                if (isConfidenceQualified(fp, confidenceField, confidenceThreshold)) {
+                    String pointName = fp.getString(pointNameField);
+                    if (StringUtils.hasText(pointName)) {
+                        qualifiedPointNames.add(pointName);
+                    }
+                }
+            }
+            if (qualifiedPointNames.isEmpty()) return texts;
+
+            List<JSONObject> pointDetails = VectorUtils.extractArrayItemsFromJson(dataContent, pointArrayPath + "[*]");
+            Map<String, Double> contributionMap = buildContributionMap(dataContent, contributionPath);
+
+            for (String pointName : qualifiedPointNames) {
+                try {
+                    JSONObject matchedPoint = null;
+                    for (JSONObject detail : pointDetails) {
+                        if (pointName.equals(detail.getString("点"))) {
+                            matchedPoint = detail;
+                            break;
+                        }
+                    }
+                    if (matchedPoint == null) continue;
+
+                    List<String> itemNames = "substance".equals(target)
+                            ? extractSubstanceNames(matchedPoint)
+                            : extractFormNames(matchedPoint);
+                    for (String name : itemNames) {
+                        Double contribution = contributionMap.get(name);
+                        if (contribution != null && contribution >= contributionThreshold) {
+                            texts.add(name);
+                        }
+                    }
+                } catch (Exception e) {
+                    log.debug("extractTextsFromPointDecomposition 单点处理异常 pointName={}: {}", pointName, e.getMessage());
+                }
+            }
+        } catch (Exception e) {
+            log.error("extractTextsFromPointDecomposition 失败: {}", e.getMessage(), e);
+        }
+        return texts;
+    }
+
+    private List<String> extractSubstanceNames(JSONObject point) {
+        List<String> names = new ArrayList<>();
+        JSONObject substance = point.getJSONObject("实质");
+        if (substance == null) return names;
+        for (String key : new String[]{"具体元素", "具象概念", "抽象概念"}) {
+            try {
+                collectNamesFromArray(substance.getJSONArray(key), names);
+            } catch (Exception e) {
+                log.debug("extractSubstanceNames key={} 异常: {}", key, e.getMessage());
+            }
+        }
+        return names;
+    }
+
+    private List<String> extractFormNames(JSONObject point) {
+        List<String> names = new ArrayList<>();
+        JSONObject form = point.getJSONObject("形式");
+        if (form == null) return names;
+        for (String key : new String[]{"具体元素形式", "具象概念形式", "整体形式"}) {
+            try {
+                collectNamesFromArray(form.getJSONArray(key), names);
+            } catch (Exception e) {
+                log.debug("extractFormNames key={} 异常: {}", key, e.getMessage());
+            }
+        }
+        return names;
+    }
+
+    private void collectNamesFromArray(JSONArray array, List<String> names) {
+        if (array == null || array.isEmpty()) return;
+        for (int i = 0; i < array.size(); i++) {
+            try {
+                JSONObject item = array.getJSONObject(i);
+                if (item != null) {
+                    String name = item.getString("名称");
+                    if (StringUtils.hasText(name)) {
+                        names.add(name);
+                    }
+                }
+            } catch (Exception e) {
+                log.debug("collectNamesFromArray 单元素解析异常: {}", e.getMessage());
+            }
+        }
+    }
+
+    private Map<String, Double> buildContributionMap(JSONObject dataContent, String contributionPath) {
+        Map<String, Double> map = new HashMap<>();
+        try {
+            List<JSONObject> contributions = VectorUtils.extractArrayItemsFromJson(dataContent, contributionPath + "[*]");
+            for (JSONObject c : contributions) {
+                try {
+                    String word = c.getString("词");
+                    Double contribution = c.getDouble("贡献度");
+                    if (StringUtils.hasText(word) && contribution != null) {
+                        map.put(word, contribution);
+                    }
+                } catch (Exception e) {
+                    log.debug("buildContributionMap 单元素解析异常: {}", e.getMessage());
+                }
+            }
+        } catch (Exception e) {
+            log.error("构建贡献度查找表失败: {}", e.getMessage());
+        }
+        return map;
+    }
 
+    private JSONObject navigateToObject(JSONObject json, String path) {
+        if (json == null || !StringUtils.hasText(path) || !path.startsWith("$.")) return null;
+        try {
+            String pathContent = path.substring(2);
+            String[] parts = pathContent.split("\\.");
+            Object current = json;
+            for (String part : parts) {
+                if (current instanceof JSONObject) {
+                    current = ((JSONObject) current).get(part);
+                } else {
+                    return null;
+                }
+            }
+            return current instanceof JSONObject ? (JSONObject) current : null;
         } catch (Exception e) {
-            log.error("素材批量向量化任务异常: {}", e.getMessage(), e);
-            return new ReturnT<>(ReturnT.FAIL_CODE, "任务异常: " + e.getMessage());
+            return null;
+        }
+    }
+
+    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;
+    }
+
+    // ====================================================================
+    // 通用辅助
+    // ====================================================================
+
+    private List<DeconstructVectorConfig> getEnabledConfigsBySourceField(String sourceField) {
+        DeconstructVectorConfigExample example = new DeconstructVectorConfigExample();
+        example.createCriteria()
+                .andEnabledEqualTo((short) 1)
+                .andSourceFieldEqualTo(sourceField);
+        example.setOrderByClause("priority ASC");
+        return vectorConfigMapper.selectByExample(example);
+    }
+
+    private void awaitAndShutdown(List<Future<?>> futures, ExecutorService executor,
+                                  long timeoutMinutes, String taskDesc) {
+        long deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(timeoutMinutes);
+        int completed = 0;
+        for (Future<?> future : futures) {
+            long remaining = deadline - System.currentTimeMillis();
+            if (remaining <= 0) {
+                log.error("{} 整体超时({}分钟),已取消剩余任务 (已完成 {}/{})",
+                        taskDesc, timeoutMinutes, completed, futures.size());
+                for (Future<?> f : futures) {
+                    f.cancel(true);
+                }
+                break;
+            }
+            try {
+                future.get(remaining, TimeUnit.MILLISECONDS);
+                completed++;
+            } catch (Exception e) {
+                log.error("{} 并发任务等待异常: {}", taskDesc, e.getMessage());
+            }
         }
+        executor.shutdown();
     }
 
     /**
-     * 分页查询解构完成的内容
+     * 入参 N 解析为 maxMaterialCount
      */
-    private List<DeconstructContent> queryDeconstructedContents(int pageNum, int pageSize) {
-        DeconstructContentExample example = new DeconstructContentExample();
-        example.createCriteria().andStatusEqualTo((short) 2);
-        example.setOrderByClause("id ASC LIMIT " + pageSize + " OFFSET " + (pageNum * pageSize));
-        return deconstructContentMapper.selectByExample(example);
+    private Integer parseMaxCount(String param) {
+        if (!StringUtils.hasText(param)) return null;
+        try {
+            int v = Integer.parseInt(param.trim());
+            return v > 0 ? v : null;
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    /**
+     * 归一化 AIGC bizUniqueId 为 materialId 字符串。
+     * 外部合作素材为文件 MD5(32 位 hex),内部素材通常为数字字符串。
+     */
+    private String normalizeMaterialId(String bizUniqueId) {
+        if (!StringUtils.hasText(bizUniqueId)) {
+            return null;
+        }
+        return bizUniqueId.trim();
+    }
+
+    /** 一页素材解析后的 dataContent + sourceType,多 config 共享。 */
+    private static final class ParsedMaterial {
+        final JSONObject dataContent;
+        final Short sourceType;
+
+        ParsedMaterial(JSONObject dataContent, Short sourceType) {
+            this.dataContent = dataContent;
+            this.sourceType = sourceType;
+        }
     }
 }

+ 96 - 0
core/src/main/java/com/tzld/videoVector/model/entity/MaterialMatch.java

@@ -0,0 +1,96 @@
+package com.tzld.videoVector.model.entity;
+
+/**
+ * 素材向量匹配结果实体
+ * 与 VideoMatch 对称,用于素材(图文/长文)向量召回结果
+ */
+public class MaterialMatch {
+
+    /** 素材ID(内部为数字字符串,外部合作为文件 MD5) */
+    private String materialId;
+
+    /** 余弦相似度分值(-1 ~ 1,越大越相似) */
+    private double score;
+
+    /** 命中的配置编码(用于区分来源) */
+    private String configCode;
+
+    /** 向量点索引(多点模式下区分同一素材的不同向量点) */
+    private Integer pointIndex;
+
+    /** 向量化原文 */
+    private String text;
+
+    /** 素材来源:1=外部合作,2=内部素材 */
+    private Short sourceType;
+
+    public MaterialMatch() {
+    }
+
+    public MaterialMatch(String materialId, double score) {
+        this.materialId = materialId;
+        this.score = score;
+    }
+
+    public MaterialMatch(String materialId, double score, String configCode) {
+        this.materialId = materialId;
+        this.score = score;
+        this.configCode = configCode;
+    }
+
+    public String getMaterialId() {
+        return materialId;
+    }
+
+    public void setMaterialId(String materialId) {
+        this.materialId = materialId;
+    }
+
+    public double getScore() {
+        return score;
+    }
+
+    public void setScore(double score) {
+        this.score = score;
+    }
+
+    public String getConfigCode() {
+        return configCode;
+    }
+
+    public void setConfigCode(String configCode) {
+        this.configCode = configCode;
+    }
+
+    public Integer getPointIndex() {
+        return pointIndex;
+    }
+
+    public void setPointIndex(Integer pointIndex) {
+        this.pointIndex = pointIndex;
+    }
+
+    public String getText() {
+        return text;
+    }
+
+    public void setText(String text) {
+        this.text = text;
+    }
+
+    public Short getSourceType() {
+        return sourceType;
+    }
+
+    public void setSourceType(Short sourceType) {
+        this.sourceType = sourceType;
+    }
+
+    @Override
+    public String toString() {
+        return "MaterialMatch{materialId=" + materialId + ", score=" + score +
+                ", configCode='" + configCode + "'" +
+                ", pointIndex=" + pointIndex +
+                ", sourceType=" + sourceType + "}";
+    }
+}

+ 10 - 4
core/src/main/java/com/tzld/videoVector/model/param/recall/MatchByTextParam.java

@@ -12,12 +12,18 @@ public class MatchByTextParam {
     private String queryText;
 
     /**
-     * 向量配置编码
-     * 当前已支持: VIDEO_TOPIC(选题) / VIDEO_INSPIRATION(灵感点)
+     * 向量配置编码(视频与素材共用同一套 deconstruct_vector_config)
+     * 当前已支持: VIDEO_TOPIC(选题) / VIDEO_INSPIRATION(灵感点)
      * 不传则用默认 VIDEO_TOPIC
      */
     private String configCode;
 
-    /** 返回 Top-N,默认 10 */
-    private Integer topN = 10;
+    /** 视频与素材各自的默认返回条数;未传 videoTopN / materialTopN 时分别回落到此值,缺省 50 */
+    private Integer topN = 50;
+
+    /** 视频返回条数;不传则与 topN 相同 */
+    private Integer videoTopN;
+
+    /** 素材返回条数;不传则与 topN 相同 */
+    private Integer materialTopN;
 }

+ 140 - 0
core/src/main/java/com/tzld/videoVector/model/po/pgVector/MaterialDeconstructResult.java

@@ -0,0 +1,140 @@
+package com.tzld.videoVector.model.po.pgVector;
+
+import java.util.Date;
+
+/**
+ * Database Table Remarks:
+ *   素材解构结果缓存(对称 video_deconstruct_result)
+ *
+ * This class corresponds to the database table material_deconstruct_result
+ */
+public class MaterialDeconstructResult {
+    /**
+     * Database Column Remarks:
+     *   主键ID
+     *
+     * This field corresponds to the database column material_deconstruct_result.id
+     */
+    private Long id;
+
+    /**
+     * Database Column Remarks:
+     *   素材ID(内部素材为数字字符串,外部合作为文件 MD5)
+     *
+     * This field corresponds to the database column material_deconstruct_result.material_id
+     */
+    private String materialId;
+
+    /**
+     * Database Column Remarks:
+     *   解构来源: aigc_deconstruct
+     *
+     * This field corresponds to the database column material_deconstruct_result.source
+     */
+    private String source;
+
+    /**
+     * Database Column Remarks:
+     *   解构结果 JSON 完整内容(dataContent)
+     *
+     * This field corresponds to the database column material_deconstruct_result.result
+     */
+    private String result;
+
+    /**
+     * Database Column Remarks:
+     *   素材来源: 1=外部合作, 2=内部素材
+     *
+     * This field corresponds to the database column material_deconstruct_result.source_type
+     */
+    private Short sourceType;
+
+    /**
+     * Database Column Remarks:
+     *   首次写入时间
+     *
+     * This field corresponds to the database column material_deconstruct_result.create_time
+     */
+    private Date createTime;
+
+    /**
+     * Database Column Remarks:
+     *   最近更新时间
+     *
+     * This field corresponds to the database column material_deconstruct_result.update_time
+     */
+    private Date updateTime;
+
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    public String getMaterialId() {
+        return materialId;
+    }
+
+    public void setMaterialId(String materialId) {
+        this.materialId = materialId;
+    }
+
+    public String getSource() {
+        return source;
+    }
+
+    public void setSource(String source) {
+        this.source = source;
+    }
+
+    public String getResult() {
+        return result;
+    }
+
+    public void setResult(String result) {
+        this.result = result;
+    }
+
+    public Short getSourceType() {
+        return sourceType;
+    }
+
+    public void setSourceType(Short sourceType) {
+        this.sourceType = sourceType;
+    }
+
+    public Date getCreateTime() {
+        return createTime;
+    }
+
+    public void setCreateTime(Date createTime) {
+        this.createTime = createTime;
+    }
+
+    public Date getUpdateTime() {
+        return updateTime;
+    }
+
+    public void setUpdateTime(Date updateTime) {
+        this.updateTime = updateTime;
+    }
+
+    @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(", materialId=").append(materialId);
+        sb.append(", source=").append(source);
+        sb.append(", result=").append(result);
+        sb.append(", sourceType=").append(sourceType);
+        sb.append(", createTime=").append(createTime);
+        sb.append(", updateTime=").append(updateTime);
+        sb.append("]");
+        return sb.toString();
+    }
+}

+ 201 - 0
core/src/main/java/com/tzld/videoVector/model/po/pgVector/MaterialVector.java

@@ -0,0 +1,201 @@
+package com.tzld.videoVector.model.po.pgVector;
+
+import java.util.Date;
+
+/**
+ * Database Table Remarks:
+ *   素材向量存储表
+ *
+ * This class corresponds to the database table material_vectors
+ */
+public class MaterialVector {
+    /**
+     * Database Column Remarks:
+     *   自增主键
+     *
+     * This field corresponds to the database column material_vectors.id
+     */
+    private Long id;
+
+    /**
+     * Database Column Remarks:
+     *   素材ID(内部素材为数字字符串,外部合作为文件 MD5)
+     *
+     * This field corresponds to the database column material_vectors.material_id
+     */
+    private String materialId;
+
+    /**
+     * Database Column Remarks:
+     *   向量化配置编码(如VIDEO_TOPIC、VIDEO_KEYPOINT等)
+     *
+     * This field corresponds to the database column material_vectors.config_code
+     */
+    private String configCode;
+
+    /**
+     * Database Column Remarks:
+     *   向量数据(pgvector vector(1024)类型,余弦距离检索)
+     *
+     * This field corresponds to the database column material_vectors.embedding
+     */
+    private String embedding;
+
+    /**
+     * Database Column Remarks:
+     *   创建时间
+     *
+     * This field corresponds to the database column material_vectors.created_at
+     */
+    private Date createdAt;
+
+    /**
+     * Database Column Remarks:
+     *   更新时间
+     *
+     * This field corresponds to the database column material_vectors.updated_at
+     */
+    private Date updatedAt;
+
+    /**
+     * Database Column Remarks:
+     *   向量点索引:单点模式=0,多点模式=0,1,2,...
+     *
+     * This field corresponds to the database column material_vectors.point_index
+     */
+    private Integer pointIndex;
+
+    /**
+     * Database Column Remarks:
+     *   向量化的原始文本内容
+     *
+     * This field corresponds to the database column material_vectors.text
+     */
+    private String text;
+
+    /**
+     * This field corresponds to the database column material_vectors.text_hash
+     */
+    private String textHash;
+
+    /**
+     * Database Column Remarks:
+     *   素材来源:1=外部合作,2=内部素材
+     *
+     * This field corresponds to the database column material_vectors.source_type
+     */
+    private Short sourceType;
+
+    /**
+     * 余弦相似度得分(仅搜索时使用,非持久化字段)
+     */
+    private Double score;
+
+    public Double getScore() {
+        return score;
+    }
+
+    public void setScore(Double score) {
+        this.score = score;
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    public String getMaterialId() {
+        return materialId;
+    }
+
+    public void setMaterialId(String materialId) {
+        this.materialId = materialId;
+    }
+
+    public String getConfigCode() {
+        return configCode;
+    }
+
+    public void setConfigCode(String configCode) {
+        this.configCode = configCode;
+    }
+
+    public String getEmbedding() {
+        return embedding;
+    }
+
+    public void setEmbedding(String embedding) {
+        this.embedding = embedding;
+    }
+
+    public Date getCreatedAt() {
+        return createdAt;
+    }
+
+    public void setCreatedAt(Date createdAt) {
+        this.createdAt = createdAt;
+    }
+
+    public Date getUpdatedAt() {
+        return updatedAt;
+    }
+
+    public void setUpdatedAt(Date updatedAt) {
+        this.updatedAt = updatedAt;
+    }
+
+    public Integer getPointIndex() {
+        return pointIndex;
+    }
+
+    public void setPointIndex(Integer pointIndex) {
+        this.pointIndex = pointIndex;
+    }
+
+    public String getText() {
+        return text;
+    }
+
+    public void setText(String text) {
+        this.text = text;
+    }
+
+    public String getTextHash() {
+        return textHash;
+    }
+
+    public void setTextHash(String textHash) {
+        this.textHash = textHash;
+    }
+
+    public Short getSourceType() {
+        return sourceType;
+    }
+
+    public void setSourceType(Short sourceType) {
+        this.sourceType = sourceType;
+    }
+
+    @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(", materialId=").append(materialId);
+        sb.append(", configCode=").append(configCode);
+        sb.append(", embedding=").append(embedding);
+        sb.append(", createdAt=").append(createdAt);
+        sb.append(", updatedAt=").append(updatedAt);
+        sb.append(", pointIndex=").append(pointIndex);
+        sb.append(", text=").append(text);
+        sb.append(", textHash=").append(textHash);
+        sb.append(", sourceType=").append(sourceType);
+        sb.append("]");
+        return sb.toString();
+    }
+}

+ 39 - 0
core/src/main/java/com/tzld/videoVector/model/vo/recall/MaterialDetailVO.java

@@ -0,0 +1,39 @@
+package com.tzld.videoVector.model.vo.recall;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 素材详情 VO(modality=MATERIAL 互斥下发)
+ *
+ * 字段约束参见前端文档 recallTest_matchByText.md §3.5;
+ * 当前只下发已有数据源的字段,其它字段保持 null 由前端用 -- 占位。
+ */
+@Data
+public class MaterialDetailVO {
+
+    /** 素材标题 / 名称 */
+    private String title;
+
+    /** 图片张数(冗余字段,等价于 imageList.length) */
+    private Integer imageCount;
+
+    /** 来源(外部合作 / 内部素材,对应 material_deconstruct_result.source_type) */
+    private String source;
+
+    /** 上传 / 采集时间(暂无数据源) */
+    private String uploadTime;
+
+    /** 使用次数(暂无数据源) */
+    private String usageCount;
+
+    /** 风格 / 主题 / 情绪等标签(暂无数据源) */
+    private List<String> tags;
+
+    /**
+     * 解构(与视频 deconstruct 子结构对齐:topic + 灵感点/关键点/目的点 及其实质)
+     */
+    private Map<String, Object> deconstruct;
+}

+ 6 - 6
core/src/main/java/com/tzld/videoVector/model/vo/recall/RecallResultVO.java

@@ -6,23 +6,23 @@ import java.util.List;
 
 /**
  * 召回结果包装
- * 前端按 modality 字段对 items 分组展示模态Tab。
+ * 前端按 modality 字段对 items 分组展示模态 Tab。
  */
 @Data
 public class RecallResultVO {
 
-    /** 召回结果(已 enrich,带模态信息) */
+    /** 命中明细列表 */
     private List<VideoMatchEnrichedVO> items;
 
+    /** 总条数 = videoCount + materialCount + articleCount */
+    private int total;
+
     /** 命中视频数 */
     private int videoCount;
 
-    /** 命中素材数 */
+    /** 命中素材数(不含长文) */
     private int materialCount;
 
     /** 命中长文数 */
     private int articleCount;
-
-    /** 总条数 */
-    private int total;
 }

+ 31 - 22
core/src/main/java/com/tzld/videoVector/model/vo/recall/VideoMatchEnrichedVO.java

@@ -7,63 +7,72 @@ import java.util.List;
 import java.util.Map;
 
 /**
- * 单条召回结果(模态感知 enrich 后)
+ * 单条召回结果(模态感知 enrich 后)
  *
- * 召回是多对多对称架构,一次召回可能混合返回视频和素材。
- * 通过 modality 字段区分,前端按模态分组展示。
+ * 召回是多对多对称架构一次召回可能混合返回视频和素材。
+ * 通过 modality 字段区分前端按模态分组展示。
  */
 @Data
 public class VideoMatchEnrichedVO {
 
-    /** 业务ID (视频时为 wx_video.id, 素材时为 channelContentId 数值化) */
+    /** 内容 ID(视频 id / 素材 id / 长文 id 复用此字段) */
     private Long id;
 
-    /** 模态 */
+    /**
+     * 素材原始 ID(modality=MATERIAL 时下发)
+     * 内部素材为数字字符串(与 id 一致),外部合作素材为文件 MD5(32 位 hex)。
+     * 前端:id 为空时取本字段展示。
+     */
+    private String materialId;
+
+    /** 模态:VIDEO / MATERIAL / ARTICLE */
     private Modality modality;
 
-    /** 命中的向量配置编码,如 VIDEO_TOPIC / VIDEO_INSPIRATION */
+    /** 命中的召回维度,与请求里的 configCode 一致 */
     private String configCode;
 
-    /** 相似度分数 */
+    /** 向量相似度,推荐 [0, 1];缺失时为 null */
     private Double score;
 
-    /** 标题 */
     private String title;
 
-    /** 封面/缩略图 */
+    /** 视频/素材封面;长文无封面时前端取 imageList[0] 兜底 */
     private String cover;
 
-    /** 视频URL (仅 modality=VIDEO 有效) */
+    /** 视频/素材源地址 */
     private String videoUrl;
 
-    /** 图列表 (仅 modality=MATERIAL 有效) */
+    /** 长文配图列表 */
     private List<String> imageList;
 
-    /** 正文 (仅 modality=ARTICLE 有效) */
+    /** 长文正文 */
     private String bodyText;
 
-    /** 播放量,占位 "--" */
+    /** 以下为旧版兼容字段,占位 "--" */
     private String playCount;
 
-    /** 曝光量,占位 "--" */
     private String exposure;
 
-    /** CTR,占位 "--" */
     private String ctr;
 
-    /** 阅读数,占位 "--" */
     private String readCount;
 
-    /** ROV,占位 "--" */
+    /** 优先取自 videoDetail.rov,缺失时为 "--" */
     private String rov;
 
     /**
-     * 视频详情(扁平 KV)
-     * 内嵌 deconstruct 子对象 = { topic, 灵感点, 灵感点-实质, 关键点, 关键点-实质, 目的点, 目的点-实质 }
-     * 来源 Redis recall:vid_decode:{vid}
+     * 运营 / 分发指标 + 解构详情(deconstruct 子对象)
+     * modality=VIDEO 时下发;其余模态为 null
      */
     private Map<String, Object> videoDetail;
 
-    /** 向量化原文 */
-    private String text;
+    /**
+     * 素材详情,modality=MATERIAL 时下发;其余模态为 null
+     */
+    private MaterialDetailVO materialDetail;
+
+    /**
+     * 长文详情,modality=ARTICLE 时下发;当前未实现,预留占位
+     */
+    private Object articleDetail;
 }

+ 61 - 0
core/src/main/java/com/tzld/videoVector/service/MaterialVectorStoreService.java

@@ -0,0 +1,61 @@
+package com.tzld.videoVector.service;
+
+import com.tzld.videoVector.model.entity.MaterialMatch;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 素材向量存储服务接口(pgvector 实现)
+ * 支持按配置编码(configCode)存储多类型素材向量
+ */
+public interface MaterialVectorStoreService {
+
+    /**
+     * @return true 写入成功,false 参数校验失败
+     */
+    boolean save(String configCode, String materialId, List<Float> vector, String text, Short sourceType);
+
+    /**
+     * @return true 写入成功,false 参数校验失败
+     */
+    boolean save(String configCode, String materialId, int pointIndex, List<Float> vector, String text, Short sourceType);
+
+    boolean exists(String configCode, String materialId);
+
+    Set<String> existsByIds(String configCode, Collection<String> materialIds);
+
+    /** @deprecated 不支持单条精确查询,请使用 searchTopN */
+    List<Float> getVector(String configCode, String materialId);
+
+    /** @deprecated 不支持批量精确查询,请使用 searchTopN */
+    Map<String, List<Float>> getVectors(String configCode, Collection<String> materialIds);
+
+    Set<String> getAllMaterialIds(String configCode);
+
+    void delete(String configCode, String materialId);
+
+    void deleteBatch(String configCode, Collection<String> materialIds);
+
+    /** 删除多点模式下 point_index >= minPointIndex 的旧向量 */
+    void deleteAbovePointIndex(String configCode, String materialId, int minPointIndex);
+
+    List<Float> getVectorByTextHash(String textHash, String configCode);
+
+    /**
+     * 根据 text_hash 查询缓存的 embedding 原始字符串,不做 Float 解析/序列化,
+     * 直接传给 searchTopN 的 ::vector cast,避免 Java Float 回环精度损失。
+     */
+    String getRawVectorByTextHash(String textHash, String configCode);
+
+    List<MaterialMatch> searchTopN(String configCode, List<Float> queryVector, int topN);
+
+    /**
+     * 用原始 embedding 字符串搜索(绕过 Java Float 回环)
+     */
+    List<MaterialMatch> searchTopNByRawVector(String configCode, String rawVector, int topN);
+
+    List<MaterialMatch> searchTopNBySource(String configCode, List<Float> queryVector, int topN, Short sourceType);
+}

+ 284 - 0
core/src/main/java/com/tzld/videoVector/service/impl/PgMaterialVectorStoreServiceImpl.java

@@ -0,0 +1,284 @@
+package com.tzld.videoVector.service.impl;
+
+import com.tzld.videoVector.common.constant.VectorConstants;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialVectorMapperExt;
+import com.tzld.videoVector.model.entity.MaterialMatch;
+import com.tzld.videoVector.model.po.pgVector.MaterialVector;
+import com.tzld.videoVector.service.MaterialVectorStoreService;
+import com.tzld.videoVector.util.Md5Util;
+import com.tzld.videoVector.util.VectorUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class PgMaterialVectorStoreServiceImpl implements MaterialVectorStoreService {
+
+    @Autowired
+    private MaterialVectorMapperExt materialVectorMapperExt;
+
+    @Override
+    public boolean save(String configCode, String materialId, List<Float> vector, String text, Short sourceType) {
+        return save(configCode, materialId, 0, vector, text, sourceType);
+    }
+
+    @Override
+    public boolean save(String configCode, String materialId, int pointIndex, List<Float> vector, String text, Short sourceType) {
+        if (!StringUtils.hasText(materialId) || vector == null || vector.isEmpty()) {
+            log.error("save 参数非法,configCode={}, materialId={}", configCode, materialId);
+            return false;
+        }
+        if (configCode == null || configCode.isEmpty()) {
+            log.error("save configCode 不能为空");
+            return false;
+        }
+
+        String embedding = vectorToString(vector);
+        String textHash = (text != null && !text.isEmpty()) ? Md5Util.encoderByMd5(text) : null;
+        materialVectorMapperExt.upsertVector(materialId, configCode, pointIndex, embedding, text, textHash, sourceType);
+        log.debug("保存素材向量成功,configCode={}, materialId={}, pointIndex={}, sourceType={}, 维度={}",
+                configCode, materialId, pointIndex, sourceType, vector.size());
+        return true;
+    }
+
+    @Override
+    public boolean exists(String configCode, String materialId) {
+        if (!StringUtils.hasText(materialId) || configCode == null || configCode.isEmpty()) return false;
+        return materialVectorMapperExt.existsByMaterialIdAndConfigCode(materialId, configCode) > 0;
+    }
+
+    @Override
+    public Set<String> existsByIds(String configCode, Collection<String> materialIds) {
+        if (materialIds == null || materialIds.isEmpty() || configCode == null || configCode.isEmpty()) {
+            return Collections.emptySet();
+        }
+
+        List<String> idList = new ArrayList<>(materialIds);
+        Set<String> existing = new HashSet<>();
+        for (int i = 0; i < idList.size(); i += VectorConstants.ODPS_IN_BATCH_SIZE) {
+            int end = Math.min(i + VectorConstants.ODPS_IN_BATCH_SIZE, idList.size());
+            List<String> batch = idList.subList(i, end);
+            List<String> found = materialVectorMapperExt.selectExistingMaterialIds(batch, configCode);
+            if (found != null) {
+                existing.addAll(found);
+            }
+        }
+        return existing;
+    }
+
+    @Override
+    public List<Float> getVector(String configCode, String materialId) {
+        throw new UnsupportedOperationException("getVector 暂不支持,请使用 searchTopN");
+    }
+
+    @Override
+    public Map<String, List<Float>> getVectors(String configCode, Collection<String> materialIds) {
+        throw new UnsupportedOperationException("getVectors 暂不支持,请使用 searchTopN");
+    }
+
+    @Override
+    public Set<String> getAllMaterialIds(String configCode) {
+        if (configCode == null || configCode.isEmpty()) {
+            return Collections.emptySet();
+        }
+        List<String> ids = materialVectorMapperExt.selectAllMaterialIds(configCode);
+        if (ids == null) return Collections.emptySet();
+        return new HashSet<>(ids);
+    }
+
+    @Override
+    public void delete(String configCode, String materialId) {
+        if (!StringUtils.hasText(materialId) || configCode == null || configCode.isEmpty()) return;
+        materialVectorMapperExt.deleteByMaterialIdAndConfigCode(materialId, configCode);
+        log.info("删除素材向量成功,configCode={}, materialId={}", configCode, materialId);
+    }
+
+    @Override
+    public void deleteBatch(String configCode, Collection<String> materialIds) {
+        if (materialIds == null || materialIds.isEmpty() || configCode == null || configCode.isEmpty()) return;
+
+        List<String> idList = new ArrayList<>(materialIds);
+        for (int i = 0; i < idList.size(); i += VectorConstants.ODPS_IN_BATCH_SIZE) {
+            int end = Math.min(i + VectorConstants.ODPS_IN_BATCH_SIZE, idList.size());
+            List<String> batch = idList.subList(i, end);
+            materialVectorMapperExt.deleteBatchByMaterialIds(batch, configCode);
+        }
+        log.info("批量删除素材向量成功,configCode={}, 数量={}", configCode, materialIds.size());
+    }
+
+    @Override
+    public void deleteAbovePointIndex(String configCode, String materialId, int minPointIndex) {
+        if (!StringUtils.hasText(materialId) || configCode == null || configCode.isEmpty()) return;
+        materialVectorMapperExt.deleteAbovePointIndex(materialId, configCode, minPointIndex);
+    }
+
+    @Override
+    public List<Float> getVectorByTextHash(String textHash, String configCode) {
+        if (textHash == null || textHash.isEmpty() || configCode == null || configCode.isEmpty()) return null;
+        try {
+            MaterialVector mv = materialVectorMapperExt.selectByTextHashAndConfigCode(textHash, configCode);
+            if (mv == null) {
+                log.info("getVectorByTextHash MISS: textHash={}, configCode={}", textHash, configCode);
+                return null;
+            }
+            if (mv.getEmbedding() == null) {
+                log.info("getVectorByTextHash HIT but embedding IS NULL: textHash={}, configCode={}, materialId={}",
+                        textHash, configCode, mv.getMaterialId());
+                return null;
+            }
+            List<Float> vector = VectorUtils.parseVectorString(mv.getEmbedding());
+            if (vector == null || vector.isEmpty()) {
+                log.info("getVectorByTextHash HIT but parseVectorString FAILED: textHash={}, configCode={}, embeddingLen={}",
+                        textHash, configCode, mv.getEmbedding().length());
+                return null;
+            }
+            log.info("getVectorByTextHash HIT OK: textHash={}, configCode={}, materialId={}, dim={}",
+                    textHash, configCode, mv.getMaterialId(), vector.size());
+            return vector;
+        } catch (Exception e) {
+            log.error("根据 text_hash 查询素材向量失败,hash={}, configCode={}, error={}", textHash, configCode, e.getMessage());
+            return null;
+        }
+    }
+
+    @Override
+    public String getRawVectorByTextHash(String textHash, String configCode) {
+        if (textHash == null || textHash.isEmpty() || configCode == null || configCode.isEmpty()) return null;
+        try {
+            MaterialVector mv = materialVectorMapperExt.selectByTextHashAndConfigCode(textHash, configCode);
+            if (mv == null) {
+                log.info("getRawVectorByTextHash MISS: textHash={}, configCode={}", textHash, configCode);
+                return null;
+            }
+            String raw = mv.getEmbedding();
+            if (raw == null || raw.isEmpty()) {
+                log.info("getRawVectorByTextHash HIT but embedding IS NULL: textHash={}, configCode={}, materialId={}",
+                        textHash, configCode, mv.getMaterialId());
+                return null;
+            }
+            // 验证 embedding 格式:以 [ 开头,至少几十个字符
+            if (raw.length() < 10 || !raw.trim().startsWith("[")) {
+                log.info("getRawVectorByTextHash HIT but format SUSPECT: textHash={}, configCode={}, len={}, preview={}",
+                        textHash, configCode, raw.length(), raw.substring(0, Math.min(80, raw.length())));
+                return null;
+            }
+            log.info("getRawVectorByTextHash HIT OK: textHash={}, configCode={}, materialId={}, len={}, preview={}",
+                    textHash, configCode, mv.getMaterialId(), raw.length(),
+                    raw.substring(0, Math.min(80, raw.length())));
+            return raw;
+        } catch (Exception e) {
+            log.error("getRawVectorByTextHash 异常,hash={}, configCode={}, error={}", textHash, configCode, e.getMessage());
+            return null;
+        }
+    }
+
+    @Override
+    public List<MaterialMatch> searchTopNByRawVector(String configCode, String rawVector, int topN) {
+        if (rawVector == null || rawVector.isEmpty() || topN <= 0) {
+            return Collections.emptyList();
+        }
+        if (configCode == null || configCode.isEmpty()) {
+            log.error("searchTopNByRawVector configCode 不能为空");
+            return Collections.emptyList();
+        }
+        log.info("searchTopNByRawVector raw前100字符: {}, topN={}, configCode={}",
+                rawVector.substring(0, Math.min(100, rawVector.length())), topN, configCode);
+        List<MaterialVector> results = materialVectorMapperExt.searchTopN(configCode, rawVector, topN);
+        if (results == null || results.isEmpty()) {
+            log.info("素材向量库为空或无匹配结果,configCode={}", configCode);
+            return Collections.emptyList();
+        }
+        List<MaterialMatch> matches = convertToMatch(results, configCode);
+        log.info("searchTopNByRawVector DB返回 {} 行, configCode={}", results.size(), configCode);
+        return matches;
+    }
+
+    @Override
+    public List<MaterialMatch> searchTopN(String configCode, List<Float> queryVector, int topN) {
+        if (queryVector == null || queryVector.isEmpty() || topN <= 0) {
+            return Collections.emptyList();
+        }
+        if (configCode == null || configCode.isEmpty()) {
+            log.error("searchTopN configCode 不能为空");
+            return Collections.emptyList();
+        }
+
+        String queryVectorStr = vectorToString(queryVector);
+        log.info("searchTopN SQL vector前100字符: {}, topN={}, configCode={}",
+                queryVectorStr.substring(0, Math.min(100, queryVectorStr.length())), topN, configCode);
+        List<MaterialVector> results = materialVectorMapperExt.searchTopN(configCode, queryVectorStr, topN);
+        if (results == null || results.isEmpty()) {
+            log.info("素材向量库为空或无匹配结果,configCode={}", configCode);
+            return Collections.emptyList();
+        }
+
+        List<MaterialMatch> matches = convertToMatch(results, configCode);
+        log.info("searchTopN DB返回 {} 行, configCode={}", results.size(), configCode);
+        return matches;
+    }
+
+    @Override
+    public List<MaterialMatch> searchTopNBySource(String configCode, List<Float> queryVector, int topN, Short sourceType) {
+        if (queryVector == null || queryVector.isEmpty() || topN <= 0) {
+            return Collections.emptyList();
+        }
+        if (configCode == null || configCode.isEmpty()) {
+            log.error("searchTopNBySource configCode 不能为空");
+            return Collections.emptyList();
+        }
+        if (sourceType == null) {
+            return searchTopN(configCode, queryVector, topN);
+        }
+
+        String queryVectorStr = vectorToString(queryVector);
+        List<MaterialVector> results = materialVectorMapperExt.searchTopNBySource(configCode, queryVectorStr, topN, sourceType);
+        if (results == null || results.isEmpty()) {
+            log.info("素材向量库无匹配结果,configCode={}, sourceType={}", configCode, sourceType);
+            return Collections.emptyList();
+        }
+
+        return convertToMatch(results, configCode);
+    }
+
+    private List<MaterialMatch> convertToMatch(List<MaterialVector> results, String configCode) {
+        return results.stream()
+                .map(mv -> {
+                    double scoreVal = mv.getScore() != null ? mv.getScore() : 0.0;
+                    MaterialMatch m = new MaterialMatch(mv.getMaterialId(), scoreVal, configCode);
+                    m.setPointIndex(mv.getPointIndex());
+                    m.setText(mv.getText());
+                    m.setSourceType(mv.getSourceType());
+                    return m;
+                })
+                .collect(Collectors.toList());
+    }
+
+    private String vectorToString(List<Float> vector) {
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < vector.size(); i++) {
+            if (i > 0) sb.append(",");
+            // Float.toString() 对 |v| < 1e-3 的值会输出科学计数法(如 6.399564E-4)
+            // pgvector 的 ::vector 只认标准十进制格式, 必须用 BigDecimal.toPlainString() 兜底
+            float v = vector.get(i);
+            String s = Float.toString(v);
+            if (s.indexOf('E') >= 0 || s.indexOf('e') >= 0) {
+                s = new java.math.BigDecimal(s).toPlainString();
+            }
+            sb.append(s);
+        }
+        sb.append("]");
+        return sb.toString();
+    }
+}

+ 6 - 1
core/src/main/java/com/tzld/videoVector/service/impl/PgVectorStoreServiceImpl.java

@@ -286,7 +286,12 @@ public class PgVectorStoreServiceImpl implements VectorStoreService {
         StringBuilder sb = new StringBuilder("[");
         for (int i = 0; i < vector.size(); i++) {
             if (i > 0) sb.append(",");
-            sb.append(vector.get(i));
+            float v = vector.get(i);
+            String s = Float.toString(v);
+            if (s.indexOf('E') >= 0 || s.indexOf('e') >= 0) {
+                s = new java.math.BigDecimal(s).toPlainString();
+            }
+            sb.append(s);
         }
         sb.append("]");
         return sb.toString();

+ 653 - 193
core/src/main/java/com/tzld/videoVector/service/recall/impl/VectorRecallTestServiceImpl.java

@@ -6,22 +6,28 @@ import com.alibaba.fastjson.JSONObject;
 import com.tzld.videoVector.api.VideoApiService;
 import com.tzld.videoVector.common.constant.VectorConstants;
 import com.tzld.videoVector.common.enums.Modality;
-import com.tzld.videoVector.dao.mapper.videoVector.deconstruct.MysqlDeconstructContentMapper;
-import com.tzld.videoVector.dao.mapper.pgVector.ext.VideoDeconstructResultMapperExt;
+import com.tzld.videoVector.dao.mapper.pgVector.DeconstructVectorConfigMapper;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialDeconstructResultMapperExt;
+import com.tzld.videoVector.model.entity.MaterialMatch;
 import com.tzld.videoVector.model.entity.VideoDetail;
 import com.tzld.videoVector.model.param.MatchTopNVideoParam;
 import com.tzld.videoVector.model.param.recall.MatchByTextParam;
 import com.tzld.videoVector.model.param.recall.MatchByVideoIdParam;
-import com.tzld.videoVector.model.po.videoVector.deconstruct.MysqlDeconstructContent;
-import com.tzld.videoVector.model.po.videoVector.deconstruct.MysqlDeconstructContentExample;
+import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfig;
+import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfigExample;
+import com.tzld.videoVector.model.po.pgVector.MaterialDeconstructResult;
 import com.tzld.videoVector.model.vo.VideoMatchResult;
 import com.tzld.videoVector.model.vo.recall.AIUnderstandingVO;
 import com.tzld.videoVector.model.vo.recall.DeconstructPointsVO;
+import com.tzld.videoVector.model.vo.recall.MaterialDetailVO;
 import com.tzld.videoVector.model.vo.recall.RecallResultVO;
 import com.tzld.videoVector.model.vo.recall.VideoBasicVO;
 import com.tzld.videoVector.model.vo.recall.VideoMatchEnrichedVO;
+import com.tzld.videoVector.service.EmbeddingService;
+import com.tzld.videoVector.service.MaterialVectorStoreService;
 import com.tzld.videoVector.service.VideoSearchService;
 import com.tzld.videoVector.service.recall.VectorRecallTestService;
+import com.tzld.videoVector.util.Md5Util;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.core.StringRedisTemplate;
@@ -29,14 +35,21 @@ import org.springframework.stereotype.Service;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
+import javax.annotation.PreDestroy;
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.Comparator;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 /**
@@ -54,14 +67,47 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
     @Resource
     private VideoApiService videoApiService;
 
+    @Autowired(required = false)
+    private StringRedisTemplate stringRedisTemplate;
+
     @Autowired
-    private MysqlDeconstructContentMapper mysqlDeconstructContentMapper;
+    private MaterialVectorStoreService materialVectorStoreService;
 
     @Autowired
-    private VideoDeconstructResultMapperExt videoDeconstructResultMapperExt;
+    private MaterialDeconstructResultMapperExt materialDeconstructResultMapperExt;
 
-    @Autowired(required = false)
-    private StringRedisTemplate stringRedisTemplate;
+    @Autowired
+    private DeconstructVectorConfigMapper deconstructVectorConfigMapper;
+
+    @Autowired
+    private EmbeddingService embeddingService;
+
+    private static final String SOURCE_AIGC = "aigc_deconstruct";
+
+    /** source_type → 中文来源标签 */
+    private static final short SOURCE_TYPE_EXTERNAL = 1;
+    private static final short SOURCE_TYPE_INTERNAL = 2;
+    private static final String SOURCE_LABEL_EXTERNAL = "外部合作";
+    private static final String SOURCE_LABEL_INTERNAL = "内部素材";
+
+    /** 并行召回线程池:视频和素材召回独立异步执行 */
+    private static final ExecutorService RECALL_EXECUTOR = new ThreadPoolExecutor(
+            8, 16, 60L, TimeUnit.SECONDS,
+            new LinkedBlockingQueue<>(128),
+            new ThreadPoolExecutor.CallerRunsPolicy());
+
+    @PreDestroy
+    public void shutdownRecallExecutor() {
+        RECALL_EXECUTOR.shutdown();
+        try {
+            if (!RECALL_EXECUTOR.awaitTermination(10, TimeUnit.SECONDS)) {
+                RECALL_EXECUTOR.shutdownNow();
+            }
+        } catch (InterruptedException e) {
+            RECALL_EXECUTOR.shutdownNow();
+            Thread.currentThread().interrupt();
+        }
+    }
 
     private static final String PLACEHOLDER = "--";
 
@@ -100,19 +146,581 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
             return empty;
         }
 
-        // 1. 调用现有召回 Service
-        MatchTopNVideoParam matchParam = new MatchTopNVideoParam();
-        matchParam.setQueryText(param.getQueryText());
-        matchParam.setConfigCode(param.getConfigCode());
-        matchParam.setTopN(param.getTopN() != null && param.getTopN() > 0 ? param.getTopN() : 10);
+        int defaultTopN = param.getTopN() != null && param.getTopN() > 0 ? param.getTopN() : 50;
+        int videoTopN = param.getVideoTopN() != null && param.getVideoTopN() > 0 ? param.getVideoTopN() : defaultTopN;
+        int materialTopN = param.getMaterialTopN() != null && param.getMaterialTopN() > 0
+                ? param.getMaterialTopN() : defaultTopN;
+        String configCode = StringUtils.hasText(param.getConfigCode())
+                ? param.getConfigCode() : VectorConstants.DEFAULT_CONFIG_CODE;
+
+        // 并行召回:视频、素材各自独立 topN
+        final int finalVideoTopN = videoTopN;
+        final int finalMaterialTopN = materialTopN;
+        final String finalConfigCode = configCode;
+        CompletableFuture<List<VideoMatchResult>> videoFuture = CompletableFuture.supplyAsync(() -> {
+            try {
+                MatchTopNVideoParam videoParam = new MatchTopNVideoParam();
+                videoParam.setQueryText(param.getQueryText());
+                videoParam.setConfigCode(configCode);
+                videoParam.setTopN(finalVideoTopN);
+                List<VideoMatchResult> matches = videoSearchService.matchTopNVideo(videoParam, true);
+                return limitVideoMatchesByScore(matches, finalVideoTopN);
+            } catch (Exception e) {
+                log.error("视频召回失败 queryText={}, error={}", param.getQueryText(), e.getMessage(), e);
+                return Collections.emptyList();
+            }
+        }, RECALL_EXECUTOR);
 
-        List<VideoMatchResult> rawMatches = videoSearchService.matchTopNVideo(matchParam, true);
-        if (CollectionUtils.isEmpty(rawMatches)) {
-            return empty;
+        CompletableFuture<List<VideoMatchEnrichedVO>> materialFuture = CompletableFuture.supplyAsync(
+                () -> recallMaterialItems(param.getQueryText(), finalConfigCode, finalMaterialTopN),
+                RECALL_EXECUTOR);
+
+        List<VideoMatchResult> videoMatches;
+        List<VideoMatchEnrichedVO> materialItems;
+        try {
+            videoMatches = videoFuture.get(30, TimeUnit.SECONDS);
+        } catch (Exception e) {
+            log.error("视频召回等待超时/异常: {}", e.getMessage(), e);
+            videoMatches = Collections.emptyList();
+        }
+        try {
+            materialItems = materialFuture.get(30, TimeUnit.SECONDS);
+        } catch (Exception e) {
+            log.error("素材召回等待超时/异常: {}", e.getMessage(), e);
+            materialItems = Collections.emptyList();
+        }
+
+        List<VideoMatchEnrichedVO> videoItems = enrichVideoMatches(videoMatches, configCode);
+        return buildResult(videoItems, materialItems);
+    }
+
+    private List<VideoMatchResult> limitVideoMatchesByScore(List<VideoMatchResult> matches, int topN) {
+        if (CollectionUtils.isEmpty(matches) || topN <= 0 || matches.size() <= topN) {
+            return matches == null ? Collections.emptyList() : matches;
+        }
+        return matches.stream()
+                .sorted(Comparator.comparing(VideoMatchResult::getScore,
+                        Comparator.nullsLast(Comparator.reverseOrder())))
+                .limit(topN)
+                .collect(Collectors.toList());
+    }
+
+    private List<VideoMatchEnrichedVO> limitEnrichedItemsByScore(List<VideoMatchEnrichedVO> items, int topN) {
+        if (CollectionUtils.isEmpty(items) || topN <= 0 || items.size() <= topN) {
+            return items == null ? Collections.emptyList() : items;
+        }
+        return items.stream()
+                .sorted(Comparator.comparing(VideoMatchEnrichedVO::getScore,
+                        Comparator.nullsLast(Comparator.reverseOrder())))
+                .limit(topN)
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 素材文本召回:material_vectors → material_deconstruct_result
+     */
+    private List<VideoMatchEnrichedVO> recallMaterialItems(String queryText, String configCode, int topN) {
+        try {
+            int candidate = Math.max(topN * VectorConstants.MULTI_POINT_RECALL_CANDIDATE_FACTOR,
+                    VectorConstants.MULTI_POINT_RECALL_MIN_CANDIDATES);
+
+            // 优先尝试 text_hash 缓存:直接用 PG 返回的原始 embedding 字符串搜索,
+            // 绕过 Java Float.parseFloat/Float.toString 回环的精度损失
+            String textHash = Md5Util.encoderByMd5(queryText);
+            if (StringUtils.hasText(textHash)) {
+                String rawVector = materialVectorStoreService.getRawVectorByTextHash(textHash, configCode);
+                if (rawVector != null && !rawVector.isEmpty()) {
+                    log.info("素材召回 使用缓存的原始向量字符串,跳过 Float 回环, configCode={}", configCode);
+                    List<MaterialMatch> raw = materialVectorStoreService.searchTopNByRawVector(
+                            configCode, rawVector, candidate);
+                    List<MaterialMatch> matches = deduplicateMaterialMatches(raw, topN);
+                    if (!CollectionUtils.isEmpty(matches)) {
+                        List<String> matchSample = new ArrayList<>();
+                        for (MaterialMatch m : matches) {
+                            matchSample.add(m.getMaterialId() + ":" + String.format("%.4f", m.getScore()));
+                        }
+                        log.info("素材召回(rawVector) 去重后({}条): {}, configCode={}",
+                                matches.size(), matchSample, configCode);
+                        return limitEnrichedItemsByScore(enrichMaterialMatches(matches, configCode), topN);
+                    }
+                    log.info("素材召回(rawVector) 无结果, configCode={}", configCode);
+                    return Collections.emptyList();
+                }
+                log.info("素材召回 text_hash 缓存未命中, textHash={}, 降级到 embedding API", textHash);
+            }
+
+            // 降级:embedding API → Float 向量 → 搜索(非缓存路径,容忍精度损失)
+            List<Float> queryVector = resolveQueryVectorForMaterial(queryText, configCode);
+            if (queryVector == null || queryVector.isEmpty()) {
+                log.info("素材召回: 无法获取查询向量, queryText={}", queryText);
+                return Collections.emptyList();
+            }
+            log.info("素材召回 使用 embedding API 向量, dim={}", queryVector.size());
+            List<MaterialMatch> raw = materialVectorStoreService.searchTopN(configCode, queryVector, candidate);
+            List<MaterialMatch> matches = deduplicateMaterialMatches(raw, topN);
+            if (CollectionUtils.isEmpty(matches)) {
+                log.info("素材召回 material_vectors 无结果, configCode={}", configCode);
+                return Collections.emptyList();
+            }
+            List<String> matchSample = new ArrayList<>();
+            for (MaterialMatch m : matches) {
+                matchSample.add(m.getMaterialId() + ":" + String.format("%.4f", m.getScore()));
+            }
+            log.info("素材召回(embedding API) 去重后({}条): {}, configCode={}", matches.size(), matchSample, configCode);
+            return limitEnrichedItemsByScore(enrichMaterialMatches(matches, configCode), topN);
+        } catch (Exception e) {
+            log.error("素材召回 material_vectors 异常: {}", e.getMessage(), e);
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * 为素材召回单独解析查询向量(复用 text_hash 缓存 + embedding API)
+     */
+    private List<Float> resolveQueryVectorForMaterial(String queryText, String configCode) {
+        if (!StringUtils.hasText(queryText)) {
+            return null;
+        }
+        DeconstructVectorConfig config = getVectorConfigByCode(configCode);
+        if (config == null) {
+            config = new DeconstructVectorConfig();
+            config.setConfigCode(configCode);
+        }
+        log.info("resolveQueryVectorForMaterial: queryText={}, configCode={}, model={}, dim={}",
+                queryText, configCode, config.getEmbeddingModel(), config.getDimension());
+
+        // 1. 先查 material_vectors 的 text_hash 缓存
+        String textHash = Md5Util.encoderByMd5(queryText);
+        if (StringUtils.hasText(textHash)) {
+            log.info("resolveQueryVectorForMaterial textHash={}, 开始查 text_hash 缓存", textHash);
+            List<Float> cached = materialVectorStoreService.getVectorByTextHash(textHash, configCode);
+            if (cached != null && !cached.isEmpty()) {
+                log.info("resolveQueryVectorForMaterial 命中 text_hash 缓存,dim={}", cached.size());
+                return cached;
+            }
+            log.info("resolveQueryVectorForMaterial text_hash 缓存未命中,降级到 embedding API");
+        }
+
+        // 2. 调用 embedding API(与入库时相同的 model / dimension)
+        try {
+            log.info("resolveQueryVectorForMaterial 调用 embedding API: text={}, model={}, dim={}",
+                    queryText, config.getEmbeddingModel(), config.getDimension());
+            List<Float> result = embeddingService.embed(queryText, config);
+            log.info("resolveQueryVectorForMaterial embedding API 返回, dim={}", result != null ? result.size() : 0);
+            return result;
+        } catch (Exception e) {
+            log.error("素材召回 embedding 失败: queryText={}, error={}", queryText, e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 多点向量去重:同一 materialId 保留最高分
+     */
+    private List<MaterialMatch> deduplicateMaterialMatches(List<MaterialMatch> matches, int topN) {
+        if (CollectionUtils.isEmpty(matches)) {
+            return Collections.emptyList();
+        }
+        Map<String, MaterialMatch> deduped = new LinkedHashMap<>();
+        for (MaterialMatch m : matches) {
+            if (m == null || !StringUtils.hasText(m.getMaterialId())) {
+                continue;
+            }
+            MaterialMatch existing = deduped.get(m.getMaterialId());
+            if (existing == null || m.getScore() > existing.getScore()) {
+                deduped.put(m.getMaterialId(), m);
+            }
         }
+        return deduped.values().stream().limit(topN).collect(Collectors.toList());
+    }
 
-        // 2. 解析并 enrich
-        return enrich(rawMatches);
+    private DeconstructVectorConfig getVectorConfigByCode(String configCode) {
+        if (!StringUtils.hasText(configCode)) {
+            return null;
+        }
+        try {
+            DeconstructVectorConfigExample example = new DeconstructVectorConfigExample();
+            example.createCriteria().andConfigCodeEqualTo(configCode);
+            List<DeconstructVectorConfig> configs = deconstructVectorConfigMapper.selectByExample(example);
+            if (!CollectionUtils.isEmpty(configs)) {
+                return configs.get(0);
+            }
+            log.info("未找到 configCode={} 的向量配置,将使用默认 embedding 参数", configCode);
+        } catch (Exception e) {
+            log.error("查询向量配置失败,configCode={}, error={}", configCode, e.getMessage(), e);
+        }
+        return null;
+    }
+
+    /**
+     * 视频召回结果 enrich
+     */
+    private List<VideoMatchEnrichedVO> enrichVideoMatches(List<VideoMatchResult> matches, String requestConfigCode) {
+        if (CollectionUtils.isEmpty(matches)) {
+            return Collections.emptyList();
+        }
+        Set<Long> videoIds = matches.stream()
+                .map(VideoMatchResult::getVideoId)
+                .filter(java.util.Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        Map<Long, VideoDetail> videoDetails = videoIds.isEmpty()
+                ? Collections.emptyMap()
+                : videoApiService.getVideoDetail(videoIds);
+
+        List<VideoMatchEnrichedVO> items = new ArrayList<>(matches.size());
+        for (VideoMatchResult m : matches) {
+            if (m == null || m.getVideoId() == null) continue;
+
+            VideoMatchEnrichedVO vo = new VideoMatchEnrichedVO();
+            vo.setId(m.getVideoId());
+            vo.setModality(Modality.VIDEO);
+            vo.setConfigCode(requestConfigCode);
+            vo.setScore(m.getScore());
+
+            VideoDetail vd = videoDetails.get(m.getVideoId());
+            if (vd != null) {
+                vo.setTitle(vd.getTitle());
+                vo.setVideoUrl(vd.getVideoPath());
+                vo.setCover(vd.getCover());
+            }
+
+            vo.setVideoDetail(m.getVideoDetail());
+            applyCompatibilityFields(vo);
+            items.add(vo);
+        }
+        return items;
+    }
+
+    /**
+     * 素材召回结果 enrich
+     * 数据源:material_deconstruct_result.result(dataContent JSON)+ source_type
+     * - title / cover / imageList:从 dataContent 提取
+     * - materialDetail.deconstruct:解析 dataContent 得到 topic + 灵感点/关键点/目的点
+     * - materialDetail.source:source_type → 中文标签
+     */
+    private List<VideoMatchEnrichedVO> enrichMaterialMatches(List<MaterialMatch> matches, String requestConfigCode) {
+        if (CollectionUtils.isEmpty(matches)) {
+            return Collections.emptyList();
+        }
+        List<String> materialIds = matches.stream()
+                .map(MaterialMatch::getMaterialId)
+                .filter(java.util.Objects::nonNull)
+                .collect(Collectors.toList());
+        Map<String, MaterialDeconstructResult> rowByMaterialId = loadMaterialDeconstructRows(materialIds);
+
+        List<VideoMatchEnrichedVO> items = new ArrayList<>(matches.size());
+        for (MaterialMatch m : matches) {
+            if (m == null || m.getMaterialId() == null) continue;
+            VideoMatchEnrichedVO vo = new VideoMatchEnrichedVO();
+            vo.setMaterialId(m.getMaterialId());
+            try {
+                vo.setId(Long.parseLong(m.getMaterialId()));
+            } catch (NumberFormatException ignored) {
+                // 外部 MD5 素材无数值型 id,仅 materialId 字段下发
+            }
+            vo.setModality(Modality.MATERIAL);
+            vo.setConfigCode(requestConfigCode);
+            vo.setScore(m.getScore());
+
+            MaterialDeconstructResult row = rowByMaterialId.get(m.getMaterialId());
+            JSONObject raw = parseResultJson(row);
+            MaterialBasicMeta basic = raw != null ? extractMaterialBasicMeta(raw) : null;
+            Map<String, Object> deconstructFlat = raw != null ? buildDeconstructFromRaw(raw) : null;
+
+            if (basic != null) {
+                vo.setTitle(basic.title);
+                applyMaterialImagesAndCover(vo, basic.imagesJson);
+            }
+
+            MaterialDetailVO detail = new MaterialDetailVO();
+            if (basic != null) {
+                detail.setTitle(basic.title);
+            }
+            if (vo.getImageList() != null) {
+                detail.setImageCount(vo.getImageList().size());
+            }
+            Short sourceType = m.getSourceType();
+            if (sourceType == null && row != null) {
+                sourceType = row.getSourceType();
+            }
+            detail.setSource(mapSourceTypeToLabel(sourceType));
+            detail.setDeconstruct(deconstructFlat);
+            vo.setMaterialDetail(detail);
+
+            applyCompatibilityFields(vo);
+            items.add(vo);
+        }
+        return items;
+    }
+
+    private String mapSourceTypeToLabel(Short sourceType) {
+        if (sourceType == null) {
+            return null;
+        }
+        if (sourceType == SOURCE_TYPE_EXTERNAL) {
+            return SOURCE_LABEL_EXTERNAL;
+        }
+        if (sourceType == SOURCE_TYPE_INTERNAL) {
+            return SOURCE_LABEL_INTERNAL;
+        }
+        return null;
+    }
+
+    /**
+     * 批量加载 material_deconstruct_result 原始行(保留 source_type / result)
+     */
+    private Map<String, MaterialDeconstructResult> loadMaterialDeconstructRows(List<String> materialIds) {
+        if (CollectionUtils.isEmpty(materialIds)) {
+            return Collections.emptyMap();
+        }
+        Map<String, MaterialDeconstructResult> result = new HashMap<>();
+        try {
+            List<MaterialDeconstructResult> rows = materialDeconstructResultMapperExt
+                    .selectResultsByMaterialIds(SOURCE_AIGC, materialIds);
+            if (CollectionUtils.isEmpty(rows)) {
+                return result;
+            }
+            for (MaterialDeconstructResult row : rows) {
+                if (row == null || !StringUtils.hasText(row.getMaterialId())) {
+                    continue;
+                }
+                result.putIfAbsent(row.getMaterialId(), row);
+            }
+        } catch (Exception e) {
+            log.error("批量加载 material_deconstruct_result 失败: {}", e.getMessage(), e);
+        }
+        return result;
+    }
+
+    private JSONObject parseResultJson(MaterialDeconstructResult row) {
+        if (row == null || !StringUtils.hasText(row.getResult())) {
+            return null;
+        }
+        try {
+            return JSON.parseObject(row.getResult());
+        } catch (Exception e) {
+            log.info("解析 material_deconstruct_result.result 失败 materialId={}: {}",
+                    row.getMaterialId(), e.getMessage());
+            return null;
+        }
+    }
+
+    private Map<String, Object> buildDeconstructFromRaw(JSONObject raw) {
+        if (raw == null) {
+            return null;
+        }
+        try {
+            JSONObject decoded = videoSearchService.parseDecodeResult(raw);
+            JSONObject base = decoded != null ? decoded : raw;
+            Map<String, Object> flat = buildFlatDeconstruct(base);
+            return (flat != null && !flat.isEmpty()) ? flat : null;
+        } catch (Exception e) {
+            log.info("解析素材解构失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private MaterialBasicMeta extractMaterialBasicMeta(JSONObject raw) {
+        if (raw == null) {
+            return null;
+        }
+        MaterialBasicMeta meta = new MaterialBasicMeta();
+        meta.title = firstNonBlankString(
+                nestedString(raw, "target_post", "title"),
+                raw.getString("title"),
+                raw.getString("标题"),
+                raw.getString("contentTitle"),
+                raw.getString("素材标题"),
+                nestedString(raw, "input", "title"),
+                nestedString(raw, "content", "title"),
+                nestedString(raw, "最终选题", "name"),
+                nestedString(raw, "最终选题", "title")
+        );
+        JSONArray images = nestedArray(raw, "target_post", "images");
+        if (images == null) {
+            images = nestedArray(raw, "target_post", "imageList");
+        }
+        if (images == null) {
+            images = raw.getJSONArray("images");
+        }
+        if (images == null) {
+            images = raw.getJSONArray("imageList");
+        }
+        if (images == null) {
+            JSONObject input = raw.getJSONObject("input");
+            if (input != null) {
+                images = input.getJSONArray("images");
+                if (images == null) {
+                    images = input.getJSONArray("imageList");
+                }
+            }
+        }
+        if (images != null && !images.isEmpty()) {
+            meta.imagesJson = images.toJSONString();
+        }
+        if (!StringUtils.hasText(meta.title) && !StringUtils.hasText(meta.imagesJson)) {
+            return null;
+        }
+        return meta;
+    }
+
+    /**
+     * cover 取自 imagesJson  JSON 数组的第一张图
+     */
+    private void applyMaterialImagesAndCover(VideoMatchEnrichedVO vo, String imagesJson) {
+        List<String> imageList = parseImages(imagesJson);
+        if (CollectionUtils.isEmpty(imageList)) {
+            return;
+        }
+        vo.setImageList(imageList);
+        vo.setCover(imageList.get(0));
+    }
+
+    private String nestedString(JSONObject parent, String objKey, String fieldKey) {
+        if (parent == null) {
+            return null;
+        }
+        JSONObject nested = parent.getJSONObject(objKey);
+        return nested != null ? nested.getString(fieldKey) : null;
+    }
+
+    private JSONArray nestedArray(JSONObject parent, String objKey, String fieldKey) {
+        if (parent == null) {
+            return null;
+        }
+        JSONObject nested = parent.getJSONObject(objKey);
+        return nested != null ? nested.getJSONArray(fieldKey) : null;
+    }
+
+    private String firstNonBlankString(String... values) {
+        if (values == null) {
+            return null;
+        }
+        for (String v : values) {
+            if (StringUtils.hasText(v)) {
+                return v;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 把解构 JSON 转成扁平结构 (topic + 灵感点/关键点/目的点 及其实质)
+     * 与 VideoSearchServiceImpl#buildFlatDeconstruct 输出一致
+     */
+    private Map<String, Object> buildFlatDeconstruct(JSONObject src) {
+        if (src == null) {
+            return null;
+        }
+        Map<String, Object> out = new LinkedHashMap<>();
+        String topic = src.getString("topic");
+        if (topic != null) {
+            out.put("topic", topic);
+        }
+
+        Map<String, List<String>> nameByType = new LinkedHashMap<>();
+        Map<String, List<Map<String, Object>>> essenceByType = new LinkedHashMap<>();
+        for (String t : new String[]{"灵感点", "关键点", "目的点"}) {
+            nameByType.put(t, new ArrayList<>());
+            essenceByType.put(t, new ArrayList<>());
+        }
+
+        JSONArray points = src.getJSONArray("highValuePoints");
+        if (points != null) {
+            for (int j = 0; j < points.size(); j++) {
+                JSONObject p = points.getJSONObject(j);
+                if (p == null) {
+                    continue;
+                }
+                String type = p.getString("type");
+                if (type == null || !nameByType.containsKey(type)) {
+                    continue;
+                }
+                String name = p.getString("name");
+                if (name != null && !name.isEmpty()) {
+                    nameByType.get(type).add(name);
+                }
+                JSONArray essences = p.getJSONArray("essences");
+                if (essences != null) {
+                    for (int k = 0; k < essences.size(); k++) {
+                        JSONObject e = essences.getJSONObject(k);
+                        if (e == null) {
+                            continue;
+                        }
+                        Map<String, Object> item = new LinkedHashMap<>();
+                        item.put("word", e.getString("word"));
+                        item.put("score", e.getDouble("score"));
+                        essenceByType.get(type).add(item);
+                    }
+                }
+            }
+        }
+
+        for (String t : new String[]{"灵感点", "关键点", "目的点"}) {
+            out.put(t, nameByType.get(t));
+            out.put(t + "-实质", essenceByType.get(t));
+        }
+        return out;
+    }
+
+    /**
+     * 组装返回结果:视频 + 素材合并为 items。
+     * 各模态在前置链路已按 videoTopN / materialTopN 各自截断,此处仅拼接 + 计数,不做合并截断。
+     */
+    private RecallResultVO buildResult(List<VideoMatchEnrichedVO> videoItems,
+                                       List<VideoMatchEnrichedVO> materialItems) {
+        if (videoItems == null) {
+            videoItems = Collections.emptyList();
+        }
+        if (materialItems == null) {
+            materialItems = Collections.emptyList();
+        }
+
+        List<VideoMatchEnrichedVO> all = new ArrayList<>(videoItems.size() + materialItems.size());
+        all.addAll(videoItems);
+        all.addAll(materialItems);
+
+        int videoCount = 0;
+        int materialCount = 0;
+        int articleCount = 0;
+        for (VideoMatchEnrichedVO item : all) {
+            if (item.getModality() == Modality.VIDEO) {
+                videoCount++;
+            } else if (item.getModality() == Modality.ARTICLE) {
+                articleCount++;
+            } else {
+                materialCount++;
+            }
+        }
+
+        RecallResultVO vo = new RecallResultVO();
+        vo.setItems(all);
+        vo.setVideoCount(videoCount);
+        vo.setMaterialCount(materialCount);
+        vo.setArticleCount(articleCount);
+        vo.setTotal(videoCount + materialCount + articleCount);
+        return vo;
+    }
+
+    private void applyCompatibilityFields(VideoMatchEnrichedVO vo) {
+        vo.setPlayCount(PLACEHOLDER);
+        vo.setExposure(PLACEHOLDER);
+        vo.setCtr(PLACEHOLDER);
+        vo.setReadCount(PLACEHOLDER);
+        vo.setRov(extractRovString(vo.getVideoDetail()));
+    }
+
+    private String extractRovString(Map<String, Object> videoDetail) {
+        if (videoDetail == null) {
+            return PLACEHOLDER;
+        }
+        Object rovObj = videoDetail.get("rov");
+        if (rovObj == null) {
+            return PLACEHOLDER;
+        }
+        String rovStr = String.valueOf(rovObj).trim();
+        return StringUtils.hasText(rovStr) ? rovStr : PLACEHOLDER;
     }
 
     @Override
@@ -126,13 +734,16 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         MatchTopNVideoParam matchParam = new MatchTopNVideoParam();
         matchParam.setChannelContentId(String.valueOf(param.getVideoId()));
         matchParam.setConfigCode(param.getConfigCode());
-        matchParam.setTopN(param.getTopN() != null && param.getTopN() > 0 ? param.getTopN() : 10);
+        matchParam.setTopN(param.getTopN() != null && param.getTopN() > 0 ? param.getTopN() : 50);
 
         List<VideoMatchResult> rawMatches = videoSearchService.matchTopNVideo(matchParam, true);
         if (CollectionUtils.isEmpty(rawMatches)) {
             return empty;
         }
-        return enrich(rawMatches);
+        String configCode = StringUtils.hasText(param.getConfigCode())
+                ? param.getConfigCode() : VectorConstants.DEFAULT_CONFIG_CODE;
+        List<VideoMatchEnrichedVO> videoItems = enrichVideoMatches(rawMatches, configCode);
+        return buildResult(videoItems, Collections.emptyList());
     }
 
     @Override
@@ -221,178 +832,32 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         }
     }
 
-    /**
-     * 召回结果模态感知 enrich
-     *
-     * 流程:
-     *  - 提取所有 id
-     *  - 查 deconstruct_content WHERE channel_content_id IN (...) 拿 content_type
-     *  - 视频走 VideoApiService 取权威详情
-     *  - 素材/长文用 deconstruct_content 数据
-     *  - 默认按视频处理(用户确认 content_type 缺省语义)
-     */
-    private RecallResultVO enrich(List<VideoMatchResult> rawMatches) {
-        // 转成内部 MatchItem(过滤 null/无效 id)
-        List<MatchItem> matches = new ArrayList<>(rawMatches.size());
-        for (VideoMatchResult r : rawMatches) {
-            if (r == null || r.getVideoId() == null) {
-                continue;
-            }
-            MatchItem mi = new MatchItem();
-            mi.id = r.getVideoId();
-            mi.configCode = r.getConfigCode();
-            mi.score = r.getScore();
-            mi.videoDetail = r.getVideoDetail();
-            mi.text = r.getText();
-            matches.add(mi);
-        }
-
-        if (matches.isEmpty()) {
-            return emptyResult();
-        }
-
-        // 提取 id 列表(string形式,用于查 channel_content_id)
-        Set<Long> allIds = matches.stream().map(m -> m.id).collect(Collectors.toSet());
-        List<String> idStrings = allIds.stream().map(String::valueOf).collect(Collectors.toList());
-
-        // 查 deconstruct_content
-        Map<String, MysqlDeconstructContent> contentByCcid = queryDeconstructContent(idStrings);
-
-        // 收集需要走 VideoApiService 的视频id
-        Set<Long> videoIds = new HashSet<>();
-        Map<Long, Modality> modalityMap = new HashMap<>();
-
-        for (MatchItem m : matches) {
-            MysqlDeconstructContent c = contentByCcid.get(String.valueOf(m.id));
-            Modality modality = (c == null) ? Modality.VIDEO : Modality.fromContentType(c.getContentType());
-            modalityMap.put(m.id, modality);
-            if (modality == Modality.VIDEO) {
-                videoIds.add(m.id);
-            }
-        }
-
-        // 批量取视频详情
-        Map<Long, VideoDetail> videoDetails = videoIds.isEmpty()
-                ? Collections.emptyMap()
-                : videoApiService.getVideoDetail(videoIds);
-
-        // 组装 VO
-        List<VideoMatchEnrichedVO> items = new ArrayList<>(matches.size());
-        int videoCount = 0;
-        int materialCount = 0;
-        int articleCount = 0;
-
-        for (MatchItem m : matches) {
-            Modality modality = modalityMap.get(m.id);
-            VideoMatchEnrichedVO vo = new VideoMatchEnrichedVO();
-            vo.setId(m.id);
-            vo.setModality(modality);
-            vo.setConfigCode(m.configCode);
-            vo.setScore(m.score);
-            vo.setPlayCount(PLACEHOLDER);
-            vo.setExposure(PLACEHOLDER);
-            vo.setCtr(PLACEHOLDER);
-            vo.setReadCount(PLACEHOLDER);
-            vo.setRov(PLACEHOLDER);
-
-            MysqlDeconstructContent content = contentByCcid.get(String.valueOf(m.id));
-
-            switch (modality) {
-                case VIDEO:
-                    VideoDetail vd = videoDetails.get(m.id);
-                    if (vd != null) {
-                        vo.setTitle(vd.getTitle());
-                        vo.setVideoUrl(vd.getVideoPath());
-                        vo.setCover(vd.getCover());
-                    } else if (content != null) {
-                        // 长视频API查不到,降级用本地 deconstruct_content
-                        vo.setTitle(content.getTitle());
-                        vo.setVideoUrl(content.getVideoUrl());
-                    }
-                    videoCount++;
-                    break;
-                case MATERIAL:
-                    if (content != null) {
-                        vo.setTitle(content.getTitle());
-                        vo.setImageList(parseImages(content.getImages()));
-                        if (!CollectionUtils.isEmpty(vo.getImageList())) {
-                            vo.setCover(vo.getImageList().get(0));
-                        }
-                    }
-                    materialCount++;
-                    break;
-                case ARTICLE:
-                    if (content != null) {
-                        vo.setTitle(content.getTitle());
-                        vo.setBodyText(content.getBodyText());
-                    }
-                    articleCount++;
-                    break;
-                default:
-                    videoCount++;
-                    break;
-            }
-
-            // 填充 videoDetail (已由 VideoSearchServiceImpl 嵌入 deconstruct 子对象)
-            vo.setVideoDetail(m.videoDetail);
-            vo.setText(m.text);
-
-            items.add(vo);
-        }
-
-        RecallResultVO result = new RecallResultVO();
-        result.setItems(items);
-        result.setVideoCount(videoCount);
-        result.setMaterialCount(materialCount);
-        result.setArticleCount(articleCount);
-        result.setTotal(items.size());
-        return result;
-    }
-
-    /**
-     * 按 channelContentId 批量查 deconstruct_content
-     */
-    private Map<String, MysqlDeconstructContent> queryDeconstructContent(List<String> channelContentIds) {
-        if (CollectionUtils.isEmpty(channelContentIds)) {
-            return Collections.emptyMap();
-        }
-        try {
-            MysqlDeconstructContentExample example = new MysqlDeconstructContentExample();
-            example.createCriteria().andChannelContentIdIn(channelContentIds);
-            List<MysqlDeconstructContent> list = mysqlDeconstructContentMapper.selectByExample(example);
-            // channel_content_id 可能重复(同一内容多次解构),保留最新一条
-            Map<String, MysqlDeconstructContent> map = new HashMap<>();
-            for (MysqlDeconstructContent c : list) {
-                String ccid = c.getChannelContentId();
-                if (ccid == null) {
-                    continue;
-                }
-                MysqlDeconstructContent prev = map.get(ccid);
-                if (prev == null || (c.getId() != null && (prev.getId() == null || c.getId() > prev.getId()))) {
-                    map.put(ccid, c);
-                }
-            }
-            return map;
-        } catch (Exception e) {
-            log.error("queryDeconstructContent error: {}", e.getMessage(), e);
-            return Collections.emptyMap();
-        }
-    }
-
     private List<String> parseImages(String imagesJson) {
         if (!StringUtils.hasText(imagesJson)) {
             return Collections.emptyList();
         }
         try {
-            JSONArray arr = JSON.parseArray(imagesJson);
-            if (arr == null) {
+            String normalized = imagesJson.trim();
+            // 兼容双重 JSON 编码,临时兜底;根本修复应在数据写入 material_deconstruct_result.result 时做一次 JSON 规范化
+            if (normalized.startsWith("\"") && normalized.endsWith("\"")) {
+                Object unquoted = JSON.parse(normalized);
+                if (unquoted instanceof String) {
+                    normalized = ((String) unquoted).trim();
+                }
+            }
+            JSONArray arr = JSON.parseArray(normalized);
+            if (arr == null || arr.isEmpty()) {
                 return Collections.emptyList();
             }
             List<String> result = new ArrayList<>(arr.size());
             for (int i = 0; i < arr.size(); i++) {
-                String s = arr.getString(i);
-                if (StringUtils.hasText(s)) {
-                    result.add(s);
+                Object item = arr.get(i);
+                if (item == null) {
+                    continue;
+                }
+                String url = item instanceof String ? (String) item : String.valueOf(item);
+                if (StringUtils.hasText(url)) {
+                    result.add(url.trim());
                 }
             }
             return result;
@@ -412,14 +877,9 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         return vo;
     }
 
-    /**
-     * 解析后的单条 match
-     */
-    private static class MatchItem {
-        Long id;
-        String configCode;
-        Double score;
-        Map<String, Object> videoDetail;
-        String text;
+    /** 从 AIGC dataContent 提取的素材基础元数据 */
+    private static class MaterialBasicMeta {
+        String title;
+        String imagesJson;
     }
 }

+ 56 - 0
core/src/main/resources/mapper/pgVector/ext/MaterialDeconstructResultMapperExt.xml

@@ -0,0 +1,56 @@
+<?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.ext.MaterialDeconstructResultMapperExt">
+
+    <resultMap id="BaseResultMap" type="com.tzld.videoVector.model.po.pgVector.MaterialDeconstructResult">
+        <id column="id" jdbcType="BIGINT" property="id"/>
+        <result column="material_id" jdbcType="VARCHAR" property="materialId"/>
+        <result column="source" jdbcType="VARCHAR" property="source"/>
+        <result column="result" jdbcType="VARCHAR" property="result"/>
+        <result column="source_type" jdbcType="SMALLINT" property="sourceType"/>
+        <result column="create_time" jdbcType="TIMESTAMP" property="createTime"/>
+        <result column="update_time" jdbcType="TIMESTAMP" property="updateTime"/>
+    </resultMap>
+
+    <!-- 查询指定来源下已存在的 materialId 列表 -->
+    <select id="selectExistingMaterialIds" resultType="java.lang.String">
+        SELECT material_id
+        FROM material_deconstruct_result
+        WHERE source = #{source}
+        AND material_id IN
+        <foreach collection="materialIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
+    <!-- 批量插入,冲突时忽略 -->
+    <insert id="batchInsertIgnore">
+        INSERT INTO material_deconstruct_result (material_id, source, result, source_type)
+        VALUES
+        <foreach collection="list" item="item" separator=",">
+            (#{item.materialId}, #{item.source}, #{item.result}, #{item.sourceType})
+        </foreach>
+        ON CONFLICT (material_id, source) DO NOTHING
+    </insert>
+
+    <!-- 分页查询指定来源的 materialId -->
+    <select id="selectMaterialIdsBySourcePaged" resultType="java.lang.String">
+        SELECT material_id
+        FROM material_deconstruct_result
+        WHERE source = #{source}
+        ORDER BY material_id
+        LIMIT #{limit} OFFSET #{offset}
+    </select>
+
+    <!-- 批量查询指定来源和 materialId 的解构结果 -->
+    <select id="selectResultsByMaterialIds" resultMap="BaseResultMap">
+        SELECT material_id, source, result, source_type
+        FROM material_deconstruct_result
+        WHERE source = #{source}
+        AND material_id IN
+        <foreach collection="materialIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
+</mapper>

+ 153 - 0
core/src/main/resources/mapper/pgVector/ext/MaterialVectorMapperExt.xml

@@ -0,0 +1,153 @@
+<?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.ext.MaterialVectorMapperExt">
+
+    <!-- ResultMap for MaterialVector with score -->
+    <resultMap id="MaterialVectorResultMap" type="com.tzld.videoVector.model.po.pgVector.MaterialVector">
+        <id column="id" property="id" jdbcType="BIGINT"/>
+        <result column="material_id" jdbcType="VARCHAR" property="materialId"/>
+        <result column="config_code" property="configCode" jdbcType="VARCHAR"/>
+        <result column="embedding" property="embedding" jdbcType="VARCHAR"/>
+        <result column="created_at" property="createdAt" jdbcType="TIMESTAMP"/>
+        <result column="updated_at" property="updatedAt" jdbcType="TIMESTAMP"/>
+        <result column="point_index" property="pointIndex" jdbcType="INTEGER"/>
+        <result column="text" property="text" jdbcType="VARCHAR"/>
+        <result column="text_hash" property="textHash" jdbcType="VARCHAR"/>
+        <result column="source_type" property="sourceType" jdbcType="SMALLINT"/>
+        <result column="score" property="score" jdbcType="DOUBLE"/>
+    </resultMap>
+
+    <!-- 插入或更新素材向量 (ON CONFLICT DO UPDATE) -->
+    <insert id="upsertVector">
+        INSERT INTO material_vectors (
+            material_id,
+            config_code,
+            point_index,
+            embedding,
+            text,
+            text_hash,
+            source_type,
+            created_at,
+            updated_at
+        ) VALUES (
+            #{materialId},
+            #{configCode},
+            #{pointIndex},
+            #{embedding}::vector,
+            #{text},
+            #{textHash},
+            #{sourceType},
+            NOW(),
+            NOW()
+        )
+        ON CONFLICT (config_code, material_id, point_index)
+        DO UPDATE SET
+            embedding = EXCLUDED.embedding,
+            text = EXCLUDED.text,
+            text_hash = EXCLUDED.text_hash,
+            source_type = EXCLUDED.source_type,
+            updated_at = NOW()
+    </insert>
+
+    <!-- 检查素材向量是否存在 -->
+    <select id="existsByMaterialIdAndConfigCode" resultType="int">
+        SELECT COUNT(1)
+        FROM material_vectors
+        WHERE material_id = #{materialId}
+          AND config_code = #{configCode}
+        LIMIT 1
+    </select>
+
+    <!-- 批量检查素材向量是否存在 -->
+    <select id="selectExistingMaterialIds" resultType="java.lang.String">
+        SELECT DISTINCT material_id
+        FROM material_vectors
+        WHERE config_code = #{configCode}
+          AND material_id IN
+        <foreach collection="materialIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
+    <!-- 获取指定配置下所有素材ID -->
+    <select id="selectAllMaterialIds" resultType="java.lang.String">
+        SELECT DISTINCT material_id
+        FROM material_vectors
+        WHERE config_code = #{configCode}
+        ORDER BY material_id
+    </select>
+
+    <!-- 向量相似度检索 Top-N (余弦相似度) -->
+    <select id="searchTopN" resultMap="MaterialVectorResultMap">
+        SELECT
+            material_id,
+            config_code,
+            text,
+            source_type,
+            1 - (embedding &lt;=&gt; #{queryVector}::vector) AS score
+        FROM material_vectors
+        WHERE config_code = #{configCode}
+        ORDER BY embedding &lt;=&gt; #{queryVector}::vector
+        LIMIT #{topN}
+    </select>
+
+    <!-- 按素材来源进行向量相似度检索 Top-N -->
+    <select id="searchTopNBySource" resultMap="MaterialVectorResultMap">
+        SELECT
+            material_id,
+            config_code,
+            text,
+            source_type,
+            1 - (embedding &lt;=&gt; #{queryVector}::vector) AS score
+        FROM material_vectors
+        WHERE config_code = #{configCode}
+          AND source_type = #{sourceType}
+        ORDER BY embedding &lt;=&gt; #{queryVector}::vector
+        LIMIT #{topN}
+    </select>
+
+    <!-- 根据 text_hash 查询向量,embedding::text 保证 PG 输出高精度文本,避免 JDBC 驱动 PGobject.getValue() 精度损失 -->
+    <select id="selectByTextHashAndConfigCode" resultMap="MaterialVectorResultMap">
+        SELECT
+            id,
+            material_id,
+            config_code,
+            embedding::text AS embedding,
+            created_at,
+            updated_at,
+            point_index,
+            text,
+            text_hash,
+            source_type
+        FROM material_vectors
+        WHERE text_hash = #{textHash}
+          AND config_code = #{configCode}
+        LIMIT 1
+    </select>
+
+    <!-- 删除指定素材的所有向量 -->
+    <delete id="deleteByMaterialIdAndConfigCode">
+        DELETE FROM material_vectors
+        WHERE material_id = #{materialId}
+          AND config_code = #{configCode}
+    </delete>
+
+    <!-- 批量删除素材向量 -->
+    <delete id="deleteBatchByMaterialIds">
+        DELETE FROM material_vectors
+        WHERE config_code = #{configCode}
+          AND material_id IN
+        <foreach collection="materialIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <!-- 删除多点模式下 point_index >= minPointIndex 的旧向量 -->
+    <delete id="deleteAbovePointIndex">
+        DELETE FROM material_vectors
+        WHERE material_id = #{materialId}
+          AND config_code = #{configCode}
+          AND point_index >= #{minPointIndex}
+    </delete>
+
+</mapper>

+ 152 - 0
server/src/main/java/com/tzld/videoVector/MaterialEmbeddingTestRunner.java

@@ -0,0 +1,152 @@
+package com.tzld.videoVector;
+
+import com.tzld.videoVector.job.MaterialVectorJob;
+import com.xxl.job.core.biz.model.ReturnT;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.FilterType;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+/**
+ * 离线测试启动入口(独立 main,不依赖 XXL-Job / Eureka / Apollo)
+ * <p>
+ * 用途:本地手动跑一次 MaterialVectorJob,验证 AIGC API → material_deconstruct_result → embedding → material_vectors 链路。
+ * <p>
+ * 启动方式(在项目根目录):
+ * <pre>
+ *   ./script/run_material_embedding_test.sh        # 默认处理 5 条
+ *   ./script/run_material_embedding_test.sh 10     # 处理 10 条
+ * </pre>
+ * 或在 IDE 中直接 Run 这个类,VM Options 加:
+ * <pre>
+ *   -Dspring.profiles.active=test-local
+ *   -Dembedding.test.maxTaskCount=5
+ * </pre>
+ * <p>
+ * 排除项:
+ * <ul>
+ *   <li>{@link com.tzld.videoVector.config.XxlJobConfig}:跳过 XxlJob admin 注册</li>
+ *   <li>filter / aop / controller 包:避免不必要的 Web 依赖</li>
+ *   <li>非 MaterialVectorJob 的其它 Job:避免 @ApolloJsonValue 等无关依赖加载失败</li>
+ *   <li>application-test-local.yml 中关闭 Eureka / Apollo</li>
+ * </ul>
+ */
+@SpringBootApplication
+@ComponentScan(
+        basePackages = "com.tzld.videoVector",
+        excludeFilters = {
+                // 排除主 Application(带 @EnableDiscoveryClient/@EnableFeignClients)
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.Application"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.config\\.XxlJobConfig"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.config\\.AliOssConfig"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.config\\.SwaggerConfig"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.config\\.WebMvcConfig"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.config\\.SchedulingConfig"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.filter\\..*"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.aop\\..*"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.controller\\..*"),
+                // 仅保留 MaterialVectorJob,排除其它 Job(避免 @ApolloJsonValue 等无关依赖)
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.job\\.VideoTitleVectorJob"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.job\\.AiUnderstandingSyncJob"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.job\\.ChannelDemandMatchJob"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.job\\.VideoDetailSyncJob"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.job\\.VideoVectorJob"),
+                // 排除不需要的 Service 实现
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.service\\.impl\\.MaterialSearchServiceImpl"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.service\\.impl\\.VideoSearchServiceImpl"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.service\\.recall\\..*")
+        }
+)
+public class MaterialEmbeddingTestRunner {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(MaterialEmbeddingTestRunner.class);
+
+    public static void main(String[] args) {
+        if (System.getProperty("spring.profiles.active") == null) {
+            System.setProperty("spring.profiles.active", "test-local");
+        }
+        System.setProperty("spring.main.web-application-type", "none");
+        // 只加载 test-local 配置,跳过 application.yml(含未过滤的 @mavenBuildTime@ 占位符)
+        System.setProperty("spring.config.location", "classpath:/application-test-local.yml");
+
+        ConfigurableApplicationContext ctx = SpringApplication.run(MaterialEmbeddingTestRunner.class, args);
+        LOGGER.info("=========== 测试运行器启动完成,开始执行 MaterialVectorJob ===========");
+        Runtime.getRuntime().addShutdownHook(new Thread(() -> ctx.close()));
+    }
+
+    @Component
+    @Profile("test-local")
+    public static class TestKickoffRunner implements CommandLineRunner {
+
+        private final MaterialVectorJob job;
+        private final org.springframework.core.env.Environment env;
+
+        public TestKickoffRunner(MaterialVectorJob job,
+                                 org.springframework.core.env.Environment env) {
+            this.job = job;
+            this.env = env;
+        }
+
+        @Override
+        public void run(String... args) {
+            try {
+                Integer maxMaterialCount = parseMax(args);
+                LOGGER.info(">>> [TestRunner] 开始执行 MaterialVectorJob.runOnce(maxMaterialCount={})", maxMaterialCount);
+                ReturnT<String> result = job.runOnce(maxMaterialCount);
+                LOGGER.info(">>> [TestRunner] 执行完成,code={}, msg={}",
+                        result == null ? -1 : result.getCode(),
+                        result == null ? null : result.getMsg());
+            } catch (Exception e) {
+                LOGGER.error(">>> [TestRunner] 执行异常: {}", e.getMessage(), e);
+            } finally {
+                LOGGER.info(">>> [TestRunner] 退出 JVM");
+                System.exit(0);
+            }
+        }
+
+        /**
+         * 解析 maxMaterialCount,优先级: 命令行参数 --max=N > -Dembedding.test.maxTaskCount > 默认 5
+         */
+        private Integer parseMax(String[] args) {
+            for (String a : args) {
+                if (a != null && a.startsWith("--max=")) {
+                    try {
+                        return Integer.parseInt(a.substring("--max=".length()).trim());
+                    } catch (NumberFormatException ignored) {
+                    }
+                }
+            }
+            String prop = env.getProperty("embedding.test.maxTaskCount");
+            if (prop != null && !prop.isEmpty()) {
+                try {
+                    return Integer.parseInt(prop.trim());
+                } catch (NumberFormatException ignored) {
+                }
+            }
+            return 5;
+        }
+    }
+}

+ 2 - 1
server/src/main/java/com/tzld/videoVector/controller/VectorRecallTestController.java

@@ -41,7 +41,8 @@ public class VectorRecallTestController {
     /**
      * 文本召回 (Tab2)
      * POST /videoVector/recallTest/matchByText
-     * body: { "queryText": "...", "configCode": "VIDEO_TOPIC", "topN": 10 }
+     * body: { "queryText": "...", "configCode": "VIDEO_TOPIC", "topN": 50, "videoTopN": 50, "materialTopN": 50 }
+     * 视频与素材各自按 videoTopN / materialTopN 返回(未传则与 topN 相同),items 合并后不再截断
      */
     @PostMapping("/matchByText")
     public CommonResponse<RecallResultVO> matchByText(@RequestBody MatchByTextParam param) {

+ 9 - 6
server/src/main/java/com/tzld/videoVector/controller/XxlJobController.java

@@ -14,9 +14,6 @@ public class XxlJobController {
     @Autowired
     private VideoVectorJob videoVectorJob;
 
-    @Autowired
-    private MaterialDeconstructCheckJob materialDeconstructCheckJob;
-
     @Autowired
     private MaterialVectorJob materialVectorJob;
 
@@ -66,9 +63,9 @@ public class XxlJobController {
 
     // ==================== 素材相关任务 ====================
 
-    @GetMapping("/checkMaterialDeconstructJob")
-    public CommonResponse<Void> checkMaterialDeconstructJob() {
-        materialDeconstructCheckJob.checkMaterialDeconstructJob(null);
+    @GetMapping("/syncMaterialDeconstructJob")
+    public CommonResponse<Void> syncMaterialDeconstructJob() {
+        materialVectorJob.syncMaterialDeconstructJob(null);
         return CommonResponse.success();
     }
 
@@ -78,6 +75,12 @@ public class XxlJobController {
         return CommonResponse.success();
     }
 
+    @GetMapping("/materialJob")
+    public CommonResponse<Void> materialJob() {
+        materialVectorJob.materialJob(null);
+        return CommonResponse.success();
+    }
+
     // ==================== 视频详情同步任务 ====================
 
     @GetMapping("/syncVideoDetailJob")