首页 / 资讯中心 / 文章详情

Go语言与Gin框架实现新闻推荐:TF-IDF和余弦相似度实战指南

Go语言与Gin框架实现新闻推荐:TF-IDF和余弦相似度实战指南 ★ FEATURED ARTICLE
几个月前有个朋友找我说想给他的新闻阅读站点加一个“猜你喜欢”的功能需求不复杂用户点开一篇新闻旁边列出几篇内容相似的文章。技术栈要我拿主意我第一反应不是Python而是Go——原因很简单服务端是Go语言写的为了一个推荐模块再单独养一套Python服务实在不划算。而且这种基于内容的推荐核心就两个算法TF-IDF做文本向量化余弦相似度算新闻之间的相似程度。这两个东西用Go实现并不难配合Gin框架暴露接口一个从零到一的新闻推荐系统很快就跑起来了。这篇文章就完整记录一下整个项目的搭建过程包括设计思路、算法原理、Gin项目结构、TF-IDF的具体实现、余弦相似度的计算方式以及我在实际踩坑后总结的一些注意事项。如果你手上也有一个Go语言项目需要做简单的内容推荐或者你正准备学习Gin框架的完整实战这篇文章应该能帮你省不少时间。1. 项目整体设计与思路拆解1.1 为什么采用“基于内容”的推荐方案推荐系统大体分两类协同过滤和基于内容。协同过滤需要大量用户行为数据比如点击、收藏、评分通过用户与物品的交互矩阵来做推荐。这类方案效果通常更好但对冷启动阶段的项目来说很难落地——没有用户数据模型就无从谈起。我这次做的新闻推荐数据来源只有新闻正文本身没有用户行为记录。所以直接选基于内容的推荐把每篇新闻转成一组特征向量然后通过向量之间的余弦相似度找出“和当前新闻最像的几篇”。这个方案的好处是零冷启动问题新增新闻立刻就能被推荐也不需要维护庞大的用户行为日志。用TF-IDF做向量化的另一个好处是可解释性强——某一篇新闻为什么被推荐出来背后的关键词权重是肉眼可见的调试起来非常方便。1.2 技术选型Gin框架的核心价值Go语言的Web框架里Gin是当前社区活跃度最高的一个API设计简洁中间件机制非常灵活性能也足够硬。对推荐系统这种“接口不多、但每个接口都要能扛住并发”的场景非常合适。举个具体的例子Gin的路由分组和中间件设计可以很干净地把“推荐接口”和其他业务接口隔离。推荐服务往往需要额外的耗时——比如向量计算、排序——我可以用一个单独的中间件做超时控制而不是把这些逻辑散落在各个handler里。此外Gin的JSON序列化性能在主流Go框架里是第一梯队推荐接口返回的JSON结构通常嵌套较深用Gin的c.JSON方法直接输出实测在并发压力下表现很稳。1.3 架构模块划分整个项目没有引入消息队列、大数据平台这些重型组件核心架构保持轻量新闻数据(内存/JSON) → 中文分词 → 停用词过滤 → TF-IDF向量化 → 余弦相似度计算 → TopN排序 → Gin API返回模块划分为四个部分数据层负责新闻的存取预处理层做分词和去停用词算法层实现TF-IDF和余弦相似度接口层用Gin封装HTTP服务。这种分层的好处是每一部分都可以独立测试算法层尤其关键可以直接写单元测试验证相似度计算的正确性。2. TF-IDF与余弦相似度的核心原理2.1 TF-IDF抽丝剥茧TF-IDF由两部分组成词频Term Frequency和逆文档频率Inverse Document Frequency。词频很好理解就是一个词在一篇文档里出现的次数占比。公式是TF 该词在文档中出现的次数 / 文档的总词数但单纯用词频有很明显的缺陷“新闻”、“记者”、“今天”这类词在每篇文章里都大量出现词频极高但对区分文章主题毫无帮助。所以要用IDF来降低这种通用词的权重。IDF log(总文档数 / (包含该词的文档数 1))加1是为了防止除数为零。如果一个词在越多的文档中出现IDF越接近零权重就越低反过来如果一个词只在少数文章里出现说明它具备很强的区分度IDF权重就高。最终的TF-IDF值就是两者相乘TF-IDF TF * IDF这样一篇文章就被映射成了一个向量向量的每一维是词条对应的TF-IDF值。两个向量之间的夹角越小说明两篇文章内容越接近。2.2 余弦相似度的几何直觉余弦相似度的公式很多人背过cos(θ) (A · B) / (|A| * |B|)分子是向量A和B的点积分母是两个向量的模长相乘。结果落在-1到1之间文本向量由于TF-IDF值非负结果落在0到1之间越接近1表示越相似。这里有个很形象的几何类比把每篇文章想象成多维空间里的一条射线词条就是坐标轴。TF-IDF值决定了射线在某个坐标轴方向上延伸的距离。两篇文章越相似它们对应的射线方向越接近夹角越小余弦值越接近1。不同文章的用词风格千差万别在这个高维空间里自然被“岔开”。2.3 为什么这个组合适合新闻场景新闻推荐有一个不同于商品推荐的特点新闻的生命周期很短热点内容可能几小时后就过时了。这意味着系统需要能够快速处理新增内容并且旧内容不会对结果产生过大的干扰。TF-IDF加余弦相似度正好满足这些要求。TF-IDF向量化是纯粹的文本统计不依赖复杂的神经网络模型计算速度足够快余弦相似度只关注向量方向而非长度受文档长度的影响较小——一篇300字快讯和一篇5000字深度报道只要核心主题词相似依然可以被正确匹配。这在新闻聚合场景里是个很实用的特性。3. 代码结构设计3.1 项目目录规划一个清晰的项目结构是长时间维护的基石。我的设计如下news-recommender/ ├── main.go // 程序入口初始化Gin引擎和路由 ├── go.mod // Go模块管理 ├── models/ │ └── news.go // 新闻数据结构和存储 ├── service/ │ ├── segmenter.go // 中文分词封装 │ ├── tfidf.go // TF-IDF计算逻辑 │ └── recommender.go // 推荐服务相似度计算排序 └── handler/ └── news_handler.go // Gin路由对应的handler层目录不复杂的核心原因是这个推荐系统本身逻辑就这么多强行拆出多层目录反而降低开发效率。等将来扩展了用户行为数据、增加了协同过滤模块再按业务域拆分也不迟。3.2 依赖准备创建项目并拉取依赖go mod init news-recommender go get -u github.com/gin-gonic/gin go get -u github.com/yanyiwu/gojiebagojieba是Go语言下最成熟的结巴分词库基于C的cppjieba封装分词效果和Python版保持一致。中文分词这一步是绕不开的因为TF-IDF依赖的词必须是完整语义单元——你总不能拿“你好世界”这种原始字符串去做词频统计要先切分成“你好”和“世界”。3.3 新闻数据模型的简单设计新闻模型不需要太复杂核心字段够用即可package models type News struct { ID int64 json:id Title string json:title Content string json:content Category string json:category Tags []string json:tags,omitempty }内存存储用一个map配合读写锁做并发控制package models import sync var ( newsStore make(map[int64]*News) mu sync.RWMutex ) func StoreNews(news *News) { mu.Lock() defer mu.Unlock() newsStore[news.ID] news } func GetAllNews() []*News { mu.RLock() defer mu.RUnlock() newsList : make([]*News, 0, len(newsStore)) for _, news : range newsStore { newsList append(newsList, news) } return newsList }用读写锁是因为推荐场景读多写少同一篇新闻可能被并发访问多次但新增新闻的频率相对低。实际的扩展场景中可以接入MySQL或Redis但演示项目保持内存存储把精力聚焦在推荐算法本体上。4. 分词与预处理环节4.1 中文分词的封装中文文本和英文不一样词与词之间没有天然的空格分隔必须先分词。gojieba的基本用法package service import ( github.com/yanyiwu/gojieba ) var jieba *gojieba.Jieba func InitSegmenter() { jieba gojieba.NewJieba() } // 分词并过滤停用词返回词列表 func Segment(text string) []string { words : jieba.CutForSearch(text, true) return filterStopWords(words) }CutForSearch是搜索引擎模式它会在精准分词的基础上补充一些可能的长词组合比如“上海浦东机场”会被切出“上海”“浦东”“机场”同时还会保留一个“上海浦东机场”的组合词。对推荐场景来说这种模式通常比默认模式效果好因为推荐系统需要捕捉到长尾关键词。注意gojieba的词典加载是内存密集型的大概要占用几百MB内存。如果部署环境内存紧张可以考虑用sego这类更轻量的分词库或者把gojieba独立成一个微服务。4.2 停用词过滤技巧停用词表是中文NLP必备的基础资源里面收录了“的、了、是、在、和”之类的高频虚词以及“记者”“报道”这类新闻通用语。我用的停用词表是从网上找的通用中文停用词表大概1200多个词同时自己补充了新闻领域的高频词。var stopWords map[string]bool{} func init() { // 实际项目从文件加载 for _, w : range []string{的, 了, 是, 在, 和, 记者, 报道, 新闻} { stopWords[w] true } } func filterStopWords(words []string) []string { result : make([]string, 0, len(words)) for _, w : range words { if w || stopWords[w] { continue } result append(result, w) } return result }这里有个细节值得留意停用词表不是越大越好过度过滤会误伤有区分度的词。比如“开发”这个词在互联网新闻里高频出现但它恰恰是区分“开发者大会”和“房地产新闻”的关键词不能简单过滤掉。我的建议是先跑一批真实新闻数据统计高频词分布再决定哪些词需要进停用词表。4.3 构建词典的实践细节由于新闻总数不大演示阶段几千篇我直接在启动时把所有新闻分词建立一个全局的文档频率表DF表。DF表的含义是“某个词出现在多少篇文档中”这是计算IDF的必要前置数据。// 统计DF func BuildDF(newsList []*models.News) map[string]int { df : make(map[string]int) for _, news : range newsList { words : Segment(news.Title news.Content) // 先做去重一篇文章里同一个词只计入一次DF wordSet : make(map[string]bool) for _, w : range words { wordSet[w] true } for w : range wordSet { df[w] } } return df }注意这里必须先去重再统计DF。如果一个词在一篇文章里出现10次DF只应该加1否则高频词会在文档频率上被严重放大扭曲IDF计算。这一点我见过很多初学者栽跟头包括我自己最早实现的时候也漏了这一步。5. TF-IDF与推荐引擎实现5.1 构建稀疏向量新闻文本的词表很容易膨胀到几万维如果用稠密数组表示每个向量内存会直接爆炸。实际操作中我用map[string]float64表示稀疏向量只记录非零维度。// 计算单篇新闻的TF-IDF向量 func ComputeTFIDF(words []string, df map[string]int, totalDocs int) map[string]float64 { tf : make(map[string]int) for _, w : range words { tf[w] } vector : make(map[string]float64) docWordCount : len(words) for w, count : range tf { // TF 词频 / 文档总词数 tfValue : float64(count) / float64(docWordCount) // IDF log((总文档数 1) / (包含该词的文档数 1)) 1 idfValue : math.Log(float64(totalDocs1)/float64(df[w]1)) 1 vector[w] tfValue * idfValue } return vector }IDF计算这里用了一个平滑公式log(N1)/(df1)再加1。加1是为了保证即使某个词的IDF计算值很小也不至于降到零从而完全失去对词的区分作用。这是Lucene常用的做法比裸的log(N/df)更稳定。5.2 余弦相似度函数实现稀疏向量之间的点积只需要遍历两个向量中较小那个map的键即可复杂度从O(V)降到O(k)k远小于词表大小V。func CosineSimilarity(vecA, vecB map[string]float64) float64 { var dotProduct, normA, normB float64 // 先算分子和normA for k, v : range vecA { dotProduct v * vecB[k] normA v * v } // 再算normB for _, v : range vecB { normB v * v } if normA 0 || normB 0 { return 0 } return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB)) }这个实现有个小技巧计算点积的同时就把normA算完了省了一次完整遍历。normB单独遍历因为vecA可能比vecB小很多但normB必须看全所有维度。5.3 推荐器核心逻辑有了向量计算能力推荐逻辑就非常直接了把目标新闻的向量和全量新闻向量逐一比对按相似度降序取TopN。注意要跳过自己。package service import ( news-recommender/models sort ) type NewsRecommendation struct { News *models.News json:news Similarity float64 json:similarity } type Recommender struct { df map[string]int totalDocs int vectors map[int64]map[string]float64 } func NewRecommender(newsList []*models.News) *Recommender { df : BuildDF(newsList) recommender : Recommender{ df: df, totalDocs: len(newsList), vectors: make(map[int64]map[string]float64), } for _, news : range newsList { words : Segment(news.Title news.Content) recommender.vectors[news.ID] ComputeTFIDF(words, df, len(newsList)) } return recommender } func (r *Recommender) RecommendByNewsID(newsID int64, topN int) []NewsRecommendation { targetVec : r.vectors[newsID] if targetVec nil { return nil } results : make([]NewsRecommendation, 0) for id, vec : range r.vectors { if id newsID { continue } sim : CosineSimilarity(targetVec, vec) results append(results, NewsRecommendation{ News: models.GetNewsByID(id), Similarity: sim, }) } sort.Slice(results, func(i, j int) bool { return results[i].Similarity results[j].Similarity }) if len(results) topN { results results[:topN] } return results }这里的推荐器在构造时会一次性计算全量新闻的TF-IDF向量以空间换时间。每次推荐查询只需要做N次点积运算性能上完全够撑起中小规模的新闻站。6. 构建Gin接口层6.1 主程序搭建Gin服务Gin的启动代码非常简洁package main import ( news-recommender/handler news-recommender/models news-recommender/service github.com/gin-gonic/gin ) func main() { // 初始化分词器 service.InitSegmenter() // 导入几条演示新闻数据 loadDemoData() // 构造推荐器启动时计算全量向量 newsList : models.GetAllNews() recommender : service.NewRecommender(newsList) // 初始化Gin引擎 r : gin.Default() // 注册路由 handler.RegisterRoutes(r, recommender) // 启动服务默认监听8080 r.Run(:8080) }Gin的gin.Default()自带Logger和Recovery两个中间件前者打印请求日志后者捕获panic防止进程崩溃。对于正式环境这些中间件可以根据需要替换成自定义版本。6.2 路由与handler的分离handler层负责解析请求参数、调用推荐器、返回JSON。我习惯把所有路由注册收敛到一个函数里package handler import ( net/http strconv news-recommender/service github.com/gin-gonic/gin ) func RegisterRoutes(r *gin.Engine, recommender *service.Recommender) { h : NewsHandler{recommender: recommender} api : r.Group(/api/v1) { api.GET(/news/:id/recommend, h.Recommend) api.GET(/health, func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{status: ok}) }) } } type NewsHandler struct { recommender *service.Recommender } func (h *NewsHandler) Recommend(c *gin.Context) { id, err : strconv.ParseInt(c.Param(id), 10, 64) if err ! nil { c.JSON(http.StatusBadRequest, gin.H{error: invalid news id}) return } topN, _ : strconv.Atoi(c.DefaultQuery(topN, 5)) result : h.recommender.RecommendByNewsID(id, topN) if result nil { c.JSON(http.StatusNotFound, gin.H{error: news not found}) return } c.JSON(http.StatusOK, gin.H{data: result}) }这里有个小细节topN如果用户不传默认返回5条。strconv.Atoi如果遇到非法参数返回0推荐器的切片截取逻辑results[:0]会返回空数组这种边界情况不会panic接口行为比较安全。提示推荐接口应该允许调用方控制返回数量但一定要设置合理上限。我在生产环境一般限制最大20条防止有人传一个TopN100000把服务打垮。6.3 演示数据与接口验证为了验证功能我写了一个loadDemoData函数库里面放几篇不同主题的新闻func loadDemoData() { models.StoreNews(models.News{ ID: 1, Title: Go语言发布新版本 性能显著提升, Content: Go语言官方发布了最新版本编译器性能提升明显并发编程能力进一步增强。开发者社区反响热烈。, Category: 科技, }) models.StoreNews(models.News{ ID: 2, Title: Gin框架入门教程 使用Go语言开发Web应用, Content: Gin是Go语言最流行的Web框架它为开发者提供了简洁的API和高效的请求处理能力。本文介绍Gin的基本用法。, Category: 科技, }) models.StoreNews(models.News{ ID: 3, Title: 厨师推荐秋季养生汤的做法大全, Content: 秋季养生汤以清淡为主适合选用莲藕、山药、百合等食材。汤品风味独特营养丰富。, Category: 生活, }) // 更多新闻... }启动服务后访问接口curl http://localhost:8080/api/v1/news/1/recommend?topN3返回结果看起来就像这样{ data: [ { news: { id: 2, title: Gin框架入门教程 使用Go语言开发Web应用, content: Gin是Go语言最流行的Web框架..., category: 科技 }, similarity: 0.236 }, { news: { id: 20, title: 微服务架构下Go语言的最佳实践, content: ..., category: 科技 }, similarity: 0.184 } ] }可以看到和“Go语言新版本”最相似的确实是科技类新闻和“养生汤”的相似度则会非常低。TF-IDF加余弦相似度的效果在这个简单的演示数据集上能够直观体现。7. 实际效果与常见问题排查7.1 推荐效果的关键瓶颈分词质量TF-IDF的调优空间主要在分词的准确性和停用词表的合理性。我实际测试时发现两个问题一是分词粒度对相似度影响很大。比如“Go语言”在gojieba的策略下可能被切成“Go”和“语言”两个词但如果新闻库里同时有“Go语言”这种完整词两者各自拆出的向量差异就会导致本来应该高相似度的文章得分偏低。解决办法是根据领域词库配置自定义词典把“Go语言”“Gin框架”“微服务”这类技术名词收进词典分词的准确性会显著提高。二是停用词表需要结合语料特点来定制。通用停用词表覆盖了“的、了、是”但新闻领域的“记者”“编辑”“本报”这些词就要额外添加。我维护了一个自定义停用词文件随着语料增多逐步迭代每次更新后重跑一遍离线评估看推荐结果是否符合直觉这也是基于内容的推荐系统最实用的人工验收方式。7.2 空向量与分母为零问题如果一篇文章全部是停用词——比如短标题、空正文——分词过滤后可能一个有效词都没有得到空向量。两个空向量的余弦相似度是0除0会panic。我的处理办法是func CosineSimilarity(vecA, vecB map[string]float64) float64 { if len(vecA) 0 || len(vecB) 0 { return 0 } // 原有逻辑... }保底返回相似度0既不会崩也不会让无内容的文章占据推荐结果。这种边界情况在实际环境中很常见新闻站经常有图片快讯、视频新闻正文可能非常短搞不好就是纯停用词。7.3 性能定位与优化方向全量遍历计算相似度的方案在几千篇新闻时响应时间在个位数毫秒级别完全感知不到。但当新闻规模到十万篇以上一遍遍历做点积就会到百毫秒级别这时候需要优化。优化的首选不是换算法而是缩小候选集。新闻推荐不需要在全量新闻里找相似只需要在同一分类下找就行。我加了分类预过滤的逻辑推荐前先限定category相同的新闻再计算相似度候选集从十万降到几千速度提升非常明显。如果后续真的需要全量计算可以引入倒排索引加速候选集召回再配合向量化运算做精排。另外一个容易忽略的瓶颈是推荐器的构建过程。每次服务重启TF-IDF向量就要全量重算一遍新闻量大时启动耗时可能跑到几秒甚至十几秒。解决办法是把向量计算和推荐器构建做成独立的离线任务把计算好的向量序列化到文件或Redis里服务启动时直接加载。7.4 内存占用与并发安全gojieba的词典加载本来就吃内存再加上全量新闻的TF-IDF向量放进map里内存开销会随着新闻数量线性增长。我实测大概5万篇新闻的情况下内存占用接近1GB。如果发现内存告急先检查是不是每个请求都重新构建了推荐器——正确的做法是在main函数里只构建一次所有请求共享同一个推荐器实例。并发安全方面推荐器如果只是读操作——查询向量、计算相似度——那么内部不使用锁也是安全的因为map只有并发写才会触发panic只读不写是安全的。但如果后续要动态添加新新闻就得在前面提到的读写锁之外再给推荐器增加一个“增量更新到向量集合”的带锁写入方法。注意map的并发读本身虽然是安全的但并发读写会panic所以推荐器一旦暴露了写接口所有访问路径必须统一加锁。8. 扩展方向从演示到生产级系统基于内容的推荐用TF-IDF和余弦相似度只是第一步。我做完这个演示后又沿着几个方向做了扩展这里顺便分享一下思路。第一是引入高斯权重的时间衰减因子。新闻有时效性同样相似的两条新闻昨天发布的应该比上周发布的排序更靠前。实现上可以在排序时对相似度乘以一个衰减系数比如score similarity / (1 ageHours * alpha)alpha根据业务调节。第二是合并分类加分项。内容相似度之外给同一分类的新闻额外加一个固定的小权重比如0.05。这样即使两篇新闻关键词重叠不多只要属于同一栏目也会保底出现在彼此的推荐列表里。第三是用户行为反馈。当新闻站积累了一定量的点击数据后可以把协同过滤加进来做成混合推荐。基于内容的推荐作为召回层协同过滤作为精排层的补充两者结果做加权融合或者用接口逻辑动态切换是比较容易落地的过度演进路径。这个项目做到这里推荐接口已经从零到一实现了整个代码量不过几百行。核心无非是分词、TF-IDF、余弦相似度、Gin路由这几块拼装起来但每个环节都有不少值得琢磨的细节。我自己在构建的过程中踩得最深的坑就是DF统计忘了先去重导致IDF计算偏差推荐结果里全是“记者报道”这种通用词的影子。如果你也准备自己动手搭一套建议先把流程跑通再回头做调优——基于内容的推荐最大的优点就是每一步都能看到结果调试起来非常直观。
阅读完成 · 觉得有帮助?
咨询建站