旅行平台上,用户选酒店时同时依赖两类信号——图片和评论。图片告诉你泳池长什么样,评论告诉你泳池到底干净不干净。但这两类数据在传统架构里各自独立:图片走视觉检索,评论走文本搜索,结果对不上。Agoda 的做法是给两者套上一层共享的话题分类(topic taxonomy),让 7 亿多张图片和多语言评论在同一套语义空间里被检索和关联。
为什么要统一话题空间
旅行场景的多模态检索有几个硬约束:
- 评论是多语言的。一条泰语评论和一条英语评论可能都在说"早餐好",纯文本检索跨语言就断了。
- 图片没有天然文本。一张阳台照片本身不携带"海景""安静"这类用户真正关心的语义标签。
- 用户查询是模糊的。"适合家庭入住"这种需求,既需要图片里有儿童泳池,也需要评论里提到"带孩子住很方便"。
如果图片和评论各自建索引,用户搜"海景房",图片检索返回一堆阳台照片,文本检索返回一堆提到 ocean view 的评论,但两者没有交集——你不知道哪张照片对应的酒店确实被客人评价为海景好。共享话题分类就是在这个交叉点上做桥接。
核心架构:分类驱动的多模态对齐
Agoda 的系统不是用端到端的多模态大模型直接对齐,而是用一层人工+算法共建的话题分类作为中间锚点。思路可以拆成三步:
1. 话题分类定义
分类是一棵受控词汇树,比如:
amenities
├── pool
│ ├── infinity_pool
│ ├── kids_pool
├── breakfast
│ ├── buffet
│ ├── continental
location
├── beachfront
├── city_center
room_quality
├── cleanliness
├── view
│ ├── ocean_view
│ ├── mountain_view
每个话题节点是一个语义锚点。图片和评论都被映射到这棵树的节点上,而不是各自建独立的标签体系。
2. 图片侧:离线富化
7 亿张图片不可能实时打标。Agoda 采用离线批量富化:
- 用视觉模型(大概率是 CLIP 类或自研视觉分类器)对每张图片预测话题分类概率分布。
- 图片不是只打一个标签,而是输出一个话题概率向量——一张泳池照片可能同时有
{pool: 0.85, kids_pool: 0.3, cleanliness: 0.1},因为画面里同时出现了儿童区域。 - 结果写入离线存储,供在线检索时低延迟读取。
3. 评论侧:多语言话题抽取
评论的处理更复杂,因为要跨语言:
- 对每条评论做多语言 NLP 分析(可能是多语言 BERT 或翻译+单语言模型的组合),抽取评论提到的话题及情感倾向。
- 一条日语评论「朝食がとても美味しかった」映射到
{breakfast/buffet: positive}。 - 评论同样产出话题概率向量,和图片在同一棵分类树下对齐。
在线检索:低延迟多模态召回
离线富化完成后,在线检索只需要在话题空间里做匹配,不需要实时跑模型。这把延迟压到很低。
用户查询先被解析成话题向量(比如"家庭海景度假"→ {kids_pool: 0.7, ocean_view: 0.8, beachfront: 0.6}),然后同时召回:
- 图片话题向量最接近的图片集合。
- 评论话题向量最接近的评论集合。
- 两者通过话题节点天然关联——同一酒店的图片和评论在同一个话题节点下聚合,用户可以看到"这张泳池照片对应的酒店,评论里关于泳池的评价是正面还是负面"。
下面用一个简化示例演示这种话题向量检索的核心逻辑:
"""
简化版:基于共享话题分类的多模态检索示例
依赖:pip install numpy
假设你已经有图片和评论的话题概率向量,存储在内存中。
实际生产中会用向量数据库(如 Milvus / Vespa)替代。
"""
import numpy as np
# ---- 话题分类树(简化) ----
TOPICS = [
"pool", "kids_pool", "infinity_pool",
"breakfast_buffet", "breakfast_continental",
"ocean_view", "mountain_view", "city_center",
"cleanliness", "beachfront",
]
# ---- 模拟数据:酒店的多模态条目 ----
# 每条记录 = (hotel_id, modality, topic_prob_vector)
# modality: "image" 或 "review"
catalog = [
# 酒店 A:有儿童泳池、海景、早餐自助
("A", "image", np.array([0.85, 0.30, 0.05, 0.02, 0.01, 0.80, 0.05, 0.10, 0.10, 0.70])),
("A", "review", np.array([0.60, 0.45, 0.00, 0.70, 0.05, 0.75, 0.02, 0.15, 0.80, 0.65])),
# 酒店 B:有普通泳池、山景、早餐简单
("B", "image", np.array([0.70, 0.05, 0.00, 0.03, 0.40, 0.05, 0.80, 0.30, 0.50, 0.10])),
("B", "review", np.array([0.50, 0.02, 0.00, 0.20, 0.60, 0.05, 0.70, 0.25, 0.40, 0.08])),
# 酒店 C:市中心、干净、无泳池
("C", "image", np.array([0.00, 0.00, 0.00, 0.01, 0.01, 0.00, 0.00, 0.90, 0.85, 0.00])),
("C", "review", np.array([0.00, 0.00, 0.00, 0.10, 0.10, 0.00, 0.00, 0.85, 0.90, 0.00])),
]
def parse_query(query_desc: dict) -> np.ndarray:
"""把用户需求描述转为话题概率向量。
query_desc = {"kids_pool": 0.7, "ocean_view": 0.8, ...}
未提及的话题默认 0。
"""
vec = np.zeros(len(TOPICS))
for topic, weight in query_desc.items():
idx = TOPICS.index(topic)
vec[idx] = weight
return vec
def multimodal_retrieve(query_vec: np.ndarray, top_k: int = 3):
"""在共享话题空间里同时检索图片和评论,按酒店聚合返回。"""
# 计算每条记录与查询的余弦相似度
scores = []
for hotel_id, modality, topic_vec in catalog:
sim = np.dot(query_vec, topic_vec) / (
np.linalg.norm(query_vec) * np.linalg.norm(topic_vec) + 1e-8
)
scores.append((hotel_id, modality, sim, topic_vec))
# 按酒店聚合:取图片最高分 + 评论最高分
hotel_scores = {}
for hotel_id, modality, sim, topic_vec in scores:
if hotel_id not in hotel_scores:
hotel_scores[hotel_id] = {"image": 0.0, "review": 0.0, "image_vec": None, "review_vec": None}
if sim > hotel_scores[hotel_id][modality]:
hotel_scores[hotel_id][modality] = sim
hotel_scores[hotel_id][f"{modality}_vec"] = topic_vec
# 综合得分 = 0.5 * image_sim + 0.5 * review_sim(可调权重)
ranked = sorted(
hotel_scores.items(),
key=lambda x: 0.5 * x[1]["image"] + 0.5 * x[1]["review"],
reverse=True,
)
return ranked[:top_k]
# ---- 运行示例 ----
if __name__ == "__main__":
# 用户需求:适合家庭的海景度假
query = parse_query({
"kids_pool": 0.7,
"ocean_view": 0.8,
"beachfront": 0.6,
"cleanliness": 0.5,
})
results = multimodal_retrieve(query, top_k=3)
print("查询:适合家庭的海景度假")
print("-" * 50)
for hotel_id, info in results:
combined = 0.5 * info["image"] + 0.5 * info["review"]
print(f"酒店 {hotel_id} | 综合得分: {combined:.3f}")
print(f" 图片相似度: {info['image']:.3f} 评论相似度: {info['review']:.3f}")
# 展示该酒店在关键话题上的图片/评论概率对比
for t in ["kids_pool", "ocean_view", "beachfront", "cleanliness"]:
idx = TOPICS.index(t)
img_val = info["image_vec"][idx] if info["image_vec"] is not None else 0
rev_val = info["review_vec"][idx] if info["review_vec"] is not None else 0
print(f" {t:20s} | 图片: {img_val:.2f} 评论: {rev_val:.2f}")
print()
运行前只需 pip install numpy,然后直接 python 执行。输出会显示酒店 A 在 kids_pool、ocean_view 等话题上图片和评论都高匹配,酒店 C 则几乎不匹配——这就是共享话题空间带来的多模态对齐效果。
实际生产中,你会把 catalog 替换成向量数据库的查询,把 parse_query 替换成轻量分类模型或规则引擎,权重比例根据业务 A/B 测试调整。
离线富化的工程细节
7 亿图片的离线处理不是小事。几个关键决策:
- 批量而非流式。图片打标是全量离线跑,不跟在线请求耦合。酒店新增图片后,定期批量补标,而不是逐张实时处理。这牺牲了一点新鲜度,换来的是吞吐量和成本可控。
- 多语言评论的抽取策略。大概率是翻译管道+单语言模型,而非直接用多语言模型处理所有语种。原因是低资源语言(如泰语、越南语)的多语言模型质量不够,翻译到英语后再抽话题更稳定。翻译本身也是离线批量完成。
- 话题分类的更新。分类树不是一成不变的。新话题(比如"电动充电桩")需要定期评审加入。这涉及人工审核+自动发现候选话题的混合流程。
落地时的权衡与风险
这套架构有几个值得注意的边界:
| 维度 | 优势 | 风险/代价 |
|---|---|---|
| 话题分类 vs 端到端多模态 | 语义可控、可审计、跨语言天然对齐 | 分类树有覆盖盲区,新话题响应慢 |
| 离线富化 vs 实时推理 | 延迟低、成本可控 | 新图片/新评论有标注延迟窗口 |
| 评论翻译管道 | 低资源语言也能覆盖 | 翻译误差会传导到话题抽取 |
| 话题概率向量 vs 硬标签 | 一张图片可以同时属于多个话题 | 存储和索引更复杂,阈值选择影响召回 |
如果你的场景是长尾品类(比如民宿、小众目的地),话题分类的覆盖度会是个持续痛点。Agoda 作为 OTA 巨头,酒店品类相对标准化,分类树能覆盖大部分需求。但即便如此,"氛围感""设计风格"这类主观话题仍然很难用受控词汇精确表达。
可以这样实践
如果你的团队也想搭类似系统,建议按这个顺序推进:
- 先定话题分类,再选模型。分类树是整个系统的骨架,模型只是往骨架上填数据的工具。花时间把分类树定义好,比选哪个视觉模型重要得多。
- 从单语言验证开始。先在一个语言(比如英语)上把图片→话题和评论→话题两条管道跑通,确认对齐效果,再扩展多语言。
- 离线优先。在线检索只做向量匹配,不跑模型。所有重计算放离线。这是 Agoda 能在 7 亿规模上保持低延迟的关键。
- 向量数据库选型。Milvus、Vespa、Weaviate 都能做话题向量的存储和检索。选型时关注混合查询能力(话题向量+元数据过滤,比如按城市、价格区间过滤后再做向量召回)。
- 设置反馈闭环。用户点击和预订数据回灌到话题权重和分类覆盖度的迭代中。如果"海景房"检索结果用户总不点,说明话题向量或分类定义有问题,需要调。
Agoda 这套系统的核心洞察不是技术多先进,而是用受控话题分类把两个模态拉到同一张地图上。这个思路在任何多模态检索场景——电商(商品图+买家评论)、餐饮(菜品图+食客点评)——都可以复用。关键是舍得花时间把分类树做对,而不是急着上多模态大模型。