标签相似度匹配业务文档
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.java的matchUsers - 业务实现:
src/main/java/com/yupi/yupao/service/impl/UserServiceImpl.java的matchUsers - 算法工具:
src/main/java/com/yupi/yupao/utils/AlgorithmUtils.java的minDistance(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[返回匹配结果]
具体步骤如下:
UserController校验num,当前只允许返回 1~20 个用户。- 通过 Session 获取当前登录用户。
- 查询
tags不为空的用户作为候选集合。 - 排除当前用户自己,以及标签为空的候选用户。
- 使用编辑距离算法计算当前用户与每个候选用户的标签差异。
- 按差异值从小到大排序。
- 取前
num个用户。 - 根据用户 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());
由于第一次查询只获取了 id 和 tags,排序完成后,代码会根据排好序的用户 ID 再查询完整信息,并进行脱敏。这样既能完成算法计算,也能保证最终返回给前端的用户信息相对安全。
8. 复杂度分析
设:
- 候选用户数量为
U; - 当前用户标签数量为
n; - 候选用户标签数量为
m。
单次编辑距离计算的复杂度为:
时间复杂度:O(n × m)
空间复杂度:O(n × m)
对所有候选用户计算时,大致为:
O(U × n × m)
之后还需要对候选用户排序,排序复杂度约为:
O(U log U)
因此,当前方案在用户数量较少、每个用户标签数量有限时可以正常工作;当用户规模变大时,主要压力来自“查询所有候选用户”和“逐个在 Java 内存中计算”。
9. 当前方案的优点
- 规则清楚,结果容易解释。
- 不依赖机器学习模型或额外推荐服务。
- 用户标签结构灵活,早期不需要复杂的标签关系表。
- 算法代码独立在
AlgorithmUtils中,便于单元测试。 - 可以较快验证伙伴匹配这一核心业务是否成立。
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 相似度、加权匹配、关系表、缓存或更复杂的推荐模型。