电商推荐系统实战:如何用知识图谱预训练提升商品排序效果(附代码)

最近和几个做电商推荐的朋友聊天,大家普遍有个感觉:传统的协同过滤和深度模型好像越来越“卷”不动了。用户行为稀疏、新商品冷启动、长尾效应这些问题,单靠用户-商品交互数据去硬啃,天花板越来越明显。我们团队去年开始尝试把商品背后的知识体系——也就是知识图谱——引入到排序模型里,不是简单地把属性当特征喂进去,而是先对图谱本身做一次大规模的预训练,让模型先“理解”商品之间的复杂关系。折腾了大半年,线上A/B测试的点击率和GMV提升都挺可观。今天我就把这套从图谱构建、预训练到模型融合的实战经验,结合具体的代码,拆开揉碎了讲给你听。

1. 从零构建电商商品知识图谱:不只是属性关联

很多人一提到商品知识图谱,第一反应就是把商品的类目、品牌、材质这些属性连起来,形成一个“属性网络”。这当然没错,但如果我们只做到这一步,图谱的价值可能只发挥了30%。一个真正有“理解”能力的商品图谱,必须深度融合用户行为语义商品内在逻辑

1.1 定义图谱的核心关系与实体

实体不只有“商品”本身。在我们的实践中,定义了四类核心实体:

  • 商品(Item):图谱的中心节点,每个SKU对应一个实体。
  • 概念(Concept):包括类目、品牌、标签、风格关键词等。例如,“连衣裙”是一个类目概念,“复古风”是一个风格概念。
  • 用户行为模式(Behavior Pattern):这不是指单个用户,而是从群体行为中抽象出的模式,如“点击了A又点击B”、“收藏了X后购买了Y”。我们将高频的共现行为序列抽象为模式实体。
  • 场景(Session Context):从用户单次会话中提取的关键词、意图标签。例如,一次搜索“送男友生日礼物”的会话,可以抽象出“礼物”、“男性”、“生日”等场景实体。

关系则更为丰富,我们将其分为显式关系和隐式关系两大类:

关系类型具体关系说明数据来源
显式关系belongs_to商品属于某个类目/品牌商品后台数据
has_tag商品带有某个属性标签商品后台数据
also_bought同时购买(强关联)订单数据
also_viewed详情页浏览(替代关联)点击流数据
隐式关系semantic_similar语义相似(嵌入模型计算)商品标题/描述文本向量
complementary互补品(如手机与壳)跨类目共现分析
sequence_next行为流中的前后序用户会话序列挖掘

提示:隐式关系的挖掘是提升图谱质量的关键。我们使用Sentence-BERT对商品文本信息编码,通过余弦相似度筛选出semantic_similar关系;利用关联规则挖掘(如Apriori算法)跨会话发现complementary关系。

1.2 图谱构建的工程化流程与代码示例

构建十亿级(Billion-scale)的商品图谱,手动操作是天方夜谭。我们设计了一套自动化的流水线。核心步骤包括数据清洗、实体链接、关系抽取和图存储。

首先,我们需要将原始数据(商品表、用户行为日志)转化为三元组(头实体,关系,尾实体)的格式。以下是一个简化的数据预处理Python脚本片段:

import pandas as pd
import json
from typing import List, Tuple

def generate_triples_from_behavior(log_df: pd.DataFrame) -> List[Tuple]:
    """
    从用户行为日志中生成共现关系三元组。
    例如,同一session内先后点击itemA和itemB,生成 (itemA, 'sequence_next', itemB)
    """
    triples = []
    # 按会话分组
    for session_id, group in log_df.groupby('session_id'):
        items = group['item_id'].tolist()
        # 生成连续点击序列关系
        for i in range(len(items) - 1):
            triples.append((f"Item:{items[i]}", 'sequence_next', f"Item:{items[i+1]}"))
        # 生成会话内共现关系(简化版)
        unique_items = list(set(items))
        for i in range(len(unique_items)):
            for j in range(i+1, len(unique_items)):
                triples.append((f"Item:{unique_items[i]}", 'co_occur_in_session', f"Item:{unique_items[j]}"))
    return list(set(triples))  # 去重

# 假设log_data是读取的行为日志DataFrame
behavior_triples = generate_triples_from_behavior(log_data)

接下来,需要将三元组存入图数据库以便后续查询和采样。我们选用Neo4j,因为它对关系查询非常友好。以下是使用py2neo驱动入库的示例:

from py2neo import Graph, Node, Relationship

class KnowledgeGraphBuilder:
    def __init__(self, uri, user, password):
        self.graph = Graph(uri, auth=(user, password))

    def create_entity_node(self, entity_type, entity_id, properties=None):
        """创建实体节点"""
        label = entity_type.capitalize()
        node = Node(label, id=entity_id, **properties)
        self.graph.create(node)
        return node

    def create_relationship(self, head_node, relation_type, tail_node):
        """创建关系边"""
        rel = Relationship(head_node, relation_type, tail_node)
        self.graph.create(rel)

# 使用示例
builder = KnowledgeGraphBuilder("bolt://localhost:7687", "neo4j", "password")
item_node = builder.create_entity_node('Item', '12345', {'name': '某品牌连衣裙'})
category_node = builder.create_entity_node('Category', 'cat_001', {'name': '连衣裙'})
builder.create_relationship(item_node, 'BELONGS_TO', category_node)

2. 知识图谱预训练:让模型学会“商品语言”

图谱建好了,怎么用?传统做法是直接拿图神经网络(GNN)跑一遍,得到商品嵌入(embedding)然后当成特征输入排序模型。但我们发现,在电商这种动态变化、关系复杂的场景下,直接端到端训练GNN效果不稳定,尤其是对新商品不友好。预训练的思路就是:先让模型在海量、静态的图谱结构数据上“自学”商品和关系的通用表示,形成一个强大的“商品语言模型”,然后再针对具体的排序任务进行微调。

2.1 预训练任务设计:超越TransE和TransR

早期知识图谱表示学习喜欢用TransE、TransR这类基于距离的模型,它们假设关系是头尾实体向量在某个空间的翻译。但在电商图谱里,关系太复杂了。also_bought(也买)和complementary(互补)在语义上很接近,但强度不同;belongs_to(属于)具有明显的层次结构。

我们借鉴了自然语言处理中掩码语言模型(MLM)的思想,为图谱设计了两个预训练任务:

  1. 关系感知的实体掩码预测:随机掩码掉图谱中某个实体的邻居(或关系),让模型根据上下文(相连的其他实体和关系)预测被掩码的部分。这迫使模型理解局部图结构。
    # 伪代码:构造掩码样本
    # 给定一个三元组 (h, r, t),随机选择掩码头实体h、关系r或尾实体t
    # 例如,掩码尾实体t,输入为 (h, r, [MASK]),模型需要从所有实体中预测出t。
    
  2. 对比学习任务:这是提升模型区分能力的关键。对于一个三元组(h, r, t),我们通过以下方式构造负样本:
    • 实体替换:随机替换头实体或尾实体为图谱中其他实体。
    • 关系替换:随机将关系替换为其他关系。
    • 硬负样本挖掘:用已训练的简单模型(如TransE)找出那些距离正样本很近,但实际错误的实体对作为负样本,增加难度。 模型的目标是拉近正样本三元组的表示距离,推远负样本的距离。

2.2 预训练模型架构与代码核心

我们采用图注意力网络(GAT)作为编码器主干,因为它能对不同邻居分配不同权重,更适合电商图谱中关系重要性各异的特点。预训练模型的核心是一个多任务学习框架。

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GATConv

class GraphPretrainModel(nn.Module):
    def __init__(self, num_entities, num_relations, embedding_dim, gnn_layers=2):
        super().__init__()
        self.entity_embed = nn.Embedding(num_entities, embedding_dim)
        self.relation_embed = nn.Embedding(num_relations, embedding_dim)

        # GNN编码器
        self.gnn_layers = nn.ModuleList()
        for _ in range(gnn_layers):
            self.gnn_layers.append(GATConv(embedding_dim, embedding_dim))

        # 用于掩码预测的MLP头
        self.mlm_head = nn.Linear(embedding_dim, embedding_dim)
        # 用于对比学习的投影头
        self.projection_head = nn.Sequential(
            nn.Linear(embedding_dim, embedding_dim),
            nn.ReLU(),
            nn.Linear(embedding_dim, 128)  # 投影到对比学习空间
        )

    def forward(self, entity_ids, edge_index, edge_type):
        # 获取初始嵌入
        x = self.entity_embed(entity_ids)
        # 通过GNN层传递消息
        for conv in self.gnn_layers:
            x = conv(x, edge_index)
            x = F.relu(x)
        return x  # 返回所有实体的最终表示

    def compute_contrastive_loss(self, anchor_emb, positive_emb, negative_embs, temperature=0.1):
        """计算InfoNCE对比损失"""
        # anchor: 正样本头实体表示
        # positive: 正样本尾实体表示
        # negative_embs: 一批负样本尾实体表示
        pos_sim = F.cosine_similarity(anchor_emb, positive_emb, dim=-1) / temperature
        neg_sims = torch.stack([F.cosine_similarity(anchor_emb, neg, dim=-1) / temperature for neg in negative_embs])
        logits = torch.cat([pos_sim.unsqueeze(1), neg_sims.T], dim=1)
        labels = torch.zeros(logits.size(0), dtype=torch.long).to(logits.device)  # 正样本在0位置
        loss = F.cross_entropy(logits, labels)
        return loss

预训练完成后,entity_embed这个嵌入层就包含了经过海量图谱结构知识熏陶的商品通用表示。我们可以把它保存下来,作为下游排序模型的静态先验知识输入。

3. 融合预训练图谱表示与排序模型

预训练得到的商品嵌入是静态的、通用的。而排序任务需要动态的、与当前用户上下文高度相关的表示。如何融合这两者,是决定效果上限的关键。直接拼接(concatenate)是一种方式,但更优雅的方式是让它们进行“对话”。

3.1 设计融合架构:门控与交叉注意力

我们尝试了三种融合方式,最终线上采用的是门控特征融合交叉注意力机制的结合。

  1. 门控特征融合:为预训练嵌入(E_pt)和实时用户-商品交互特征(E_rt)分别学习一个权重。

    class GatedFusion(nn.Module):
        def __init__(self, dim):
            super().__init__()
            self.gate = nn.Linear(dim * 2, dim)
            self.transform = nn.Linear(dim * 2, dim)
    
        def forward(self, pt_emb, rt_emb):
            combined = torch.cat([pt_emb, rt_emb], dim=-1)
            gate_value = torch.sigmoid(self.gate(combined))
            fused = gate_value * pt_emb + (1 - gate_value) * rt_emb
            # 再加一个非线性变换
            output = torch.tanh(self.transform(torch.cat([fused, rt_emb], dim=-1)))
            return output
    

    这个门控网络让模型自己决定,对于当前预测,应该更相信通用的知识表示,还是更相信实时交互信号。

  2. 交叉注意力机制:将用户当前的行为序列(例如最近点击的5个商品)作为Query,将候选商品及其在图谱中的邻居(通过预训练嵌入表示)作为Key和Value,进行注意力计算。这相当于让用户行为去“查询”知识图谱,找出最相关的知识信息来辅助决策。

    # 伪代码思路
    # user_seq_emb: 用户行为序列嵌入 [seq_len, dim]
    # candidate_kg_emb: 候选商品及其图谱邻居的嵌入 [num_neighbors+1, dim]
    # 计算注意力分数,加权聚合图谱信息,得到增强后的候选商品表示。
    

3.2 下游排序模型与训练技巧

融合后的特征,可以输入任何你喜欢的排序模型,如DeepFM、DIN、BST等。这里以DeepFM为例,展示如何接入:

class DeepFMWithKGPretrain(nn.Module):
    def __init__(self, feature_columns, pt_embedding_matrix, hidden_units):
        super().__init__()
        # 1. 常规特征处理部分
        self.dense_feature_layer = ... # 处理数值特征
        self.sparse_embedding_layers = ... # 处理类别特征

        # 2. 知识图谱预训练嵌入层(冻结或微调)
        self.pt_embedding = nn.Embedding.from_pretrained(pt_embedding_matrix, freeze=False) # 我们选择微调

        # 3. 融合模块
        self.fusion_module = GatedFusion(dim=pt_embedding_matrix.size(1))

        # 4. DeepFM组件
        self.fm = FMComponent()
        self.dnn = DNN(input_dim=fused_dim + other_feature_dim, hidden_units=hidden_units)

    def forward(self, inputs):
        # 提取常规特征
        dense_input, sparse_ids, candidate_item_id = inputs
        other_feat = self._process_regular_features(dense_input, sparse_ids)

        # 提取知识特征
        candidate_pt_emb = self.pt_embedding(candidate_item_id) # [bs, dim]
        # 假设我们通过图数据库实时查询到了候选商品的实时交互特征(简化)
        candidate_rt_emb = self._get_realtime_embedding(candidate_item_id)

        # 融合
        fused_kg_emb = self.fusion_module(candidate_pt_emb, candidate_rt_emb)

        # 拼接所有特征
        all_feat = torch.cat([fused_kg_emb, other_feat], dim=1)

        # DeepFM计算
        fm_output = self.fm(all_feat)
        dnn_output = self.dnn(all_feat)
        final_output = torch.sigmoid(fm_output + dnn_output)
        return final_output

注意:预训练嵌入层是否微调(freeze=False)是一个需要AB测试的超参数。对于数据丰富的头部商品,微调可能更好;对于长尾商品,冻结预训练权重可能更能保持其泛化性。

训练时,由于引入了预训练表示,模型收敛速度通常会更快。建议采用渐进式学习率预热(Warmup)在排序损失中加入正则化项,防止微调过程“遗忘”预训练中学到的宝贵知识。我们使用了以下正则化:

# 在总损失中加入一个约束,让微调后的商品嵌入不要偏离预训练初始值太远
def knowledge_preservation_loss(current_emb, initial_pt_emb, lambda=0.01):
    return lambda * F.mse_loss(current_emb, initial_pt_emb.detach())

total_loss = ranking_loss + knowledge_preservation_loss(candidate_pt_emb, initial_embedding)

4. 实战效果分析与部署考量

这套方案在我们两个主要的电商场景(首页信息流和搜索排序)上线后,进行了为期一个月的A/B测试。核心指标对比如下:

指标对照组(原模型)实验组(图谱预训练模型)相对提升
点击率(CTR)基准+3.7%显著
转化率(CVR)基准+2.1%显著
人均GMV基准+5.4%显著
长尾商品曝光占比基准+15.2%非常显著

最让我们兴奋的不是整体指标的提升,而是长尾商品曝光和转化的大幅增加。这验证了我们的初衷:知识图谱预训练提供的先验知识,极大地缓解了冷启动和数据稀疏问题。一个没有任何历史行为的新款连衣裙,因为其类目、品牌、风格标签在图谱中与热门商品有紧密连接,也能获得不错的初始排序分数。

在部署层面,这套系统带来了新的挑战和优化点:

  • 图谱更新:商品和关系是动态变化的。我们采用了增量更新策略。每天凌晨,用前一天的新数据更新图谱三元组,并启动一个轻量级的“增量预训练”任务,只对新出现的实体和关系进行少量epoch的训练,快速更新嵌入表,而不需要全量重训。
  • 服务化:排序模型实时服务需要快速获取候选商品的预训练嵌入。我们将十亿级别的商品嵌入存储在高性能的向量数据库(如Milvus或Faiss)中,通过商品ID实现毫秒级查询。图谱关系查询则依赖Neo4j,对于实时请求,我们只查询一跳内的核心邻居,确保延迟可控。
  • 计算成本:预训练阶段确实耗费资源,但这是一次性的、离线的成本。上线后,线上推理的额外开销主要在于一次向量检索和轻量的融合计算,对于大多数公司是可以接受的。我们通过嵌入量化(如FP16)和模型蒸馏,将融合模型的体积压缩了40%,基本消除了对线上延迟的影响。

踩过最大的一个坑,是初期盲目地将所有挖掘出的隐式关系都塞进图谱,导致图谱噪声过大,预训练效果反而下降。后来我们引入了关系置信度分数,并设置了阈值过滤,只保留高置信度的关系,模型的鲁棒性才得到保障。另一个经验是,预训练任务的设计比模型结构更重要,一个好的对比学习负样本构造策略,带来的提升可能比换一个更复杂的GNN编码器还要大。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐