SpringBoot与Vector Search整合,实现短视频内容实时推荐系统

whdahanh 发布于 2025-09-29 159 次阅读


相比传统的基于关键词匹配的搜索,向量搜索能捕捉到更深层次的语义相似性。即使两个视频标题没有共同词汇,但如果内容相似,它们的向量也会接近,就能被推荐出来。

什么是向量 (Vector)?

想象一下,你有一段文字,比如一句话:“我喜欢在阳光明媚的日子里去公园散步”。

  • 传统方式: 计算机可能把它看作一个由字符组成的字符串。
  • 向量化: 通过某种算法(比如 Word2Vec, BERT 等),我们可以把这个句子转换成一组数字,比如 [0.2, -0.5, 0.8, 0.1, ...]。这组数字组成的列表就是一个向量。这个向量的每个维度(0.2, -0.5...)都编码了这句话的某些语义信息。

那么,为什么需要向量呢?

因为计算机很擅长处理数字,但不太容易直接理解文字、图片、声音的“含义”。向量提供了一种方式,把复杂的非结构化数据(如文本、图像)映射到一个高维的数字空间(向量空间)里。在这个空间里,相似的东西(比如意思相近的句子,或者视觉上相似的图片)它们的向量就会比较接近(在空间里的距离比较近),不相似的东西向量就会比较远。

实现步骤

  1. 数据准备:
    • 用户画像向量化: 系统收集用户的行为数据(看了哪些视频、点赞了哪些、停留时间等),然后用机器学习模型把这些行为抽象成一个数字列表,比如 [0.7, -0.2, 0.5, ...](128维),这就是用户兴趣向量。
    • 视频内容向量化: 系统分析视频的元数据、视觉特征、音频特征等,也用模型将其转换成一个数字列表,比如 [0.6, -0.1, 0.4, ...](128维),这就是视频内容向量。
  2. 存储到 Elasticsearch:   (留意下字段类型是dense_vector)
  3. 每个用户的文档里,除了存 userIdusername,还会存一个 interestVector 字段,类型是 dense_vector,值就是上面算出来的向量。
  4. 每个视频的文档里,除了存 videoIdtitlecategory,还会存一个 contentVector 字段,类型也是 dense_vector,值是视频的向量。
  5. 执行推荐 (向量搜索):    3.1 当用户 A 请求推荐时,系统获取到 A 的 interestVector(查询向量)。     3.2 系统向 Elasticsearch 发起一个 knn 搜索请求:          3.2.1 在 videos 索引里搜索。         3.2.2 使用字段 contentVector 进行 kNN 搜索。          3.2.3 查询向量是用户 A 的 interestVector。          3.2.4 要求返回最相似的 k 个视频 (k=10)。     3.3   Elasticsearch 内部利用优化算法,快速计算并找出那些 contentVector 和用户 A 的 interestVector 最接近的视频文档。 https://wxa.wxs.qq.com/tmpl/oc/base_tmpl.html     3.4   Elasticsearch 返回这些最相似的视频文档给你的用户,完成推荐。

代码实操

        <!-- Spring Boot Data Elasticsearch 模块,提供与 ES 的集成 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>

        <!-- Elasticsearch Java API Client,用于执行更复杂的原生 ES 查询 -->
        <dependency>
            <groupId>co.elastic.clients</groupId>
            <artifactId>elasticsearch-java</artifactId>
            <version>8.13.2</version> <!-- 版本需与你的 Elasticsearch 服务版本匹配 -->
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

application.properties

# 配置 Elasticsearch 连接信息
elasticsearch.host=localhost
elasticsearch.port=9200

# 开启 Elasticsearch 客户端的网络层日志,用于调试
# logging.level.org.springframework.data.elasticsearch.client.WIRE=trace

Elasticsearch客户端配置类

package com.example.config;

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.elasticsearch.client.ClientConfiguration;
import org.springframework.data.elasticsearch.client.elc.ElasticsearchConfiguration;
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;

@Configuration
@EnableElasticsearchRepositories(basePackages = "com.example.repository")
publicclass ElasticsearchConfig extends ElasticsearchConfiguration {

    @Value("${elasticsearch.host}")
    private String host;

    @Value("${elasticsearch.port}")
    privateint port;

    /**
     * 配置 Spring Data Elasticsearch 使用的客户端连接。
     * 这是 Spring Data Elasticsearch 推荐的方式。
     * @return ClientConfiguration 对象
     */

    @Override
    public ClientConfiguration clientConfiguration() {
        return ClientConfiguration.builder()
                .connectedTo(host + ":" + port)
                .build();
    }

    /**
     * 配置 Elasticsearch Java API Client (高级客户端)。
     * 当需要执行 Spring Data Elasticsearch 不直接支持的复杂查询(如 kNN)时使用。
     * @return ElasticsearchClient 实例
     */

    @Bean
    public ElasticsearchClient elasticsearchClient() {
        // 1. 创建低级 REST 客户端
        RestClient restClient = RestClient.builder(
                new HttpHost(host, port)).build();

        // 2. 使用 Jackson 作为 JSON 映射器创建传输层
        ElasticsearchTransport transport = new RestClientTransport(
                restClient, new JacksonJsonpMapper());

        // 3. 创建并返回高级客户端
        returnnew ElasticsearchClient(transport);
    }
}

用户实体类

package com.example.model;

import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document; 
import org.springframework.data.elasticsearch.annotations.Field; 
import org.springframework.data.elasticsearch.annotations.FieldType; 

import java.util.List;

@Document(indexName = "users")
publicclass User {

    @Id
    private String id;

    @Field(type = FieldType.Text) // 映射为 ES Text 类型,适合全文搜索
    private String username;

    /**
     * 用户兴趣向量字段。
     * 关键注解:
     * - type = FieldType.Dense_Vector: 指定为密集向量类型,ES 用于向量搜索。
     * - dims = 128: 指定向量的维度。必须与实际生成的向量维度一致。
     * 这个字段将用于存储用户兴趣的数值化表示。
     */

    @Field(type = FieldType.Dense_Vector, dims = 128)
    private List<Float> interestVector;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public List<Float> getInterestVector() {
        return interestVector;
    }

    public void setInterestVector(List<Float> interestVector) {
        this.interestVector = interestVector;
    }

    @Override
    public String toString() {
        return"User{" +
                "id='" + id + '\'' +
                ", username='" + username + '\'' +
                ", interestVector=" + interestVector +
                '}';
    }
}

视频实体类

package com.example.model;

import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

import java.util.List;

@Document(indexName = "videos")
publicclass Video {

    @Id
    private String id;

    @Field(type = FieldType.Text) // 视频标题
    private String title;

    @Field(type = FieldType.Keyword) // 视频分类,Keyword 类型适合精确匹配和聚合
    private String category;

    /**
     * 视频内容特征向量字段。
     * 同样使用 Dense_Vector 类型。
     * 重要:维度必须与 User 的 interestVector 维度相同,才能进行向量相似度计算。
     */

    @Field(type = FieldType.Dense_Vector, dims = 128)
    private List<Float> contentVector;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getCategory() {
        return category;
    }

    public void setCategory(String category) {
        this.category = category;
    }

    public List<Float> getContentVector() {
        return contentVector;
    }

    public void setContentVector(List<Float> contentVector) {
        this.contentVector = contentVector;
    }

    @Override
    public String toString() {
        return"Video{" +
                "id='" + id + '\'' +
                ", title='" + title + '\'' +
                ", category='" + category + '\'' +
                ", contentVector=" + contentVector +
                '}';
    }
}

向量服务类

package com.example.service;

import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
import java.util.stream.DoubleStream;

@Service
publicclass VectorService {

    // 定义向量维度,必须与模型输出和 ES 字段定义一致
    privatestaticfinalint VECTOR_DIMENSIONS = 128;
    // 用于生成模拟向量的随机数生成器
    privatefinal Random random = new Random();

    /**
     * 生成用户兴趣向量。
     * 在真实系统中,这个向量应该由机器学习模型根据用户的历史行为(观看、点赞、评论等)
     * 动态计算得出。这里使用随机数模拟。
     * @return 代表用户兴趣的向量 List<Float>
     */

    public List<Float> generateUserInterestVector() {
        // 生成 128 个 -1.0 到 1.0 之间的随机双精度数,并转换为 Float List
        return random.doubles(VECTOR_DIMENSIONS, -1.0, 1.0)
                     .boxed() // 将 double 转为 Double
                     .map(Double::floatValue) // 将 Double 转为 Float
                     .collect(Collectors.toList()); // 收集到 List
    }

    /**
     * 生成视频内容特征向量。
     * 在真实系统中,这个向量应由内容理解模型根据视频的元数据、视觉、音频特征等
     * 计算得出。这里同样使用随机数模拟。
     * @return 代表视频内容的向量 List<Float>
     */

    public List<Float> generateVideoContentVector() {
        return random.doubles(VECTOR_DIMENSIONS, -1.0, 1.0)
                     .boxed()
                     .map(Double::floatValue)
                     .collect(Collectors.toList());
    }
}

推荐服务类

package com.example.service;

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import com.example.model.Video;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.client.elc.NativeQuery;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
publicclass RecommendationService {

    // 注入 Elasticsearch Java API Client,用于执行 kNN 等高级查询
    @Autowired
    private ElasticsearchClient elasticsearchClient;

    // 注入 Spring Data Elasticsearch 操作模板,用于更便捷的数据操作和部分查询
    @Autowired
    private ElasticsearchOperations elasticsearchOperations;

    /**
     * 使用 Elasticsearch 的 k-Nearest Neighbor (kNN) 搜索功能
     * 来查找与用户兴趣向量最相似的视频。
     * 这是 Elasticsearch 8.x+ 推荐的高效向量相似度搜索方式。
     *
     * @param userInterestVector 用户当前的兴趣向量。
     * @param k                  需要返回的推荐视频数量。
     * @return 推荐的视频列表。
     */

    public List<Video> recommendVideosKnn(List<Float> userInterestVector, int k) {
        try {
            // 1. 构建 kNN 搜索请求
            SearchRequest searchRequest = SearchRequest.of(s -> s
                    .index("videos") // 指定要在哪个索引中搜索
                    .size(k) // 设置返回结果的数量
                    .knn(knn -> knn // 配置 kNN 查询参数
                            .field("contentVector") // 指定进行 kNN 搜索的向量字段(视频的特征向量)
                            .queryVector(userInterestVector) // 提供查询向量(用户的兴趣向量)
                            .k(k) // 指定要返回的最邻近向量数量
                            .numCandidates(k * 10) // 搜索的候选集大小,通常大于 k 以提高精度,但会影响性能
                    )
            );

            // 2. 执行搜索请求,并指定返回的文档类型为 Video.class
            SearchResponse<Video> response = elasticsearchClient.search(searchRequest, Video.class);

            // 3. 处理响应结果,提取 Video 对象
            return response.hits().hits().stream()
                    .map(Hit::source) // 获取每个命中文档的 source (即 Video 对象)
                    .filter(video -> video != null) // 过滤掉 null 值
                    .collect(Collectors.toList()); // 收集到 List

        } catch (IOException e) {
           // 在实际应用中,应使用日志框架(如 SLF4J)记录错误
           System.err.println("执行 kNN 搜索时发生错误: " + e.getMessage());
           e.printStackTrace();
           returnnew ArrayList<>(); // 发生错误时返回空列表
        }
    }

    /**
     * (备选方案)使用 script_score 查询结合余弦相似度脚本
     * 来计算向量相似度并进行排序。
     * 此方法更灵活,但通常比 kNN 搜索慢,尤其在大数据集上。
     *
     * @param userInterestVector 用户当前的兴趣向量。
     * @param k                  需要返回的推荐视频数量。
     * @return 推荐的视频列表。
     */

    public List<Video> recommendVideosScriptScore(List<Float> userInterestVector, int k) {
       try {
           // 1. 构建用于计算余弦相似度的 Painless 脚本
           // 'cosineSimilarity' 是 Elasticsearch 提供的内置函数
           // 'params.query_vector' 是我们传入的查询向量
           // 'contentVector' 是文档中的向量字段
           String scriptSource = "cosineSimilarity(params.query_vector, 'contentVector') + 1.0";
           // +1.0 是为了将范围从 [-1, 1] 转换为 [0, 2],确保分数为正

           // 2. 准备脚本参数
           Map<String, Object> scriptParams = new HashMap<>();
           scriptParams.put("query_vector", userInterestVector);

           // 3. 构建 script_score 查询
           co.elastic.clients.elasticsearch._types.query_dsl.Query query = co.elastic.clients.elasticsearch._types.query_dsl.Query.of(q -> q
                   .scriptScore(ss -> ss
                           .query(q2 -> q2.matchAll(m -> m)) // 对所有文档进行评分
                           .script(s -> s
                                   .inline(i -> i
                                           .source(scriptSource) // 内联脚本源码
                                           .params(scriptParams) // 脚本参数
                                   )
                           )
                   )
           );

           // 4. 使用 Spring Data Elasticsearch 的 NativeQuery 包装查询
           NativeQuery nativeQuery = NativeQuery.builder()
                   .withQuery(query) // 设置查询条件
                   .withPageable(org.springframework.data.domain.PageRequest.of(0, k)) // 设置分页
                   .build();

           // 5. 执行搜索
           SearchHits<Video> searchHits = elasticsearchOperations.search(nativeQuery, Video.class);

           // 6. 处理结果
           return searchHits.getSearchHits().stream()
                   .map(SearchHit::getContent) // 获取内容 (Video 对象)
                   .collect(Collectors.toList());

       } catch (Exception e) {
           System.err.println("执行 script_score 搜索时发生错误: " + e.getMessage());
           e.printStackTrace();
           returnnew ArrayList<>();
       }
    }
}

Recommendation Controller

package com.example.controller;

import com.example.model.Video;
import com.example.service.RecommendationService;
import com.example.service.VectorService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/recommendations")
publicclass RecommendationController {

    @Autowired
    private RecommendationService recommendationService; // 注入推荐服务

    @Autowired
    private VectorService vectorService; // 注入向量服务

    /**
     * GET /api/recommendations/user/{userId}
     * 为指定用户获取视频推荐。
     *
     * @param userId 用户ID,从路径变量获取。
     * @param k      推荐数量,从查询参数获取,默认为 5。
     * @return 推荐的视频列表。
     */

    @GetMapping("/user/{userId}")
    public List<Video> getRecommendationsForUser(
            @PathVariable String userId, // 路径变量 {userId}
            @RequestParam(defaultValue = "5") int k) { // 查询参数 ?k=...

        // --- 模拟获取用户向量 ---
        // 在真实应用中,这里应该是:
        // Optional<User> userOpt = userRepository.findById(userId);
        // if (userOpt.isPresent()) {
        //     List<Float> userVector = userOpt.get().getInterestVector();
        // } else { ... 处理用户未找到 ... }

        // --- 演示:为用户生成一个随机的兴趣向量 ---
        List<Float> userInterestVector = vectorService.generateUserInterestVector();
        System.out.println("为用户 " + userId + " 生成的兴趣向量 (前5维): " +
                userInterestVector.subList(0, Math.min(5, userInterestVector.size())) + "...");

        // 调用推荐服务的 kNN 方法获取推荐
        return recommendationService.recommendVideosKnn(userInterestVector, k);
    }

    /**
     * POST /api/recommendations/vector
     * 直接使用提供的向量获取推荐。适用于测试或客户端已生成向量的场景。
     *
     * @param vector 请求体中的向量数据。
     * @param k      推荐数量。
     * @return 推荐的视频列表。
     */

    @PostMapping("/vector")
    public List<Video> getRecommendationsByVector(
            @RequestBody List<Float> vector, // 从请求体读取向量
            @RequestParam(defaultValue = "5") int k) {

        // 直接使用传入的向量进行推荐
        return recommendationService.recommendVideosKnn(vector, k);
    }
}

提供用于填充测试数据的 API 接口

package com.example.controller;

import com.example.model.User;
import com.example.model.Video;
import com.example.repository.UserRepository;
import com.example.repository.VideoRepository;
import com.example.service.VectorService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.stream.IntStream;

@RestController
@RequestMapping("/api/data")
publicclass DataManagementController {

    @Autowired
    private UserRepository userRepository; // 用户数据访问

    @Autowired
    private VideoRepository videoRepository; // 视频数据访问

    @Autowired
    private VectorService vectorService; // 向量服务

    /**
     * POST /api/data/populate
     * 填充 Elasticsearch 索引的端点。
     * 创建 100 个用户和 1000 个视频,并为它们生成随机向量。
     * 主要用于测试和演示目的。
     */

    @PostMapping("/populate")
    public String populateData() {
        // 1. 清空现有数据(可选,为了演示干净)
        userRepository.deleteAll();
        videoRepository.deleteAll();

        // 2. 创建并保存 100 个用户
        IntStream.range(1, 101).forEach(i -> {
            User user = new User();
            user.setId("user_" + i); // 设置用户 ID
            user.setUsername("User" + i); // 设置用户名
            user.setInterestVector(vectorService.generateUserInterestVector()); // 设置兴趣向量
            userRepository.save(user); // 保存到 ES
        });

        // 3. 创建并保存 1000 个视频
        IntStream.range(1, 1001).forEach(i -> {
            Video video = new Video();
            video.setId("video_" + i); // 设置视频 ID
            video.setTitle("Video Title " + i); // 设置标题
            video.setCategory("Category " + (i % 10)); // 设置分类 (10个类别)
            video.setContentVector(vectorService.generateVideoContentVector()); // 设置内容向量
            videoRepository.save(video); // 保存到 ES
        });

        return"成功填充数据:创建了 100 个用户和 1000 个视频。";
    }

    /**
     * GET /api/data/users
     * 获取所有用户数据
     */

    @GetMapping("/users")
    public Iterable<User> getAllUsers() {
        return userRepository.findAll();
    }

    /**
     * GET /api/data/videos
     * 获取所有视频数据
     */

    @GetMapping("/videos")
    public Iterable<Video> getAllVideos() {
        return videoRepository.findAll();
    }
}

User Repository

package com.example.repository;

import com.example.model.User;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
public interface UserRepository extends ElasticsearchRepository<User, String> {

}

Video Repository

package com.example.repository;

import com.example.model.Video;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

public interface VideoRepository extends ElasticsearchRepository<Video, String> {

}

Application

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}


微信扫描下方的二维码阅读本文

此作者没有提供个人介绍
最后更新于 2025-09-29