Explorar el Código

新增素材匹配

luojunhui hace 1 semana
padre
commit
78663b6c05

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

@@ -49,4 +49,11 @@ public interface MaterialVectorMapperExt {
     int deleteAbovePointIndex(@Param("materialId") String materialId,
                               @Param("configCode") String configCode,
                               @Param("minPointIndex") int minPointIndex);
+
+    /** 查询指定素材已向量化的所有 configCode */
+    List<String> selectDistinctConfigCodesByMaterialId(@Param("materialId") String materialId);
+
+    /** 查询指定素材在指定 configCode 下的所有向量(按 pointIndex 升序) */
+    List<MaterialVector> selectVectorsByMaterialIdAndConfigCode(@Param("materialId") String materialId,
+                                                                @Param("configCode") String configCode);
 }

+ 19 - 0
core/src/main/java/com/tzld/videoVector/model/param/recall/MatchByMaterialIdParam.java

@@ -0,0 +1,19 @@
+package com.tzld.videoVector.model.param.recall;
+
+import lombok.Data;
+
+/**
+ * 素材ID召回参数 (matchByMaterialId)
+ */
+@Data
+public class MatchByMaterialIdParam {
+
+    /** 素材 ID(必填) */
+    private String materialId;
+
+    /** 召回维度,不传则使用该素材已向量化的所有维度 */
+    private String configCode;
+
+    /** 返回 Top-N,默认 50 */
+    private Integer topN = 50;
+}

+ 19 - 0
core/src/main/java/com/tzld/videoVector/model/vo/recall/MaterialBasicVO.java

@@ -0,0 +1,19 @@
+package com.tzld.videoVector.model.vo.recall;
+
+import lombok.Data;
+
+/**
+ * 素材基础信息 VO(materialDetail 接口用)
+ */
+@Data
+public class MaterialBasicVO {
+
+    /** 素材 ID */
+    private String materialId;
+
+    /** 素材图片 CDN 地址 */
+    private String imageUrl;
+
+    /** 素材标题 */
+    private String title;
+}

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

@@ -58,4 +58,10 @@ public interface MaterialVectorStoreService {
     List<MaterialMatch> searchTopNByRawVector(String configCode, String rawVector, int topN);
 
     List<MaterialMatch> searchTopNBySource(String configCode, List<Float> queryVector, int topN, Short sourceType);
+
+    /** 查询指定素材已向量化的所有 configCode */
+    List<String> getDistinctConfigCodes(String materialId);
+
+    /** 查询指定素材在指定 configCode 下的所有向量行(raw embedding),用于获取素材自身向量进行跨模态召回 */
+    List<com.tzld.videoVector.model.po.pgVector.MaterialVector> getVectorsByMaterialId(String materialId, String configCode);
 }

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

@@ -252,6 +252,24 @@ public class PgMaterialVectorStoreServiceImpl implements MaterialVectorStoreServ
         return convertToMatch(results, configCode);
     }
 
+    @Override
+    public List<String> getDistinctConfigCodes(String materialId) {
+        if (!StringUtils.hasText(materialId)) {
+            return Collections.emptyList();
+        }
+        List<String> codes = materialVectorMapperExt.selectDistinctConfigCodesByMaterialId(materialId);
+        return codes != null ? codes : Collections.emptyList();
+    }
+
+    @Override
+    public List<MaterialVector> getVectorsByMaterialId(String materialId, String configCode) {
+        if (!StringUtils.hasText(materialId) || !StringUtils.hasText(configCode)) {
+            return Collections.emptyList();
+        }
+        List<MaterialVector> vectors = materialVectorMapperExt.selectVectorsByMaterialIdAndConfigCode(materialId, configCode);
+        return vectors != null ? vectors : Collections.emptyList();
+    }
+
     private List<MaterialMatch> convertToMatch(List<MaterialVector> results, String configCode) {
         return results.stream()
                 .map(mv -> {

+ 18 - 0
core/src/main/java/com/tzld/videoVector/service/recall/VectorRecallTestService.java

@@ -1,9 +1,11 @@
 package com.tzld.videoVector.service.recall;
 
+import com.tzld.videoVector.model.param.recall.MatchByMaterialIdParam;
 import com.tzld.videoVector.model.param.recall.MatchByTextParam;
 import com.tzld.videoVector.model.param.recall.MatchByVideoIdParam;
 import com.tzld.videoVector.model.vo.recall.AIUnderstandingVO;
 import com.tzld.videoVector.model.vo.recall.DeconstructPointsVO;
+import com.tzld.videoVector.model.vo.recall.MaterialBasicVO;
 import com.tzld.videoVector.model.vo.recall.RecallResultVO;
 import com.tzld.videoVector.model.vo.recall.VideoBasicVO;
 
@@ -58,4 +60,20 @@ public interface VectorRecallTestService {
      * @return AI理解结果,无数据返回 null (严禁mock)
      */
     AIUnderstandingVO getAiUnderstanding(Long videoId);
+
+    /**
+     * 素材ID召回 — 用素材自身的向量做跨模态相似搜索
+     *
+     * @param param 素材ID召回参数
+     * @return 召回结果(视频 + 素材 + 文章混合)
+     */
+    RecallResultVO matchByMaterialId(MatchByMaterialIdParam param);
+
+    /**
+     * 素材详情预览 — 返回素材图片URL和标题
+     *
+     * @param materialId 素材ID
+     * @return 素材基础信息,不存在返回 null
+     */
+    MaterialBasicVO getMaterialDetail(String materialId);
 }

+ 383 - 0
core/src/main/java/com/tzld/videoVector/service/recall/impl/VectorRecallTestServiceImpl.java

@@ -12,7 +12,9 @@ import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialDeconstructResultMap
 import com.tzld.videoVector.model.entity.ArticleMatch;
 import com.tzld.videoVector.model.entity.MaterialMatch;
 import com.tzld.videoVector.model.entity.VideoDetail;
+import com.tzld.videoVector.model.entity.VideoMatch;
 import com.tzld.videoVector.model.param.MatchTopNVideoParam;
+import com.tzld.videoVector.model.param.recall.MatchByMaterialIdParam;
 import com.tzld.videoVector.model.param.recall.MatchByTextParam;
 import com.tzld.videoVector.model.param.recall.MatchByVideoIdParam;
 import com.tzld.videoVector.model.po.pgVector.ArticleDeconstructResult;
@@ -23,6 +25,7 @@ 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.ArticleDetailVO;
+import com.tzld.videoVector.model.vo.recall.MaterialBasicVO;
 import com.tzld.videoVector.model.vo.recall.MaterialDetailVO;
 import com.tzld.videoVector.model.vo.recall.RecallResultVO;
 import com.tzld.videoVector.model.vo.recall.VideoBasicVO;
@@ -30,11 +33,17 @@ import com.tzld.videoVector.model.vo.recall.VideoMatchEnrichedVO;
 import com.tzld.videoVector.service.ArticleVectorStoreService;
 import com.tzld.videoVector.service.EmbeddingService;
 import com.tzld.videoVector.service.MaterialVectorStoreService;
+import com.tzld.videoVector.service.VectorStoreService;
 import com.tzld.videoVector.service.VideoSearchService;
 import com.tzld.videoVector.service.recall.VectorRecallTestService;
 import com.tzld.videoVector.util.Md5Util;
+import com.tzld.videoVector.util.RedisUtils;
+import com.tzld.videoVector.util.VectorUtils;
+import com.tzld.videoVector.model.po.pgVector.MaterialVector;
+import com.google.common.collect.Lists;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.util.CollectionUtils;
@@ -45,9 +54,11 @@ 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.Objects;
 import java.util.Set;
 import java.util.Comparator;
 import java.util.concurrent.CompletableFuture;
@@ -93,6 +104,15 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
     @Autowired
     private EmbeddingService embeddingService;
 
+    @Autowired
+    private VectorStoreService vectorStoreService;
+
+    @Autowired
+    private RedisUtils redisUtils;
+
+    @Value("${video.detail.metrics.days:7}")
+    private int metricsDays;
+
     private static final String SOURCE_AIGC = "aigc_deconstruct";
 
     /** source_type → 中文来源标签 */
@@ -1021,6 +1041,369 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         return buildResult(videoItems, Collections.emptyList(), Collections.emptyList());
     }
 
+    // ====================================================================
+    // matchByMaterialId — 素材ID跨模态召回
+    // ====================================================================
+
+    @Override
+    public RecallResultVO matchByMaterialId(MatchByMaterialIdParam param) {
+        RecallResultVO empty = emptyResult();
+        if (param == null || !StringUtils.hasText(param.getMaterialId())) {
+            log.info("matchByMaterialId: materialId 为空");
+            return empty;
+        }
+
+        String materialId = param.getMaterialId().trim();
+        int topN = param.getTopN() != null && param.getTopN() > 0 ? param.getTopN() : 50;
+
+        List<String> configCodes;
+        if (StringUtils.hasText(param.getConfigCode())) {
+            configCodes = Collections.singletonList(param.getConfigCode());
+        } else {
+            configCodes = materialVectorStoreService.getDistinctConfigCodes(materialId);
+            if (configCodes.isEmpty()) {
+                log.info("matchByMaterialId: materialId={} 无向量数据", materialId);
+                return empty;
+            }
+        }
+        log.info("matchByMaterialId: materialId={}, topN={}, configCodes={}", materialId, topN, configCodes);
+
+        int candidateTopN = Math.max(topN * VectorConstants.MULTI_POINT_RECALL_CANDIDATE_FACTOR,
+                VectorConstants.MULTI_POINT_RECALL_MIN_CANDIDATES);
+
+        List<VideoMatchEnrichedVO> allResults = Collections.synchronizedList(new ArrayList<>());
+        List<CompletableFuture<Void>> allFutures = new ArrayList<>();
+
+        for (String configCode : configCodes) {
+            List<MaterialVector> vectors = materialVectorStoreService.getVectorsByMaterialId(materialId, configCode);
+            if (vectors.isEmpty()) continue;
+
+            // 取第一个点向量的 raw embedding
+            String rawEmbedding = vectors.get(0).getEmbedding();
+            if (!StringUtils.hasText(rawEmbedding)) continue;
+
+            List<Float> queryVector = VectorUtils.parseVectorString(rawEmbedding);
+            if (queryVector == null || queryVector.isEmpty()) continue;
+
+            final String cc = configCode;
+            final List<Float> qv = queryVector;
+            final int ctn = candidateTopN;
+            final int tn = topN;
+
+            // 视频召回:同维度搜索,不跨 configCode
+            allFutures.add(CompletableFuture.runAsync(() -> {
+                try {
+                    List<VideoMatch> matches = vectorStoreService.searchTopN(cc, qv, ctn);
+                    List<VideoMatch> deduped = deduplicateVideoMatches(matches, tn);
+                    log.info("matchByMaterialId 视频搜索 cc={}: {} 条, 去重后 {} 条",
+                            cc, matches != null ? matches.size() : 0, deduped.size());
+                    if (!deduped.isEmpty()) {
+                        allResults.addAll(enrichVideoMatchesFromVectorStore(deduped, cc));
+                    }
+                } catch (Exception e) {
+                    log.error("matchByMaterialId 视频搜索失败 configCode={}: {}", cc, e.getMessage());
+                }
+            }, RECALL_EXECUTOR));
+
+            // 素材召回(排除自身)
+            allFutures.add(CompletableFuture.runAsync(() -> {
+                try {
+                    List<MaterialMatch> matches = materialVectorStoreService.searchTopN(cc, qv, ctn);
+                    matches = matches.stream()
+                            .filter(m -> !materialId.equals(m.getMaterialId()))
+                            .collect(Collectors.toList());
+                    List<MaterialMatch> deduped = deduplicateMaterialMatches(matches, tn);
+                    if (!deduped.isEmpty()) {
+                        allResults.addAll(enrichMaterialMatches(deduped, cc));
+                    }
+                } catch (Exception e) {
+                    log.error("matchByMaterialId 素材搜索失败 configCode={}: {}", cc, e.getMessage());
+                }
+            }, RECALL_EXECUTOR));
+
+            // 文章召回
+            allFutures.add(CompletableFuture.runAsync(() -> {
+                try {
+                    List<ArticleMatch> matches = articleVectorStoreService.searchTopN(cc, qv, ctn);
+                    List<ArticleMatch> deduped = deduplicateArticleMatches(matches, tn);
+                    if (!deduped.isEmpty()) {
+                        allResults.addAll(enrichArticleMatches(deduped, cc));
+                    }
+                } catch (Exception e) {
+                    log.error("matchByMaterialId 文章搜索失败 configCode={}: {}", cc, e.getMessage());
+                }
+            }, RECALL_EXECUTOR));
+        }
+
+        // 等待并行搜索完成
+        for (CompletableFuture<Void> future : allFutures) {
+            try {
+                future.get(30, TimeUnit.SECONDS);
+            } catch (Exception e) {
+                log.error("matchByMaterialId 并行搜索等待异常: {}", e.getMessage());
+            }
+        }
+
+        // 跨 configCode 去重:同一 (id, modality) 保留最高分
+        List<VideoMatchEnrichedVO> merged = deduplicateCrossConfigCode(allResults);
+
+        // 按模态拆分,各自独立排序截断到 topN(避免跨模态分数竞争,视频分数可能天然低于素材自身匹配)
+        List<VideoMatchEnrichedVO> videoItems = merged.stream()
+                .filter(it -> it.getModality() == Modality.VIDEO)
+                .sorted(Comparator.comparing(VideoMatchEnrichedVO::getScore,
+                        Comparator.nullsLast(Comparator.reverseOrder())))
+                .limit(topN)
+                .collect(Collectors.toList());
+        List<VideoMatchEnrichedVO> materialItems = merged.stream()
+                .filter(it -> it.getModality() == Modality.MATERIAL)
+                .sorted(Comparator.comparing(VideoMatchEnrichedVO::getScore,
+                        Comparator.nullsLast(Comparator.reverseOrder())))
+                .limit(topN)
+                .collect(Collectors.toList());
+        List<VideoMatchEnrichedVO> articleItems = merged.stream()
+                .filter(it -> it.getModality() == Modality.ARTICLE)
+                .sorted(Comparator.comparing(VideoMatchEnrichedVO::getScore,
+                        Comparator.nullsLast(Comparator.reverseOrder())))
+                .limit(topN)
+                .collect(Collectors.toList());
+
+        log.info("matchByMaterialId 按模态截断后: video={}, material={}, article={}",
+                videoItems.size(), materialItems.size(), articleItems.size());
+
+        // 把输入素材自身加入结果(score=1.0, modality=MATERIAL)
+        VideoMatchEnrichedVO selfItem = enrichSelfMaterial(materialId, configCodes.isEmpty() ? null : configCodes.get(0));
+        if (selfItem != null) {
+            materialItems.add(0, selfItem);
+        }
+
+        RecallResultVO result = buildResult(videoItems, materialItems, articleItems);
+        log.info("matchByMaterialId 完成: total={}, video={}, material={}, article={}",
+                result.getTotal(), result.getVideoCount(), result.getMaterialCount(), result.getArticleCount());
+        return result;
+    }
+
+    /**
+     * 视频向量搜索结果去重:同一 videoId 保留最高分
+     */
+    private List<VideoMatch> deduplicateVideoMatches(List<VideoMatch> matches, int topN) {
+        if (CollectionUtils.isEmpty(matches)) return Collections.emptyList();
+        Map<Long, VideoMatch> deduped = new LinkedHashMap<>();
+        for (VideoMatch m : matches) {
+            if (m == null || m.getVideoId() == null) continue;
+            VideoMatch existing = deduped.get(m.getVideoId());
+            if (existing == null || m.getScore() > existing.getScore()) {
+                deduped.put(m.getVideoId(), m);
+            }
+        }
+        return deduped.values().stream().limit(topN).collect(Collectors.toList());
+    }
+
+    /**
+     * 跨 configCode 去重:同一 (id, modality) 保留最高分
+     */
+    private List<VideoMatchEnrichedVO> deduplicateCrossConfigCode(List<VideoMatchEnrichedVO> items) {
+        if (CollectionUtils.isEmpty(items)) return Collections.emptyList();
+        Map<String, VideoMatchEnrichedVO> deduped = new LinkedHashMap<>();
+        for (VideoMatchEnrichedVO item : items) {
+            if (item == null || item.getModality() == null) continue;
+            String key = keyOf(item);
+            VideoMatchEnrichedVO existing = deduped.get(key);
+            if (existing == null || (item.getScore() != null &&
+                    (existing.getScore() == null || item.getScore() > existing.getScore()))) {
+                deduped.put(key, item);
+            }
+        }
+        return new ArrayList<>(deduped.values());
+    }
+
+    private String keyOf(VideoMatchEnrichedVO item) {
+        String idPart = item.getMaterialId() != null ? "mat:" + item.getMaterialId()
+                : item.getArticleId() != null ? "art:" + item.getArticleId()
+                : "vid:" + item.getId();
+        return item.getModality() + ":" + idPart;
+    }
+
+    /**
+     * 从 vectorStore(video_vectors)搜索结果 enrich,对齐 VideoSearchServiceImpl 的 enrichVideoDetail + enrichDeconstruct
+     */
+    private List<VideoMatchEnrichedVO> enrichVideoMatchesFromVectorStore(List<VideoMatch> matches, String configCode) {
+        if (CollectionUtils.isEmpty(matches)) return Collections.emptyList();
+
+        List<Long> videoIdList = matches.stream()
+                .map(VideoMatch::getVideoId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (videoIdList.isEmpty()) return Collections.emptyList();
+
+        // 1. 视频基础信息(标题、封面、视频地址),按 100 条分批避免 API 超时
+        Map<Long, VideoDetail> videoDetails = new HashMap<>();
+        for (List<Long> batch : Lists.partition(videoIdList, 100)) {
+            Map<Long, VideoDetail> batchResult = videoApiService.getVideoDetail(new HashSet<>(batch));
+            if (batchResult != null) {
+                videoDetails.putAll(batchResult);
+            }
+        }
+
+        // 2. 批量读取视频运营指标: video:detail:{days}d:{videoId}
+        Map<Long, Map<String, Object>> metricsCache = new HashMap<>();
+        try {
+            List<String> metricsKeys = videoIdList.stream()
+                    .map(id -> VectorConstants.VIDEO_DETAIL_DAYS_KEY_PREFIX + metricsDays + "d:" + id)
+                    .collect(Collectors.toList());
+            List<String> metricsValues = redisUtils.mGet(metricsKeys);
+            if (metricsValues != null) {
+                for (int i = 0; i < videoIdList.size() && i < metricsValues.size(); i++) {
+                    String json = metricsValues.get(i);
+                    if (StringUtils.hasText(json)) {
+                        try {
+                            Map<String, Object> detail = JSONObject.parseObject(json, Map.class);
+                            if (detail != null && !detail.isEmpty()) {
+                                metricsCache.put(videoIdList.get(i), detail);
+                            }
+                        } catch (Exception e) {
+                            log.debug("解析视频指标失败 videoId={}", videoIdList.get(i));
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.error("批量读取视频指标失败: {}", e.getMessage());
+        }
+
+        // 3. 批量读取解构缓存: recall:vid_decode:{videoId}
+        Map<Long, Map<String, Object>> deconstructCache = new HashMap<>();
+        try {
+            List<String> decodeKeys = videoIdList.stream()
+                    .map(id -> REDIS_KEY_DECODE_PREFIX + id)
+                    .collect(Collectors.toList());
+            List<String> decodeValues = redisUtils.mGet(decodeKeys);
+            if (decodeValues != null) {
+                for (int i = 0; i < videoIdList.size() && i < decodeValues.size(); i++) {
+                    String json = decodeValues.get(i);
+                    if (StringUtils.hasText(json)) {
+                        JSONObject obj = JSON.parseObject(json);
+                        Map<String, Object> flat = obj != null ? buildFlatDeconstruct(obj) : null;
+                        if (flat != null && !flat.isEmpty()) {
+                            deconstructCache.put(videoIdList.get(i), flat);
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.error("批量读取解构缓存失败: {}", e.getMessage());
+        }
+
+        // 4. 组装 VideoMatchEnrichedVO
+        List<VideoMatchEnrichedVO> items = new ArrayList<>(matches.size());
+        for (VideoMatch m : matches) {
+            if (m == null || m.getVideoId() == null) continue;
+            VideoMatchEnrichedVO vo = new VideoMatchEnrichedVO();
+            vo.setId(m.getVideoId());
+            vo.setModality(Modality.VIDEO);
+            vo.setConfigCode(configCode);
+            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());
+            }
+
+            // videoDetail map: 合并运营指标 + 解构(对齐 VideoSearchServiceImpl 输出格式)
+            Map<String, Object> detailMap = new LinkedHashMap<>();
+            Map<String, Object> metrics = metricsCache.get(m.getVideoId());
+            if (metrics != null) {
+                detailMap.putAll(metrics);
+            }
+            Map<String, Object> deconstruct = deconstructCache.get(m.getVideoId());
+            if (deconstruct != null) {
+                detailMap.put("deconstruct", deconstruct);
+            }
+            vo.setVideoDetail(detailMap.isEmpty() ? null : detailMap);
+
+            applyCompatibilityFields(vo);
+            items.add(vo);
+        }
+        return items;
+    }
+
+    /**
+     * 构建输入素材自身的 enriched 条目(score=1.0)
+     */
+    private VideoMatchEnrichedVO enrichSelfMaterial(String materialId, String configCode) {
+        Map<String, MaterialDeconstructResult> rows = loadMaterialDeconstructRows(Collections.singletonList(materialId));
+        MaterialDeconstructResult row = rows.get(materialId);
+
+        JSONObject raw = row != null ? parseResultJson(row) : null;
+        MaterialBasicMeta basic = raw != null ? extractMaterialBasicMeta(raw) : null;
+        Map<String, Object> deconstructFlat = raw != null ? buildDeconstructFromRaw(raw) : null;
+
+        VideoMatchEnrichedVO vo = new VideoMatchEnrichedVO();
+        vo.setMaterialId(materialId);
+        try {
+            vo.setId(Long.parseLong(materialId));
+        } catch (NumberFormatException ignored) {
+        }
+        vo.setModality(Modality.MATERIAL);
+        vo.setConfigCode(StringUtils.hasText(configCode) ? configCode : null);
+        vo.setScore(1.0);
+
+        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 = row != null ? row.getSourceType() : null;
+        detail.setSource(mapSourceTypeToLabel(sourceType));
+        detail.setDeconstruct(deconstructFlat);
+        vo.setMaterialDetail(detail);
+
+        applyCompatibilityFields(vo);
+        return vo;
+    }
+
+    // ====================================================================
+    // materialDetail — 素材详情预览
+    // ====================================================================
+
+    @Override
+    public MaterialBasicVO getMaterialDetail(String materialId) {
+        if (!StringUtils.hasText(materialId)) {
+            return null;
+        }
+        String trimmed = materialId.trim();
+        Map<String, MaterialDeconstructResult> rows = loadMaterialDeconstructRows(Collections.singletonList(trimmed));
+        MaterialDeconstructResult row = rows.get(trimmed);
+        if (row == null) {
+            log.info("getMaterialDetail: materialId={} 不存在", trimmed);
+            return null;
+        }
+
+        JSONObject raw = parseResultJson(row);
+        MaterialBasicMeta basic = raw != null ? extractMaterialBasicMeta(raw) : null;
+
+        MaterialBasicVO vo = new MaterialBasicVO();
+        vo.setMaterialId(trimmed);
+        if (basic != null) {
+            vo.setTitle(basic.title);
+        }
+        // 取第一张图片作为预览
+        List<String> images = basic != null && basic.imagesJson != null
+                ? parseImages(basic.imagesJson) : Collections.emptyList();
+        vo.setImageUrl(!images.isEmpty() ? images.get(0) : null);
+
+        return vo;
+    }
+
     @Override
     public DeconstructPointsVO getDeconstructPoints(Long videoId) {
         if (videoId == null || videoId <= 0L) {

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

@@ -150,4 +150,30 @@
           AND point_index >= #{minPointIndex}
     </delete>
 
+    <!-- 查询指定素材已向量化的所有 configCode -->
+    <select id="selectDistinctConfigCodesByMaterialId" resultType="java.lang.String">
+        SELECT DISTINCT config_code
+        FROM material_vectors
+        WHERE material_id = #{materialId}
+    </select>
+
+    <!-- 查询指定素材在指定 configCode 下的所有向量(embedding::text 保证精度) -->
+    <select id="selectVectorsByMaterialIdAndConfigCode" 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 material_id = #{materialId}
+          AND config_code = #{configCode}
+        ORDER BY point_index ASC
+    </select>
+
 </mapper>

+ 20 - 0
server/src/main/java/com/tzld/videoVector/controller/VectorRecallTestController.java

@@ -1,10 +1,12 @@
 package com.tzld.videoVector.controller;
 
 import com.tzld.videoVector.common.base.CommonResponse;
+import com.tzld.videoVector.model.param.recall.MatchByMaterialIdParam;
 import com.tzld.videoVector.model.param.recall.MatchByTextParam;
 import com.tzld.videoVector.model.param.recall.MatchByVideoIdParam;
 import com.tzld.videoVector.model.vo.recall.AIUnderstandingVO;
 import com.tzld.videoVector.model.vo.recall.DeconstructPointsVO;
+import com.tzld.videoVector.model.vo.recall.MaterialBasicVO;
 import com.tzld.videoVector.model.vo.recall.RecallResultVO;
 import com.tzld.videoVector.model.vo.recall.VideoBasicVO;
 import com.tzld.videoVector.service.recall.VectorRecallTestService;
@@ -78,4 +80,22 @@ public class VectorRecallTestController {
     public CommonResponse<AIUnderstandingVO> aiUnderstanding(@RequestParam("videoId") Long videoId) {
         return CommonResponse.success(vectorRecallTestService.getAiUnderstanding(videoId));
     }
+
+    /**
+     * 素材ID召回 (素材相似度召回 Tab)
+     * POST /videoVector/recallTest/matchByMaterialId
+     */
+    @PostMapping("/matchByMaterialId")
+    public CommonResponse<RecallResultVO> matchByMaterialId(@RequestBody MatchByMaterialIdParam param) {
+        return CommonResponse.success(vectorRecallTestService.matchByMaterialId(param));
+    }
+
+    /**
+     * 素材详情预览 (素材相似度召回 Tab — 输入防抖预览)
+     * GET /videoVector/recallTest/materialDetail?materialId=xxx
+     */
+    @GetMapping("/materialDetail")
+    public CommonResponse<MaterialBasicVO> materialDetail(@RequestParam("materialId") String materialId) {
+        return CommonResponse.success(vectorRecallTestService.getMaterialDetail(materialId));
+    }
 }