关注

AIGC书虫福音:智能推荐让你3天读完一年份的好书

AIGC书虫福音:智能推荐让你3天读完一年份的好书

友情提示:本文 1.2w+ 字,代码量巨大,建议先收藏再蹲坑慢慢看。
如果你一边看一边骂“这写的啥玩意儿”,右下角有个“踩”按钮,使劲戳,我脸皮厚。


先吐槽两句,再进入正题

前阵子我在地铁上,隔壁小姐姐抱着手机嘎嘎乐,我斜眼一瞅——好家伙,微信读书给她推了一本《如何优雅地拒绝前男友的饭局》,她一边看一边点头,就差把“老娘学会了”写在脸上。那一刻我深刻体会到:推荐系统要是懂人心,比相亲对象还靠谱。

可转念一想,我去年双11买的《C++ Primer》还在墙角吃灰,Kindle 盖泡面都盖出包浆了,凭啥人家能 3 天刷 20 本,我连目录都没啃完?

直到我撸完这套 AIGC 推荐链路,才悟了——不是我不爱看书,是系统没把我当“人”看。今天就把我踩过的坑、写过的 bug、被产品小姐姐追着打的 727 个需求,一股脑倒出来。


别被“智能”俩字唬住,它也有翻车现场

先给你们看一段凌晨两点的日志,我当时差点把键盘掀了:

[02:14:53] user_id: 9527, scene: "深夜emo", action: "scroll", 
           sentiment: -0.82, recall_books: ["《如何戒掉拖延症》", 
           《自控力》,"《早睡早起身体好》"]  
[02:14:54] user_close_app

翻译成人话:用户都emo到快哭了,系统还给他喂鸡汤,让他早点睡。
这感觉就像你跟闺蜜哭诉“我失恋了”,她回你“多喝热水”。

结论: 情绪识别不准,推荐就是社死现场。


推荐系统怎么突然变得这么聪明?

2.1 从“关键词”到“读心术”

传统协同过滤,说白了就是“你宿舍老二爱看《鬼吹灯》,你大概率也爱看”。
现在呢?大模型把你所有行为嚼碎了咽下去:

  • 翻页速度 0.8 秒 → 你可能在扫情节,爽点要密集
  • 凌晨 1 点还在划 → 大概率失眠,来点短篇诗歌
  • 看到“分手”关键词停顿 3 秒 → 情感空窗期,推《霍乱时期的爱情》

代码说话最实在,走,上 Python:

# 片段 1:情绪 + 场景特征拼接
import torch
from transformers import AutoTokenizer, AutoModel

tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")
bert = AutoModel.from_pretrained("bert-base-chinese")

def build_user_feat(user_id, scene, scroll_speed, stay_time, sentiment):
    """
    把用户行为变成 768 维向量,供下游大模型吃
    """
    text = f"{scene} 划动{scroll_speed}秒 停留{stay_time}秒 情绪{sentiment}"
    inputs = tokenizer(text, return_tensors="pt")
    with torch.no_grad():
        cls_vec = bert(**inputs).last_hidden_state[:, 0]  # [1,768]
    return cls_vec.squeeze(0)  # [768]
# 片段 2:双塔召回,向量检索 100 ms 内返回 200 本书
import faiss
import numpy as np

book_index = faiss.read_index("book_embedding.index")   # 200 万本书向量
user_vec = build_user_feat("9527", "深夜emo", 0.8, 3.2, -0.82)
user_vec = user_vec.numpy().astype("float32")

D, I = book_index.search(user_vec.reshape(1, -1), k=200)  # I 是书籍 id
recall_ids = I[0]  # 200 本候选
# 片段 3:大模型精排——把 200 本压到 10 本
from transformers import AutoModelForSequenceClassification

ranker = AutoModelForSequenceClassification.from_pretrained("our-ranker-bert")
top10 = []
for book_id in recall_ids:
    book_meta = get_book_meta(book_id)  # 标题、简介、封面 OCR 文本
    text = f"用户情绪-0.82,场景深夜emo。书籍:{book_meta}"
    inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=256)
    score = ranker(**inputs).logits[0, 1].item()  # 0~1 匹配度
    top10.append((book_id, score))
top10 = sorted(top10, key=lambda x: -x[0])[:10]

精排完再把 10 本书甩给前端,让客户端决定“要不要装文艺、要不要偷偷推广告位”。


前端怎么让用户觉得“这书我非看不可”

3.1 别把“猜你喜欢”挂嘴边,太油腻

产品小姐姐原话:“用户一看见‘猜你喜欢’就想翻白眼,好像被大数据扒光了。”
于是我连夜把文案改了:

原来改后
猜你喜欢编辑部今晚也在熬夜看
相似推荐这本书的闺蜜
热门畅销上周被借爆 3 次

React 代码走一个:

// components/RecCard.tsx
import { memo } from "react";
import { ReactComponent as ArrowSvg } from "./arrow.svg";

interface Props {
  book: any;
  reason: string;  // 推荐理由,后端把大模型吐的文案塞进来
}

const RecCard = memo(({ book, reason }: Props) => {
  return (
    <div className="rec-card">
      <img src={book.cover} alt={book.title} loading="lazy" />
      <div className="info">
        <h3>{book.title}</h3>
        <p className="author">{book.author}</p>
        {/* 关键:把推荐理由写成人话 */}
        <p className="why">
          <ArrowSvg className="icon" />
          {reason}
        </p>
      </div>
      {/* 一键吐槽 */}
      <button
        className="dislike-btn"
        onClick={() => {
          fetch("/api/feedback", {
            method: "POST",
            body: JSON.stringify({ bid: book.id, action: "dislike" }),
          });
        }}
      >
        这啥玩意
      </button>
    </div>
  );
});
export default RecCard;
/* rec-card.scss */
.rec-card {
  position: relative;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  transition: transform 0.25s;
  &:hover {
    transform: translateY(-4px);
  }
  .why {
    font-size: 13px;
    color: #666;
    margin-top: 6px;
    display: flex;
    align-items: center;
    .icon {
      width: 12px;
      height: 12px;
      margin-right: 4px;
      fill: #ff5722;
    }
  }
  .dislike-btn {
    position: absolute;
    right: 8px;
    top: 8px;
    border: none;
    background: rgba(0, 0, 0, 0.5);
    color: #fff;
    font-size: 12px;
    padding: 4px 8px;
    border-radius: 4px;
    cursor: pointer;
  }
}

3.2 允许用户“一键吐槽”——负反馈是宝藏

很多团队只顾着点“喜欢”,把“讨厌”按钮藏得比前任还深。
其实“踩”才是让模型快速收敛的加速器。

// hooks/useFeedback.ts
import { useCallback } from "react";

export function useFeedback() {
  const send = useCallback(
    async (params: { bid: number; action: "like" | "dislike"; scene?: string }) => {
      await fetch("/api/feedback", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ ...params, ts: Date.now() }),
      });
      // 本地立刻把卡片干掉,别等后端 200
      // 这里用 optimistic update,用户体验爽到飞起
    },
    []
  );
  return send;
}

冷启动用户:别把新人劝退在 5 秒内

新用户打开 App,后台一片空白,连性别都没填,怎么推?
三板斧:

  1. 先问场景,不让用户填年龄收入——“通勤/睡前/蹲坑”三选一,点一下就行。
  2. 用“伪互动”套偏好——甩 6 张封面,让用户选“想翻/不想翻”,其实是快速标注。
  3. 前端立刻把标注打包发回,服务端走“小样本学习”——用 6 个样本微调用户向量。
# 片段 4:6 次点击 → 微调用户向量
import torch.nn as nn

class UserVectorAdapter(nn.Module):
    def __init__(self, dim=768):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(dim, 512),
            nn.ReLU(),
            nn.Linear(512, dim),
        )
    def forward(self, base_vec, likes, dislikes):
        """
        likes:   List[Tensor] 用户点“想翻”的书籍向量
        dislikes:List[Tensor] 用户点“不想翻”的书籍向量
        """
        if likes:
            like_vec = torch.stack(likes).mean(0)
        else:
            like_vec = torch.zeros(768)
        if dislikes:
            dislike_vec = torch.stack(dislikes).mean(0)
        else:
            dislike_vec = torch.zeros(768)
        delta = like_vec - dislike_vec  # 喜好方向
        return base_vec + 0.3 * self.net(delta)  # 0.3 是超参,别问我怎么调,问就是 grid search

自动生成章节摘要——让“太长不看”党也能装文化人

产品提需求:“用户分享书籍到朋友圈,要配一段 50 字摘要,逼格要高。”
我当场脑壳痛:50 字还要逼格?

解决方案:

  1. 用 BART-Chinese 微调,训练集把“章节全文”→“豆瓣短评高赞”做平行语料。
  2. 推理阶段把全文按 512 token 滑窗,抽 3 个最“高潮”窗口,再分别生成 50 字,最后选 ROUGE 分最高那段。
# 片段 5:摘要服务
from transformers import BartForConditionalGeneration, BartTokenizer

model = BartForConditionalGeneration.from_pretrained("bart-book-summary")
tok = BartTokenizer.from_pretrained("bart-book-summary")

def gen_summary(chapter_text: str) -> str:
    inputs = tok(chapter_text, return_tensors="pt", max_length=512, truncation=True)
    ids = model.generate(
        inputs.input_ids,
        max_length=60,
        min_length=40,
        num_beams=4,
        temperature=0.8,
        repetition_penalty=2.0,
    )
    return tok.decode(ids[0], skip_special_tokens=True)

前端直接调:

// 分享面板弹窗
const shareHandler = async (chapterId: number) => {
  const { summary } = await fetch(`/api/summary?cid=${chapterId}`).then((r) => r.json());
  wx.shareTimeline({
    title: `我在读《${bookTitle}》\n${summary}`,
    imageUrl: cover,
  });
};

根据心情生成专属书单封面——中二病也要满足

需求:用户选“丧/燃/甜/佛系”四个标签,前端实时生成封面,不让设计师加班。

技术选型:

  • 前端用 Canvas + 粒子字体,把书名拆成粒子,根据情绪配色。
  • 丧 = 冷灰 + 雨滴下落;燃 = 红橙渐变 + 火焰向上;甜 = 粉系爱心飘;佛系 = 墨绿 + 静止禅意。
// components/MoodCover.tsx
import { useRef, useEffect } from "react";

const palette = {
  丧: { bg: "#3a3a3c", particle: "#a0a0a0", gravity: 0.2 },
  燃: { bg: "#ff3300", particle: "#ffcc00", gravity: -0.2 },
  甜: { bg: "#ffe0f0", particle: "#ff66b2", gravity: 0.05 },
  佛系: { bg: "#4a5d23", particle: "#9bb471", gravity: 0 },
};

export default function MoodCover({ mood, title }: { mood: keyof typeof palette; title: string }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current!;
    const ctx = canvas.getContext("2d")!;
    const { bg, particle, gravity } = palette[mood];
    canvas.width = 300;
    canvas.height = 400;
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, 300, 400);

    // 简单画个粒子
    ctx.fillStyle = particle;
    for (let i = 0; i < 80; i++) {
      const x = Math.random() * 300;
      const y = Math.random() * 400;
      const r = Math.random() * 2;
      ctx.beginPath();
      ctx.arc(x, y, r, 0, Math.PI * 2);
      ctx.fill();
    }

    // 写字
    ctx.font = "bold 24px PingFang SC";
    ctx.fillStyle = "#fff";
    ctx.textAlign = "center";
    const lines = title.length > 8 ? [title.slice(0, 8), title.slice(8)] : [title];
    lines.forEach((line, i) => ctx.fillText(line, 150, 200 + i * 30));
  }, [mood, title]);

  return <canvas ref={canvasRef} style={{ borderRadius: 8 }} />;
}

隐私红线别踩,推荐多样性也别放飞

7.1 隐私合规三板斧

  1. 前端只上传“脱敏行为”——把 user_id 用 md5+salt 打散,后台映射到真实 id。
  2. 情绪识别模型跑在端侧——用 ONNX Runtime + Int8 量化,40 ms 出结果,原始文本不上传。
  3. 提供“一键影子模式”——用户点一下,所有行为不记录,推荐降级到热门榜单。

7.2 多样性怎么保

  • 后处理加“MMR 最大边缘相关”——在相关度基础上惩罚相似书籍,强制插一本冷门诗集。
  • 每周一次“人工彩蛋”——运营小姐姐手动塞 5 本绝版旧书,防止信息茧房。
# 片段 6:MMR 简单实现
def mmr_select(scores, embeddings, lamb=0.5, k=10):
    """
    scores: List[float] 相关度分数
    embeddings: List[Tensor] 书籍向量
    lamb: 平衡相关度与多样性
    """
    selected = []
    idx_set = set(range(len(scores)))
    for _ in range(k):
        best = -1
        best_val = -float("inf")
        for i in idx_set:
            sim_to_selected = 0
            for j in selected:
                sim = embeddings[i] @ embeddings[j]  # 余弦
                sim_to_selected = max(sim_to_selected, sim)
            val = lamb * scores[i] - (1 - lamb) * sim_to_selected
            if val > best_val:
                best_val, best = val, i
        selected.append(best)
        idx_set.remove(best)
    return selected

尾声:下次你深夜点开一本冷门诗集,别惊讶

它可能不是算法想赚钱,只是 AI 偷看了你朋友圈那句“好累”,于是悄悄把温柔塞到你手心。

别骂它偷窥,要怪就怪人类自己——
我们写代码的时候,早就把“懂人心”当成 KPI 了。

(完)

在这里插入图片描述

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/master_chenchen/article/details/156913636

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--