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

什么是向量 (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维),这就是视频内容向量。

  • 存储到 Elasticsearch:   (留意下字段类型是dense_vector)

    • 每个用户的文档里,除了存 userIdusername,还会存一个 interestVector 字段,类型是 dense_vector,值就是上面算出来的向量。

    • 每个视频的文档里,除了存 videoIdtitlecategory,还会存一个 contentVector 字段,类型也是 dense_vector,值是视频的向量。

  • 执行推荐 (向量搜索):

        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 最接近的视频文档。 

         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);
        }
    }

    关注我,送Java福利

    /**
     * 这段代码只有Java开发者才能看得懂!
     * 关注我微信公众号之后,
     * 发送:"666",
     * 即可获得一本由Java大神一手面试经验诚意出品
     * 《Java开发者面试百宝书》Pdf电子书
     * 福利截止日期为2025年02月28日止
     * 手快有手慢没!!!
    */
    System.out.println("请关注我的微信公众号:");
    System.out.println("Java知识日历");
Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐