这一页回答什么
本页回答三个问题。第一,Phoenix 怎样把数量无上限的用户、帖子、作者、IP 编号映射到固定大小的嵌入表。第二,Phoenix 怎样把一个实体的多个哈希结果合成一个向量。第三,2026-08 起的生产配置里,哪些实体仍用哈希表示,哪些已改用语义 ID。
旧页面引用的
_hash_ids(run_pipeline.py)与build_unified_emb_table已随演示代码删除。现在 Rust 代码实现哈希函数,训练配置定义哈希常量。
核心结论
- 编号不直接作为行索引:每个编号经 2 个线性同余哈希映射到桶,再加上该实体在统一表里的偏移(
phoenix/crates/common/xai-recsys/src/model_config.rs:154-199)。 - 编号 0 永远映射到 0:0 表示 padding(填充位)。模型检查“第一个哈希是否为 0”,以此判断该位置是否有效(
model_config.rs:155-157、phoenix/xrex/models/recsys_model.py:1709)。 - 所有实体共用一张表:第 0 行留给 padding,之后是 64 行,再依次是用户、帖子、作者、IP 区段(
model_config.rs:114-128、phoenix/xrex/models/recsys_embedding.py:230-248)。 - 哈希常量是写死的开源默认值:每类实体有 2 组
scale/bias和 1 个modulus。排序配置与召回配置的常量相同(phoenix/xrex/configs/xrecsys.py:172-183、phoenix/xrex/configs/xrecsys_two_tower.py:186-197)。 - 模型用投影合并多个哈希嵌入:新代码把每个哈希嵌入各乘一个投影矩阵,再相加(
phoenix/xrex/models/recsys_feature_prep.py:384-395)。旧式路径仍是“先拼接,再乘一个大投影”(recsys_model.py:1673-1711)。 - 生产召回不再查帖子 ID 嵌入,也没有用户 ID 嵌入:召回用语义 ID 加作者哈希表示帖子身份(
xrecsys_two_tower.py:463-465)。排序仍保留帖子与用户哈希嵌入,并增加了语义 ID(xrecsys.py:343-352)。
为什么用哈希
用户、帖子、作者编号数以十亿计,并且持续增长,因此无法给每个编号分配一行嵌入。哈希把编号压缩到固定容量的桶里。代价是碰撞:不同编号可能落进同一个桶。每个实体用 2 个独立哈希,两者同时碰撞的概率很低。两个嵌入合并后,单次碰撞的影响被摊薄(phoenix/README.md:241-248)。
哈希函数
Rust 函数 HashTableConfig::hash_item_id 实现帖子哈希(phoenix/crates/common/xai-recsys/src/model_config.rs:171-185)。用户、作者、IP 的哈希函数结构相同:
if item_id == 0 { return 0; }
let mut h = (item_id * item_hash_scales[index] + item_biases[index]) % item_modulus;
if h < 0 { h += item_modulus; }
let bucket = (h % (effective_item_hash_buckets() as i64 - 1) + 1) as usize;
(bucket + offset_item()) as i32- 第一步做线性同余:编号乘一个常数,加一个常数,再对一个大质数取模。
- 第二步把结果映射到
[1, 桶数 - 1],加上该实体区段的偏移,得到统一表里的行号。 - 用户词表大小为 0 时(召回配置),用户哈希退化为常数 1 或 0(
model_config.rs:158-160)。
服务引擎把这些函数暴露给 Python(phoenix/crates/serving/xai-recsys-engine/src/python.rs:3683-3690,类型声明在 xai_recsys_engine.pyi:261-263)。
常量(开源默认值)
下表取自 xrecsys.py:172-183(xrecsys_two_tower.py:186-197 的值相同):
| 实体 | hash_scales |
biases |
modulus |
|---|---|---|---|
| 用户 | 196_742_702, 1_852_108_266 | 1_935_840_681, 167_407_236 | 2_859_568_897 |
| 帖子 | 2_161_410_491, 1_754_358_832 | 1_935_840_681, 167_407_236 | 2_361_375_383 |
| 作者 | 371_965_780, 328_930_218 | 139_686_260, 37_755_056 | 631_860_353 |
| IP | 529_482_163, 1_327_604_891 | 742_961_053, 318_205_477 | 1_073_741_789 |
源码把用户 biases 写作 193_5840_681, 16_7407_236,数值与上表相同(xrecsys.py:173)。每类实体的哈希个数等于 hash_scales 的长度,即 2(model_config.rs:130-144)。
统一嵌入表
flowchart LR
R0["第 0 行<br/>padding"] --> R1["64 行<br/>保留区"]
R1 --> RU["用户区段"]
RU --> RI["帖子区段"]
RI --> RA["作者区段"]
RA --> RP["IP 区段"]
- 偏移:
offset_user = 1 + 64。之后每段的起点等于前一段的起点加前一段的词表大小(model_config.rs:114-128,Python 侧在recsys_embedding.py:234-248)。 - 表的总行数为
input_vocab_size = 用户 + 帖子 + 作者 + IP + 60 + 1,并向上取整到 512 的倍数(xrecsys.py:139-153)。其中 60 是ACTION_TYPE_MAP_LEN(phoenix/xrex/configs/xrecsys.py:48)。Rust 侧的偏移却按 64 行保留区计算,两侧数字不一致;这 4 行差额能否被向上取整的余量覆盖,取决于各词表大小,源码未说明。源码未注明 64 行保留区的用途。 - 表宽
emb_table_width(排序旗舰为 1024)不同于模型隐藏维emb_size(2560)。模型先把查到的行投影到emb_size,再使用(recsys_feature_prep.py:388-395)。
下表列出各配置的区段大小(开源默认值):
| 配置 | 用户 | 帖子 | 作者 | IP |
|---|---|---|---|---|
排序 home_direct_packed |
1 亿 | 1 亿 | 3000 万 | 1000 万 |
| 排序 nano | 10 万 | 10 万 | 3 万 | 0 |
召回 xrecsys_two_tower / combined |
0 | 0(哈希桶 1 亿) | 3000 万 | 0 |
| 召回 nano | 0 | 0(哈希桶 10 万) | 3 万 | 0 |
出处:xrecsys.py:574-577、:618-622,xrecsys_two_tower.py:507-512、:514-520、:576-580。召回配置的帖子区段为 0,但仍有 item_hash_vocab_size 个哈希桶。模型照常计算帖子哈希值,用于 log-Q 计数、批内去重和 SID 哈希层,但不再用它查嵌入(推测,依据 recsys_two_tower_model.py:1202-1260、recsys_model.py:1750-1757)。
多个哈希嵌入如何合并
模型有两条合并路径:
特征准备路径(排序旗舰、combined 召回,feature_prep_enabled=True):模型把每个哈希嵌入各乘一个 [emb_table_width, emb_size] 投影,再把结果相加(recsys_feature_prep.py:384-395)。例如,用户 token = Σ user_hash_i × P_i + Σ ip_hash_j × Q_j(:502-531)。帖子与作者的计算方式相同(_build_seq_tokens,:1134-1162)。
旧式 reduce 路径(召回旗舰 xrecsys_two_tower、召回 nano):
block_user_reduce:把用户的 2 个哈希嵌入拼成[B, 1, 2D],乘proj_mat_1回到 D 维,再加上 IP 嵌入之和(recsys_model.py:1673-1711)。block_history_reduce:在每个历史位置,把帖子嵌入、SID 嵌入、作者嵌入、动作嵌入、产品面嵌入拼接,乘proj_mat_3回到 D 维(recsys_model.py:1947-2060)。
两条路径都用“第一个哈希是否为 0”生成 padding 掩码(recsys_feature_prep.py:1160、recsys_model.py:1709)。
语义 ID:帖子的另一种身份
生产配置给每条帖子加一个 6 层 × 256 码的语义 ID。语义 ID 由帖子的多模态嵌入经残差量化得到(phoenix/README.md:241-248)。实现要点如下(recsys_model.py:1714-1835):
- 6 层各有一张
[257, sid_embed_dim]表(256 个码加 1 个 padding),模型用一次 one-hot 查出全部层。 sid_hash_level=True时,模型再加第 7 层:把帖子的第一个哈希值乘1540483477,做异或右移 16 位,再对 256 取模。这一层相当于由帖子 ID 决定的“尾码”(:1750-1757)。sid_cross_attn=True时,各层先做一次小规模注意力,再求平均;否则直接求和(:1761-1780)。combined召回另外加入前两层组合、前三层组合的前缀嵌入(sid_prefix2_embed_dim=128、sid_prefix3_embed_dim=128,xrecsys_two_tower.py:327-328)。
SID 的生成与服务见 Phoenix 训练。
训练时怎样更新
嵌入表使用稀疏的逐行 AdaGrad 更新,与稠密参数的优化器分开。每一步先对本批用到的哈希行去重,再按行累加梯度,只更新被用到的行(phoenix/reference/train_step.py:33-55、:101-148,phoenix/TRAINING.md:26-37)。旗舰排序配置的嵌入学习率为 0.28,半衰期为 2500 步(xrecsys.py:408-415)。
设计决策
| 决策 | 理由 |
|---|---|
| 每个实体用 2 个哈希 | 两个哈希同时碰撞的概率低;合并后,单次碰撞的影响被摊薄(phoenix/README.md:241-248) |
| 编号 0 → 哈希 0 | padding 与有效位置在同一张表里区分;掩码只检查第一个哈希 |
| 使用一张统一表 | 一次 gather 即可查出一批里所有实体的行,便于去重与稀疏更新 |
| 召回改用语义 ID 表示帖子 | 同主题帖子共享前缀;新帖不需要积累互动,也有可用的向量(phoenix/README.md:109-115) |
源码锚点
phoenix/crates/common/xai-recsys/src/model_config.rs:71-260——HashTableConfig、偏移与哈希函数phoenix/xrex/models/recsys_embedding.py:176-280—— Python 侧的HashKeys、HashTablephoenix/xrex/configs/xrecsys.py:137-180—— 词表大小与哈希常量phoenix/xrex/models/recsys_feature_prep.py:384-395、:502-531—— 各哈希嵌入分别投影后相加phoenix/xrex/models/recsys_model.py:1673-2060——block_user_reduce、embed_entity_sid、block_history_reducephoenix/reference/train_step.py:33-148—— 嵌入查找、去重与稀疏更新
相关页面
- Phoenix 排序模型 —— 哈希嵌入构成排序模型的输入
- Phoenix 召回:双塔检索架构 —— 召回只用作者哈希与语义 ID
- Phoenix 训练 —— 稀疏优化器与语义 ID 的生成
- RecsysAggregatedModel(排序模型) ——
block_*_reduce所在的文件