Browse Source

code-review-by-cursor

luojunhui 1 week ago
parent
commit
1d096058b8

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

@@ -1,6 +1,7 @@
 package com.tzld.videoVector.service;
 
 import com.tzld.videoVector.model.entity.MaterialMatch;
+import com.tzld.videoVector.model.po.pgVector.MaterialVector;
 
 import java.util.Collection;
 import java.util.List;
@@ -63,5 +64,5 @@ public interface MaterialVectorStoreService {
     List<String> getDistinctConfigCodes(String materialId);
 
     /** 查询指定素材在指定 configCode 下的所有向量行(raw embedding),用于获取素材自身向量进行跨模态召回 */
-    List<com.tzld.videoVector.model.po.pgVector.MaterialVector> getVectorsByMaterialId(String materialId, String configCode);
+    List<MaterialVector> getVectorsByMaterialId(String materialId, String configCode);
 }

+ 5 - 0
core/src/main/java/com/tzld/videoVector/service/VectorStoreService.java

@@ -178,4 +178,9 @@ public interface VectorStoreService {
      */
     List<VideoMatch> searchTopN(String configCode, List<Float> queryVector, int topN);
 
+    /**
+     * 用原始 embedding 字符串搜索(绕过 Java Float 回环精度损失)
+     */
+    List<VideoMatch> searchTopNByRawVector(String configCode, String rawVector, int topN);
+
 }

+ 23 - 4
core/src/main/java/com/tzld/videoVector/service/impl/PgVectorStoreServiceImpl.java

@@ -264,7 +264,29 @@ public class PgVectorStoreServiceImpl implements VectorStoreService {
             return Collections.emptyList();
         }
 
-        List<VideoMatch> matches = results.stream()
+        return convertSearchResults(results);
+    }
+
+    @Override
+    public List<VideoMatch> searchTopNByRawVector(String configCode, String rawVector, int topN) {
+        if (rawVector == null || rawVector.isEmpty() || topN <= 0) {
+            return Collections.emptyList();
+        }
+        if (configCode == null || configCode.isEmpty()) {
+            configCode = DEFAULT_CONFIG_CODE;
+        }
+        log.info("searchTopNByRawVector raw前100字符: {}, topN={}, configCode={}",
+                rawVector.substring(0, Math.min(100, rawVector.length())), topN, configCode);
+        List<VideoVectorSearchResult> results = videoVectorMapperExt.searchTopNByCosine(configCode, rawVector, topN);
+        if (results == null || results.isEmpty()) {
+            log.info("searchTopNByRawVector 无匹配结果,configCode={}", configCode);
+            return Collections.emptyList();
+        }
+        return convertSearchResults(results);
+    }
+
+    private List<VideoMatch> convertSearchResults(List<VideoVectorSearchResult> results) {
+        return results.stream()
                 .map(vv -> {
                     VideoMatch m = new VideoMatch(vv.getVideoId(), vv.getScore() != null ? vv.getScore() : 0.0);
                     m.setPointIndex(vv.getPointIndex());
@@ -272,9 +294,6 @@ public class PgVectorStoreServiceImpl implements VectorStoreService {
                     return m;
                 })
                 .collect(Collectors.toList());
-
-//        log.info("pgvector搜索完成,configCode={},返回 {} 条结果", configCode, matches.size());
-        return matches;
     }
 
     // ---------------------------------------------------------------- 工具方法

+ 13 - 0
core/src/main/java/com/tzld/videoVector/service/impl/RedisVectorStoreServiceImpl.java

@@ -9,6 +9,7 @@ import com.tzld.videoVector.model.entity.VideoMatch;
 import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfig;
 import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfigExample;
 import com.tzld.videoVector.service.VectorStoreService;
+import com.tzld.videoVector.util.VectorUtils;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.core.RedisCallback;
@@ -367,6 +368,18 @@ public class RedisVectorStoreServiceImpl implements VectorStoreService {
         return topMatches;
     }
 
+    @Override
+    public List<VideoMatch> searchTopNByRawVector(String configCode, String rawVector, int topN) {
+        if (rawVector == null || rawVector.isEmpty() || topN <= 0) {
+            return Collections.emptyList();
+        }
+        List<Float> queryVector = VectorUtils.parseVectorString(rawVector);
+        if (queryVector == null || queryVector.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return searchTopN(configCode, queryVector, topN);
+    }
+
     /**
      * 并行搜索 TopN(使用堆排序优化)
      */

+ 186 - 135
core/src/main/java/com/tzld/videoVector/service/recall/impl/VectorRecallTestServiceImpl.java

@@ -40,7 +40,6 @@ 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;
@@ -54,11 +53,9 @@ 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;
@@ -486,18 +483,8 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
             }
 
             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);
+            fillMaterialDetailVO(detail, basic, row, deconstructFlat, m.getSourceType());
+            fillMaterialDetailImageCount(detail, vo.getImageList());
             vo.setMaterialDetail(detail);
 
             applyCompatibilityFields(vo);
@@ -849,12 +836,82 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         if (images != null && !images.isEmpty()) {
             meta.imagesJson = images.toJSONString();
         }
+        meta.uploadTime = firstNonBlankString(
+                nestedString(raw, "target_post", "uploadTime"),
+                nestedString(raw, "target_post", "createTime"),
+                raw.getString("uploadTime"),
+                raw.getString("createTime"),
+                raw.getString("采集时间")
+        );
+        meta.usageCount = firstNonBlankString(
+                nestedString(raw, "target_post", "usageCount"),
+                raw.getString("usageCount"),
+                raw.getString("使用次数")
+        );
+        meta.tags = extractStringList(raw, "target_post", "tags");
+        if (meta.tags == null) {
+            meta.tags = extractStringList(raw, null, "tags");
+        }
+        if (meta.tags == null) {
+            String tagsStr = firstNonBlankString(raw.getString("标签"), nestedString(raw, "target_post", "label"));
+            if (StringUtils.hasText(tagsStr)) {
+                meta.tags = java.util.Arrays.stream(tagsStr.split("[,,]"))
+                        .map(String::trim)
+                        .filter(StringUtils::hasText)
+                        .collect(Collectors.toList());
+            }
+        }
         if (!StringUtils.hasText(meta.title) && !StringUtils.hasText(meta.imagesJson)) {
             return null;
         }
         return meta;
     }
 
+    private List<String> extractStringList(JSONObject raw, String objKey, String fieldKey) {
+        JSONArray arr = objKey != null ? nestedArray(raw, objKey, fieldKey) : raw.getJSONArray(fieldKey);
+        if (arr == null || arr.isEmpty()) {
+            return null;
+        }
+        List<String> list = new ArrayList<>(arr.size());
+        for (int i = 0; i < arr.size(); i++) {
+            Object item = arr.get(i);
+            if (item == null) {
+                continue;
+            }
+            String s = String.valueOf(item).trim();
+            if (StringUtils.hasText(s)) {
+                list.add(s);
+            }
+        }
+        return list.isEmpty() ? null : list;
+    }
+
+    private void fillMaterialDetailVO(MaterialDetailVO detail, MaterialBasicMeta basic,
+                                      MaterialDeconstructResult row, Map<String, Object> deconstructFlat,
+                                      Short sourceTypeOverride) {
+        if (detail == null) {
+            return;
+        }
+        if (basic != null) {
+            detail.setTitle(basic.title);
+            detail.setUploadTime(basic.uploadTime);
+            detail.setUsageCount(basic.usageCount);
+            detail.setTags(basic.tags);
+        }
+        Short sourceType = sourceTypeOverride;
+        if (sourceType == null && row != null) {
+            sourceType = row.getSourceType();
+        }
+        detail.setSource(mapSourceTypeToLabel(sourceType));
+        detail.setDeconstruct(deconstructFlat);
+    }
+
+    private void fillMaterialDetailImageCount(MaterialDetailVO detail, List<String> imageList) {
+        if (detail != null && imageList != null) {
+            detail.setImageCount(imageList.size());
+        }
+    }
+
     /**
      * cover 取自 imagesJson  JSON 数组的第一张图
      */
@@ -1058,7 +1115,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
 
         List<String> configCodes;
         if (StringUtils.hasText(param.getConfigCode())) {
-            configCodes = Collections.singletonList(param.getConfigCode());
+            configCodes = Collections.singletonList(param.getConfigCode().trim());
         } else {
             configCodes = materialVectorStoreService.getDistinctConfigCodes(materialId);
             if (configCodes.isEmpty()) {
@@ -1068,47 +1125,52 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         }
         log.info("matchByMaterialId: materialId={}, topN={}, configCodes={}", materialId, topN, configCodes);
 
+        // 收集所有有效向量点(支持多点模式)
+        List<MaterialVectorQuery> vectorQueries = new ArrayList<>();
+        for (String configCode : configCodes) {
+            List<MaterialVector> vectors = materialVectorStoreService.getVectorsByMaterialId(materialId, configCode);
+            for (MaterialVector vector : vectors) {
+                if (vector != null && StringUtils.hasText(vector.getEmbedding())) {
+                    vectorQueries.add(new MaterialVectorQuery(configCode, vector.getEmbedding()));
+                }
+            }
+        }
+        if (vectorQueries.isEmpty()) {
+            log.info("matchByMaterialId: materialId={} 无有效向量 embedding", materialId);
+            return empty;
+        }
+
         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;
+        for (MaterialVectorQuery query : vectorQueries) {
+            final String cc = query.configCode;
+            final String rawEmbedding = query.rawEmbedding;
             final int ctn = candidateTopN;
             final int tn = topN;
 
-            // 视频召回:同维度搜索,不跨 configCode
             allFutures.add(CompletableFuture.runAsync(() -> {
                 try {
-                    List<VideoMatch> matches = vectorStoreService.searchTopN(cc, qv, ctn);
+                    List<VideoMatch> matches = vectorStoreService.searchTopNByRawVector(cc, rawEmbedding, 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));
+                        List<VideoMatchResult> videoResults = toVideoMatchResults(deduped, cc);
+                        populateVideoMatchResultDetails(videoResults);
+                        allResults.addAll(enrichVideoMatches(videoResults, cc));
                     }
                 } catch (Exception e) {
-                    log.error("matchByMaterialId 视频搜索失败 configCode={}: {}", cc, e.getMessage());
+                    log.error("matchByMaterialId 视频搜索失败 configCode={}: {}", cc, e.getMessage(), e);
                 }
             }, RECALL_EXECUTOR));
 
-            // 素材召回(排除自身)
             allFutures.add(CompletableFuture.runAsync(() -> {
                 try {
-                    List<MaterialMatch> matches = materialVectorStoreService.searchTopN(cc, qv, ctn);
+                    List<MaterialMatch> matches = materialVectorStoreService.searchTopNByRawVector(cc, rawEmbedding, ctn);
                     matches = matches.stream()
                             .filter(m -> !materialId.equals(m.getMaterialId()))
                             .collect(Collectors.toList());
@@ -1117,37 +1179,33 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                         allResults.addAll(enrichMaterialMatches(deduped, cc));
                     }
                 } catch (Exception e) {
-                    log.error("matchByMaterialId 素材搜索失败 configCode={}: {}", cc, e.getMessage());
+                    log.error("matchByMaterialId 素材搜索失败 configCode={}: {}", cc, e.getMessage(), e);
                 }
             }, RECALL_EXECUTOR));
 
-            // 文章召回
             allFutures.add(CompletableFuture.runAsync(() -> {
                 try {
-                    List<ArticleMatch> matches = articleVectorStoreService.searchTopN(cc, qv, ctn);
+                    List<ArticleMatch> matches = articleVectorStoreService.searchTopNByRawVector(cc, rawEmbedding, 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());
+                    log.error("matchByMaterialId 文章搜索失败 configCode={}: {}", cc, e.getMessage(), e);
                 }
             }, RECALL_EXECUTOR));
         }
 
-        // 等待并行搜索完成
         for (CompletableFuture<Void> future : allFutures) {
             try {
                 future.get(30, TimeUnit.SECONDS);
             } catch (Exception e) {
-                log.error("matchByMaterialId 并行搜索等待异常: {}", e.getMessage());
+                log.error("matchByMaterialId 并行搜索等待异常: {}", e.getMessage(), e);
             }
         }
 
-        // 跨 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,
@@ -1170,11 +1228,10 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         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);
-        }
+        String selfConfigCode = StringUtils.hasText(param.getConfigCode())
+                ? param.getConfigCode().trim()
+                : vectorQueries.get(0).configCode;
+        ensureSelfMaterialInResults(materialItems, materialId, selfConfigCode, topN);
 
         RecallResultVO result = buildResult(videoItems, materialItems, articleItems);
         log.info("matchByMaterialId 完成: total={}, video={}, material={}, article={}",
@@ -1223,110 +1280,97 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         return item.getModality() + ":" + idPart;
     }
 
+    private List<VideoMatchResult> toVideoMatchResults(List<VideoMatch> matches, String configCode) {
+        if (CollectionUtils.isEmpty(matches)) {
+            return Collections.emptyList();
+        }
+        List<VideoMatchResult> results = new ArrayList<>(matches.size());
+        for (VideoMatch match : matches) {
+            if (match == null || match.getVideoId() == null) {
+                continue;
+            }
+            results.add(new VideoMatchResult(configCode, match.getVideoId(), match.getScore(), match.getText()));
+        }
+        return results;
+    }
+
     /**
-     * 从 vectorStore(video_vectors)搜索结果 enrich,对齐 VideoSearchServiceImpl 的 enrichVideoDetail + enrichDeconstruct
+     * 填充 VideoMatchResult.videoDetail(运营指标 + 解构),对齐 VideoSearchServiceImpl。
      */
-    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);
-            }
+    private void populateVideoMatchResultDetails(List<VideoMatchResult> results) {
+        if (CollectionUtils.isEmpty(results)) {
+            return;
         }
 
-        // 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)
+            List<String> metricsKeys = results.stream()
+                    .map(r -> VectorConstants.VIDEO_DETAIL_DAYS_KEY_PREFIX + metricsDays + "d:" + r.getVideoId())
                     .collect(Collectors.toList());
             List<String> metricsValues = redisUtils.mGet(metricsKeys);
             if (metricsValues != null) {
-                for (int i = 0; i < videoIdList.size() && i < metricsValues.size(); i++) {
+                for (int i = 0; i < results.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));
+                    if (!StringUtils.hasText(json)) {
+                        continue;
+                    }
+                    try {
+                        Map<String, Object> detail = JSONObject.parseObject(json, Map.class);
+                        if (detail != null && !detail.isEmpty()) {
+                            results.get(i).setVideoDetail(detail);
                         }
+                    } catch (Exception e) {
+                        log.debug("解析视频指标失败 videoId={}", results.get(i).getVideoId());
                     }
                 }
             }
         } catch (Exception e) {
-            log.error("批量读取视频指标失败: {}", e.getMessage());
+            log.error("批量读取视频指标失败: {}", e.getMessage(), e);
         }
 
-        // 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)
+            List<String> decodeKeys = results.stream()
+                    .map(r -> VectorConstants.VID_DECODE_KEY_PREFIX + r.getVideoId())
                     .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);
-                        }
+            if (decodeValues == null) {
+                return;
+            }
+            for (int i = 0; i < results.size(); i++) {
+                String json = i < decodeValues.size() ? decodeValues.get(i) : null;
+                if (!StringUtils.hasText(json)) {
+                    continue;
+                }
+                try {
+                    Map<String, Object> deconstructFlat = buildFlatDeconstruct(JSON.parseObject(json));
+                    if (deconstructFlat == null || deconstructFlat.isEmpty()) {
+                        continue;
                     }
+                    VideoMatchResult result = results.get(i);
+                    Map<String, Object> detailMap = result.getVideoDetail();
+                    if (detailMap == null) {
+                        detailMap = new LinkedHashMap<>();
+                        result.setVideoDetail(detailMap);
+                    }
+                    detailMap.put("deconstruct", deconstructFlat);
+                } catch (Exception e) {
+                    log.debug("解析视频解构失败 videoId={}: {}", results.get(i).getVideoId(), e.getMessage());
                 }
             }
         } catch (Exception e) {
-            log.error("批量读取解构缓存失败: {}", e.getMessage());
+            log.error("批量读取解构缓存失败: {}", e.getMessage(), e);
         }
+    }
 
-        // 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);
+    /**
+     * 保证输入素材自身出现在 material 结果中(score=1.0),且 material 列表不超过 topN。
+     */
+    private void ensureSelfMaterialInResults(List<VideoMatchEnrichedVO> materialItems,
+                                             String materialId, String configCode, int topN) {
+        materialItems.removeIf(it -> materialId.equals(it.getMaterialId()));
+        materialItems.add(0, enrichSelfMaterial(materialId, configCode));
+        while (materialItems.size() > topN) {
+            materialItems.remove(materialItems.size() - 1);
         }
-        return items;
     }
 
     /**
@@ -1356,15 +1400,8 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         }
 
         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);
+        fillMaterialDetailVO(detail, basic, row, deconstructFlat, row != null ? row.getSourceType() : null);
+        fillMaterialDetailImageCount(detail, vo.getImageList());
         vo.setMaterialDetail(detail);
 
         applyCompatibilityFields(vo);
@@ -1539,6 +1576,19 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
     private static class MaterialBasicMeta {
         String title;
         String imagesJson;
+        String uploadTime;
+        String usageCount;
+        List<String> tags;
+    }
+
+    private static class MaterialVectorQuery {
+        final String configCode;
+        final String rawEmbedding;
+
+        MaterialVectorQuery(String configCode, String rawEmbedding) {
+            this.configCode = configCode;
+            this.rawEmbedding = rawEmbedding;
+        }
     }
 
     private static class ArticleBasicMeta {
@@ -1551,3 +1601,4 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         List<String> images;
     }
 }
+