第2241篇:零售业AI的工程实践——个性化推荐与动态定价系统
2026/4/30大约 7 分钟
第2241篇:零售业AI的工程实践——个性化推荐与动态定价系统
适读人群:零售技术工程师、电商后端开发者、推荐系统工程师 | 阅读时长:约16分钟 | 核心价值:深度拆解零售场景推荐系统的工程挑战,以及动态定价的落地逻辑和监管边界
做电商推荐系统的时候,有件事让我印象特别深。我们上线了一个新的协同过滤模型,A/B测试数据看起来很漂亮——CTR提升了8%,转化率提升了5%。但是有个运营找到我说,她发现用户开始抱怨"推荐来推荐去都是同样的东西"。
我去看了用户反馈,确实有这种声音。模型学到的是"用户买了A类商品,大概率还会买A类",所以推荐列表越来越同质化。用户第一次看到推荐,觉得"哦还挺准";但第三次、第四次还是这些,就开始觉得平台在给自己"贴标签"。
这就是推荐系统工程里很典型的探索-利用困境(Exploration-Exploitation Tradeoff):过度利用会让推荐越来越窄,用户感知越来越差;过度探索则短期指标下降,业务方不接受。
零售推荐系统的架构
零售推荐系统的完整链路:
每一层都有独立的优化空间,工程实现时需要根据流量分层:
- 召回层:处理百万级商品,快速缩减到千级候选集,优先考虑速度
- 排序层:精确预测每个候选商品的点击/购买概率,可以用重模型
- 业务规则层:在算法结果基础上叠加运营需求,这层的逻辑往往最复杂
多路召回实现
@Service
public class RecallService {
@Autowired
private CollaborativeFilteringRecaller cfRecaller;
@Autowired
private ContentBasedRecaller contentRecaller;
@Autowired
private HotItemRecaller hotItemRecaller;
@Autowired
private UserEmbeddingRecaller userEmbeddingRecaller;
/**
* 多路并行召回,合并去重
*/
public List<RecallItem> recall(String userId, RecallContext context) {
// 并行执行各路召回
CompletableFuture<List<RecallItem>> cfFuture =
CompletableFuture.supplyAsync(() ->
cfRecaller.recall(userId, 200));
CompletableFuture<List<RecallItem>> contentFuture =
CompletableFuture.supplyAsync(() ->
contentRecaller.recall(userId, context.getRecentViewedItems(), 200));
CompletableFuture<List<RecallItem>> hotFuture =
CompletableFuture.supplyAsync(() ->
hotItemRecaller.recall(context.getCategoryId(), 100));
CompletableFuture<List<RecallItem>> embeddingFuture =
CompletableFuture.supplyAsync(() ->
userEmbeddingRecaller.recall(userId, 300));
// 合并结果,去重,保留召回分数
Map<String, RecallItem> mergedItems = new LinkedHashMap<>();
Stream.of(cfFuture, contentFuture, hotFuture, embeddingFuture)
.forEach(future -> {
try {
List<RecallItem> items = future.get(200, TimeUnit.MILLISECONDS);
for (RecallItem item : items) {
mergedItems.merge(item.getItemId(), item,
(existing, newItem) -> {
// 取最高分,并记录召回来源
existing.setScore(Math.max(existing.getScore(),
newItem.getScore()));
existing.addRecallSource(newItem.getRecallSource());
return existing;
});
}
} catch (TimeoutException e) {
log.warn("召回超时: {}", future);
} catch (Exception e) {
log.error("召回异常", e);
}
});
return new ArrayList<>(mergedItems.values());
}
}
// 向量召回:基于用户embedding和商品embedding的ANN检索
@Service
public class UserEmbeddingRecaller {
@Autowired
private MilvusClient milvusClient; // 向量数据库
@Autowired
private UserEmbeddingService userEmbeddingService;
public List<RecallItem> recall(String userId, int topK) {
// 获取用户实时embedding(融合了最近行为)
float[] userEmbedding = userEmbeddingService.getUserEmbedding(userId);
if (userEmbedding == null) {
return Collections.emptyList(); // 新用户没有embedding
}
// ANN检索:在商品向量库中找最近邻
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName("item_embeddings")
.withVectors(Collections.singletonList(toList(userEmbedding)))
.withTopK(topK)
.withMetricType(MetricType.IP) // 内积相似度
.withParams("{\"nprobe\": 16}")
.build();
R<SearchResults> response = milvusClient.search(searchParam);
if (response.getStatus() != R.Status.Success.getCode()) {
log.error("向量召回失败: {}", response.getMessage());
return Collections.emptyList();
}
return parseSearchResults(response.getData());
}
}排序层:深度兴趣网络(DIN)
精排模型需要捕捉用户对候选商品的即时兴趣,DIN通过注意力机制聚焦于用户历史行为中与当前候选商品相关的部分:
@Service
public class RankingService {
@Autowired
private DINModelClient dinModelClient;
@Autowired
private FeatureBuilder featureBuilder;
/**
* 对召回结果精排
*/
public List<RankedItem> rank(String userId, List<RecallItem> candidates,
RankContext context) {
if (candidates.isEmpty()) return Collections.emptyList();
// 批量构建特征
List<RankingFeature> features = candidates.stream()
.map(item -> featureBuilder.buildRankingFeature(userId, item, context))
.collect(Collectors.toList());
// 批量推理(利用模型的批处理能力)
List<Double> scores = dinModelClient.predictBatch(features);
// 组合排序分数和业务分数
List<RankedItem> rankedItems = new ArrayList<>();
for (int i = 0; i < candidates.size(); i++) {
RecallItem item = candidates.get(i);
double modelScore = scores.get(i);
double businessScore = calculateBusinessScore(item, context);
// 最终分 = 模型分 * 业务权重
double finalScore = modelScore * 0.7 + businessScore * 0.3;
rankedItems.add(RankedItem.builder()
.itemId(item.getItemId())
.modelScore(modelScore)
.businessScore(businessScore)
.finalScore(finalScore)
.build());
}
// 按最终分降序
rankedItems.sort(Comparator.comparingDouble(RankedItem::getFinalScore).reversed());
return rankedItems;
}
/**
* 计算业务分数
* 综合考虑:库存、毛利率、商家评分、新品加权等
*/
private double calculateBusinessScore(RecallItem item, RankContext context) {
ItemBusinessInfo info = itemBusinessRepo.findById(item.getItemId());
double score = 0.5; // 基准分
// 毛利率加权(促进平台盈利)
if (info.getMarginRate() > 0.3) score += 0.1;
if (info.getMarginRate() > 0.5) score += 0.1;
// 新品扶持
if (info.isNewItem()) score += 0.15;
// 库存预警:库存不足时降权(避免推荐买不了的商品)
if (info.getStock() < 10) score -= 0.2;
if (info.getStock() == 0) return 0; // 无货直接过滤
// 商家信用分
score += info.getSellerScore() / 10.0 * 0.1;
return Math.max(0, Math.min(1, score));
}
}多样性打散:解决推荐同质化
推荐列表最终展示前,进行多样性调整:
@Service
public class DiversityReranker {
/**
* MMR算法(最大边际相关性)
* 在相关性和多样性之间找平衡
* λ=0时纯多样性,λ=1时纯相关性
*/
public List<RankedItem> mmrRerank(List<RankedItem> items,
Map<String, float[]> itemEmbeddings,
double lambda, int topN) {
List<RankedItem> selected = new ArrayList<>();
List<RankedItem> candidates = new ArrayList<>(items);
// 先选分数最高的一个
selected.add(candidates.remove(0));
while (selected.size() < topN && !candidates.isEmpty()) {
double bestMMRScore = Double.NEGATIVE_INFINITY;
int bestIdx = 0;
for (int i = 0; i < candidates.size(); i++) {
RankedItem candidate = candidates.get(i);
float[] candidateEmb = itemEmbeddings.get(candidate.getItemId());
// 相关性:原始模型分
double relevance = candidate.getFinalScore();
// 与已选商品的最大相似度
double maxSimilarity = selected.stream()
.map(s -> itemEmbeddings.get(s.getItemId()))
.mapToDouble(selEmb -> cosineSimilarity(candidateEmb, selEmb))
.max().orElse(0);
// MMR分数
double mmrScore = lambda * relevance - (1 - lambda) * maxSimilarity;
if (mmrScore > bestMMRScore) {
bestMMRScore = mmrScore;
bestIdx = i;
}
}
selected.add(candidates.remove(bestIdx));
}
return selected;
}
private double cosineSimilarity(float[] a, float[] b) {
double dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-8);
}
}动态定价:边界与实现
动态定价在零售行业是个敏感话题。几个关键边界:
可以做的:
- 同品同价的前提下,对不同用户展示不同的优惠券(本质是差异化促销)
- 根据需求和库存动态调整商品价格(对所有用户一致)
- 大促前后的价格策略
绝对不能做的:
- 对同一商品对不同用户展示不同价格(大数据杀熟,违法)
- 基于用户的支付能力歧视性定价
@Service
public class DynamicPricingService {
/**
* 基于库存和需求的动态价格计算
* 注意:所有用户看到的价格必须一致
*/
public PricingDecision calculatePrice(String itemId, PricingContext context) {
ItemPricingConfig config = pricingConfigRepo.findByItemId(itemId);
// 基础价格
double basePrice = config.getBasePrice();
double minPrice = config.getMinPrice(); // 保底价(不低于成本)
double maxPrice = config.getMaxPrice(); // 封顶价(品牌保护)
// 需求弹性调整
double demandFactor = calculateDemandFactor(itemId, context);
// 库存压力调整
double inventoryFactor = calculateInventoryFactor(itemId);
// 竞品价格参考
double competitorFactor = getCompetitorPriceFactor(itemId);
// 综合价格
double targetPrice = basePrice * demandFactor * inventoryFactor * competitorFactor;
// 边界约束
targetPrice = Math.max(minPrice, Math.min(maxPrice, targetPrice));
// 心理定价:取整到.9结尾
targetPrice = Math.floor(targetPrice) + 0.9;
if (targetPrice > maxPrice) targetPrice -= 1;
return PricingDecision.builder()
.itemId(itemId)
.recommendedPrice(targetPrice)
.priceChangePct((targetPrice - basePrice) / basePrice * 100)
.reason(buildPricingReason(demandFactor, inventoryFactor))
.effectiveFrom(LocalDateTime.now())
.build();
}
private double calculateInventoryFactor(String itemId) {
InventoryInfo inventory = inventoryRepo.findByItemId(itemId);
double inventoryDays = inventory.getStock() /
Math.max(1, inventory.getDailySalesAvg());
// 库存积压(>90天):降价促销
if (inventoryDays > 90) return 0.85;
if (inventoryDays > 60) return 0.92;
// 库存紧张(<7天):适当提价或维持价格
if (inventoryDays < 7) return 1.05;
if (inventoryDays < 3) return 1.10;
return 1.0; // 正常库存
}
}在线学习:让推荐系统实时响应
用户偏好是实时变化的,需要近实时的模型更新:
@Service
public class OnlineLearningService {
@Autowired
private KafkaTemplate<String, UserBehavior> kafkaTemplate;
/**
* 用户行为实时反馈到推荐模型
* 使用流式增量更新,而非每次全量训练
*/
@KafkaListener(topics = "user-behavior-events")
public void processBehavior(UserBehavior behavior) {
switch (behavior.getType()) {
case PURCHASE:
// 正样本:强信号,权重高
updateUserEmbedding(behavior.getUserId(),
behavior.getItemId(), 2.0);
break;
case CLICK:
// 弱正样本
updateUserEmbedding(behavior.getUserId(),
behavior.getItemId(), 0.5);
break;
case EXPOSE_WITHOUT_CLICK:
// 负样本:展示了但没点击
updateUserEmbedding(behavior.getUserId(),
behavior.getItemId(), -0.3);
break;
}
}
private void updateUserEmbedding(String userId, String itemId, double weight) {
// 使用FTRL(Follow the Regularized Leader)在线更新
float[] itemEmb = itemEmbeddingService.getEmbedding(itemId);
float[] userEmb = userEmbeddingService.getEmbedding(userId);
if (itemEmb == null || userEmb == null) return;
// 梯度方向:朝向item embedding方向移动
float learningRate = 0.01f;
for (int i = 0; i < userEmb.length; i++) {
float gradient = (float)(weight * (itemEmb[i] - userEmb[i]));
userEmb[i] += learningRate * gradient;
}
// L2归一化
normalize(userEmb);
userEmbeddingService.updateEmbedding(userId, userEmb);
}
}效果评估:超越CTR
仅看CTR会让推荐系统越来越窄。评估推荐系统需要多维指标:
- 转化相关:CTR、CVR、GMV贡献
- 多样性:推荐列表的品类分布熵值
- 新鲜度:推荐列表中新品的占比
- 覆盖率:被推荐到的商品占总商品的比例(Coverage)
- 长期用户留存:30天、90天的用户留存率
最后这个指标最难测量,但最重要。一个让用户"越买越上瘾"的推荐系统和让用户"越看越烦"的推荐系统,短期CTR可能差不多,但3个月后的留存率会相差很大。
