SpringBoot与Vector Search整合,实现短视频内容实时推荐系统
向量提供了一种方式,把复杂的非结构化数据(如文本、图像)映射到一个高维的数字空间(向量空间)里。在这个空间里,相似的东西(比如意思相近的句子,或者视觉上相似的图片)它们的向量就会比较接近(在空间里的距离比较近),不相似的东西向量就会比较远。即使两个视频标题没有共同词汇,但如果内容相似,它们的向量也会接近,就能被推荐出来。3.2.4 要求返回最相似的。3.2.2 使用字段。
相比传统的基于关键词匹配的搜索,向量搜索能捕捉到更深层次的语义相似性。即使两个视频标题没有共同词汇,但如果内容相似,它们的向量也会接近,就能被推荐出来。
什么是向量 (Vector)?
想象一下,你有一段文字,比如一句话:“我喜欢在阳光明媚的日子里去公园散步”。
-
传统方式: 计算机可能把它看作一个由字符组成的字符串。
-
向量化: 通过某种算法(比如 Word2Vec, BERT 等),我们可以把这个句子转换成一组数字,比如
[0.2, -0.5, 0.8, 0.1, ...]。这组数字组成的列表就是一个向量。这个向量的每个维度(0.2, -0.5...)都编码了这句话的某些语义信息。
那么,为什么需要向量呢?
因为计算机很擅长处理数字,但不太容易直接理解文字、图片、声音的“含义”。向量提供了一种方式,把复杂的非结构化数据(如文本、图像)映射到一个高维的数字空间(向量空间)里。在这个空间里,相似的东西(比如意思相近的句子,或者视觉上相似的图片)它们的向量就会比较接近(在空间里的距离比较近),不相似的东西向量就会比较远。
实现步骤
-
数据准备:
-
用户画像向量化: 系统收集用户的行为数据(看了哪些视频、点赞了哪些、停留时间等),然后用机器学习模型把这些行为抽象成一个数字列表,比如
[0.7, -0.2, 0.5, ...](128维),这就是用户兴趣向量。 -
视频内容向量化: 系统分析视频的元数据、视觉特征、音频特征等,也用模型将其转换成一个数字列表,比如
[0.6, -0.1, 0.4, ...](128维),这就是视频内容向量。
-
存储到 Elasticsearch: (留意下字段类型是dense_vector)
-
-
每个用户的文档里,除了存
userId,username,还会存一个interestVector字段,类型是dense_vector,值就是上面算出来的向量。 -
每个视频的文档里,除了存
videoId,title,category,还会存一个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=traceElasticsearch客户端配置类
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知识日历");
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)