用共享话题分类打通图片与评论:Agoda 的多模态内容检索系统

2026-05-19 31 预计阅读时间:1 分钟
来源:infoq.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:15 分钟

在线旅游平台上,酒店图片和用户评论是两条各自运转的内容管线——图片靠视觉标签分类,评论靠文本情感分析。用户搜索"带泳池的海景房"时,图片检索和评论检索各自返回结果,互不关联,体验割裂。Agoda 的做法是:建一套共享的话题分类体系(topic taxonomy),让图片和评论在同一语义空间里对齐,再通过离线富化 + 低延迟服务层,在 7 亿张图片和海量多语言评论之间实现毫秒级多模态检索。

两条管线为什么必须合并

传统架构下,图片管线产出的是视觉特征向量,评论管线产出的是文本 embedding。两者维度不同、语义空间不同,无法直接做跨模态召回。举个例子:用户想找"适合家庭入住、有儿童泳池"的酒店,图片侧可能召回所有带泳池的图,但无法区分成人泳池和儿童泳池;评论侧能精准筛出"适合家庭"的酒店,却无法展示泳池长什么样。

只有把两条管线映射到同一套话题标签上,才能做到:用评论中的"儿童泳池"话题,直接召回对应泳池图片;用图片中的"海景阳台"话题,筛选出评论里提到阳台体验的段落。这就是 Agoda 共享话题分类的核心动机。

共享话题分类的设计要点

话题分类不是简单打标签,而是要同时覆盖视觉可观察的属性和文本可表达的属性。Agoda 的做法可以拆成三层:

第一层:话题本体定义。 话题不是自由文本,而是受控词表。比如 pool.childrenview.oceanroom.family,每个话题有唯一 ID、层级关系和跨语言别名。受控词表保证了图片标签和评论标签用的是同一套"语言"。

第二层:图片侧映射。 对每张图片,用视觉模型(如 CLIP 或自研模型)识别出可观察的话题,输出一组话题 ID + 置信度。比如一张泳池图片可能映射到 {pool.children: 0.82, pool.outdoor: 0.65}

第三层:评论侧映射。 对每条评论,用 NLP 模型(多语言 BERT 类模型)抽取评论段落对应的话题。评论的优势在于能表达主观体验——"泳池水温合适,小孩玩得很开心"映射到 {pool.children: 0.9, experience.positive: 0.85},这是图片侧无法单独提供的信号。

映射完成后,图片和评论在话题空间里对齐,多模态检索就变成了:给定一组话题 ID,同时从图片索引和评论索引中召回相关内容,再按置信度和业务权重融合排序。

离线富化:7 亿图片的批量处理管线

7 亿张图片不可能实时打标,Agoda 采用了离线富化管线:

  • 图片按酒店批次分组,定期从存储拉取新增和更新的图片。
  • 视觉模型以批量推理方式运行,输出话题映射结果写入特征库。
  • 评论侧同理,多语言模型按语言分片批量处理,话题映射结果写入同一特征库。
  • 富化结果定期同步到在线索引,保证检索服务读取的是最新映射。

离线管线的关键约束是幂等性和增量处理——同一张图片不能因为重复处理而产生冲突映射,新增图片要能快速进入索引而不触发全量重建。

下面是一个简化版离线富化管线的示例,用 Python 模拟图片话题映射的批量处理流程:

import hashlib
import json
from pathlib import Path
from datetime import datetime

# 模拟视觉模型:输入图片路径,输出话题映射
def mock_visual_model(image_path: str) -> dict:
    """模拟 CLIP 类模型的推理结果,返回话题 ID 和置信度"""
    # 实际生产中这里调用 GPU 推理服务
    hash_val = int(hashlib.md5(image_path.encode()).hexdigest()[:8], 16)
    topics = {}
    if hash_val % 3 == 0:
        topics["pool.children"] = 0.82
        topics["pool.outdoor"] = 0.65
    elif hash_val % 3 == 1:
        topics["view.ocean"] = 0.91
        topics["room.balcony"] = 0.73
    else:
        topics["room.family"] = 0.88
        topics["food.breakfast"] = 0.60
    return topics

# 离线富化:批量处理图片,写入特征库
def enrich_images(image_dir: str, feature_store_path: str):
    """扫描图片目录,对每张图片做话题映射,增量写入特征库"""
    feature_store = Path(feature_store_path)
    feature_store.parent.mkdir(parents=True, exist_ok=True)

    # 加载已有特征库,保证幂等
    existing = {}
    if feature_store.exists():
        existing = json.loads(feature_store.read_text())

    processed = 0
    skipped = 0
    for img_path in Path(image_dir).glob("**/*.jpg"):
        image_id = img_path.stem  # 用文件名作为图片唯一 ID
        if image_id in existing:
            skipped += 1
            continue  # 增量处理:跳过已映射的图片

        topics = mock_visual_model(str(img_path))
        existing[image_id] = {
            "topics": topics,
            "enriched_at": datetime.utcnow().isoformat(),
            "source_path": str(img_path),
        }
        processed += 1

    # 写回特征库
    feature_store.write_text(json.dumps(existing, indent=2, ensure_ascii=False))
    print(f"处理完成: 新映射 {processed} 张, 跳过 {skipped} 张, 总计 {len(existing)} 张")

# 运行示例
if __name__ == "__main__":
    # 先创建模拟图片目录
    demo_dir = "/tmp/agoda_demo_images"
    Path(demo_dir).mkdir(exist_ok=True)
    for i in range(10):
        Path(f"{demo_dir}/hotel_img_{i}.jpg").touch()

    enrich_images(demo_dir, "/tmp/agoda_feature_store.json")

运行后会在 /tmp/agoda_feature_store.json 中生成每张图片的话题映射记录。再次运行时,已有图片会被跳过,新增图片才会处理——这就是增量富化的基本模式。

低延迟服务:在线检索架构

离线富化解决了批量处理问题,但用户搜索时需要毫秒级响应。Agoda 的在线服务层大致这样运作:

  1. 话题索引:将图片和评论的话题映射分别构建为倒排索引或向量索引,以话题 ID 为 key,内容 ID + 置信度为 value。
  2. 多模态召回:用户查询先映射到话题 ID(通过查询理解模型),然后同时查询图片索引和评论索引,合并召回结果。
  3. 融合排序:图片和评论结果按话题置信度、内容质量分数、业务权重(如评论新鲜度、图片清晰度)综合排序。
  4. 缓存与预计算:高频查询的话题组合做结果缓存,冷门查询走实时召回。

下面是一个用 Elasticsearch 模拟多模态检索的 YAML 配置和查询示例:

# docker-compose.yaml — 本地启动 ES 用于模拟多模态检索索引
version: "3.8"
services:
  elasticsearch:
    image: elasticsearch:8.12.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ports:
      - "9200:9200"

启动 ES 后,创建索引并写入模拟数据:

import requests

ES_URL = "http://localhost:9200"

# 创建多模态内容索引:图片和评论共用同一索引,通过 content_type 区分
index_mapping = {
    "mappings": {
        "properties": {
            "content_type": {"type": "keyword"},  # "image" 或 "review"
            "hotel_id": {"type": "keyword"},
            "topic_ids": {"type": "keyword"},      # 话题 ID 列表,支持倒排检索
            "confidence": {"type": "float"},        # 话题置信度
            "language": {"type": "keyword"},        # 评论语言
            "text_snippet": {"type": "text"},       # 评论原文片段
            "image_url": {"type": "keyword"},       # 图片 URL
        }
    }
}

requests.put(f"{ES_URL}/multimodal_content", json=index_mapping)

# 写入模拟数据:图片和评论共享话题 ID
docs = [
    # 图片文档
    {"content_type": "image", "hotel_id": "H001", "topic_ids": ["pool.children", "pool.outdoor"],
     "confidence": 0.82, "language": None, "text_snippet": None, "image_url": "https://cdn.example.com/H001_pool.jpg"},
    # 评论文档 — 同一话题 pool.children
    {"content_type": "review", "hotel_id": "H001", "topic_ids": ["pool.children", "experience.positive"],
     "confidence": 0.9, "language": "en", "text_snippet": "Kids loved the pool, warm water and safe area.", "image_url": None},
    # 另一组:海景话题
    {"content_type": "image", "hotel_id": "H002", "topic_ids": ["view.ocean", "room.balcony"],
     "confidence": 0.91, "language": None, "text_snippet": None, "image_url": "https://cdn.example.com/H002_sea.jpg"},
    {"content_type": "review", "hotel_id": "H002", "topic_ids": ["view.ocean", "experience.positive"],
     "confidence": 0.85, "language": "zh", "text_snippet": "阳台看海太棒了,日出绝美。", "image_url": None},
]

for i, doc in enumerate(docs):
    requests.post(f"{ES_URL}/multimodal_content/_doc/{i+1}", json=doc)

# 多模态检索:用话题 ID 同时召回图片和评论
query = {
    "query": {
        "bool": {
            "filter": [
                {"terms": {"topic_ids": ["pool.children"]}}  # 搜索"儿童泳池"话题
            ]
        }
    },
    "sort": [{"confidence": "desc"}],
    "size": 10
}

resp = requests.get(f"{ES_URL}/multimodal_content/_search", json=query)
hits = resp.json()["hits"]["hits"]
for h in hits:
    src = h["_source"]
    kind = src["content_type"]
    if kind == "image":
        print(f"[图片] 酒店 {src['hotel_id']}{src['image_url']} (置信度 {src['confidence']})")
    else:
        print(f"[评论] 酒店 {src['hotel_id']}{src['text_snippet']} (置信度 {src['confidence']})")

运行结果会同时输出儿童泳池的图片和对应评论——这就是共享话题分类带来的跨模态对齐效果。实际生产中,Agoda 用的是更高效的向量索引(如 FAISS 或自研引擎),但核心逻辑一致:话题 ID 是跨模态召回的桥梁。

落地时的取舍与风险

这套架构有几个值得注意的边界:

话题词表的粒度选择。 太粗(比如只有"泳池"一个话题)无法区分儿童泳池和成人泳池;太细(比如"泳池-温水-儿童-浅水区")会导致映射覆盖率低、评论中很少命中。Agoda 的实践是保持中等粒度,用层级结构兼顾——顶层 pool 保证覆盖率,子层 pool.children 保证区分度。

多语言评论的映射一致性。 同一个话题在不同语言中的表达差异很大——中文说"阳台看海",英文说"ocean view from balcony",泰语可能是另一套表达。话题映射模型必须在各语言上做充分评估,否则某些语言的评论会系统性缺失话题标签。

图片话题的置信度阈值。 视觉模型对模糊场景(比如远处有个泳池但不确定是不是儿童泳池)会输出低置信度。阈值设太高会丢失内容,设太低会引入噪声。建议按话题分别调阈值——高频话题可以宽松些,低频话题严格些。

离线与在线的数据延迟。 新上传的图片和新发表的评论,从产生到被离线富化写入索引,存在分钟级到小时级的延迟。对于"最新评论"这类实时性要求高的场景,可能需要一条轻量级实时管线做补充。

如果你要在自己的业务中尝试类似架构,可以按这个清单推进:

  1. 先定义话题词表——从现有标签体系和用户高频搜索词中提取,控制在 200-500 个话题,带层级。
  2. 图片侧先用 CLIP 做零样本映射验证覆盖率,再考虑训练定制模型。
  3. 评论侧用多语言 NER + 分类模型做话题抽取,优先覆盖 Top 3 语言。
  4. 离线管线先跑全量,再切换到增量;在线检索先用 ES 验证,再换高性能引擎。
  5. 上线后重点监控:话题覆盖率(有多少内容没命中任何话题)、跨模态对齐率(同一酒店的图片和评论是否共享话题)、低置信度内容占比。

Agoda 的这套系统证明了:多模态检索的关键不是把图片和评论硬塞进同一个向量空间,而是找到一条两者都能自然映射的语义桥梁——共享话题分类就是那座桥。


相关推荐