相比传统的基于关键词匹配的搜索,向量搜索能捕捉到更深层次的语义相似性。即使两个视频标题没有共同词汇,但如果内容相似,它们的向量也会接近,就能被推荐出来。
什么是向量 (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最接近的视频文档。 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);
}
}
微信扫描下方的二维码阅读本文

Comments NOTHING