住宿搜索的难点不只是找出“质量最高”的房源,而是判断某位旅客在当前阶段最可能需要什么。来源没有提供技术摘要,因此无法确认 Airbnb 实际采用的模型、特征或系统架构。仅从标题可以确定一个重要方向:个性化搜索不应只观察当前查询,还要从旅客跨越发现、比较、收藏、预订与入住的完整旅程中学习。
为什么单次查询不够
同一句“东京住宿”,在不同旅程阶段可能表达完全不同的需求:
- 刚开始规划时,用户可能在比较区域、价格和房型,日期尚未确定。
- 已收藏多个新宿房源后,用户再次搜索东京,区域偏好已经相当明确。
- 临近出发时,用户可能更关心即时确认、入住时间和交通距离。
- 完成一次家庭旅行后,下一次搜索仍可能延续对整套房源、厨房和多床位的偏好。
传统排序通常以查询、房源质量和总体转化率为中心。旅程驱动的个性化则增加了一层上下文:当前请求属于哪个阶段,哪些历史行为仍然有效,以及本次搜索与过去行程是否相关。
这意味着系统需要区分三类信号:
- 短期意图:最近查看的城市、日期、价格区间和房型,通常对当前会话影响最大。
- 稳定偏好:长期反复出现的整套房源、卧室数量或设施偏好,但不能把偶发行为误认为永久标签。
- 旅程状态:探索、收敛、已预订或行程结束。状态不同,同一行为的解释也不同。
把旅程转换成排序特征
可以将旅客行为表示为带时间戳的事件流,例如 search、view、save、book 和 trip_completed。在线排序服务不必读取完整历史,而应由特征流水线提前生成紧凑状态:
- 最近目的地及其时间衰减权重;
- 最近浏览和收藏房源的价格中位数;
- 偏好的房型、区域和设施分布;
- 当前旅程阶段;
- 当前候选房源与历史偏好的匹配程度。
一个可解释的初始排序公式可以写成:
final_score = 0.55 * relevance
+ 0.20 * listing_quality
+ 0.15 * journey_match
+ 0.10 * preference_match
这些权重只是可以这样实践的起点,并不代表 Airbnb 的实现。生产系统通常还要加入可订状态、价格、地理距离、业务约束和探索机制。尤其要避免让个性化分数压过硬约束:没有可用日期的房源,无论多符合偏好都不应排在可预订房源之前。
一个可运行的旅程感知排序原型
下面的 Python 示例只使用标准库,可以直接保存为 journey_ranker.py 后运行。它演示如何对历史事件做时间衰减,再把用户近期表现出的区域与房型偏好加入基础相关性分数。
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from math import exp
@dataclass(frozen=True)
class Event:
kind: str
area: str
room_type: str
occurred_at: datetime
@dataclass(frozen=True)
class Listing:
id: str
area: str
room_type: str
relevance: float
quality: float
def decay(age_days: float, half_life_days: float = 14.0) -> float:
return exp(-0.693 * age_days / half_life_days)
def build_preferences(events: list[Event], now: datetime):
area_scores: dict[str, float] = {}
room_scores: dict[str, float] = {}
action_weight = {"view": 1.0, "save": 2.5, "book": 4.0}
for event in events:
age = max(0.0, (now - event.occurred_at).total_seconds() / 86400)
weight = action_weight.get(event.kind, 0.5) * decay(age)
area_scores[event.area] = area_scores.get(event.area, 0.0) + weight
room_scores[event.room_type] = room_scores.get(event.room_type, 0.0) + weight
return area_scores, room_scores
def normalize_match(value: float, scores: dict[str, float]) -> float:
maximum = max(scores.values(), default=0.0)
return 0.0 if maximum == 0 else scores.get(value, 0.0) / maximum
def rank(listings: list[Listing], events: list[Event], now: datetime):
area_scores, room_scores = build_preferences(events, now)
ranked = []
for listing in listings:
area_match = normalize_match(listing.area, area_scores)
room_match = normalize_match(listing.room_type, room_scores)
preference_match = 0.6 * area_match + 0.4 * room_match
score = (
0.60 * listing.relevance
+ 0.25 * listing.quality
+ 0.15 * preference_match
)
ranked.append((round(score, 4), listing))
return sorted(ranked, key=lambda item: item[0], reverse=True)
if __name__ == "__main__":
now = datetime.now(timezone.utc)
events = [
Event("view", "Shinjuku", "entire_home", now - timedelta(days=7)),
Event("save", "Shinjuku", "entire_home", now - timedelta(days=2)),
Event("view", "Asakusa", "private_room", now - timedelta(days=1)),
]
listings = [
Listing("A", "Shinjuku", "entire_home", 0.78, 0.90),
Listing("B", "Asakusa", "private_room", 0.84, 0.88),
Listing("C", "Shibuya", "hotel_room", 0.91, 0.92),
]
for score, listing in rank(listings, events, now):
print(f"{listing.id}: score={score}, area={listing.area}")
运行命令:
python journey_ranker.py
这个原型适合验证特征定义和排序直觉,但不能直接承担生产流量。实际系统需要处理事件乱序、跨设备身份、特征延迟、冷启动和毫秒级在线服务,并为每个特征维护版本与生成时间。
评估不能只盯着点击率
旅程学习很容易制造短期指标提升:模型把用户看过的相似房源不断推到前面,点击率可能上升,但结果会越来越单一。更可靠的评估应覆盖完整漏斗:
- 搜索结果点击率和收藏率;
- 从搜索到预订的转化率与所需时间;
- 取消率、客服问题和入住后满意度;
- 结果多样性、新房源曝光与价格覆盖;
- 新用户、低频用户及不同地区用户的分组表现。
离线评估还要防止标签泄漏。例如,使用预订之后产生的事件去预测该次预订,会得到虚高结果。训练样本必须按照事件时间截断,确保模型只能看到排序发生之前可用的信息。
隐私边界同样不可后补。系统应限制事件保留期限,对敏感属性和精确位置保持克制,提供关闭个性化或清除历史的能力,并记录每个在线特征的用途。跨旅程使用偏好时,还应设置衰减与重置机制,避免一次商务出行永久改变家庭度假的搜索结果。
落地时从可解释的小闭环开始
采用旅程信号时,可以按以下顺序推进:
- 先定义旅程边界和事件语义,解决重复、缺失与乱序问题。
- 从近期目的地、收藏区域和房型等少量可解释特征开始。
- 保留无个性化基线,并通过 A/B 测试检查完整预订漏斗。
- 为新用户设置可靠的热门度与相关性回退策略。
- 监控多样性、取消率、特征新鲜度和不同用户群体的效果。
- 给历史偏好设置时间衰减、行程结束重置和用户控制入口。
旅程驱动搜索的价值,不在于无限积累用户数据,而在于正确理解行为发生的上下文。一个收藏动作究竟代表长期偏好、当前行程中的比较,还是已经失效的计划,往往比动作本身更重要。系统只有同时处理时间、阶段和边界,个性化才会真正帮助旅客缩短决策过程,而不是把过去不断复制到下一次搜索中。