MVP 版推荐系统 Go 实现框架说明
本方案完全基于 Go 原生实现,无额外大数据/算法组件依赖,复用你现有的 PostgreSQL + Redis 存储,采用「多路召回 → 规则过滤 → 加权排序 → 结果缓存」的标准推荐架构,代码结构预留扩展位,后续可平滑升级到向量召回、模型精排。
技术栈底座:
- Web 框架:Gin(与现有业务栈统一)
- ORM:GORM
- 缓存:go-redis v9
- 核心算法:纯规则 + 标签权重计算,无第三方算法库
一、项目目录结构
ai-programming-site/
├── cmd/
│ └── api/
│ └── main.go # 服务入口
├── internal/
│ ├── recommend/ # 推荐系统核心模块
│ │ ├── model/ # 数据模型(DB结构体)
│ │ ├── dao/ # 数据访问层(DB/Redis)
│ │ ├── recall/ # 召回层:多路召回策略
│ │ │ ├── base.go # 召回器通用接口
│ │ │ ├── tag_recall.go # 标签兴趣召回(核心)
│ │ │ ├── hot_recall.go # 热门内容召回
│ │ │ ├── new_recall.go # 新品内容召回
│ │ │ └── similar_recall.go # 相似文章召回(详情页用)
│ │ ├── filter/ # 过滤层
│ │ │ ├── read_filter.go # 已读/已收藏过滤
│ │ │ └── diversity_filter.go# 多样性打散过滤
│ │ ├── rank/ # 排序层
│ │ │ └── weight_rank.go # 加权综合排序
│ │ ├── interest/ # 用户兴趣计算
│ │ │ └── interest_service.go# 兴趣标签权重更新
│ │ └── service.go # 推荐主服务编排
│ ├── handler/ # HTTP接口层
│ │ └── recommend_handler.go
│ └── dto/ # 请求/响应结构体
├── pkg/
│ └── cache/ # Redis通用封装
└── config/ # 配置文件
二、核心数据模型(model 层)
对应数据库表设计,核心围绕「文章标签」和「用户兴趣权重」构建。
1. 文章与标签模型
// internal/recommend/model/article.go
package model
import "time"
// Article 文章主表
type Article struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"type:varchar(255);not null"`
Category string `gorm:"type:varchar(50);comment:一级分类:教程/资讯/工具"`
Difficulty string `gorm:"type:varchar(20);comment:难度:入门/进阶/实战"`
PublishAt time.Time `gorm:"comment:发布时间"`
// 热度分(定时任务预计算)
HotScore float64 `gorm:"type:decimal(10,4);default:0;comment:热度分"`
}
// ArticleTag 文章-标签关联表(多对多)
type ArticleTag struct {
ID uint `gorm:"primaryKey"`
ArticleID uint `gorm:"index;not null"`
TagName string `gorm:"type:varchar(50);index;not null"` // 如: Cursor、Go、React、Prompt
Weight float64 `gorm:"type:decimal(5,2);default:1.0;comment:标签在文章中的权重"`
}
2. 用户行为与兴趣模型
// internal/recommend/model/user.go
package model
import "time"
// UserBehavior 用户行为日志表
type UserBehavior struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index;not null"`
ArticleID uint `gorm:"index;not null"`
Behavior string `gorm:"type:varchar(20);comment:view/like/favorite/read_complete"`
StaySeconds int `gorm:"comment:停留时长(秒)"`
CreatedAt time.Time `gorm:"autoCreateTime"`
}
// UserInterestTag 用户兴趣标签表
type UserInterestTag struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index:idx_user_tag,unique;not null"`
TagName string `gorm:"index:idx_user_tag,unique;type:varchar(50);not null"`
Weight float64 `gorm:"type:decimal(10,4);default:0;comment:兴趣权重"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
三、召回层实现(recall 层)
统一召回器接口,所有召回策略实现该接口,方便后续新增召回通道。
1. 通用召回接口
// internal/recommend/recall/base.go
package recall
import "ai-programming-site/internal/recommend/model"
// RecallItem 召回结果项
type RecallItem struct {
ArticleID uint
Score float64 // 召回通道内的原始得分
Channel string // 召回通道标识
}
// Recaller 召回器接口
type Recaller interface {
Name() string
// DoRecall 执行召回,返回候选文章列表
DoRecall(userID uint, limit int) ([]RecallItem, error)
}
2. 核心:标签兴趣召回
根据用户兴趣标签权重,匹配文章标签,计算相关度得分。
// internal/recommend/recall/tag_recall.go
package recall
import (
"ai-programming-site/internal/recommend/dao"
"ai-programming-site/internal/recommend/model"
)
type TagRecaller struct {
articleDao *dao.ArticleDao
interestDao *dao.InterestDao
}
func NewTagRecaller() *TagRecaller {
return &TagRecaller{
articleDao: dao.NewArticleDao(),
interestDao: dao.NewInterestDao(),
}
}
func (r *TagRecaller) Name() string {
return "tag_recall"
}
func (r *TagRecaller) DoRecall(userID uint, limit int) ([]RecallItem, error) {
// 1. 获取用户TopN兴趣标签(按权重倒序取前20个)
userTags, err := r.interestDao.GetUserTopTags(userID, 20)
if err != nil {
return nil, err
}
if len(userTags) == 0 {
return []RecallItem{}, nil
}
// 2. 根据标签批量查询关联文章
tagNames := make([]string, 0, len(userTags))
tagWeightMap := make(map[string]float64)
for _, tag := range userTags {
tagNames = append(tagNames, tag.TagName)
tagWeightMap[tag.TagName] = tag.Weight
}
articleTags, err := r.articleDao.GetArticlesByTags(tagNames)
if err != nil {
return nil, err
}
// 3. 计算文章相关度得分 = 累加(用户标签权重 * 文章标签权重)
scoreMap := make(map[uint]float64)
for _, at := range articleTags {
if userWeight, ok := tagWeightMap[at.TagName]; ok {
scoreMap[at.ArticleID] += userWeight * at.Weight
}
}
// 4. 按得分倒序,取前limit个
items := make([]RecallItem, 0, len(scoreMap))
for aid, score := range scoreMap {
items = append(items, RecallItem{
ArticleID: aid,
Score: score,
Channel: r.Name(),
})
}
// 按得分排序,截断
// 此处省略排序逻辑,可引入sort包实现
if len(items) > limit {
items = items[:limit]
}
return items, nil
}
3. 热门召回 + 新品召回
作为保底召回,解决冷启动与兴趣覆盖不足的问题。
// internal/recommend/recall/hot_recall.go
package recall
import "ai-programming-site/internal/recommend/dao"
type HotRecaller struct {
articleDao *dao.ArticleDao
}
func NewHotRecaller() *HotRecaller {
return &HotRecaller{articleDao: dao.NewArticleDao()}
}
func (r *HotRecaller) Name() string { return "hot_recall" }
func (r *HotRecaller) DoRecall(_ uint, limit int) ([]RecallItem, error) {
// 取近7天热度分最高的文章
articles, err := r.articleDao.GetHotArticles(7, limit)
if err != nil {
return nil, err
}
items := make([]RecallItem, 0, len(articles))
for _, art := range articles {
items = append(items, RecallItem{
ArticleID: art.ID,
Score: art.HotScore,
Channel: r.Name(),
})
}
return items, nil
}
新品召回逻辑类似,按发布时间倒序取最新文章,得分按时效衰减计算(发布越近得分越高)。
四、过滤层实现(filter 层)
对多路召回合并后的候选集进行过滤,保证体验。
1. 已读/已收藏过滤
// internal/recommend/filter/read_filter.go
package filter
import "ai-programming-site/internal/recommend/dao"
type ReadFilter struct {
behaviorDao *dao.BehaviorDao
}
func NewReadFilter() *ReadFilter {
return &ReadFilter{behaviorDao: dao.NewBehaviorDao()}
}
// DoFilter 过滤用户已读、已收藏、近30天已推荐过的文章
func (f *ReadFilter) DoFilter(userID uint, articleIDs []uint) ([]uint, error) {
// 批量查询用户历史行为中的文章ID
readIDs, err := f.behaviorDao.GetUserReadArticleIDs(userID, 30)
if err != nil {
return nil, err
}
readSet := make(map[uint]struct{})
for _, id := range readIDs {
readSet[id] = struct{}{}
}
result := make([]uint, 0, len(articleIDs))
for _, id := range articleIDs {
if _, ok := readSet[id]; !ok {
result = append(result, id)
}
}
return result, nil
}
2. 多样性过滤
控制同分类、同标签文章占比,避免结果同质化,例如同一分类连续出现不超过2篇。
五、排序层实现(rank 层)
将多路召回的得分做归一化处理后,按权重加权求和,得到最终排序分。
// internal/recommend/rank/weight_rank.go
package rank
import (
"ai-programming-site/internal/recommend/model"
"ai-programming-site/internal/recommend/recall"
"math"
)
// 各通道权重(可配置化)
var channelWeight = map[string]float64{
"tag_recall": 0.6, // 标签相关度权重最高
"hot_recall": 0.3, // 热度权重次之
"new_recall": 0.1, // 时效性权重最低
}
type WeightRanker struct{}
func NewWeightRanker() *WeightRanker {
return &WeightRanker{}
}
// DoRank 对召回结果进行加权排序
func (r *WeightRanker) DoRank(items []recall.RecallItem) []recall.RecallItem {
if len(items) == 0 {
return items
}
// 1. 按通道分组,做min-max归一化,消除量纲差异
channelItems := make(map[string][]recall.RecallItem)
for _, item := range items {
channelItems[item.Channel] = append(channelItems[item.Channel], item)
}
scoreMap := make(map[uint]float64)
for channel, channelList := range channelItems {
// 计算当前通道的最大最小得分
minScore, maxScore := math.MaxFloat64, -math.MaxFloat64
for _, item := range channelList {
if item.Score < minScore {
minScore = item.Score
}
if item.Score > maxScore {
maxScore = item.Score
}
}
// 归一化 + 加权
weight := channelWeight[channel]
for _, item := range channelList {
normScore := 0.0
if maxScore != minScore {
normScore = (item.Score - minScore) / (maxScore - minScore)
}
scoreMap[item.ArticleID] += normScore * weight
}
}
// 2. 按最终得分倒序排序
// 此处省略排序逻辑,返回排序后的列表
result := make([]recall.RecallItem, 0, len(scoreMap))
for aid, score := range scoreMap {
result = append(result, recall.RecallItem{
ArticleID: aid,
Score: score,
})
}
return result
}
六、用户兴趣更新服务
用户产生行为后,异步更新兴趣标签权重,是推荐效果的核心反馈机制。
// internal/recommend/interest/interest_service.go
package interest
import (
"ai-programming-site/internal/recommend/dao"
"ai-programming-site/internal/recommend/model"
)
// 行为权重系数(可配置)
var behaviorWeight = map[string]float64{
"view": 1.0, // 普通浏览
"read_complete": 2.0, // 读完
"like": 3.0, // 点赞
"favorite": 5.0, // 收藏
}
type InterestService struct {
articleDao *dao.ArticleDao
interestDao *dao.InterestDao
}
func NewInterestService() *InterestService {
return &InterestService{
articleDao: dao.NewArticleDao(),
interestDao: dao.NewInterestDao(),
}
}
// UpdateInterest 用户产生行为后,更新兴趣标签权重
func (s *InterestService) UpdateInterest(userID uint, articleID uint, behavior string) error {
// 1. 获取文章的所有标签
tags, err := s.articleDao.GetArticleTags(articleID)
if err != nil {
return err
}
if len(tags) == 0 {
return nil
}
// 2. 获取行为对应的权重系数
weightFactor, ok := behaviorWeight[behavior]
if !ok {
weightFactor = 1.0
}
// 3. 批量更新用户兴趣标签权重
// 规则:新权重 = 旧权重 * 0.95(时间衰减) + 文章标签权重 * 行为系数
for _, tag := range tags {
addWeight := tag.Weight * weightFactor
err := s.interestDao.UpsertUserTagWeight(userID, tag.TagName, addWeight, 0.95)
if err != nil {
return err
}
}
return nil
}
// AsyncUpdateInterest 异步更新,不阻塞主请求
func (s *InterestService) AsyncUpdateInterest(userID uint, articleID uint, behavior string) {
go func() {
_ = s.UpdateInterest(userID, articleID, behavior)
}()
}
七、推荐主服务编排
串联「缓存读取 → 多路召回 → 过滤 → 排序 → 回写缓存 → 返回结果」全流程。
// internal/recommend/service.go
package recommend
import (
"ai-programming-site/internal/recommend/dao"
"ai-programming-site/internal/recommend/filter"
"ai-programming-site/internal/recommend/rank"
"ai-programming-site/internal/recommend/recall"
"ai-programming-site/pkg/cache"
"encoding/json"
"fmt"
"time"
)
type RecommendService struct {
recallers []recall.Recaller
readFilter *filter.ReadFilter
ranker *rank.WeightRanker
articleDao *dao.ArticleDao
cache *cache.RedisClient
}
func NewRecommendService() *RecommendService {
return &RecommendService{
// 注册所有召回器
recallers: []recall.Recaller{
recall.NewTagRecaller(),
recall.NewHotRecaller(),
recall.NewNewRecaller(),
},
readFilter: filter.NewReadFilter(),
ranker: rank.NewWeightRanker(),
articleDao: dao.NewArticleDao(),
cache: cache.GetRedis(),
}
}
// GetHomeRecommend 获取首页个性化推荐
func (s *RecommendService) GetHomeRecommend(userID uint, page, pageSize int) ([]model.Article, error) {
cacheKey := fmt.Sprintf("recommend:home:%d", userID)
offset := (page - 1) * pageSize
// 1. 先读缓存,命中直接返回
cacheData, err := s.cache.Get(cacheKey)
if err == nil && cacheData != "" {
var allIDs []uint
_ = json.Unmarshal([]byte(cacheData), &allIDs)
// 分页截取,查询文章详情返回
return s.articleDao.GetArticlesByIDs(allIDs[offset:offset+pageSize])
}
// 2. 多路召回并行执行
allItems := make([]recall.RecallItem, 0)
// 实际项目可用errgroup并发执行
for _, r := range s.recallers {
items, err := r.DoRecall(userID, 100) // 每路召回100条候选
if err != nil {
continue // 单路召回失败不影响整体
}
allItems = append(allItems, items...)
}
// 3. 提取文章ID,执行过滤
articleIDs := make([]uint, 0, len(allItems))
idMap := make(map[uint]recall.RecallItem)
for _, item := range allItems {
if _, ok := idMap[item.ArticleID]; !ok {
idMap[item.ArticleID] = item
articleIDs = append(articleIDs, item.ArticleID)
}
}
filteredIDs, err := s.readFilter.DoFilter(userID, articleIDs)
if err != nil {
return nil, err
}
// 4. 还原召回项,执行排序
filteredItems := make([]recall.RecallItem, 0, len(filteredIDs))
for _, id := range filteredIDs {
filteredItems = append(filteredItems, idMap[id])
}
rankedItems := s.ranker.DoRank(filteredItems)
// 5. 提取最终ID列表,写入缓存(有效期1小时)
finalIDs := make([]uint, 0, len(rankedItems))
for _, item := range rankedItems {
finalIDs = append(finalIDs, item.ArticleID)
}
cacheBytes, _ := json.Marshal(finalIDs)
_ = s.cache.Set(cacheKey, string(cacheBytes), time.Hour)
// 6. 分页返回文章详情
end := offset + pageSize
if end > len(finalIDs) {
end = len(finalIDs)
}
return s.articleDao.GetArticlesByIDs(finalIDs[offset:end])
}
八、HTTP 接口层
// internal/handler/recommend_handler.go
package handler
import (
"ai-programming-site/internal/recommend"
"ai-programming-site/internal/recommend/interest"
"github.com/gin-gonic/gin"
"net/http"
)
type RecommendHandler struct {
recommendSvc *recommend.RecommendService
interestSvc *interest.InterestService
}
func NewRecommendHandler() *RecommendHandler {
return &RecommendHandler{
recommendSvc: recommend.NewRecommendService(),
interestSvc: interest.NewInterestService(),
}
}
// GetHomeRecommend 首页推荐接口
func (h *RecommendHandler) GetHomeRecommend(c *gin.Context) {
userID := c.GetUint("user_id") // 从鉴权中获取用户ID
page := c.DefaultInt("page", 1)
pageSize := c.DefaultInt("page_size", 10)
articles, err := h.recommendSvc.GetHomeRecommend(userID, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取推荐失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": articles})
}
// ReportBehavior 行为上报接口(浏览/点赞/收藏)
func (h *RecommendHandler) ReportBehavior(c *gin.Context) {
var req struct {
ArticleID uint `json:"article_id" binding:"required"`
Behavior string `json:"behavior" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"})
return
}
userID := c.GetUint("user_id")
// 异步更新兴趣,不阻塞
h.interestSvc.AsyncUpdateInterest(userID, req.ArticleID, req.Behavior)
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "上报成功"})
}
九、冷启动与补充优化
- 新用户冷启动
- 未登录/无行为用户,直接返回热门召回 + 入门分类内容,不执行标签召回;
- 首次访问弹出兴趣选择弹窗(3-5个标签),选中后直接初始化用户兴趣权重,跳过冷启动期。
- 热度分预计算
- 用定时任务(如
cron库)每天凌晨计算近7天文章热度分,公式参考:
热度分 = 点击数0.4 + 读完数0.3 + 收藏数0.2 + 点赞数0.1,并做时间衰减。
- 缓存失效策略
- 用户产生收藏、点赞等强行为时,主动删除该用户的推荐缓存,下次请求重新计算,保证实时反馈。
- 平滑升级路径
- 后续新增向量召回:只需新增
vector_recall.go实现 Recaller 接口,接入召回列表即可; - 后续替换模型排序:只需新增
model_rank.go替换当前 WeightRanker,主流程无需改动。