做 ELMA 的起因很普通。饭点打开地图和点评,附近能吃的店一大堆,筛了半天还是不知道去哪。
所以首页没有餐厅列表。定位后点“帮我选”,页面直接给一家。用户可以选正餐、小吃快餐、饮品甜品或者随便,也能填不想吃的东西,但这些都是可选纠偏,不会变成一份问卷。
一次只给一家后,麻烦都到了服务端:哪些店不能进来,评分该信多少,换一家会不会重复,用户点了“就它了”算不算喜欢,以及最近刚吃过的店要不要继续出现。
先过滤,再排
附近餐厅从高德获取,按距离、预算、品类、营业状态和“不想吃”做硬过滤。品类暂时只分四个大类,没有继续细到湘菜、烧烤或拉面。现有地点数据还撑不起稳定的细分类,界面做得太精确只会制造错觉。
高风险候选会在个性化和探索之前剔除,不能靠用户偏好把它重新捞回来。其余候选再算 LowRegretScore。V0.4 把餐厅质量、风险安全、口味匹配、预算、距离和近期多样性拆成六项:
WeightedScore =
25% RestaurantQuality + 25% RiskSafety + 20% TasteMatch
+ 12% BudgetFit + 10% DistanceFit + 8% RecentDiversity
之后只减一项最强的近期惩罚,再加本次探索奖励,结果限制在 0 到 100。权重是目前的产品规则,不是训练出来的参数。真实使用数据还少,先用能解释、能写测试的办法。
服务端会在高分候选里做品类多样化,再从 Top-10 按分数加权、不放回地抽出最多 6 家。分高的出现概率大一些,同一家又不会长期占住第一个位置。客户端仍然只拿当前这一家来展示。
换一家只读这次的候选池
每次点击“换一家”都重新跑推荐,写起来简单,实际用起来很乱。外部地点数据可能有变化,刚看过的店可能再次出现,剩余次数也不好解释。
创建推荐时,服务端会冻结最多 6 家候选。第一次展示一家,之后最多换 5 次,每次取下一家没出现过的店。候选用完后再点,会回到最初推荐,不临时生成第七家。
候选的风险、个性化分项、理由和跨平台证据摘要一起保存。换一家不再调用地图,也不受刚提交的反馈影响。页面显示的 alternativesRemaining 由服务端返回,前端不自己推算。
评分旁边还要放一个置信度
评分高不等于数据就可靠。4.9 分可能来自稳定口碑,也可能样本很少,或者两个平台对同一家店的记录差得很远。
V0.2 先用了规则风险模型,检查评论模板相似度、短期集中出现、近期趋势和数据是否充足。没有评论 Evidence 时不会按零风险处理,而是降低置信度,并给排序加一部分不确定性补偿。Evidence 文件读取失败或没有匹配,只降级这一份证据,推荐还能继续。
V0.3 加入百度 Place 做交叉核对。高德仍是餐厅主实体,百度提供第二份地点信息。名称、坐标、地址和电话归一化后计算匹配分,同名分店还要满足距离等条件;一个百度地点也不能同时匹配多家高德餐厅。
找不到百度匹配时保持中性,不把“没找到”说成平台冲突。匹配成功后,综合评分差异才进入风险计算。缺失字段保留为空,不补成零。
现在用的仍是确定性规则,阈值集中在配置里,也有对应测试。真实样本还不够,先不上机器学习。
用户口味、行为和近期饮食历史不会进入 RiskScore。有人不喜欢某家店,只会改变这个人的后续排序,不会把餐厅的客观风险改高。风险与口味分开以后,后面做风险校准也不用先从数据里拆掉用户偏好。
反馈留给下一次推荐
客户端保存一个匿名 UUID,不要求注册。用户可以反馈“喜欢”“一般”或“不喜欢”,服务端据此更新 TasteProfile。
现在的画像记录细品类、价格档、距离档和五种口味。点完三态反馈后,可以顺手选择辣、甜、油、咸、清淡,最多 3 个,也可以跳过。口味只更新用户实际勾选的项,不根据店名猜。
同一口味至少被两个不同匿名用户标注后,才会成为这家店的公共特征;当前用户自己标过的内容可以立即用于自己的下一次推荐。没有可靠标签时按中性处理,不补一套看起来很懂菜名的推断规则。
画像权重限制在 [-3, 3],并按 90 天半衰期减弱。可信度也不会一开始就是 1:
confidence = min(1, (显式反馈数 + floor(已处理隐式行为数 / 3)) / 5)
最终偏好会按这个可信度向中性 50 分收缩。新用户是 50 分和 0 可信度,一两次反馈只会轻微改变结果。
画像已经从旧版 JSON 迁到单独的数据库表,用乐观锁和有限重试处理并发更新。V0.4 回滚一段时间再恢复时,还会补处理期间产生的新反馈,避免版本切换把画像留在旧位置。
当前候选池不会跟着反馈重排。用户已经拿到一组有限选项,这次就按原顺序走完;新画像从下一次“帮我选”开始使用。
点击和离开也算信号,但权重很轻
三态反馈发生得不算频繁,完全靠它学习,画像会长时间停在初始值。V0.4 又记录了几种行为:展示、换一家由服务端写入;客户端提交“就它了”、成功打开地图和未操作离开。
客户端行为带持久化 eventId。网络重试继续使用同一个 ID,第一次写入返回 201,重复命中返回 200,不会重复计数。行为请求失败也不能挡住导航或返回首页。
“就它了”和打开地图各加 0.1,同一会话、同一家店合计最多 0.2;换一家减 0.1,跳过减 0.05。信号先按特征放进 30 天累加器,同一特征凑到 3 个事件后才写入长期画像,单批调整也有限制。
这些行为只能说明意向。只有喜欢、一般和不喜欢三种反馈会被记成真正吃过,并进入近期饮食历史。
最近吃过,只做降权
7 天内对同一家店点过不喜欢,惩罚是 40 分;同店其他反馈是 30 分。连续两个自然日吃过同品类减 12 分,最近一个自然日吃过减 5 分。几条同时命中时只取最强的一条,日期按 Asia/Shanghai 计算。
这里没有写“吃过一次,七天内禁止出现”。附近候选少时,硬删除很容易把结果清空。降权后它通常会往后排,实在没有更合适的店,也还能回来。
留一点低风险探索
如果画像只会强化已有偏好,用户喜欢过一次粉面,后面可能一直看到粉面。现在首次展示有 20% 概率尝试低风险的新类型,其余情况走个性化排序。
探索候选必须是 LOW 风险,风险置信度不低于 0.6,近 7 天没吃过同品类,也不能命中明确负偏好或 30 天内同品类不喜欢。没有合格候选就退回正常排序。探索只决定第一次展示,后面的换一家仍然使用冻结候选池。
这个比例暂时不会自己变化。现阶段样本不够,固定规则更容易看出哪里出了问题。
想多看一点时再深挖
默认推荐只查高德、百度和本地评论 Evidence。B站、小红书与大众点评的公开 Web 搜索线索没有放进主链路,因为它们慢,也可能失败。多数饭点请求用不到这一步。
结果页有“深挖一下这家”。用户主动点击后,服务端才通过 Brave Search 查询三个站点,只读取公开索引里的标题、链接、摘要和时间,不打开结果页或评论区。
这些摘要按规则统计正面、负面、运营提醒和营销倾向,生成独立的 deep-risk-v0.1。它对基础风险最多调整 10 分,也不能单独把原本非高风险的餐厅推成高风险。某个来源失败就标为不可用,基础判断照常返回。
深挖结果不改推荐排序、候选池、换一家顺序或 TasteProfile。它只是给已经感兴趣的餐厅补一些公开线索。
项目接口以 OpenAPI 为准。创建推荐、换一家、反馈和深挖都先确定契约,再改后端与小程序类型。风险分数会带等级、置信度和算法版本,经纬度统一使用 GCJ-02。前端不做排名,也不直接调用高德 Web Service。
V0.4 已在内测环境跑过一次完整链路:新用户从中性画像开始,行为重试不会重复写入,反馈后旧会话仍按原快照换店,下一次推荐才切到个性化模式。外部证据仍有覆盖盲区,规则阈值也需要更多真实使用来调。现在缺的是校准数据,不是再往公式里塞一层看起来聪明的模型。