3130 字
约 10 分钟
2
标签相似度匹配业务文档

标签相似度匹配业务文档

1. 业务概述

伙伴匹配系统希望帮助用户找到技能、兴趣或学习方向相近的伙伴。系统目前以用户标签作为用户画像的基础,例如:

["Java", "后端", "MySQL"]

用户访问匹配接口后,系统会把当前用户的标签与其他用户的标签逐一比较,计算每组标签之间的差异程度,再按照差异从小到大的顺序返回最相似的若干用户。

当前方案属于基于规则的相似度匹配,不涉及机器学习模型。它的优点是实现简单、结果容易解释,适合项目早期快速验证“按兴趣找伙伴”的需求。

2. 接口说明

接口:

GET /api/user/match

请求参数:

参数 类型 必填 说明
num long 希望返回的匹配用户数量,当前限制为 1~20

请求示例:

GET /api/user/match?num=3

接口要求用户已登录。系统会自动从 Session 中获取当前用户,不需要客户端额外传递当前用户 ID。

主要代码入口:

  • 控制器:src/main/java/com/yupi/yupao/controller/UserController.javamatchUsers
  • 业务实现:src/main/java/com/yupi/yupao/service/impl/UserServiceImpl.javamatchUsers
  • 算法工具:src/main/java/com/yupi/yupao/utils/AlgorithmUtils.javaminDistance(List<String>, List<String>)

3. 数据结构

用户标签目前保存在 user 表的 tags 字段中,字段类型是字符串,实际内容使用 JSON 数组表示:

["Java", "后端", "MySQL"]

业务代码使用 Gson 将字符串解析为 Java 集合:

List<String> tagList = gson.fromJson(
        loginUser.getTags(),
        new TypeToken<List<String>>() {}.getType()
);

因此,算法真正比较的不是完整的 JSON 字符串,而是两个标签列表:

List<String> currentUserTags;
List<String> candidateUserTags;

4. 业务处理流程

flowchart TD
    A[用户请求匹配接口] --> B[校验 num 和登录状态]
    B --> C[读取当前用户标签]
    C --> D[查询所有有标签的候选用户]
    D --> E[排除当前用户和无效标签]
    E --> F[计算每个候选用户的编辑距离]
    F --> G[按距离从小到大排序]
    G --> H[截取前 num 个用户]
    H --> I[重新查询完整信息并脱敏]
    I --> J[返回匹配结果]

具体步骤如下:

  1. UserController 校验 num,当前只允许返回 1~20 个用户。
  2. 通过 Session 获取当前登录用户。
  3. 查询 tags 不为空的用户作为候选集合。
  4. 排除当前用户自己,以及标签为空的候选用户。
  5. 使用编辑距离算法计算当前用户与每个候选用户的标签差异。
  6. 按差异值从小到大排序。
  7. 取前 num 个用户。
  8. 根据用户 ID 重新查询完整用户信息,并调用 getSafetyUser 进行脱敏后返回。

5. 编辑距离的业务含义

编辑距离表示:

将一个标签列表变成另一个标签列表,最少需要多少次编辑操作。

允许的操作有三种:

操作 含义 示例
删除 删除一个标签 删除 MySQL
插入 增加一个标签 增加 Redis
替换 将一个标签改成另一个标签 MySQL 替换为 Redis

操作次数越少,说明两组标签越接近。因此,在本项目中:

编辑距离越小 → 用户标签越相似 → 匹配排名越靠前

例如:

用户 A:Java、后端、MySQL
用户 B:Java、后端、Redis

只需要将 MySQL 替换成 Redis,编辑距离为 1,说明两名用户的标签画像比较接近。

再例如:

用户 A:Java、后端
用户 B:Java、前端、Redis

可以将 后端 替换为 前端,再插入 Redis,编辑距离为 2。

6. 动态规划实现

6.1 状态定义

AlgorithmUtils 中,算法使用二维数组:

int[][] d = new int[n + 1][m + 1];

其中:

d[i][j] = 将列表 1 的前 i 个标签变成列表 2 的前 j 个标签所需的最少操作次数

假设:

列表 1:Java、后端
列表 2:Java、前端

那么 d[1][1] 表示把 Java 变成 Java 的最小操作数,d[2][2] 表示把完整的第一个列表变成完整的第二个列表的最小操作数。

6.2 初始状态

代码先初始化第一列和第一行:

for (int i = 0; i < n + 1; i++) {
    d[i][0] = i;
}

for (int j = 0; j < m + 1; j++) {
    d[0][j] = j;
}

含义是:

  • 将前 i 个标签变成空列表,需要删除 i 次;
  • 将空列表变成前 j 个标签,需要插入 j 次。

6.3 状态转移

算法比较两个当前位置的标签:

int left = d[i - 1][j] + 1;
int down = d[i][j - 1] + 1;
int left_down = d[i - 1][j - 1];

if (!Objects.equals(tagList1.get(i - 1), tagList2.get(j - 1))) {
    left_down += 1;
}

d[i][j] = Math.min(left, Math.min(down, left_down));

三个候选值分别代表:

left      = 删除列表 1 的当前标签
down      = 向列表 1 插入列表 2 的当前标签
left_down = 两个标签相同则直接匹配,不同则替换

最后取三种操作中代价最小的一种。

如果两个标签相同:

d[i][j] = d[i - 1][j - 1];

如果两个标签不同:

d[i][j] = min(
    d[i - 1][j] + 1,
    d[i][j - 1] + 1,
    d[i - 1][j - 1] + 1
);

6.4 示例计算

比较下面两组标签:

列表 A:Java、后端
列表 B:Java、前端

动态规划表可以理解为:

             空    Java    前端
空             0      1       2
Java           1      0       1
后端           2      1       1

右下角 d[2][2] = 1,表示只需要把 后端 替换成 前端,所以两组标签的差异值为 1。

7. 当前代码实现说明

UserServiceImpl.matchUsers 的核心逻辑可以概括为:

QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.select("id", "tags");
queryWrapper.isNotNull("tags");
List<User> userList = this.list(queryWrapper);

for (User user : userList) {
    if (StringUtils.isBlank(user.getTags())
            || user.getId() == loginUser.getId()) {
        continue;
    }

    List<String> userTagList = gson.fromJson(user.getTags(), ...);
    long distance = AlgorithmUtils.minDistance(tagList, userTagList);
    list.add(new Pair<>(user, distance));
}

完成计算后,代码按距离排序并截取结果:

List<Pair<User, Long>> topUserPairList = list.stream()
        .sorted((a, b) -> (int) (a.getValue() - b.getValue()))
        .limit(num)
        .collect(Collectors.toList());

由于第一次查询只获取了 idtags,排序完成后,代码会根据排好序的用户 ID 再查询完整信息,并进行脱敏。这样既能完成算法计算,也能保证最终返回给前端的用户信息相对安全。

8. 复杂度分析

设:

  • 候选用户数量为 U
  • 当前用户标签数量为 n
  • 候选用户标签数量为 m

单次编辑距离计算的复杂度为:

时间复杂度:O(n × m)
空间复杂度:O(n × m)

对所有候选用户计算时,大致为:

O(U × n × m)

之后还需要对候选用户排序,排序复杂度约为:

O(U log U)

因此,当前方案在用户数量较少、每个用户标签数量有限时可以正常工作;当用户规模变大时,主要压力来自“查询所有候选用户”和“逐个在 Java 内存中计算”。

9. 当前方案的优点

  1. 规则清楚,结果容易解释。
  2. 不依赖机器学习模型或额外推荐服务。
  3. 用户标签结构灵活,早期不需要复杂的标签关系表。
  4. 算法代码独立在 AlgorithmUtils 中,便于单元测试。
  5. 可以较快验证伙伴匹配这一核心业务是否成立。

10. 当前方案的局限与风险

10.1 标签顺序会影响结果

编辑距离比较的是有顺序的列表,但用户标签在业务上通常是无序集合:

["Java", "后端"]
["后端", "Java"]

这两组标签实际完全相同,但按照当前算法可能产生非零距离。

10.2 所有标签权重相同

当前把每次替换都视为代价 1,没有区分核心技能和普通兴趣。例如,“Java”与“JavaScript”的差异,和“篮球”与“摄影”的差异,在算法中代价可能相同。

10.3 没有设置相似度阈值

当前逻辑只要候选用户数量足够,就会返回距离最小的前 num 个用户,即使这些用户与当前用户的标签并不太相似。

10.4 候选用户规模增大后性能下降

代码会先查询所有带标签的用户,再在内存中解析 JSON 和计算距离。用户量较大时,会增加数据库 IO、Java 堆内存和 CPU 消耗。

10.5 标签为空时需要额外保护

当前匹配逻辑默认登录用户拥有可解析的标签。如果登录用户的 tags 为空或格式错误,应在业务层提前返回空结果或参数错误,避免出现空指针或 JSON 解析异常。

11. 后续优化方向

以下内容属于改进建议,不是当前代码已经完成的功能。

11.1 使用集合相似度

对于无序标签,更适合使用 Jaccard 相似度:

Jaccard 相似度 = 标签交集数量 / 标签并集数量

例如:

A = {Java, 后端, MySQL}
B = {Java, 后端, Redis}

交集 = {Java, 后端},数量为 2
并集 = {Java, 后端, MySQL, Redis},数量为 4
相似度 = 2 / 4 = 0.5

这种方法不受标签顺序影响,而且更符合标签集合的业务含义。

11.2 给标签设置权重

可以为不同标签设置不同权重,例如:

核心技术标签:权重 3
普通兴趣标签:权重 1

这样可以让真正影响组队的技能标签发挥更大作用。

11.3 优化数据模型

当标签搜索和匹配成为高频功能时,可以将 JSON 标签逐步拆分为关系表:

user
tag
user_tag

并为 user_tag(userId, tagId) 建立索引,减少全表读取和内存过滤。

11.4 缩小候选集合

可以先根据用户所在方向、城市、年级或核心技能筛选候选人,再对较小集合计算相似度,避免对所有用户执行动态规划。

11.5 缓存匹配结果

对于短时间内重复访问的匹配请求,可以使用 Redis 缓存结果,并在用户标签发生变化时清理相关缓存。

12. 测试场景

建议至少覆盖以下场景:

场景 预期结果
当前用户没有登录 返回未登录或无权限错误
num 小于 1 返回参数错误
num 大于 20 返回参数错误
当前用户没有标签 返回空结果或明确的参数错误,不应抛出空指针
候选用户没有标签 不参与匹配
当前用户自己 不出现在匹配结果中
两组标签完全相同 距离为 0,优先返回
两组标签部分相同 根据编辑距离排序
没有任何候选用户 返回空列表
标签 JSON 格式错误 做异常处理,不应导致接口直接崩溃

13. 业务总结

当前伙伴匹配功能采用“标签画像 + 编辑距离 + 排序截取”的实现方式:

用户标签
  → 解析为标签列表
  → 与候选用户逐一计算编辑距离
  → 按距离从小到大排序
  → 返回最相似的前 N 名用户

它解决的是伙伴匹配的第一版需求:让系统能够根据用户已有标签,给出一组可解释的相似用户。后续如果用户规模、标签数量或推荐准确性要求提高,再考虑使用 Jaccard 相似度、加权匹配、关系表、缓存或更复杂的推荐模型。

标签相似度匹配业务文档
http://www.clxhxhhr.top/posts/526/
作者
clxstart
发布于
2026-09-08
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。