游戏推荐系统的特征存储:用户行为序列的向量化与实时检索
一、当推荐变成"猜你不想玩":为什么Embedding是游戏推荐的破局点
电商推荐已经卷到极致——你刚搜完"机械键盘",首页就全是键盘。但在游戏推荐领域,用户行为模式完全不同。一个玩家打开了一款MOBA游戏,玩了30分钟、3把排位全输、卸载。这个行为序列意味着什么?不是"他不喜欢MOBA",而是"他可能需要在黄金时段遇到旗鼓相当的对手"。
游戏推荐的核心差异在于:玩家的偏好是动态的、多维度交织的。同一个人,工作日午休想玩10分钟消消乐,周末晚上想肝3小时开放世界。传统协同过滤基于"相似玩家玩相似游戏"的假设,在这个场景下失效——因为同一个玩家的"相似玩家"在不同时段是完全不同的人群。
这就是向量化+Embedding检索的切入点。将玩家的行为序列(登录时段、游戏时长、对局结果、消费行为、社交互动)编码成稠密向量,在向量空间中做ANN(近似最近邻)检索,找到"在此时刻最可能感兴趣的游戏"。
二、行为序列的向量化:从离散事件到连续语义空间
Two-Tower模型是游戏推荐的经典架构。Player Tower将玩家的行为序列编码为128维向量,Game Tower将游戏的属性(类型、画风、难度曲线、社交属性)编码为同维度的向量。推荐时,计算玩家向量与候选游戏向量的余弦相似度,取Top-N。
但Player Tower的输入——玩家行为序列——本身就需要精心的特征存储设计。一个玩家3年的行为数据可能上百万条,全部参与Embedding计算既不现实也不必要。实践中提取三类特征:
class PlayerBehaviorEncoder: def __init__(self, redis_client, clickhouse_client): self.redis = redis_client self.ch = clickhouse_client def encode_player(self, player_id: str) -> np.ndarray: """将玩家行为编码为特征向量""" # 短期特征(最近1小时):从Redis直接读 short_term = self._get_short_term_features(player_id) # 中期特征(最近7天):从ClickHouse聚合 medium_term = self._get_medium_term_features(player_id) # 长期特征(全历史):从MySQL的预计算表读取 long_term = self._get_long_term_features(player_id) # 静态特征:玩家画像 profile = self._get_profile_features(player_id) # 拼接并归一化 combined = np.concatenate([short_term, medium_term, long_term, profile]) return combined / (np.linalg.norm(combined) + 1e-8) def _get_short_term_features(self, player_id: str) -> np.ndarray: """短期特征:Redis Hash,最近1小时的聚合""" key = f"player:behavior:1h:{player_id}" try: data = self.redis.hgetall(key) if data: return np.array([ float(data.get(b'game_count', 0)), float(data.get(b'win_rate', 0)), float(data.get(b'avg_session_min', 0)), float(data.get(b'spend_amount', 0)), float(data.get(b'social_count', 0)), self._encode_game_type(data.get(b'game_type', b'')), ], dtype=np.float32) except RedisError: pass return np.zeros(6, dtype=np.float32) def _get_medium_term_features(self, player_id: str) -> np.ndarray: """中期特征:ClickHouse,最近7天聚合""" try: result = self.ch.execute( """ SELECT count(DISTINCT toDate(event_time)) AS active_days, count() AS total_sessions, avg(session_duration_min) AS avg_duration, sum(spend_amount) AS total_spend, countIf(DISTINCT game_id) AS game_variety, -- 游戏偏好分布 groupArray(10)(game_type) AS top_types, -- 活跃时段分布 avg(toHour(event_time)) AS avg_hour, stddevPop(toHour(event_time)) AS hour_stddev FROM player_behavior WHERE player_id = %(pid)s AND event_time >= now() - INTERVAL 7 DAY """, {'pid': player_id} ) if result and result[0]: row = result[0] return np.array([ float(row[0] or 0), float(row[1] or 0), float(row[2] or 0), float(row[3] or 0), float(row[4] or 0), float(row[6] or 0), float(row[7] or 0), ], dtype=np.float32) except Exception: pass return np.zeros(7, dtype=np.float32) def _get_long_term_features(self, player_id: str) -> np.ndarray: """长期特征:MySQL预计算表""" try: # 使用连接池查询预计算的画像表 sql = """ SELECT total_games_played, total_hours, lifetime_spend, churn_risk_score, social_network_degree, preferred_difficulty, avg_session_count_per_week, game_loyalty_score FROM player_profile_snapshot WHERE player_id = %s """ # 省略JDBC代码... return np.zeros(8, dtype=np.float32) # 简化 except Exception: return np.zeros(8, dtype=np.float32)三、向量数据库选型:Milvus、Qdrant与Redis向量搜索的横向对比
游戏推荐场景对向量数据库有三个极致要求:
- 写入速度:每次玩家会话结束都更新Embedding,千万DAU意味着每秒10万+的向量更新
- 检索延迟:推荐接口的P99延迟必须 < 50ms,留给向量检索的时间不超过20ms
- 过滤能力:除了向量相似度,还需要按"平台(iOS/Android)"、"地区"、"年龄分级"做标量过滤
from qdrant_client import QdrantClient from qdrant_client.models import ( Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue ) class GameRecommendVectorStore: def __init__(self, host="localhost", port=6333): self.client = QdrantClient(host=host, port=port) self._init_collection() def _init_collection(self): """初始化向量集合""" try: self.client.get_collection("player_embeddings") except Exception: self.client.create_collection( collection_name="player_embeddings", vectors_config=VectorParams( size=128, distance=Distance.COSINE ) ) # 创建标量索引 self.client.create_payload_index( collection_name="player_embeddings", field_name="platform", field_schema="keyword" ) self.client.create_payload_index( collection_name="player_embeddings", field_name="region", field_schema="keyword" ) def upsert_player_embedding(self, player_id: str, embedding: list, metadata: dict): """更新玩家Embedding""" try: self.client.upsert( collection_name="player_embeddings", points=[ PointStruct( id=player_id, vector=embedding, payload={ "platform": metadata.get("platform", "unknown"), "region": metadata.get("region", "global"), "last_active": metadata.get("last_active"), "game_preferences": metadata.get("preferences", []) } ) ] ) except Exception as e: raise VectorStoreException(f"向量写入失败: {player_id}", e) def search_similar_players(self, embedding: list, top_k: int = 100, platform: str = None, region: str = None) -> list: """检索相似玩家""" search_filter = None conditions = [] if platform: conditions.append( FieldCondition(key="platform", match=MatchValue(value=platform)) ) if region: conditions.append( FieldCondition(key="region", match=MatchValue(value=region)) ) if conditions: search_filter = Filter(must=conditions) try: results = self.client.search( collection_name="player_embeddings", query_vector=embedding, limit=top_k, query_filter=search_filter, with_payload=True ) return results except Exception as e: raise VectorStoreException("向量检索失败", e)三款向量数据库的关键差异:
| 维度 | Milvus | Qdrant | Redis Vector |
|---|---|---|---|
| 索引算法 | IVF/HNSW/DISKANN | HNSW | HNSW/FLAT |
| 标量过滤 | 支持(需配置) | 原生支持 | 需额外设计 |
| 分布式 | 天然分布式 | 单节点为主 | 支持Cluster |
| 运维复杂度 | 高(K8s Operator) | 中(Docker单机) | 中(依赖Redis运维) |
| 适用规模 | 10亿+ | 1亿以下 | 1亿以下 |
对于游戏推荐场景,Qdrant是性价比最高的选择——标量过滤原生支持、单机即可承载亿级向量、运维简单。Milvus更适合需要PB级向量存储的超大规模场景。
四、Embedding时效性与系统成本的博弈
时效性陷阱:每小时更新一次玩家Embedding听起来合理,但一个玩家在15分钟内可能经历"下载新游戏→试玩→不喜欢→卸载"的完整周期。如果他的Embedding在卸载后1小时才更新,推荐系统会在接下来的45分钟里持续给他推相似游戏——制造负面体验。
解决方案是事件驱动更新:
# 监听游戏卸载事件,触发即时Embedding更新 @kafka_listener(topic="game_uninstall") def on_uninstall(event): player_id = event['player_id'] # 标记该玩家的Embedding为stale,触发紧急重算 redis.set(f"embedding:stale:{player_id}", "1", ex=3600) # 通过消息队列触发紧急Embedding更新 embedding_update_queue.send(player_id, priority="HIGH")候选池膨胀:相似玩家检索返回Top-100,每个相似玩家又通过CF召回20款游戏,候选池膨胀到2000款。排序模型需要实时处理2000个样本,这不是小开销。优化策略是两阶段漏斗——向量检索粗筛100款,轻量级LR模型精筛50款,深度模型最终排序10款。
五、总结
游戏推荐系统的特征存储是一个多层次的向量化工程:短期行为走Redis(毫秒级),中期聚合走ClickHouse(秒级),长期画像走MySQL(预计算)。玩家Embedding通过Two-Tower模型生成128维稠密向量,存入Qdrant支持毫秒级ANN检索。
与电商、短视频推荐不同,游戏推荐的Embedding必须足够"短视"——过度依赖长期特征反而会错过玩家在当下的兴趣迁移。在这个意义上,一个好的游戏推荐系统,首要能力不是"预测你将来喜欢什么",而是"立刻察觉你现在不喜欢什么"。
本文属于「行业场景与项目复盘」系列,探讨游戏推荐系统中用户行为序列的向量化存储与实时检索方案。