跳到主要内容

设计推荐系统

问题

如何用 Go 设计一个推荐系统?理解召回、排序的核心流程。

答案

推荐系统架构

阶段候选数作用
召回万级 → 千级快速筛出候选集
粗排千级 → 百级轻量模型初筛
精排百级 → 十级精确打分排序
重排十级去重、多样性、运营干预

基于协同过滤的召回

// 用户行为矩阵:user → item → score
type UserItemMatrix map[string]map[string]float64

// 基于用户的协同过滤
// 找到和目标用户最相似的 K 个用户,推荐他们喜欢的物品
func UserBasedCF(matrix UserItemMatrix, targetUser string, k int) []string {
// 1. 计算用户相似度(余弦相似度)
similarities := make(map[string]float64)
targetItems := matrix[targetUser]

for user, items := range matrix {
if user == targetUser {
continue
}
similarities[user] = cosineSimilarity(targetItems, items)
}

// 2. 取 Top-K 相似用户
topUsers := topK(similarities, k)

// 3. 推荐相似用户喜欢但目标用户没看过的物品
seen := make(map[string]bool)
for item := range targetItems {
seen[item] = true
}

scores := make(map[string]float64)
for _, user := range topUsers {
for item, score := range matrix[user] {
if !seen[item] {
scores[item] += score * similarities[user]
}
}
}

return sortByScore(scores)
}

// 余弦相似度
func cosineSimilarity(a, b map[string]float64) float64 {
var dotProduct, normA, normB float64
for key, va := range a {
if vb, ok := b[key]; ok {
dotProduct += va * vb
}
normA += va * va
}
for _, vb := range b {
normB += vb * vb
}
if normA == 0 || normB == 0 {
return 0
}
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
}

基于内容的推荐

// 物品标签向量
type Item struct {
ID string
Tags map[string]float64 // 标签 → 权重
}

// 根据用户历史偏好推荐相似物品
func ContentBasedRecommend(userHistory []Item, candidates []Item, topN int) []Item {
// 用户偏好 = 历史物品标签的加权平均
userProfile := make(map[string]float64)
for _, item := range userHistory {
for tag, weight := range item.Tags {
userProfile[tag] += weight
}
}

// 计算候选物品与用户偏好的相似度
type scored struct {
item Item
score float64
}
var results []scored
for _, candidate := range candidates {
score := cosineSimilarity(userProfile, candidate.Tags)
results = append(results, scored{candidate, score})
}

sort.Slice(results, func(i, j int) bool {
return results[i].score > results[j].score
})

items := make([]Item, 0, topN)
for i := 0; i < topN && i < len(results); i++ {
items = append(items, results[i].item)
}
return items
}

热门推荐 + Redis

// 基于 Redis ZSet 实现热门排行
func RecordClick(rdb *redis.Client, itemID string) {
// 按小时粒度记录点击量
key := fmt.Sprintf("hot:items:%s", time.Now().Format("2006010215"))
rdb.ZIncrBy(context.Background(), key, 1, itemID)
rdb.Expire(context.Background(), key, 48*time.Hour)
}

func GetHotItems(rdb *redis.Client, topN int) []string {
key := fmt.Sprintf("hot:items:%s", time.Now().Format("2006010215"))
results, _ := rdb.ZRevRangeByScore(context.Background(), key, &redis.ZRangeBy{
Min: "-inf",
Max: "+inf",
Count: int64(topN),
}).Result()
return results
}

推荐 API

func RecommendHandler(c *gin.Context) {
userID := c.Query("user_id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))

// 多路召回并发执行
var (
cfItems, hotItems, contentItems []string
)
var wg sync.WaitGroup
wg.Add(3)

go func() { defer wg.Done(); cfItems = recallByCF(userID) }()
go func() { defer wg.Done(); hotItems = recallByHot() }()
go func() { defer wg.Done(); contentItems = recallByContent(userID) }()
wg.Wait()

// 合并去重
candidates := mergeAndDedup(cfItems, hotItems, contentItems)

// 排序(简化版:按分数排序)
ranked := rank(candidates, userID)

// 截取 TopN
if len(ranked) > limit {
ranked = ranked[:limit]
}

c.JSON(200, gin.H{"items": ranked})
}

常见面试问题

Q1: 冷启动怎么解决?

答案

  • 新用户冷启动:展示热门/编辑推荐,引导用户选择兴趣标签
  • 新物品冷启动:基于内容特征推荐,利用物品标签匹配用户偏好
  • 用 Explore-Exploit 策略(如 ε-greedy)平衡探索和利用

Q2: 如何评估推荐效果?

答案

  • 离线指标:准确率、召回率、F1、AUC、NDCG
  • 在线指标:CTR(点击率)、CVR(转化率)、用户停留时长
  • A/B 测试对比不同策略

相关链接