1. 为什么经典名著做推荐,比想象中难得多
我做在线阅读平台的项目已经有几年了,从资讯流到工具类产品都碰过,但真正让我觉得"推荐系统不是套个协同过滤就能完事"的,恰恰是这个古典名著推荐项目。以前做过的商品推荐、视频推荐,核心都依赖一个东西:用户的行为数据。你点了什么、买了什么、停留了多久,这些信号越丰富,推荐就越准。但名著这块完全是另一番景象:大部分用户一年就看那么几本,而且看完之后几乎没有"反馈"可言,不点赞不评分,连写个短评的人都极少。你让协同过滤怎么学?它拿到的矩阵百分之九十九都是空的。
更麻烦的是名著的标签体系极其粗糙。市面上的书单分类基本停留在"中国古典""外国名著""小说""散文"这一层,我随便举几个真实需求:有人想找"清末民初背景下带有讽刺意味的中篇小说",有人想读"探讨个人与时代冲突的俄国文学",还有人拿着《红楼梦》问我"有没有气质类似但更短篇的作品"。你靠人工给书打标签,一个标签系统根本描述不了这种"气质"层面的相似性。我当时的判断是:要想把名著推荐做出名堂,不能只依赖统计行为,还得让系统真正"读"原文,从文本内容里提取语义特征,然后拿语义特征去做匹配。
这个项目就这么定下来了:用 Django 搭一套完整的 Web 服务,把深度学习模型生成的书籍语义向量作为推荐依据,用户在前端选择或点击某一本名著,系统返回一批"内容气质相近"的书目。整个系统既要管数据清洗、模型推理,又要管用户行为日志、API接口和后台管理,属于典型的"算法工程化落地"项目。我自己在这套系统里把从前端到模型训练的链路完整走了一遍,踩了不少坑,这篇文章就把整个过程和关键细节摊开来讲,给想做类似文本语义推荐的人一条能直接动手的路线。
1.1 评分稀疏、标签粗糙,传统推荐算法在这里失灵
先聊一个我在项目初期亲眼验证过的现象。我拿平台积累的用户阅读记录去跑了一版协同过滤,用的是最经典的物品协同过滤:先算名著之间的共同阅读关系,再根据"读过A的人还读过B"来给相似推荐。结果排序靠前的几乎全是同一批高热度书目——《三国演义》永远和《水浒传》强关联,《红楼梦》永远和《西厢记》强关联。这个结果本身没错,但完全没有挖掘空间:它只能推荐"大家都知道的名著配另一个大家都知道的名著",对小众冷门书目几乎没有任何区分度,因为共同阅读的样本少得可怜,算出来的相似度全是噪声。
这就是经典名著领域最典型的"马太效应":热门的占据全部流量,冷门的继续沉底。所以我换了个思路:既然用户行为不够,那就直接从文本里找依据。书籍本身是高度结构化的内容,每一本书的风格、主题、思想倾向都写在字里行间,只要能把这种内容信息转成数值向量,就能跨过行为稀疏的障碍,做到"内容相似即推荐"。
1.2 项目的核心思路:把"读懂书的内容"作为推荐依据
整系统最核心的算法逻辑一句话就能讲清:把每一本名著的正文切成片段,用深度学习模型把每个片段编码成一个高维语义向量,然后把全书所有片段的向量聚合起来,作为这本书的"内容指纹"。用户对某一本书感兴趣时,系统就计算这本书的向量和其他所有书向量之间的余弦相似度,按分数排序取TopN。
听起来简单,实际操作里有三个非常容易踩的坑。第一个坑是"向量怎么聚合成书级表征":直接全书平均会把风格完全不同的章节搅成一锅粥。第二个坑是"相似度分数怎么解释":向量余弦相似度0.86和0.82到底差在哪,业务方要看懂需要一套转译机制。第三个坑是"冷门书也一样要有向量":如果某本书在清洗阶段就被丢掉,后续所有环节都会受影响。我把这些坑一个个填平的过程,恰恰是整个项目里最有参考价值的部分,后面会详细展开。
2. 语料工程:名著文本的采集、清洗与结构化
推荐系统界有个老话叫"垃圾进、垃圾出",在文本语义推荐里这句话的杀伤力会放大十倍。我在做语义向量之前,先把语料整理这一步当成独立工程来做,前后花了将近两周。因为没有干净的语料,后面模型训得再好都白搭。
2.1 数据来源与收录原则
我做这个项目时,语料来源主要是公开渠道的文本库和一些数字图书馆项目的开放数据。这里有一个非常重要的原则需要前置说明:名著的版权状态差异极大,国内公版书和翻译作品的授权情况完全不同,做任何实际项目前必须逐本核对版权,不能因为"网上都传遍了"就直接抓来商用。我这个项目定位是技术验证和内部原型,但依然逐条核对了来源声明。
收录的原则我定了几条:第一,优先收足本而非节选本,因为推荐系统要抓的是全书风格,缺了开头结尾的节选本会严重扭曲向量;第二,同时保留书籍的基本元数据,包括书名、作者、成书年代、原书语种、体裁类型、内容简介;第三,同一本书的不同版本如果文本差异较大(尤其是旧译本和重新整理的通行本),只保留一个质量最高的版本,避免重复收录导致推荐时自己和自己高度相似。
这方面我踩过一个很实际的坑:早期贪多,收了三十多个来源的不同版本《战争与和平》,结果向量聚类时它们像兄弟一样紧紧抱在一起,任何其他书都挤不进来。后来设置了一个规则,同一原著只保留一个版本,问题立刻缓解。
2.2 文本清洗的完整流程
拿到原始文本后,首先是项目中最不起眼却极其重要的清洗步骤。我从网上抓下来的TXT文本五花八门——有的开头带着大段的出版社宣传页,有的章节间夹着"未完待续"或"本书由某某论坛整理上传"这类水印,有的把注释混在正文里,有的直接用OCR识别出来一堆繁体错字。这部分如果不处理干净,最后进模型的就是一锅噪音。
我的清洗管线按顺序做了几件事:先统一编码到UTF-8,遇到乱码文件单独处理;然后把文本里的全角标点统一转半角(中文语境下只保留句号、逗号、引号、书名号等必要标点);再用正则把开头结尾的广告页、版权页、整理者说明全部剥掉;接着删除空行和纯空白行;最后做繁体到简体的转换。这里特别提醒一下,一些老版本的OCR文本会出现"己、已、巳"混用的情况,模型根本分不清,最好在清洗阶段就做一遍常见错别字修正。
import re def clean_raw_text(raw: str) -> str: # 去掉整理者水印和广告信息 raw = re.sub(r"【.*?】", "", raw) raw = re.sub(r"本书来自免费阅读|更多更新尽在.*?网", "", raw) # 只保留中英文、数字和常用标点 raw = re.sub(r"[^\u4e00-\u9fa5A-Za-z0-9,。!?、;:“”‘’()《》\n]", "", raw) # 把连续的多个空行压缩成一个 raw = re.sub(r"\n{3,}", "\n\n", raw) # 去掉每一行首尾空白 lines = [line.strip() for line in raw.split("\n")] return "\n".join(lines)2.3 从"全文"到"段落级"的切分策略
清洗完之后面临的下一个问题,是模型到底按什么粒度去"读"这本书。按整本读显然不现实,Transformer类模型的输入长度有限,几百页的正文一塞就爆显存。按句子切又太碎,句子和句子之间缺乏上下文,语义向量会漂移。我最后选的是段落级切分加章节归并:先按换行符把文本切成自然段落,再把连续的小段落合并成300到500字左右的"语义块"。
这里有一个很容易被忽视的细节:合并段落时不能简单从前往后累加字数,而要根据标点和段落边界来判断。理想情况是一段话在语义上相对完整,比如讲完一个人物的一段经历再切走,而不是在句子中间硬砍。我当时的做法是:先把自然段落按顺序排列,然后设置一个滑动窗口,窗口内累计字数达到400字左右就闭合成一个语义块;如果最后一块字数少于100字,就并入前一块。这么切出来的语义块,后续编码成向量时的整体性明显比随机截断好。
3. 语义向量的生成:从预训练模型到书籍画像
数据准备好之后,真正的主角登场了——把文本变成向量。这个环节是整个推荐系统的大脑,也是最需要反复实验的地方。我在这个阶段花的时间比写Django接口多一倍,因为模型选型和向量聚合方式的差异,直接决定了推荐结果的质量上限。
3.1 为什么选预训练Transformer而非传统词向量
早先我考虑过两条路:一条是用Word2Vec把全书词语训练成词向量,然后累加得到书向量;另一条是用预训练的Transformer模型做语句级编码。实际对比之后差距非常明显。Word2Vec这类静态词向量的问题在于它把词语的多个义项压成一个点,比如"杜鹃"既是花又是鸟,"风流"在古代和现代语境里完全是两个方向,名著里偏偏充满这种多义词和修辞表达。Transformer预训练模型的优势在于它是上下文相关的,同一个词在不同句子里能生成不同的向量,"风流"在《红楼梦》里和在《世说新语》里会被编码到不同语义区域,这个特性对名著这种极度依赖语境的文本至关重要。
我最终选定基于中文预训练Transformer模型来做句子编码,并显式说明一点:不改写模型结构,直接用它的句向量输出。因为我们的目标是"书与书之间的语义相似度",不是要做文本生成或阅读理解,微调反而容易过拟合到单一数据集上,直接加载开源权重就够用了。
3.2 文本嵌入的生产管线:池化策略与归一化
模型选好之后,真正决定相似度质量的,是"怎么把多个语义块的向量合成一个书的向量"。我第一次实验时粗暴地取所有语义块的平均值,结果发现《红楼梦》和《百年孤独》的相似度居然高得离谱。原因是这两本书都有大量关于家族兴衰、时间流逝的段落,平均化把这些共通主题放大了,而把叙事风格的巨大差异抹平了。
后来我调整了策略:不再平均所有块,而是用分层聚合。先把语义块按章节归组,计算每一章的平均向量,再按章节顺序做带权聚合,权重根据章节在全书中的文本量来定。这样的书级向量既保留了各章节的风格信息,又不会让个别超长章节主导整体表征。
聚合完成之后还有一步必须做:L2归一化。余弦相似度本质上就是计算两个向量的夹角,但如果向量模长差异过大,夹角就失去可比性。我归一化后所有书向量的模长都变成1,相似度计算才真正公平。这一步在代码里是一行normalize的事,但漏掉它会导致长度长的书天然获得更高相似度,属于那种不仔细看根本发现不了、一发现就吓一跳的问题。
import torch # 假设 semantic_blocks: List[str] 是某本书的所有语义块 def encode_book(blocks, tokenizer, model): block_vecs = [] for block in blocks: inputs = tokenizer(block, return_tensors="pt", max_length=512, truncation=True) with torch.no_grad(): outputs = model(**inputs) # 取最后一层的隐状态做平均池化 vec = outputs.last_hidden_state.mean(dim=1).squeeze() block_vecs.append(vec) book_vec = torch.stack(block_vecs).mean(dim=0) book_vec = torch.nn.functional.normalize(book_vec, p=2, dim=0) return book_vec3.3 相似度计算与效果评估:量化指标加人工验证
向量生成后,推荐系统的核心就是一张相似度矩阵:任意两本书之间都能算出0到1之间的相似度分数。评估这个矩阵好不好,我用的是双轨并行。一条轨是量化:拿人工标注的"主题分类标签"做参照,比如"讽刺小说""爱情悲剧""冒险小说"这些粗粒度标签,看同类书之间平均相似度是不是显著高于异类书。另一条轨是人工评估:我请身边几位平时喜欢读名著的同事,每次随机抽十组推荐结果,让他们判断"看到这本书推荐另一本,你觉得合理吗",记录合理率。
两种评估交叉下来,我发现几个问题:一是同一作者的作品相似度天然偏高,这符合直觉但会造成推荐列表单调;二是翻译腔重的译本会和相近题材的国内作品被强行拉近,向量容易走偏;三是个别短篇集因为内容跨度大,书级向量被稀释得看不出主风格。这些问题直接推动了我后面在"多样性策略"上的优化,也让我意识到向量推荐永远不可能靠单一指标验收到完美。接下来进入工程层面,把模型封装成Web服务。
4. Django与推荐服务:把模型放进Web应用的完整链路
模型实验做完,向量库也存好了,接下来是工程化落地。我选择Django并不意外:后台管理、ORM、权限认证这些能力开箱即用,而这个项目又不止是一个推荐API,还需要有数据管理、用户行为记录、结果展示页面,Django全家桶能把整条链路串起来。这一章重点讲架构设计、API实现和用户数据回流,这些都是我实际写完跑通后认为最值得分享的部分。
4.1 架构选型:模型服务与Django进程如何解耦
最开始我犯过一个新手都会犯的错误:直接在Django的views.py里import torch然后加载模型,每次请求进来现场推理。后果不用说也能猜到——第一个请求要等十几秒,因为模型加载慢;后面每个请求都要等模型前向传播,CPU上跑一次编码动辄几百毫秒;更可怕的是Django开多进程时,每个worker都复制一份模型,内存直接翻几倍。
正确的做法是把模型推理拆成独立服务。我现在采用的架构是这样的:Django主服务负责处理用户请求、读写PostgreSQL、管理后台;一个单独部署的推理服务,用轻量级HTTP接口把模型封装起来,Django在数据入库或需要新增向量时调用它;Redis作为缓存层,存放已经算好的书籍向量和TopN推荐结果。这样模型只加载一次,Django进程的内存占用也稳定可控。
推荐接口对用户而言需要毫秒级响应,绝不能每次都跨服务做模型推理。我的策略是把所有书的向量一次性加载到Redis里,平时推荐时直接从Redis里取向量算余弦相似度,只有遇到新书入库或向量更新时才调推理服务。这等于把最重的计算放到后台异步完成,线上接口保持轻量。
4.2 推荐接口设计与实现细节
推荐接口我设计成两个:一个是GET /api/recommendations/by-book/{book_id},输入一本书,返回相似书目;另一个是GET /api/recommendations/for-user/{user_id},输入用户ID,融合用户历史行为返回个性化推荐。第一个接口的核心逻辑就是向量相似度计算,实现起来很直接:
# apps/recommend/views.py from rest_framework.views import APIView from rest_framework.response import Response class BookRecommendationView(APIView): def get(self, request, book_id): limit = int(request.query_params.get("limit", 10)) candidates = request.query_params.get("genre") # 从缓存或主服务加载该书向量 vec = self._load_book_vector(book_id) if vec is None: return Response({"error": "book not found"}, status=404) # 在候选范围内计算余弦相似度,返回TopN result = self._cosine_topk(vec, genre_filter=candidates, k=limit) return Response({"book_id": book_id, "items": result})这里有几个细节对真实体验影响特别大。第一,候选范围要支持过滤参数,比如用户只想看同类题材,或者只想看某个历史时期的作品,接口必须在计算前就缩小候选集,而不是先全量排序再砍一刀。第二,返回结果里除了相似度分数,我还额外附带了一两个"代表性语义块"里的句子,让用户知道"为什么推荐这本书给你"。第三,相似度分数要按业务口径转成0到100的整数分,不然业务方看到0.84、0.87这种数字完全没法判断到底好不好。
4.3 用户行为数据的回流与画像更新
光有"以书找书"还不够,真正完整的产品需要"以人找书"。我这套系统做了一个轻量的用户画像模块:记录用户对推荐结果的点击、收藏、阅读完成情况,每一条行为都会给用户的兴趣向量加权重。最简单的做法是:用户点击某本书,就把这本书的书级向量按0.2的权重叠加入用户向量;用户读完整本书(判定条件是对应阅读进度记录达到90%以上),权重提高到0.8。用户向量就是自己感兴趣的书的加权平均,再次推荐时,将用户向量与候选书向量算相似度,同时叠加一部分书的相似度来保证多样性。
权重怎么定不是拍脑袋来的。我拿历史数据做了一次回放测试,把用户前三个月行为喂给画像模型,看它能不能在第四个月的行为序列里命中用户实际点击的书。试了几组权重,0.2和0.8的点击/完成组合命中率最高。这个数字在你们自己的数据集上不一定适用,但"点击权重低、完整阅读权重高"这个方向是稳定有效的。
5. 线上踩坑实录:推荐系统的工程化优化
系统跑起来之后,我面临的就不是"模型有没有用",而是"系统能不能稳定服务、推荐结果是否让用户买账"了。这一章全是实战里遇到的问题和排查过程,每个问题都不是看文档能直接找到答案的,踩过的人才明白为什么推荐系统工程化比算法本身更磨人。
5.1 模型加载慢和内存膨胀问题
第一次把系统部署到生产环境,我照着前面"模型服务解耦"的思路起了一个独立推理进程,Django侧通过/infer接口调用。核对了接口逻辑没问题,但上线后马上收到告警:Django的四个worker内存全部超过1.5GB,比预期高出一大截。查了半天发现,元凶是Django的settings.py里某个信号处理器在Django启动时预加载了模型权重,而Gunicorn默认会复制出多个worker进程,每个进程各加载一份模型,内存直接翻成四份。
解决的办法是把模型加载改成惰性加载加进程内单例,只在第一次推理请求时才初始化模型对象,然后在独立推理服务里只保留一份实例。单例的写法很简单,用一个模块级变量判断是否已经加载过:
_model = None def get_model(): global _model if _model is None: _model = load_pretrained_model() return _model这里提醒一句:单例只解决进程内重复加载,解决不了多进程多副本问题。真正稳妥的方案是推理服务单独部署成一个常驻进程,CPU模式下利用共享内存或直接用Gunicorn单worker跑推理,坚决不让模型权重混进Web主服务里。
5.2 推荐结果"千篇一律":多样性策略
向量推荐上线后,运营同学反馈了一个非常典型的体验问题:"我想找的是《百年孤独》的替代品,结果推荐列表里全是拉美魔幻现实主义,翻了三页还在这个圈子里。"这在语义相似度推荐里再常见不过:余弦相似度找出来的天生就是近邻,近邻扎堆导致多样性差。
我用的优化方法叫主题分槽组合。先把所有书按元数据里的几个大类(历史、爱情、讽刺、冒险、哲理等)做预分桶,然后推荐时不是直接从全量TopN里取,而是先确定用户当前感兴趣的主题桶,再从主桶里取一半结果,从相近的2到3个副桶里各取三分之一左右,最后混合打散。这样保证推荐的每一页既有深度相关的书,也有气质相近但题材不同的书,用户试了几次之后抱怨明显变少。
具体实施还有一个小技巧:同一作者的多个作品在同一页最多出现两本,否则一眼看上去全是同一个人的书目,用户会怀疑系统是不是只按作者名做推荐。这个规则虽然简单,但在人性化体验上非常加分。
5.3 冷启动阶段怎么办
推荐系统做多了都知道,冷启动是所有算法绕不开的坎。名著推荐系统里的冷启动分成两个层次:一是新用户没有任何行为数据,二是新书刚入库没有任何缓存的向量结果。
对新用户,我的处理办法是提供一套"编辑精选混合榜单"。这套榜不是纯人工配置,而是从历史用户画像聚类的偏好分布里,挑出几个代表性书单,按比例混合呈现。比如榜单里一定有古代章回体、外国经典、近现代散文三类代表作,基本覆盖绝大多数阅读偏好,用户点进去一本,推荐接口立刻就有了起点。
对新入库的书,流程是入库时先把正文交给推理服务生成书级向量,然后异步在后台计算它和所有已有书的相似度,写进Redis缓存。这里有个容易漏掉的细节:新书入库生成向量时,如果这本书是断代史或短篇集,清洗阶段必须单独处理,否则向量会被空白段落干扰。我在短篇集上踩过一次,某些段落因为切分问题变成一两个超短句,模型输出的向量完全偏移,导致新书一入库就和完全不相关的高频用词文本靠在一起。
5.4 缓存设计与性能实测
性能方面,我的配置链条是:Redis缓存书籍向量和TopN结果,PostgreSQL存元数据和用户日志,Django内用简单内存缓存辅助。每个key都设计了明确的过期策略,书表类缓存24小时失效,用户画像类缓存1小时失效,避免数据更新后线上还吐旧结果。
实测下来的数据可以给各位一个参考:纯Django接口单机压测,120个并发请求下平均响应时间260毫秒;加了Redis缓存命中后,平均响应降到了70毫秒左右;推理服务在冷启动首次请求时需要15秒左右,预热后单次推理保持在80到120毫秒(CPU环境)。这个表现对中小型阅读平台已经足够,如果并发量再上一个量级,就要考虑把推理换成GPU部署,并加一层消息队列来削峰。
6. 做这套系统积累的一些经验和下一步计划
从数据清洗、模型选型到Django工程化,这套推荐系统完整跑下来,给我最大的感受是:深度学习推动的推荐系统,功夫不只是花在模型上,更多的在数据质量和工程链路上。向量编码这一步确实让书名之间的"气质匹配"成为可能,但真正决定这个系统能不能用于生产的,是清洗管线是否扎实、缓存设计是否合理、多样性策略是否到位。
我自己在实操中最深的三点体会,也分享给你们参考。
第一,不要迷信预训练模型现成的输出。模型给出的原始向量只是起点,池化方式、归一化、聚合层级都会显著影响结果,一定要在自己的数据集上做小规模的消融对比,哪怕只是先拿五十本书试跑一轮,也比直接上手全部语料高效得多。
第二,推荐系统的评估必须注入"人味"。量化指标能告诉你模型有没有基本区分能力,但只有让真实读者去判断"这个推荐是否合理",才能真正发现指标看不见的问题,比如同作者扎堆、翻译腔错配、用户对某类题材的隐性反感。我建议至少准备五十到一百条由真人打标的评测数据,定期回归。
第三,系统的可解释性从第一天就要设计进去。用户和运营都接受不了"算法说推给你就推给你"的黑箱,我给每个推荐结果都保留了一个"推荐理由"字段,存放的是代表该书某个主题的语义块原句。用户看到"推荐《白鲸》是因为它与《老人与海》都有大量关于人与自然对峙的描写"时,接受度完全不一样,这套机制成本极低,收益却很明显。
下一步我打算往两个方向延伸:一是把用户画像从简单的向量加权升级成读者偏好聚类,先给用户分群,再结合群内热门内容做排序融合;二是尝试引入知识图谱信息,比如人物关系、地理背景、文学流派等显式知识,和语义向量做混合推荐,进一步缓解纯粹语义相似带来的"只像不神"的问题。这个系统目前还在持续迭代,如果你也正在做类似的名著阅读推荐或文本语义匹配项目,欢迎一起交流你在语料清洗和多样性处理上的经验。