## 写在前面
最近对论坛的推荐系统做了一次深度 review,顺便调整了 CF(协同过滤)的权重配比。这篇文章总结一下现有实现、发现的问题,以及背后的思考。
---
## 一、整体架构:读写分离 + 异步计算
智柴的推荐系统遵循论坛的整体架构设计:
```
┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐
│ 用户请求 │────▶│ Redis 缓存 │────▶│ 命中直接返回 │
└─────────────────┘ └──────────────┘ └──────────────────┘
│
▼ 未命中
┌─────────────────┐
│ cache_fill_queue│
└─────────────────┘
│
▼ 后台消费
┌─────────────────┐
│process_sqlite_ │
│queue.php │
└─────────────────┘
```
**核心原则**:
- 业务进程只读写 Redis,不直接触碰 SQLite
- 推荐计算是**懒加载**的:用户请求触发 → 投递队列 → 后台异步计算 → 结果回填 Redis
- 前端等待超时 1.5 秒,超时返回热门兜底
---
## 二、三路信号融合
推荐引擎目前融合了三路信号:
| 信号类型 | 原权重 | 现权重 | 作用 |
|---------|-------|-------|------|
| User-based CF | 60% | **20%** | 找"相似用户"看过的内容 |
| Content (TF-IDF) | 40% | **80%** | 基于文本内容相似度 |
| Item-based CF | 0% | 0% | (规划中) |
### 为什么下调 CF 权重?
在 review 过程中发现了几个问题:
**1. 数据稀疏性**
```php
// 当前 CF 计算复杂度:O(N) 遍历所有用户
foreach ($allUserInteractions as $userId => $interactions) {
$similarity = $this->cosineSimilarity($target, $interactions);
}
```
用户量不大时还好,但用户行为分布极不均匀——少数活跃用户贡献大部分交互,大量用户只有 1-2 次行为。CF 在这种情况下噪声较大。
**2. 冷启动不友好**
新用户或交互少的用户,CF 很难给出合理推荐,容易陷入"热门 bias"。
**3. 缺乏时间衰减**
```php
// 当前实现:一年前发的帖子和昨天发的权重相同
$interactions[$topicId] = ($interactions[$topicId] ?? 0.0) + 3.0; // 发帖
$interactions[$topicId] = ($interactions[$topicId] ?? 0.0) + 1.0; // 回复
```
相比之下,**内容相似度(TF-IDF)** 更稳定、可解释性更强,所以把权重从 40% 上调到 80%,CF 从 60% 降到 20%。
---
## 三、内容相似度详解
现在推荐主要依赖 TF-IDF 计算内容相似度,流程如下:
### 1. 构建用户兴趣画像
```php
// 收集用户交互过的话题
foreach ($targetInteractions as $topicId => $weight) {
$tokens = tokenize($title . ' ' . $content);
$vector = tokensToVector($tokens);
foreach ($vector as $term => $count) {
// 词频 × (1 + 交互权重)
// 发帖 weight=3.0 → 倍率 4x
// 回复 weight=1.0 → 倍率 2x
$userTokens[$term] += $count * (1 + $weight);
}
}
```
### 2. 分词策略
```php
private function tokenize(string $text): array
{
// 英文:按单词切分
// "Redis Queue" → ['redis', 'queue']
// 中文:**单字拆分**
// "Redis队列" → ['redis', '队', '列']
// 停用词过滤(60+ 个中英文停用词)
$stopwords = ['的', '了', 'the', 'and', 'php', 'topic', ...];
}
```
**现状**:中文是单字拆分,不是词语。比如"机器学习"会变成 `['机', '器', '学', '习']`,丢失了词语级别的语义关联。这是目前内容相似度的主要局限。
### 3. TF-IDF 计算
```php
// IDF: 逆文档频率
$idf = log(($docCount + 1) / ($docFrequency[$term] + 1)) + 1;
// TF-IDF 向量
$tfidf = $count * $idf;
// 余弦相似度
$cosine = dotProduct($userVector, $candidateVector)
/ (norm($userVector) * norm($candidateVector));
```
### 4. 候选集构建
- 从最近发布的 100 个话题中采样
- 从最近 50 条回复中提取关联话题
- 随机丢弃 50% 样本增加多样性
- 混入 RediSearch 搜索结果(bigram 随机搜索)
---
## 四、触发机制:真正的"懒加载"
推荐计算**不是定时任务**,而是完全由用户请求触发:
```
用户访问首页/话题页
↓
检查 APCu 本地缓存(L1)
↓ 未命中
检查 Redis 分布式缓存(L2,TTL=15分钟)
↓ 未命中
投递任务到 cache_fill_queue
↓
后台进程 process_sqlite_queue.php 消费队列
↓
执行 RecommendationEngine 计算
↓
结果写回 Redis + APCu
```
**等待策略**:
- 前端轮询等待,最多 1.5 秒
- 超时返回热门兜底(随机热门话题)
- 缓存有效期 15 分钟
这种设计的好处是:
1. **节省资源**:只有活跃用户才触发计算
2. **实时性好**:新内容发布 15 分钟后就可能被推荐
3. **容错性强**:超时或失败有兜底策略
---
## 五、当前局限与未来方向
### 已知问题
| 问题 | 影响 | 优先级 |
|------|------|--------|
| 中文单字分词 | 丢失词语语义 | 高 |
| CF 无时间衰减 | 老内容权重过高 | 中 |
| 无负反馈 | 无法学习"不感兴趣" | 中 |
| 无 Item-based CF | 稀疏场景表现差 | 中 |
### 规划中的改进
**1. 引入中文分词**
考虑使用 jieba 或类似的轻量级分词器,把"机器学习"作为一个整体词处理,而不是拆成单字。
**2. Item-based CF**
User-based CF 依赖用户相似度,Item-based 则是"看过 A 的人也看了 B"。对于内容社区,Item-based 通常更稳定、可解释性更强。
```php
// 物品相似度预计算
$itemSimilarities = computeItemSimilarities($userItemMatrix);
// 缓存键: item_sim:{topic_id}
// 定时更新,实时查表
```
**3. 时间衰减因子**
```php
// 引入指数衰减
$timeDecay = exp(-$daysAgo / 30); // 30天半衰期
$weight = $baseWeight * $timeDecay;
```
**4. 标签体系**
如果用户愿意给话题打标签,可以引入标签相似度作为第三路信号。
---
## 六、总结
这次调整把 CF 权重从 60% 降到 20%,本质是**在数据稀疏的场景下,让更稳定的内容信号占据主导**。这不是说 CF 没用,而是在当前数据规模和分布下,TF-IDF 的性价比更高。
推荐系统没有银弹。User-based CF、Item-based CF、Content-based、深度学习... 每种方法都有其适用场景。关键是根据业务阶段、数据规模、计算资源做 trade-off。
智柴目前的选择是:**简单可维护 + 渐进优化**。先把内容推荐做扎实,再逐步引入更复杂的协同策略。
---
欢迎讨论,特别是关于中文分词策略和 Item-based CF 的实现思路。如果有同学做过类似的推荐系统,欢迎分享经验!
登录后可参与表态
讨论回复
0 条回复还没有人回复,快来发表你的看法吧!