游戏推荐系统的特征存储用户行为序列的向量化与实时检索一、当推荐变成猜你不想玩为什么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 fplayer:behavior:1h:{player_id} try: data self.redis.hgetall(key) if data: return np.array([ float(data.get(bgame_count, 0)), float(data.get(bwin_rate, 0)), float(data.get(bavg_session_min, 0)), float(data.get(bspend_amount, 0)), float(data.get(bsocial_count, 0)), self._encode_game_type(data.get(bgame_type, b)), ], dtypenp.float32) except RedisError: pass return np.zeros(6, dtypenp.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), ], dtypenp.float32) except Exception: pass return np.zeros(7, dtypenp.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, dtypenp.float32) # 简化 except Exception: return np.zeros(8, dtypenp.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, hostlocalhost, port6333): self.client QdrantClient(hosthost, portport) self._init_collection() def _init_collection(self): 初始化向量集合 try: self.client.get_collection(player_embeddings) except Exception: self.client.create_collection( collection_nameplayer_embeddings, vectors_configVectorParams( size128, distanceDistance.COSINE ) ) # 创建标量索引 self.client.create_payload_index( collection_nameplayer_embeddings, field_nameplatform, field_schemakeyword ) self.client.create_payload_index( collection_nameplayer_embeddings, field_nameregion, field_schemakeyword ) def upsert_player_embedding(self, player_id: str, embedding: list, metadata: dict): 更新玩家Embedding try: self.client.upsert( collection_nameplayer_embeddings, points[ PointStruct( idplayer_id, vectorembedding, 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(keyplatform, matchMatchValue(valueplatform)) ) if region: conditions.append( FieldCondition(keyregion, matchMatchValue(valueregion)) ) if conditions: search_filter Filter(mustconditions) try: results self.client.search( collection_nameplayer_embeddings, query_vectorembedding, limittop_k, query_filtersearch_filter, with_payloadTrue ) return results except Exception as e: raise VectorStoreException(向量检索失败, e)三款向量数据库的关键差异维度MilvusQdrantRedis Vector索引算法IVF/HNSW/DISKANNHNSWHNSW/FLAT标量过滤支持(需配置)原生支持需额外设计分布式天然分布式单节点为主支持Cluster运维复杂度高(K8s Operator)中(Docker单机)中(依赖Redis运维)适用规模10亿1亿以下1亿以下对于游戏推荐场景Qdrant是性价比最高的选择——标量过滤原生支持、单机即可承载亿级向量、运维简单。Milvus更适合需要PB级向量存储的超大规模场景。四、Embedding时效性与系统成本的博弈时效性陷阱每小时更新一次玩家Embedding听起来合理但一个玩家在15分钟内可能经历下载新游戏→试玩→不喜欢→卸载的完整周期。如果他的Embedding在卸载后1小时才更新推荐系统会在接下来的45分钟里持续给他推相似游戏——制造负面体验。解决方案是事件驱动更新# 监听游戏卸载事件触发即时Embedding更新 kafka_listener(topicgame_uninstall) def on_uninstall(event): player_id event[player_id] # 标记该玩家的Embedding为stale触发紧急重算 redis.set(fembedding:stale:{player_id}, 1, ex3600) # 通过消息队列触发紧急Embedding更新 embedding_update_queue.send(player_id, priorityHIGH)候选池膨胀相似玩家检索返回Top-100每个相似玩家又通过CF召回20款游戏候选池膨胀到2000款。排序模型需要实时处理2000个样本这不是小开销。优化策略是两阶段漏斗——向量检索粗筛100款轻量级LR模型精筛50款深度模型最终排序10款。五、总结游戏推荐系统的特征存储是一个多层次的向量化工程短期行为走Redis毫秒级中期聚合走ClickHouse秒级长期画像走MySQL预计算。玩家Embedding通过Two-Tower模型生成128维稠密向量存入Qdrant支持毫秒级ANN检索。与电商、短视频推荐不同游戏推荐的Embedding必须足够短视——过度依赖长期特征反而会错过玩家在当下的兴趣迁移。在这个意义上一个好的游戏推荐系统首要能力不是预测你将来喜欢什么而是立刻察觉你现在不喜欢什么。本文属于「行业场景与项目复盘」系列探讨游戏推荐系统中用户行为序列的向量化存储与实时检索方案。