
背景与挑战:从信息过载到精准发现
随着汤不热吧技术社区的内容积累突破数千篇高质量技术文章,一个日益突出的问题摆在我们面前:如何让每位开发者以最小的时间成本找到对自己最有价值的内容?传统的分类浏览和关键词搜索已经无法满足用户对个性化内容发现的需求。同一位深度学习工程师和一位运维架构师在同一时刻访问社区,他们期望看到的首页内容截然不同,但旧系统对所有用户呈现的是完全一致的文章排序。
经过三个月的架构设计与工程实现,我们正式上线了基于 Elasticsearch 8 向量检索与用户行为分析的智能内容推荐引擎。这套系统不仅实现了毫秒级的个性化推荐响应,还通过向量语义匹配突破了传统关键词匹配的局限——用户搜索”容器编排”,系统能理解并推荐 Kubernetes、Docker Swarm 乃至 Nomad 相关的高质量内容,即使文章中并未出现”容器编排”这个精确词组。
整体架构设计:三层推荐流水线
我们的推荐系统采用经典的三层漏斗架构:召回层(Recall)、粗排层(Pre-Ranking)和精排层(Ranking)。每一层都有明确的职责边界和技术选型,确保在保证推荐质量的同时满足实时性要求。

第一层:多路召回(Recall)
召回层是整个推荐系统的入口,负责从海量文章池中快速筛选出候选集。我们实现了四条并行的召回通道:
- 向量语义召回:基于 Elasticsearch 8 的 kNN 向量检索,将文章和用户兴趣编码为 768 维稠密向量,通过余弦相似度匹配
- 协同过滤召回:基于用户阅读行为的 Item-based CF,利用”阅读过此文的人还阅读了”的共现模式
- 标签图召回:基于 NebulaGraph 构建的知识图谱,沿标签-文章-作者的图结构游走扩展
- 热点召回:基于时间衰减函数的近期热门文章,确保新鲜度
第二层:粗排(Pre-Ranking)
粗排层使用轻量级双塔模型,将用户特征和文章特征分别通过两层 MLP 编码为低维嵌入,再计算内积得分。这一层的核心目标是在 10ms 内完成对召回层输出的数百篇候选文章的快速打分筛选,将候选集压缩到 Top 50。
第三层:精排(Ranking)
精排层使用 DeepFM 模型,融合稠密特征(用户阅读时长、文章发布时间等)和稀疏特征(分类、标签、作者 ID 等),通过 Feature Interaction 层捕捉特征间的二阶交互关系。精排层输出最终推荐列表,结合业务规则(去已读、多样性打散、分类配额)后呈现给用户。
核心实现:Elasticsearch 8 向量检索引擎
向量检索是我们推荐系统最核心的召回通道。我们选择 Elasticsearch 8 而非独立的 Milvus 或 Weaviate,关键原因在于社区已有的全文搜索基础设施全部构建在 ES 之上,向量检索与 BM25 文本检索的混合查询能力可以无缝融合,无需维护两套独立的检索系统。
索引映射配置
以下是我们为文章向量索引设计的映射配置,使用 HNSW 算法构建近似最近邻索引:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40 PUT tbr8-articles-vector
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"index": {
"knn": true,
"knn.algo_param.ef_search": 40
}
},
"mappings": {
"properties": {
"article_id": { "type": "keyword" },
"title": {
"type": "text",
"analyzer": "ik_max_word"
},
"content_text": {
"type": "text",
"analyzer": "ik_max_word"
},
"category": { "type": "keyword" },
"tags": { "type": "keyword" },
"publish_time": { "type": "date" },
"embedding": {
"type": "knn_vector",
"dimension": 768,
"method": {
"name": "hnsw",
"space_type": "cosinesimil",
"engine": "nmslib",
"parameters": {
"ef_construction": 128,
"m": 24
}
}
}
}
}
}
这里有几个关键参数需要说明:
1 | ef_construction |
控制构建 HNSW 图时的搜索宽度,值越大索引质量越高但构建越慢;
1 | m |
是 HNSW 图每个节点的最大连接数,24 是我们在召回率和索引大小之间的平衡点;
1 | ef_search |
控制查询时的搜索范围,40 在我们的数据规模下实现了 95%+ 的召回率。
混合查询:语义匹配与 BM25 融合
纯向量检索有时会出现”语义漂移”——向量距离接近但实际不相关。我们通过 Reciprocal Rank Fusion (RRF) 算法将向量检索结果与 BM25 文本检索结果融合,显著提升了推荐精度:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 GET tbr8-articles-vector/_search
{
"size": 20,
"knn": {
"field": "embedding",
"query_vector": [0.012, -0.034, ...],
"k": 50,
"num_candidates": 200
},
"query": {
"bool": {
"should": [
{
"multi_match": {
"query": "Kubernetes Pod 调度策略",
"fields": ["title^3", "content_text", "tags^2"],
"type": "best_fields",
"analyzer": "ik_smart"
}
}
]
}
},
"rank": {
"rrf": {
"window_size": 50,
"rank_constant": 60
}
}
}
RRF 的核心思想是将两个检索系统各自的结果排名取倒数求和,而非简单拼接。这样 BM25 精确匹配的高排名和向量语义匹配的高排名都能贡献最终得分,既保留了关键词匹配的精确性,又获得了语义理解的泛化能力。

向量生成:文章嵌入模型选型与部署
文章向量质量直接决定推荐效果上限。经过对比 bge-large-zh、text2vec-large-chinese 和 m3e-base 三个主流中文嵌入模型,我们最终选择了 bge-large-zh-v1.5 作为生产模型,理由如下:
| 模型 | 维度 | C-MTEB 得分 | 推理延迟 (RTX 4090) | 内存占用 |
|---|---|---|---|---|
| bge-large-zh-v1.5 | 1024 | 64.53 | 8ms | 1.3GB |
| text2vec-large-chinese | 1024 | 61.21 | 12ms | 1.3GB |
| m3e-base | 768 | 58.43 | 4ms | 0.4GB |
虽然 bge-large-zh-v1.5 的维度为 1024,但我们通过 PCA 降维到 768 维以平衡检索性能和语义保真度。实测降维后的召回率损失仅 1.2%,但索引大小和检索延迟均下降约 25%。
批量向量生成 Pipeline
我们使用 Python + SentenceTransformers 构建了批量向量生成管道,部署在 Kubernetes 上作为 Celery 异步任务运行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54 from sentence_transformers import SentenceTransformer
import numpy as np
from sklearn.decomposition import PCA
# 加载模型和 PCA 降维矩阵
model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
pca_matrix = np.load('/models/pca_768.npy')
mean_vec = np.load('/models/pca_mean.npy')
def generate_embedding(title: str, content: str, tags: list) -> list:
"""
生成文章的语义向量:
1. 拼接标题+摘要+标签作为输入
2. bge-large-zh 编码为 1024 维向量
3. PCA 降维到 768 维
4. L2 归一化
"""
# bge 模型推荐在输入前加"为这句话生成表示"
input_text = f"为这段文本生成表示以用于检索:{title}。{content[:512]}。标签:{','.join(tags)}"
raw_embedding = model.encode(input_text, normalize_embeddings=True)
# PCA 降维
reduced = np.dot(raw_embedding - mean_vec, pca_matrix.T)
# L2 归一化,确保余弦相似度计算正确
norm = np.linalg.norm(reduced)
if norm > 0:
reduced = reduced / norm
return reduced.tolist()
# 新文章发布时触发向量生成
@app.task(name='generate_article_embedding')
def process_article(article_id: str):
article = fetch_article(article_id)
embedding = generate_embedding(
article['title'],
article['content'],
article['tags']
)
# 写入 Elasticsearch
es.index(
index='tbr8-articles-vector',
id=article_id,
body={
'article_id': article_id,
'title': article['title'],
'content_text': article['content'][:1000],
'category': article['category'],
'tags': article['tags'],
'publish_time': article['publish_time'],
'embedding': embedding
}
)
用户画像构建:实时行为流处理
推荐系统的另一个核心组件是用户画像。我们通过 Kafka + Flink 的实时流处理管道,将用户的每次阅读、收藏、搜索行为转化为可量化的兴趣向量。
行为事件定义
我们定义了五类用户行为事件,每类事件对兴趣向量的贡献权重不同:
| 事件类型 | 权重 | 衰减系数 | 说明 |
|---|---|---|---|
| 阅读完成 (>80%) | 1.0 | 0.95/天 | 完整阅读是兴趣最强的信号 |
| 收藏 | 0.8 | 0.98/天 | 收藏但未完读,兴趣略弱 |
| 部分阅读 (30%-80%) | 0.5 | 0.90/天 | 可能只是扫读 |
| 搜索点击 | 0.3 | 0.85/天 | 搜索后的点击代表意图 |
| 跳读 (<30%) | 0.1 | 0.80/天 | 弱信号,可能误点击 |
Flink 实时画像更新作业
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 // Flink Java - 用户画像实时更新
DataStream<UserEvent> events = env
.addSource(new KafkaSource<>("tbr8-user-events"))
.keyBy(UserEvent::getUserId);
// 窗口:1小时滚动窗口,聚合用户行为
DataStream<UserProfile> profiles = events
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.aggregate(new ProfileAggregator());
// ProfileAggregator 核心逻辑
public class ProfileAggregator implements AggregateFunction<UserEvent, Acc, UserProfile> {
@Override
public Acc addEvent(UserEvent event, Acc acc) {
// 获取文章向量作为兴趣信号
float[] articleVec = embeddingCache.get(event.getArticleId());
float weight = getWeight(event.getType());
float decay = getDecay(event.getTimestamp());
// 加权累加到用户兴趣向量
for (int i = 0; i < acc.interestVec.length; i++) {
acc.interestVec[i] += articleVec[i] * weight * decay;
}
// 更新分类偏好计数
acc.categoryCount.merge(event.getCategory(), 1, Integer::sum);
return acc;
}
private float getDecay(long eventTime) {
long hoursAgo = (System.currentTimeMillis() - eventTime) / 3600000;
return (float) Math.pow(0.95, hoursAgo / 24.0);
}
}
用户画像每小时更新一次,同时存储在 Redis(供在线推荐实时查询)和 Elasticsearch(供离线分析)中。新用户首次访问时,我们使用其注册时选择的兴趣标签初始化一个冷启动画像,随着行为数据积累逐步过渡到数据驱动的画像。
性能优化:从秒级到毫秒级的工程实践
推荐系统上线初期,首页推荐接口的 P99 延迟达到 800ms,远超我们 100ms 的目标。通过以下优化手段,最终将 P99 延迟压缩到 45ms:
1. 向量检索预过滤
原方案对所有文章执行 kNN 搜索,数据量增长后延迟急剧上升。优化后,我们先通过分类和标签过滤将候选集缩小到相关子集,再在子集上执行向量检索:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 GET tbr8-articles-vector/_search
{
"size": 20,
"query": {
"bool": {
"filter": [
{ "terms": { "category": ["kubernetes", "ai-infra", "VPS和建站"] }},
{ "range": { "publish_time": { "gte": "now-180d" } }}
]
}
},
"knn": {
"field": "embedding",
"query_vector": [...],
"k": 30,
"num_candidates": 100
},
"rank": { "rrf": { "window_size": 30, "rank_constant": 60 } }
}
2. 用户画像本地缓存
每次推荐请求都从 Redis 拉取用户画像增加了 5-8ms 网络延迟。我们将活跃用户的画像缓存在推荐服务的进程内存中(Caffeine Cache),设置 5 分钟 TTL,命中率超过 92%。
3. 批量预计算
对于首页 Feed 这种高并发场景,我们每 5 分钟预计算一次热门用户群体的推荐结果,写入 CDN 边缘缓存。约 60% 的首页请求命中预计算结果,延迟降至 5ms 以内。

Kubernetes 部署架构
整个推荐系统部署在我们现有的 Kubernetes 集群中,以下是核心组件的资源分配和部署策略:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71 # 推荐服务 Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: tbr8-recommender
namespace: recommend
spec:
replicas: 3
selector:
matchLabels:
app: tbr8-recommender
template:
metadata:
labels:
app: tbr8-recommender
spec:
containers:
- name: recommender
image: registry.tbr8.org/recommender:v2.1.0
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "4"
memory: "8Gi"
ports:
- containerPort: 8080
env:
- name: ES_HOSTS
value: "elasticsearch-master.elasticsearch:9200"
- name: REDIS_HOST
value: "redis-master.redis:6379"
- name: MODEL_PATH
value: "/models/bge-large-zh-v1.5"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 15
periodSeconds: 5
---
# 向量生成 Worker (GPU)
apiVersion: apps/v1
kind: Deployment
metadata:
name: embedding-worker
namespace: recommend
spec:
replicas: 2
selector:
matchLabels:
app: embedding-worker
template:
spec:
containers:
- name: worker
image: registry.tbr8.org/embedding-worker:v2.1.0
resources:
limits:
nvidia.com/gpu: 1
memory: "8Gi"
env:
- name: CELERY_BROKER
value: "redis://redis-master.redis:6379/1"
上线效果与数据验证
推荐引擎上线两周后,我们观察到了显著的指标改善:
| 指标 | 上线前 | 上线后 | 变化 |
|---|---|---|---|
| 首页平均点击率 | 3.2% | 7.8% | +143.7% |
| 人均阅读文章数 | 1.4 篇/次 | 2.9 篇/次 | +107.1% |
| 平均会话时长 | 2.1 分钟 | 4.7 分钟 | +123.8% |
| 搜索无结果率 | 18.5% | 5.2% | -71.9% |
| 推荐接口 P99 延迟 | N/A | 45ms | 达标 |
其中搜索无结果率的大幅下降是向量语义检索的直接贡献——大量原来因为关键词不匹配而搜不到的文章,现在通过语义匹配被成功召回。一位用户搜索”微服务熔断”时,系统不仅返回了包含该关键词的文章,还推荐了关于 Hystrix、Resilience4j 和 Sentinel 的实践文章,即使这些文章的标题和正文中并未出现”熔断”一词。
后续规划:持续演进
当前系统仍有几个方向的提升空间:
- 实时个性化:目前用户画像更新频率为每小时一次,计划通过 Flink State Backend 将更新频率提升至分钟级,实现”刚读完一篇 Kubernetes 文章,刷新首页即看到相关推荐”的即时反馈体验
- 多模态理解:文章中的代码片段、架构图、截图包含大量语义信息,计划引入 CLIP 模型对图文进行联合编码,提升技术文章的向量表示质量
- A/B 实验框架:搭建基于 Kubernetes 的 A/B 实验平台,支持推荐算法的灰度发布和指标对比,用数据驱动而非直觉驱动迭代决策
- 作者推荐:将推荐粒度从文章级扩展到作者级,帮助优质内容创作者获得更多关注,形成社区正反馈循环
汤不热吧技术社区始终致力于为开发者提供最高效的技术内容获取体验。智能推荐引擎的上线标志着我们从”人找内容”到”内容找人”的范式转变,未来我们将持续迭代,让每一篇优质技术文章都能找到它最需要的读者。如果您对推荐系统的技术细节有任何疑问,欢迎在评论区讨论或通过社区反馈渠道联系我们。
汤不热吧