Ohhnews

分类导航

$ cd ..
foojay原文

构建个性化内容交付系统

#个性化推荐#内容交付#软件开发#spring ai#向量搜索

目录

前置要求 1. 数据模型 2. 项目设置 3. 构建基于内容的推荐引擎

4. 用户评分与偏好调整

5. 添加 Spring AI 嵌入与 MongoDB Atlas 向量搜索

6. 结合两种信号

7. 测试完整工作流 结论

推荐引擎通常给人一种需要复杂机器学习基础设施的印象:矩阵分解流水线、训练任务和模型服务层。这确实是一种做法,但并非唯一途径。如果你的数据已经存储在 MongoDB 中,且应用运行在 Spring Boot 上,你完全可以使用现有的工具构建一个实用的推荐系统。MongoDB 聚合流水线可以在服务端处理评分计算,而 Atlas 向量搜索则无需额外的向量数据库即可实现语义匹配。

在本文中,你将构建一个独立游戏发现平台,其中包含两种互补的推荐方法。第一种是基于内容的偏好评分:用户创建包含流派、标签和游戏机制加权偏好的个人资料,MongoDB 聚合流水线会根据这些权重对每款游戏进行评分。当用户对游戏进行评分时,系统会随时间调整其偏好权重,从而使推荐结果随着交互的增加而不断优化。第二种方法使用 Spring AI 嵌入和 MongoDB Atlas 向量搜索,能够捕捉到字面标签匹配所遗漏的语义联系。例如,标记为“探索”和“神秘”的游戏,对于喜欢“冒险”和“叙事”的用户来说应该具有吸引力,即使这些词汇在字面上并不重合。

阅读完本文,你将获得一个使用 Java 21+、Spring Boot 3.x、Spring Data MongoDB 和 Spring AI 构建的工作推荐 API,它将两种方法结合为一个统一的排名结果。嵌入层使用了 OpenAI 的 text-embedding-3-small 模型,但 Spring AI 支持的任何嵌入提供程序均可使用。完整的源代码可在 GitHub 上的配套仓库 中找到。

前置要求

  • Java 21 或更高版本
  • Spring Boot 3.x(使用 Spring Initializr 添加 Spring Data MongoDBSpring Web 依赖;Spring AI 将在本文后半部分手动添加)
  • MongoDB Atlas 集群(免费层级即可,Atlas 向量搜索需要用到)。你可以按照 MongoDB Atlas 入门指南 进行设置。
  • OpenAI API Key(用于在本文的第二部分生成嵌入)
  • 对 Spring Boot 的基本了解(控制器、服务、依赖注入)

1. 数据模型

系统需要两个集合:一个用于存储游戏,另一个用于存储用户个人资料。首先是 Game 文档:

$ java
@Document(collection = "games")
public class Game {

    @Id
    private String id;
    private String title;
    private String description;
    private String developer;
    private List<String> genres;
    private List<String> tags;
    private List<String> mechanics;
    private double rating;
    private int releaseYear;

    public Game(String title, String description, String developer,
                List<String> genres, List<String> tags, List<String> mechanics,
                double rating, int releaseYear) {
        this.title = title;
        this.description = description;
        this.developer = developer;
        this.genres = genres;
        this.tags = tags;
        this.mechanics = mechanics;
        this.rating = rating;
        this.releaseYear = releaseYear;
    }

    // getters and setters
}

genres(流派)、tags(标签)和 mechanics(机制)字段均为 List<String> 而非单一值。例如《Hades》既是 Roguelike 游戏,也是动作游戏。它拥有“快节奏”、“神话”等标签,以及“永久死亡”、“程序化生成”等机制。将这些存储为数组非常适合 MongoDB,因为你可以直接在查询和聚合流水线中对数组字段进行匹配和过滤。你在第 3 节中构建的推荐引擎正是依赖于此来查找与用户偏好重叠的游戏。

接下来是 UserProfile 文档:

$ java
@Document(collection = "user_profiles")
public class UserProfile {

    @Id
    private String id;
    private String username;
    private Preferences preferences;
    private List<GameRating> ratings;

    public UserProfile(String username, Preferences preferences) {
        this.username = username;
        this.preferences = preferences;
        this.ratings = new ArrayList<>();
    }

    // getters and setters
}

Preferences 类是一个嵌入式对象,包含三个加权映射:

$ java
public class Preferences {

    private Map<String, Double> genres;
    private Map<String, Double> tags;
    private Map<String, Double> mechanics;

    public Preferences(Map<String, Double> genres, Map<String, Double> tags,
                       Map<String, Double> mechanics) {
        this.genres = genres;
        this.tags = tags;
        this.mechanics = mechanics;
    }

    // getters and setters
}

每个映射键都是一个属性(如“roguelike”或“pixel-art”),值是 0 到 1 之间的权重,代表用户的偏好强度。相比于普通列表,这是一个经过深思熟虑的选择。偏好流派的简单列表只能告诉你某人同样喜欢 roguelike 和平台游戏。而加权映射(如 {"roguelike": 0.9, "platformer": 0.4})则清楚地表明他们更偏好 roguelike,而对平台游戏的兴趣较低。当评分引擎计算推荐结果时,它会将匹配属性乘以其权重,因此高亲和力的偏好会产生排名更高的结果。

GameRating 类是另一个嵌入式对象:

$ java
public class GameRating {

    private String gameId;
    private int score;
    private Instant ratedAt;

    public GameRating(String gameId, int score, Instant ratedAt) {
        this.gameId = gameId;
        this.score = score;
        this.ratedAt = ratedAt;
    }

    // getters and setters
}

为了使文档结构更具象,以下是 MongoDB 中两个游戏文档的示例:

$ cat
[
    {
        "_id": "64a1b2c3d4e5f6a7b8c9d0e1",
        "title": "Hollow Knight",
        "description": "A challenging 2D action-adventure through a vast underground kingdom.",
        "developer": "Team Cherry",
        "genres": ["metroidvania", "action", "platformer"],
        "tags": ["atmospheric", "difficult", "exploration", "hand-drawn"],
        "mechanics": ["ability-unlocks", "backtracking", "boss-fights"],
        "rating": 4.7,
        "releaseYear": 2017
    },
    {
        "_id": "64a1b2c3d4e5f6a7b8c9d0e2",
        "title": "Slay the Spire",
        "description": "A deck-building roguelike where you craft a unique deck and climb the Spire.",
        "developer": "Mega Crit Games",
        "genres": ["roguelike", "strategy", "card-game"],
        "tags": ["replayable", "turn-based", "procedural"],
        "mechanics": ["deck-building", "permadeath", "procedural-generation"],
        "rating": 4.8,
        "releaseYear": 2019
    }
]

注意每款游戏都有多个流派、标签和机制。当用户的偏好映射包含 {"roguelike": 0.9, "strategy": 0.6} 时,推荐引擎可以将这两个键与《杀戮尖塔》(Slay the Spire) 的流派数组进行匹配,并将权重相加来计算相关性得分。

配套仓库中包含一个实现为 CommandLineRunnerDataSeeder 组件,它会在启动时将大约 25 款独立游戏加载到 games 集合中。这为你提供了一个有意义的数据集,无需手动输入即可测试推荐效果。

2. 项目设置

前往 Spring Initializr 并配置一个新项目。选择 Maven 作为构建工具,Java 21 作为语言版本,并选择最新的 Spring Boot 3.x 版本。在依赖项中,添加 Spring WebSpring Data MongoDB。目前只需要这两个。Spring AI 将在第 5 节构建基于嵌入的推荐层时添加。生成项目,解压缩并在 IDE 中打开。

代码库被组织成几个包,你将在开发过程中逐步创建它们。domain 包存放你在上一节中定义的实体类;repository 包包含 Spring Data MongoDB 存储库接口;service 包包含推荐逻辑;controller 包公开 REST 接口,用于创建用户、获取推荐和提交评分;seeder 包包含在启动时填充数据库的 DataSeeder 类。你不需要预先创建所有包,每个包将在首次涉及的章节中介绍。

要将应用连接到你的 MongoDB Atlas 集群,请打开 src/main/resources/application.properties 并添加以下内容:

$ properties
spring.data.mongodb.uri=${MONGODB_URI:mongodb://localhost:27017/indie-game-discovery?appName=devrel-tutorial-indie-game-discovery}

${MONGODB_URI:...} 语法会从名为 MONGODB_URI 的环境变量中读取连接字符串。冒号后的值是回退方案,如果未设置环境变量,则指向本地 MongoDB 实例。appName 查询参数用于在 Atlas 连接日志和监控仪表板中标识你的应用。要使用你的 Atlas 集群,请在启动应用前设置环境变量:

$ bash
export MONGODB_URI="mongodb+srv://<username>:<password>@<cluster-url>/indie-game-discovery?appName=devrel-tutorial-indie-game-discovery"

将占位符替换为你的 Atlas 凭据和集群 URL。如果你按照前置要求中链接的 Atlas 入门指南操作,你已经拥有这些值。

如果你想跳过增量设置,直接进入完整项目,可以克隆 配套仓库。它包含了每一节的完整源代码,因此你可以跟随文章操作,或直接运行已完成的应用程序。## 3. 构建基于内容的推荐引擎

在生成推荐之前,你需要用于管理用户配置文件的端点以及用于查询游戏的仓库。首先,从一个简单的请求 DTO 和用户配置文件控制器开始。

UserProfileController

创建一个 CreateUserRequest 记录(record),用于捕获构建新配置文件所需的数据:

$ java
public record CreateUserRequest(String username, Preferences preferences) {
}

该控制器公开了两个端点:一个用于创建配置文件,另一个用于通过 ID 检索配置文件。

$ java
@RestController
@RequestMapping("/api/users")
public class UserProfileController {

    private final UserProfileRepository userProfileRepository;

    public UserProfileController(UserProfileRepository userProfileRepository) {
        this.userProfileRepository = userProfileRepository;
    }

    @PostMapping
    public ResponseEntity<UserProfile> createUser(@RequestBody CreateUserRequest request) {
        UserProfile profile = new UserProfile(request.username(), request.preferences());
        UserProfile saved = userProfileRepository.save(profile);
        return ResponseEntity.ok(saved);
    }

    @GetMapping("/{userId}")
    public ResponseEntity<UserProfile> getUser(@PathVariable String userId) {
        return userProfileRepository.findById(userId)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

UserProfileRepository 是一个标准的 Spring Data MongoDB 接口:

$ java
public interface UserProfileRepository extends MongoRepository<UserProfile, String> {
}

整个代码库均使用构造函数注入。Spring 会自动解析单个构造函数,无需使用 @Autowired 注解。

GameRepository

游戏仓库需要两个查询:一个用于获取所有游戏,另一个用于查找符合给定类型集合中任意类型的游戏。Spring Data MongoDB 可以根据方法名自动推导出这两个查询:

$ java
public interface GameRepository extends MongoRepository<Game, String> {

    List<Game> findByGenresIn(List<String> genres);
}

findByGenresIn 方法会查询 genres 数组字段,并返回至少包含所提供列表中一个类型的游戏。虽然你在主要的推荐流水线中不会直接使用此方法,但当你想要将结果缩小到特定类型子集时,它对于快速过滤非常有用。

RecommendationService 核心逻辑

推荐引擎需要解决一个具体问题:用户的偏好存储为 Map<String, Double>(加权映射,键为属性,值为亲和度分数),而每款游戏将其类型、标签和机制存储为普通的 List<String> 数组。要为游戏评分,你需要找出用户偏好映射中的哪些键出现在游戏的数组中,然后对相应的权重求和。

考虑一个具体的例子。用户有以下类型偏好:

{"roguelike": 0.9, "pixel-art": 0.7, "strategy": 0.5}

一款游戏的类型数组为 ["roguelike", "pixel-art", "metroidvania"]。匹配的类型是 "roguelike" 和 "pixel-art",因此类型得分为 0.9 + 0.7 = 1.6

该用户还有标签偏好 {"replayable": 0.8, "difficult": 0.6},而该游戏的标签为 ["replayable", "atmospheric"]。只有 "replayable" 匹配,所以标签得分为 0.8

在这种情况下没有机制匹配,所以机制得分为 0.0。总分是这三个类别的总和:

1.6 + 0.8 + 0.0 = 2.4

你可以将所有游戏拉取到应用程序中并在 Java 中计算分数,但这会浪费网络带宽。聚合管道(Aggregation pipelines)允许你将此计算推送到数据库,这样 MongoDB 只会返回已评分和排序的结果。该管道在概念上分为四个步骤:

  1. 使用 $objectToArray 将用户的偏好映射从对象({"roguelike": 0.9, "pixel-art": 0.7})转换为键值对数组。这会生成 [{"k": "roguelike", "v": 0.9}, {"k": "pixel-art", "v": 0.7}]
  2. 从该数组中提取键,并使用 $setIntersection 找出哪些键与游戏的类型/标签/机制数组重叠。
  3. 对键值对数组使用 $filter,仅保留键出现在交集中的条目,然后使用 $reduce 将其值求和为一个分数。
  4. 使用 $addFields 将计算出的分数作为一个新字段添加,并按分数降序排序。

由于管道将用户的偏好引用为字面量(而非游戏集合中的字段),因此你需要用 Java 动态构建它。以下是 RecommendationService

$ java
@Service
public class RecommendationService {

    private final MongoTemplate mongoTemplate;
    private final UserProfileRepository userProfileRepository;

    public RecommendationService(MongoTemplate mongoTemplate,
                                 UserProfileRepository userProfileRepository) {
        this.mongoTemplate = mongoTemplate;
        this.userProfileRepository = userProfileRepository;
    }

    public List<GameRecommendation> getRecommendations(String userId) {
        UserProfile user = userProfileRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("User not found: " + userId));

        Preferences prefs = user.getPreferences();

        Document genreScoreExpr = buildScoreExpression(prefs.getGenres(), "$genres");
        Document tagScoreExpr = buildScoreExpression(prefs.getTags(), "$tags");
        Document mechanicScoreExpr = buildScoreExpression(prefs.getMechanics(), "$mechanics");

        Document totalScore = new Document("$add", List.of(genreScoreExpr, tagScoreExpr, mechanicScoreExpr));

        AggregationOperation addScoreField = context ->
                new Document("$addFields", new Document("score", totalScore));

        AggregationOperation sortByScore = context ->
                new Document("$sort", new Document("score", -1));

        Aggregation aggregation = Aggregation.newAggregation(addScoreField, sortByScore);

        AggregationResults<GameRecommendation> results =
                mongoTemplate.aggregate(aggregation, "games", GameRecommendation.class);

        return results.getMappedResults();
    }

    private Document buildScoreExpression(Map<String, Double> preferenceMap, String gameField) {
        if (preferenceMap == null || preferenceMap.isEmpty()) {
            return new Document("$literal", 0.0);
        }

        Document prefObject = new Document();
        preferenceMap.forEach(prefObject::append);

        Document prefArray = new Document("$objectToArray", new Document("$literal", prefObject));

        Document prefKeys = new Document("$map",
                new Document("input", prefArray)
                        .append("as", "pref")
                        .append("in", "$$pref.k"));

        Document matchedKeys = new Document("$setIntersection", List.of(prefKeys, gameField));

        Document matchedEntries = new Document("$filter",
                new Document("input", prefArray)
                        .append("as", "entry")
                        .append("cond", new Document("$in", List.of("$$entry.k", matchedKeys))));

        return new Document("$reduce",
                new Document("input", matchedEntries)
                        .append("initialValue", 0.0)
                        .append("in", new Document("$add", List.of("$$value", "$$this.v"))));
    }
}

buildScoreExpression 方法为单个偏好类别构建聚合表达式。它将用户的偏好映射和游戏的数组字段名作为参数,然后执行四个步骤:

  1. 将偏好映射转换为 BSON Document,并将其封装在 $objectToArray 中以获取键值对数组。
  2. 使用 $map 仅提取键。
  3. 使用 $setIntersection 找出哪些键与游戏的数组字段重叠,然后使用 $filter 仅保留匹配的条目。
  4. 使用 $reduce 对匹配的值求和,从而得出该类别的分数。

getRecommendations 方法调用了三次 buildScoreExpression(分别针对类型、标签和机制),将这三个结果相加得出总分,并针对 games 集合运行该管道。

RecommendationController

控制器接收用户 ID,调用服务,并返回排名列表:

$ java
@RestController
@RequestMapping("/api/recommendations")
public class RecommendationController {

    private final RecommendationService recommendationService;

    public RecommendationController(RecommendationService recommendationService) {
        this.recommendationService = recommendationService;
    }

    @GetMapping("/{userId}")
    public ResponseEntity<List<GameRecommendation>> getRecommendations(@PathVariable String userId) {
        List<GameRecommendation> recommendations = recommendationService.getRecommendations(userId);
        return ResponseEntity.ok(recommendations);
    }
}

GameRecommendation 响应 DTO 包含了游戏字段以及计算出的分数:

$ java
public class GameRecommendation {

    private String id;
    private String title;
    private String description;
    private String developer;
    private List<String> genres;
    private List<String> tags;
    private List<String> mechanics;
    private double rating;
    private int releaseYear;
    private double score;

    public GameRecommendation() {
    }

    // getters and setters
}

MongoDB 的聚合结果可以直接映射到该类,因为 $addFields 会将 score 字段附加到现有的游戏字段旁边。Spring Data MongoDB 会自动将输出文档反序列化为 GameRecommendation 对象。

手动测试

启动应用程序并创建一个带有加权偏好的用户配置文件:

$ bash
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alex",
    "preferences": {
      "genres": {"roguelike": 0.9, "platformer": 0.5, "metroidvania": 0.7},
      "tags": {"difficult": 0.8, "atmospheric": 0.6, "replayable": 0.7},
      "mechanics": {"permadeath": 0.9, "procedural-generation": 0.6}
    }
  }'

复制返回的 id 字段并访问推荐端点:

$ bash
curl http://localhost:8080/api/recommendations/<user-id>

响应是一个按分数排序的游戏列表。以 Slay the Spire 为例,它在多个方面都有匹配:

  • 类型中的 "roguelike" (0.9)
  • 标签中的 "replayable" (0.7)
  • 机制中的 "permadeath" (0.9) 和 "procedural-generation" (0.6)

这使其总得分为 3.1。将其与 Hollow Knight 相比,后者在 "metroidvania" (0.7) 以及 "difficult" (0.8) 和 "atmospheric" (0.6) 等标签上得分较高,但缺乏 roguelike 特性,因此在排名中会更低。分数直接映射到用户的偏好权重,这使得结果易于解释和调试。## 4. 用户评分与亲和度调整

推荐引擎目前可以正常工作,但偏好权重是静态的。用户在初始设置偏好后,系统无法从其后续行为中学习。因此,我们需要一个评分端点,让用户能够对玩过的游戏进行打分,并引入调整逻辑,根据这些评分动态更新偏好权重。

评分端点

创建一个 RatingRequest 记录(Record)来捕获传入的数据:

$ java
public record RatingRequest(String gameId, int score) {
}

UserProfileController 中添加一个新端点,用于接收特定用户的评分:

$ java
@PostMapping("/{userId}/ratings")
public ResponseEntity<UserProfile> rateGame(@PathVariable String userId,
                                            @RequestBody RatingRequest request) {
    UserProfile updatedProfile = ratingService.applyRating(userId, request);
    return ResponseEntity.ok(updatedProfile);
}

控制器将所有逻辑委托给 RatingService。通过构造函数注入该服务以及现有的 UserProfileRepository

$ java
private final UserProfileRepository userProfileRepository;
private final RatingService ratingService;

public UserProfileController(UserProfileRepository userProfileRepository,
                             RatingService ratingService) {
    this.userProfileRepository = userProfileRepository;
    this.ratingService = ratingService;
}

亲和度调整逻辑

RatingService 负责处理核心的调整逻辑。当用户对游戏进行评分时,该服务会查找游戏的流派、标签和机制,然后根据以下规则调整用户偏好映射(Map)中相应的权重:

  • 4 或 5 分的评分会将权重向上推向 1.0。
  • 1 或 2 分的评分会将权重向下推向 0.0。
  • 3 分的评分则保持权重不变。

调整过程使用指数移动平均公式,该公式能自然地将权重限制在 0 到 1 之间:

newWeight = oldWeight + learningRate * (targetDirection - oldWeight)

learningRate(学习率)设定为 0.15,这意味着每次评分都会将权重向目标方向移动剩余距离的 15%。targetDirection(目标方向)对于高评分(用户希望获得更多此类属性)为 1.0,对于低评分(用户希望减少此类属性)为 0.0。

由于该公式总是使权重向目标方向移动当前值与目标值之间的一小部分距离,因此它永远不会超过 1.0 或低于 0.0。以下两个简短示例解释了原因:

  • 一个权重为 0.9 的属性获得高分后变为:0.9 + 0.15 * (1.0 - 0.9) = 0.915。因为已经接近上限,所以变化较小。
  • 一个权重为 0.2 的属性获得高分后变为:0.2 + 0.15 * (1.0 - 0.2) = 0.32。因为增长空间较大,所以跳跃幅度较大。

下面是一个具体示例。假设用户给一款“潜行-解谜”游戏打了 5 分。该游戏的流派包括 ["stealth", "puzzle"]。用户当前的流派权重包含 "stealth": 0.4"puzzle": 0.6。由于评分是 5,目标方向为 1.0:

  • Stealth:0.4 + 0.15 * (1.0 - 0.4) = 0.4 + 0.09 = 0.49。四舍五入后,Stealth 从 0.4 变为约 0.49。
  • Puzzle:0.6 + 0.15 * (1.0 - 0.6) = 0.6 + 0.06 = 0.66。Puzzle 从 0.6 变为 0.66。

同样的公式适用于游戏的标签和机制。如果游戏有一个标签是“atmospheric”(氛围感),而用户对该标签的权重是 0.5,则变为 0.5 + 0.15 * (1.0 - 0.5) = 0.575

以下是 RatingService 的实现:

$ java
@Service
public class RatingService {

    private static final double LEARNING_RATE = 0.15;

    private final MongoTemplate mongoTemplate;
    private final GameRepository gameRepository;
    private final UserProfileRepository userProfileRepository;

    public RatingService(MongoTemplate mongoTemplate,
                         GameRepository gameRepository,
                         UserProfileRepository userProfileRepository) {
        this.mongoTemplate = mongoTemplate;
        this.gameRepository = gameRepository;
        this.userProfileRepository = userProfileRepository;
    }

    public UserProfile applyRating(String userId, RatingRequest request) {
        UserProfile user = userProfileRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("User not found: " + userId));

        Game game = gameRepository.findById(request.gameId())
                .orElseThrow(() -> new RuntimeException("Game not found: " + request.gameId()));

        int score = request.score();
        if (score == 3) {
            pushRatingOnly(userId, request);
            return userProfileRepository.findById(userId).orElseThrow();
        }

        double targetDirection = score >= 4 ? 1.0 : 0.0;
        Preferences prefs = user.getPreferences();

        Map<String, Double> updatedGenres = adjustWeights(prefs.getGenres(), game.getGenres(), targetDirection);
        Map<String, Double> updatedTags = adjustWeights(prefs.getTags(), game.getTags(), targetDirection);
        Map<String, Double> updatedMechanics = adjustWeights(prefs.getMechanics(), game.getMechanics(), targetDirection);

        updateProfileInMongo(userId, request, updatedGenres, updatedTags, updatedMechanics);

        return userProfileRepository.findById(userId).orElseThrow();
    }

    private Map<String, Double> adjustWeights(Map<String, Double> currentWeights,
                                              List<String> gameAttributes,
                                              double targetDirection) {
        Map<String, Double> updated = new HashMap<>(currentWeights);
        for (String attribute : gameAttributes) {
            double oldWeight = updated.getOrDefault(attribute, 0.5);
            double newWeight = oldWeight + LEARNING_RATE * (targetDirection - oldWeight);
            updated.put(attribute, Math.round(newWeight * 1000.0) / 1000.0);
        }
        return updated;
    }

    private void pushRatingOnly(String userId, RatingRequest request) {
        GameRating rating = new GameRating(request.gameId(), request.score(), Instant.now());
        Query query = Query.query(Criteria.where("_id").is(userId));
        Update update = new Update().push("ratings", rating);
        mongoTemplate.updateFirst(query, update, UserProfile.class);
    }

    private void updateProfileInMongo(String userId, RatingRequest request,
                                      Map<String, Double> genres,
                                      Map<String, Double> tags,
                                      Map<String, Double> mechanics) {
        GameRating rating = new GameRating(request.gameId(), request.score(), Instant.now());
        Query query = Query.query(Criteria.where("_id").is(userId));
        Update update = new Update()
                .set("preferences.genres", genres)
                .set("preferences.tags", tags)
                .set("preferences.mechanics", mechanics)
                .push("ratings", rating);
        mongoTemplate.updateFirst(query, update, UserProfile.class);
    }
}

adjustWeights 方法遍历游戏的属性并将公式应用于每个匹配的权重。如果用户尚未拥有特定属性的权重(例如从未接触过的流派),则默认为 0.5 作为中立起点,并从该点开始调整。

MongoDB 更新

updateProfileInMongo 方法在一次 MongoDB 操作中完成偏好更新和评分存储。$set 操作符用重新计算的版本替换 preferences.genrespreferences.tagspreferences.mechanics 映射,而 $push 将新的 GameRating 条目追加到 ratings 数组中。由于两次修改都在一次 updateFirst 调用中完成,因此文档不会出现部分更新的中间状态。

演示

要观察反馈循环的作用,可以在提交评分前后分别调用推荐端点。使用第 3 节中的同一用户:

$ bash
# 评分前的推荐
curl http://localhost:8080/api/recommendations/<user-id>

# 对游戏进行高分评价
curl -X POST http://localhost:8080/api/users/<user-id>/ratings \
  -H "Content-Type: application/json" \
  -d '{"gameId": "<game-id>", "score": 5}'

# 评分后的推荐
curl http://localhost:8080/api/recommendations/<user-id>

比较两次响应。那些与高分游戏共享流派、标签或机制的游戏在排名中会上升,因为它们的匹配权重增加了。不共享这些属性的游戏则保持原有的分数。每次评分都会微调用户画像,经过多次评分后,偏好权重将稳定在一个符合用户真实喜好的画像上。

5. 添加 Spring AI 嵌入与 MongoDB Atlas 向量搜索

偏好引擎在用户的标签与游戏的标签完全匹配时效果很好,但它无法处理语义连接。例如,被标记为“探索”(exploration)和“神秘”(mystery)的游戏应该吸引那些喜欢“冒险”(adventure)和“叙事”(narrative)的用户,因为这些概念密切相关。当前的偏好引擎将此类匹配的分数评为零,因为字符串没有重叠。

嵌入(Embeddings)解决了这个问题。它们将文本表示为高维向量,语义相似的概念在向量空间中距离很近。无需检查两个字符串是否完全相同,而是测量它们向量表示之间的距离。

Spring AI 设置

pom.xml 中添加 Spring AI OpenAI 起步依赖。你还需要 Spring AI BOM 来管理依赖版本:

$ xml
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>

然后在 application.properties 中添加 OpenAI 配置:

$ properties
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.embedding.options.model=text-embedding-3-small

text-embedding-3-small 模型生成 1536 维的向量。请将 API 密钥存储在环境变量中,而不是硬编码在代码中。

生成嵌入

要生成嵌入,将游戏的描述、流派、标签和机制拼接成一个文本块,然后将生成的文本传递给 EmbeddingModel。首先,在 Game 类中添加一个 embedding 字段:

$ java
@Document(collection = "games")
public class Game {

    // ... 现有字段 ...

    private float[] embedding;

    // embedding 的 getter 和 setter
    public float[] getEmbedding() { return embedding; }
    public void setEmbedding(float[] embedding) { this.embedding = embedding; }
}

然后创建一个辅助方法来构建文本表示并生成嵌入:

$ java
@Service
public class EmbeddingService {

    private final EmbeddingModel embeddingModel;

    public EmbeddingService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    public float[] generateGameEmbedding(Game game) {
        String text = game.getDescription() + " "
                + "Genres: " + String.join(", ", game.getGenres()) + ". "
                + "Tags: " + String.join(", ", game.getTags()) + ". "
                + "Mechanics: " + String.join(", ", game.getMechanics()) + ".";
        return embeddingModel.embed(text);
    }
}

embed() 方法将文本发送到 OpenAI 的嵌入 API,并返回一个包含 1536 个值的 float[]。将游戏的所有元数据拼接成一个字符串,为模型提供了足够的上下文来生成有意义的向量。

DataSeeder 更新

更新 DataSeeder 以便在启动时为每个游戏生成嵌入。插入游戏文档后,遍历它们并调用 EmbeddingService

$ java
@Component
public class DataSeeder implements CommandLineRunner {

    private final GameRepository gameRepository;
    private final EmbeddingService embeddingService;

    public DataSeeder(GameRepository gameRepository, EmbeddingService embeddingService) {
        this.gameRepository = gameRepository;
        this.embeddingService = embeddingService;
    }

    @Override
    public void run(String... args) {
        // ... 现有的游戏插入逻辑 ...

        List<Game> games = gameRepository.findAll();
        for (Game game : games) {
            if (game.getEmbedding() == null) {
                game.setEmbedding(embeddingService.generateGameEmbedding(game));
                gameRepository.save(game);
            }
        }
    }
}

空值检查可以防止每次重启时重复生成嵌入。每个 API 调用都会产生费用,因此你只需要为尚未存储向量的游戏生成嵌入。

Atlas 向量搜索索引

在查询嵌入之前,你需要在 Atlas 中创建一个向量搜索索引。在 Atlas UI 中进入你的集群,选择 Atlas Search 选项卡,然后点击 Create Search Index。选择 Atlas Vector Search 作为索引类型,选择 games 集合,并使用以下索引定义:

$ cat
{
  "fields": [
    {
      "type": "vector",
      "path": "embedding",
      "numDimensions": 1536,
      "similarity": "cosine"
    }
  ]
}

将索引命名为 vector_indexnumDimensions 的值必须与嵌入模型的输出匹配,对于 text-embedding-3-small 来说是 1536。余弦相似度(Cosine similarity)是文本嵌入的标准选择,因为它测量向量之间的角度,而不受其量级的影响。

向量搜索查询

有了索引,你就可以构建一个方法来查找与用户偏好语义相似的游戏。方法是:构建用户最高偏好的文本摘要,将其嵌入,然后针对 games 集合执行 $vectorSearch 聚合操作。

RecommendationService 中添加 findSimilarGames 方法:

$ java
public List<GameRecommendation> findSimilarGames(String userId) {
    UserProfile user = userProfileRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("User not found: " + userId));

    Preferences prefs = user.getPreferences();
    String preferenceText = buildPreferenceText(prefs);
    float[] queryVector = embeddingModel.embed(preferenceText);

    List<Double> queryVectorList = new ArrayList<>();
    for (float f : queryVector) {
        queryVectorList.add((double) f);
    }

    Document vectorSearchStage = new Document("$vectorSearch",
            new Document("index", "vector_index")
                    .append("path", "embedding")
                    .append("queryVector", queryVectorList)
                    .append("numCandidates", 50)
                    .append("limit", 10));

    AggregationOperation vectorSearch = context -> vectorSearchStage;

    AggregationOperation addScore = context ->
            new Document("$addFields",
                    new Document("score", new Document("$meta", "vectorSearchScore")));

    Aggregation aggregation = Aggregation.newAggregation(vectorSearch, addScore);

    AggregationResults<GameRecommendation> results =
            mongoTemplate.aggregate(aggregation, "games", GameRecommendation.class);

    return results.getMappedResults();
}

private String buildPreferenceText(Preferences prefs) {
    List<String> parts = new ArrayList<>();
    prefs.getGenres().entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(5)
            .forEach(e -> parts.add(e.getKey()));
    prefs.getTags().entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(5)
            .forEach(e -> parts.add(e.getKey()));
    prefs.getMechanics().entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(3)
            .forEach(e -> parts.add(e.getKey()));
    return "Games with " + String.join(", ", parts);
}

buildPreferenceText 方法提取用户权重最高的流派、标签和机制,并将它们组合成类似“Games with roguelike, metroidvania, difficult, atmospheric, replayable, permadeath, procedural-generation.”的自然语言字符串。该字符串被嵌入为查询向量,$vectorSearch 使用余弦相似度找到最接近该向量的游戏嵌入。

numCandidates 参数控制搜索在返回最终 limit 个结果之前在内部考虑多少个候选者。将 numCandidates 设置得比 limit 高可以提高准确性,但会稍微增加处理开销。

为了使其生效,请在 RecommendationService 中添加 EmbeddingModel 作为依赖:

$ java
private final MongoTemplate mongoTemplate;
private final UserProfileRepository userProfileRepository;
private final EmbeddingModel embeddingModel;

public RecommendationService(MongoTemplate mongoTemplate,
                             UserProfileRepository userProfileRepository,
                             EmbeddingModel embeddingModel) {
    this.mongoTemplate = mongoTemplate;
    this.userProfileRepository = userProfileRepository;
    this.embeddingModel = embeddingModel;
}

结果

使用第 3 节中的同一用户画像,向量搜索可以挖掘出像《Outer Wilds》(标记为“探索”和“神秘”)这样的游戏,即使用户的偏好中包含的是“冒险”和“叙事”而不是这些确切的术语。偏好引擎给《Outer Wilds》的评分很低,因为没有字面上的标签重叠,但“探索”和“冒险”的嵌入向量在向量空间中很接近,因此 $vectorSearch 将其排名很高。这就是嵌入所弥补的差距。## 6. 结合两种信号

现在,你拥有了两种推荐方法,每种方法都能捕捉到对方所忽略的内容。基于内容的评分反映了用户明确表示的需求,而向量相似度则捕捉到了字面标签匹配所遗漏的语义关系。下一步是将两者合并为一个单一的排名结果。

合并方法

在第 3 节创建的 GameRecommendation 类中添加三个新字段:

$ java
public class GameRecommendation {

    // ... 现有字段 ...

    private double contentScore;
    private double similarityScore;
    private double combinedScore;

    // 所有三个评分字段的 getter 和 setter 方法
}

组合评分使用加权公式:0.6 * contentScore + 0.4 * similarityScore。基于内容的评分权重更高,因为它反映了用户明确的意图。当用户设置 "roguelike": 0.9 时,他们是在直接告诉你他们的需求。相似度评分可以挖掘出偏离既定偏好的意外结果,因此将基于内容的推荐作为主要份额,可以确保推荐结果始终扎根于用户的明确需求,同时又能受益于语义匹配。

RecommendationService 中添加一个 getCombinedRecommendations 方法,该方法调用两种方法并合并结果:

$ java
public List<GameRecommendation> getCombinedRecommendations(String userId) {
    List<GameRecommendation> contentResults = getRecommendations(userId);
    List<GameRecommendation> similarityResults = findSimilarGames(userId);

    double maxContentScore = contentResults.stream()
            .mapToDouble(GameRecommendation::getScore)
            .max().orElse(1.0);

    double maxSimilarityScore = similarityResults.stream()
            .mapToDouble(GameRecommendation::getScore)
            .max().orElse(1.0);

    Map<String, GameRecommendation> merged = new LinkedHashMap<>();

    for (GameRecommendation rec : contentResults) {
        rec.setContentScore(rec.getScore() / maxContentScore);
        rec.setSimilarityScore(0.0);
        merged.put(rec.getId(), rec);
    }

    for (GameRecommendation rec : similarityResults) {
        double normalizedSimilarity = rec.getScore() / maxSimilarityScore;
        if (merged.containsKey(rec.getId())) {
            GameRecommendation existing = merged.get(rec.getId());
            existing.setSimilarityScore(normalizedSimilarity);
        } else {
            rec.setContentScore(0.0);
            rec.setSimilarityScore(normalizedSimilarity);
            merged.put(rec.getId(), rec);
        }
    }

    for (GameRecommendation rec : merged.values()) {
        double combined = 0.6 * rec.getContentScore() + 0.4 * rec.getSimilarityScore();
        rec.setCombinedScore(Math.round(combined * 1000.0) / 1000.0);
    }

    return merged.values().stream()
            .sorted(Comparator.comparingDouble(GameRecommendation::getCombinedScore).reversed())
            .toList();
}

两种评分方法在不同的量级上运行。基于内容的评分是匹配权重的无界总和,而相似度评分是 0 到 1 之间的余弦距离。该方法通过将每组分数除以该组中的最大值来对分数进行归一化,在合并之前将两者都带入 0 到 1 的范围内。出现在两个结果集中的游戏会填充两个分数。仅出现在一个集合中的游戏,缺失的分数将记为零。

统一响应

更新 RecommendationController 中的 GET /api/recommendations/{userId} 端点,使其调用 getCombinedRecommendations 而不是 getRecommendations

$ java
@GetMapping("/{userId}")
public ResponseEntity<List<GameRecommendation>> getRecommendations(@PathVariable String userId) {
    List<GameRecommendation> recommendations =
            recommendationService.getCombinedRecommendations(userId);
    return ResponseEntity.ok(recommendations);
}

现在的响应包含了每款游戏的全部三个评分:

$ cat
[
    {
        "id": "64a1b2c3d4e5f6a7b8c9d0e2",
        "title": "Slay the Spire",
        "genres": ["roguelike", "strategy", "card-game"],
        "contentScore": 1.0,
        "similarityScore": 0.87,
        "combinedScore": 0.948
    },
    {
        "id": "64a1b2c3d4e5f6a7b8c9d0e1",
        "title": "Hollow Knight",
        "genres": ["metroidvania", "action", "platformer"],
        "contentScore": 0.68,
        "similarityScore": 0.92,
        "combinedScore": 0.776
    },
    {
        "id": "64a1b2c3d4e5f6a7b8c9d0e5",
        "title": "Outer Wilds",
        "genres": ["adventure", "exploration"],
        "contentScore": 0.12,
        "similarityScore": 0.81,
        "combinedScore": 0.396
    }
]

Slay the Spire 排名领先,因为它在两个信号上都表现良好。Hollow Knight 具有很强的相似度评分,但内容匹配稍弱。Outer Wilds 的内容评分较低,但因为它较高的相似度评分将其拉升,因此依然出现在列表中。

推荐流程

[LOADING...]

组合系统遵循以下流程:用户偏好和评分进入基于内容的评分管道,该管道使用 MongoDB 聚合计算每款游戏的得分。与此同时,用户的顶级偏好被转换为文本摘要,并通过嵌入模型生成查询向量。MongoDB Atlas 向量搜索使用该向量查找语义相似的游戏。两组分数经过归一化,并使用加权公式合并,最终输出是一个按组合分数排序的单一排名推荐列表。

调整权重

0.6/0.4 的比例是一个合理的起点,而非通用答案。合适的平衡取决于你拥有多少偏好数据。当用户提交了大量评分且其偏好权重经过良好校准时,基于内容的信号是可靠的,应给予更高的权重。对于只设置了少量初始偏好的新用户,基于内容的得分可能比较稀疏,此时增加相似度权重(例如 0.5/0.5 甚至 0.4/0.6)可以通过利用语义联系产生更好的早期推荐。

将这些权重视为可调参数,而不是固定常量。你也可以使它们针对每个用户动态变化,随着系统积累更多的评分,逐渐向基于内容的方向倾斜。

7. 测试完整工作流

在所有部分准备就绪后,走一遍完整的周期:创建用户、获取初始推荐、提交评分,并观察结果如何变化。

启动应用程序。DataSeeder 会将游戏加载到 games 集合中,并在首次运行时为每款游戏生成嵌入。应用程序准备好后,创建一个用户配置文件:

$ bash
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alex",
    "preferences": {
      "genres": {"roguelike": 0.9, "platformer": 0.5, "metroidvania": 0.7},
      "tags": {"difficult": 0.8, "atmospheric": 0.6, "replayable": 0.7},
      "mechanics": {"permadeath": 0.9, "procedural-generation": 0.6}
    }
  }'

响应中包含生成的用户 ID。复制该 ID 并请求推荐:

$ bash
curl http://localhost:8080/api/recommendations/682f1a3b5e4d
$ cat
[
    {"title": "Slay the Spire", "contentScore": 1.0, "similarityScore": 0.87, "combinedScore": 0.948},
    {"title": "Hades", "contentScore": 0.84, "similarityScore": 0.91, "combinedScore": 0.868},
    {"title": "Dead Cells", "contentScore": 0.77, "similarityScore": 0.83, "combinedScore": 0.794},
    {"title": "Hollow Knight", "contentScore": 0.68, "similarityScore": 0.92, "combinedScore": 0.776},
    {"title": "Outer Wilds", "contentScore": 0.12, "similarityScore": 0.81, "combinedScore": 0.396}
]

Slay the Spire 领先,因为它同时命中了 roguelike (0.9)、replayable (0.7)、permadeath (0.9) 和 procedural-generation (0.6)。Outer Wilds 的内容得分排名较低,因为它的标签与用户的偏好没有字面匹配,但嵌入技术仍然将其拉入了列表中。

现在提交一些评分。给 Hollow Knight 打高分,给 Slay the Spire 打低分:

$ bash
curl -X POST http://localhost:8080/api/users/682f1a3b5e4d/ratings \
  -H "Content-Type: application/json" \
  -d '{"gameId": "64a1b2c3d4e5f6a7b8c9d0e1", "score": 5}'

curl -X POST http://localhost:8080/api/users/682f1a3b5e4d/ratings \
  -H "Content-Type: application/json" \
  -d '{"gameId": "64a1b2c3d4e5f6a7b8c9d0e2", "score": 2}'

curl -X POST http://localhost:8080/api/users/682f1a3b5e4d/ratings \
  -H "Content-Type: application/json" \
  -d '{"gameId": "64a1b2c3d4e5f6a7b8c9d0e5", "score": 4}'

第一个评分将 metroidvania、action 和 platformer 的权重向上推。第二个评分将 roguelike 和 card-game 的权重拉低。第三个评分提升了 adventure 和 exploration 的权重。再次获取推荐:

$ bash
curl http://localhost:8080/api/recommendations/682f1a3b5e4d
$ cat
[
    {"title": "Hollow Knight", "contentScore": 0.91, "similarityScore": 0.92, "combinedScore": 0.914},
    {"title": "Hades", "contentScore": 0.82, "similarityScore": 0.89, "combinedScore": 0.848},
    {"title": "Dead Cells", "contentScore": 0.74, "similarityScore": 0.84, "combinedScore": 0.780},
    {"title": "Outer Wilds", "contentScore": 0.38, "similarityScore": 0.81, "combinedScore": 0.552},
    {"title": "Slay the Spire", "contentScore": 0.61, "similarityScore": 0.85, "combinedScore": 0.706}
]

Hollow Knight 从第四名跃升至第一名。它的内容得分从 0.68 增加到 0.91,因为 5 星评分提升了 metroidvania、platformer 和 atmospheric 的权重。Slay the Spire 的排名下降,因为 2 星评分拉低了 roguelike 和 card-game 的权重。Outer Wilds 得益于 4 星评分增加了 adventure 和 exploration 的权重,排名有所提升,这也使得嵌入查询转向偏向类似的游戏。每个评分都会增量式地调整偏好配置,而组合评分则会立即反映这些变化。

结论

你构建了一个具有双层结构的推荐引擎。基于内容的偏好评分使用 MongoDB 聚合管道,根据加权用户偏好匹配游戏。基于嵌入的相似度则使用 Spring AI 和 MongoDB Atlas 向量搜索,挖掘出与用户品味语义相关的游戏,即使标签没有字面重叠。用户评分通过随时间调整偏好权重,闭合了反馈循环。

在此基础上,你可以尝试更换不同的嵌入模型,观察其对相似度结果的影响。一旦拥有足够多的用户,可以添加协同过滤,根据相似用户的喜好推荐游戏。你还可以结合游戏时长、愿望清单活动或购买历史等额外信号,使偏好模型更加丰富。

完整的源代码可在配套仓库中获取。克隆它,填入你的 MongoDB Atlas 连接字符串和 OpenAI API 密钥,开始利用你自己的游戏目录和偏好配置进行实验吧。