用实时学习排序重建搜索补全:Swiggy 在 OpenSearch 上的工程实践

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

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

预计阅读时间:14 分钟

搜索补全(autocomplete)是外卖平台最敏感的交互环节——用户每敲一个字,系统就得在几十毫秒内返回最相关的建议。传统做法靠人工规则排序,规则越堆越难维护,效果也容易停滞。Swiggy 最近分享了他们在 OpenSearch 上搭建的实时 ML 排序系统:把候选生成和排序解耦,引入特征仓库供给实时信号,用学习排序(LTR)模型替代启发式规则,同时守住延迟红线、支持从用户行为持续更新模型。这套思路对任何做搜索补全的团队都有参考价值。

两阶段架构:先召回、再精排

Swiggy 的核心设计决策是把补全拆成两步:

  1. 候选生成(Candidate Generation):基于用户输入的前缀,从索引中快速拉出一批候选词条。这一步追求召回率和速度,排序可以粗糙。
  2. ML 排序(Ranking):对召回的候选集用 LTR 模型精排,输出最终展示顺序。

为什么要拆?因为 LTR 模型计算成本远高于简单的词条匹配。如果对全量候选都跑模型,延迟会炸。先召回再精排,模型只需要处理几十条候选,延迟可控。

在 OpenSearch 上的实现方式:候选生成用自定义的 suggester 或前缀匹配查询,精排用 OpenSearch 的 Learning-to-Rank 插件在 rescore 阶段注入模型。

下面是一个最小可运行的 OpenSearch LTR 配置示例,展示如何创建特征集和上传模型:

# 1. 安装 LTR 插件(假设已部署 OpenSearch 2.x)
./bin/opensearch-plugin install opensearch-ltr

# 2. 创建特征集——定义模型能看到的信号
curl -XPUT "http://localhost:9200/_ltr/_featureset/autocomplete_features" -H 'Content-Type: application/json' -d'
{
  "featureset": {
    "features": [
      {
        "name": "prefix_match_score",
        "template": {
          "match": {
            "name": "{{query}}"
          }
        }
      },
      {
        "name": "popularity_score",
        "template": {
          "function_score": {
            "field_value_factor": {
              "field": "order_count_7d",
              "modifier": "log1p"
            }
          }
        }
      },
      {
        "name": "recency_score",
        "template": {
          "function_score": {
            "field_value_factor": {
              "field": "last_ordered_hours",
              "modifier": "reciprocal",
              "factor": 0.1
            }
          }
        }
      },
      {
        "name": "category_affinity",
        "template": {
          "match": {
            "category": "{{user_pref_category}}"
          }
        }
      }
    ]
  }
}
'

# 3. 上传训练好的 XGBoost 模型(模型文件需提前导出为原始格式)
curl -XPUT "http://localhost:9200/_ltr/_model/autocomplete_ltr_v1" -H 'Content-Type: application/json' -d'
{
  "model": {
    "name": "autocomplete_ltr_v1",
    "model": {
      "type": "xgboost",
      "definition": "你的模型原始导出内容..."
    }
  }
}
'

这段配置的关键点:特征集里定义了四个信号——前缀匹配分、近7天订单热度、时间衰减分、用户品类偏好。这些信号就是模型排序的依据,后续可以随时增删特征、重新训练。

特征仓库:实时信号的供给管线

Swiggy 强调的第二个关键组件是特征仓库(Feature Store)。补全排序不能只靠静态字段,用户的实时行为——刚刚点了什么、最近搜索了什么品类、当前地理位置——对排序影响巨大。

特征仓库的职责:

  • 实时聚合:把用户最近 N 分钟的行为信号(点击率、转化率、品类偏好)实时计算并写入。
  • 低延迟读取:排序阶段需要在 10ms 内拿到特征值,特征仓库必须支持高速点查。
  • 特征版本管理:模型迭代时特征定义可能变化,仓库需要支持多版本并存。

实际工程中,特征仓库通常用 Redis 或类似的高速 KV 存储,配合 Flink/Spark Streaming 做实时聚合。下面是一个用 Python 模拟特征写入和读取的示例:

import redis
import json
import time

r = redis.Redis(host="feature-store.redis", port=6379, decode_responses=True)

def write_user_features(user_id: str, signals: dict):
    """实时写入用户特征,设置短 TTL 保证信号新鲜度"""
    key = f"user_features:{user_id}"
    r.hset(key, mapping={
        "recent_category": signals.get("last_clicked_category", ""),
        "click_count_1h": signals.get("click_count_1h", 0),
        "order_count_7d": signals.get("order_count_7d", 0),
        "last_ordered_ts": signals.get("last_ordered_ts", 0),
    })
    r.expire(key, 3600)  # 1小时过期,避免 stale 信号

def read_features_for_ranking(user_id: str, candidate_ids: list[str]) -> list[dict]:
    """排序阶段批量读取特征,必须在 10ms 内完成"""
    pipe = r.pipeline()
    # 用户级特征
    pipe.hgetall(f"user_features:{user_id}")
    # 候选词条级特征
    for cid in candidate_ids:
        pipe.hgetall(f"item_features:{cid}")
    results = pipe.execute()

    user_feat = results[0] or {}
    item_feats = results[1:]

    feature_vectors = []
    for i, cid in enumerate(candidate_ids):
        feat = item_feats[i] or {}
        feature_vectors.append({
            "candidate_id": cid,
            "prefix_match_score": float(feat.get("prefix_score", 0)),
            "popularity_score": float(feat.get("order_count_7d", 0)),
            "recency_score": float(feat.get("last_ordered_hours", 100)),
            "category_affinity": 1.0 if feat.get("category") == user_feat.get("recent_category") else 0.0,
        })
    return feature_vectors

# 模拟写入
write_user_features("u_12345", {
    "last_clicked_category": "biryani",
    "click_count_1h": 3,
    "order_count_7d": 12,
    "last_ordered_ts": int(time.time()) - 1800,
})

# 模拟排序阶段读取
features = read_features_for_ranking("u_12345", ["item_101", "item_202", "item_303"])
print(json.dumps(features, indent=2))

运行前需要本地有 Redis(docker run -d -p 6379:6379 redis),改 hostlocalhost 即可测试。这段代码演示了特征仓库的核心读写模式:pipeline 批量操作保证低延迟,TTL 保证信号不会过期滞留。

学习排序模型:从启发式到数据驱动

Swiggy 用 LTR 模型替代了之前的启发式排序。启发式排序的典型问题:

  • 规则之间权重冲突,调参靠直觉。
  • 无法利用交叉特征(比如"用户品类偏好 × 词条品类"这种组合信号)。
  • 新信号加入时规则表膨胀,维护成本高。

LTR 模型的优势在于:所有信号统一进模型,权重由数据决定,交叉特征自动学习。Swiggy 选择了 XGBoost 作为排序模型——训练快、推理也快,适合补全场景的延迟要求。

一个简化的模型训练流程示例:

import xgboost as xgb
import numpy as np

# 模拟训练数据:每行是一个 (query, candidate) 对的特征 + 标注
# 标注来源:用户实际点击的候选 = 正样本,展示未点击 = 负样本
train_data = np.array([
    # prefix_match, popularity, recency, category_affinity, label
    [0.95, 12.0, 0.5, 1.0, 1],   # 用户点击了
    [0.90, 8.0,  2.0, 0.0, 0],   # 展示但没点
    [0.80, 15.0, 1.0, 1.0, 1],   # 用户点击了
    [0.70, 3.0,  10.0, 0.0, 0],  # 展示但没点
    [0.85, 20.0, 0.3, 1.0, 1],   # 用户点击了
    [0.60, 1.0,  50.0, 0.0, 0],  # 展示但没点
])

X = train_data[:, :4]
y = train_data[:, 4]

# 训练 LTR 模型(使用 pairwise objective 更适合排序)
dtrain = xgb.DMatrix(X, label=y)
params = {
    "objective": "rank:pairwise",
    "eta": 0.1,
    "max_depth": 4,
    "eval_metric": "ndcg",
}
model = xgb.train(params, dtrain, num_boost_round=50)

# 导出模型原始格式,供 OpenSearch LTR 插件加载
model.dump_model("autocomplete_ltr_v1.raw")
print("模型已导出,可上传到 OpenSearch LTR 插件")

# 推理测试
dtest = xgb.DMatrix(np.array([[0.88, 10.0, 1.0, 1.0]]))
score = model.predict(dtest)
print(f"候选排序分: {score[0]:.4f}")

这段代码展示了 LTR 的核心训练逻辑:用 rank:pairwise 目标函数训练排序模型,标注来自用户行为(点击/未点击),导出后上传到 OpenSearch。实际生产中训练数据量远大于此,特征维度也更丰富,但骨架一样。

延迟红线怎么守住

补全场景的延迟要求极端严格——Swiggy 的目标是在 50ms 内返回结果。LTR 模型推理本身可能只要 2-5ms,但加上特征读取、候选召回、网络传输,总延迟容易超标。

Swiggy 的几招:

  • 候选集裁剪:召回阶段只取 top 50-100 条候选,模型只排这几十条,不做全量排序。
  • 特征预计算:词条级静态特征(热度、品类)提前算好存索引里,排序时直接取,不做实时聚合。只有用户级实时特征才走特征仓库。
  • 模型轻量化:XGBoost 树数量和深度控制在推理 2ms 以内,不追求极致精度。
  • 异步模型更新:新模型训练完成后异步推送到 OpenSearch 节点,不阻塞在线排序。

一个 OpenSearch 查询的完整示例,展示候选召回 + LTR rescore 的组合:

curl -XPOST "http://localhost:9200/food_items/_search" -H 'Content-Type: application/json' -d'
{
  "size": 10,
  "query": {
    "match": {
      "name": {
        "query": "bir",
        "analyzer": "prefix_analyzer"
      }
    }
  },
  "rescore": {
    "query": {
      "rescore_query": {
        "sltr": {
          "model": "autocomplete_ltr_v1",
          "params": {
            "query": "bir",
            "user_pref_category": "biryani"
          }
        }
      },
      "query_weight": 0.1,
      "rescore_query_weight": 0.9,
      "window_size": 50
    }
  }
}
'

关键参数:window_size: 50 表示只对召回的前 50 条做 LTR 精排;query_weightrescore_query_weight 控制原始匹配分和模型分的混合比例。这样既利用了模型排序能力,又把计算量限制在可控范围。

持续迭代:从用户行为到模型更新

Swiggy 系统的最后一块拼图是闭环迭代:用户在补全中的点击和下单行为被采集为标注数据,定期(或近实时)重新训练模型,推送到线上。

实操建议:

  • 标注采集:记录每次补全展示的候选列表和用户最终点击的条目,展示未点击的作为负样本。
  • 采样策略:正负样本比例严重倾斜,需要做负样本采样或使用 pairwise/listwise 目标函数。
  • 模型灰度:新模型先在部分流量上 A/B 测试,对比 NDCG 和点击率指标,确认提升后再全量上线。
  • 回退机制:如果新模型线上表现异常,能一键回退到上一版本模型。

落地检查清单

如果你也在做搜索补全的 ML 排序改造,可以对照这几项:

检查项 说明
延迟预算 补全总延迟上限是多少?模型推理 + 特征读取 + 候选召回各自分多少?
候选集大小 LTR 精排的 window_size 设多少?太大延迟炸,太小召回不够
特征分层 哪些特征可以预计算存索引?哪些必须实时读取?实时特征的 TTL 多长?
标注管线 用户行为数据从采集到可训练数据的延迟是多少?正负样本比例如何?
模型更新频率 每天更新?每小时?更新时是否灰度?回退机制是否就绪?
监控指标 线上监控 NDCG、点击率、p99 延迟,模型版本变更时指标是否自动对比?

Swiggy 的这套方案本质上是一个经典的搜索排序升级路径:从规则到模型、从静态到实时、从手工到闭环。架构上最值得借鉴的是候选生成与排序的解耦——这让每一层可以独立优化、独立扩展,也让 LTR 模型的引入不会把整个系统拖慢。


相关推荐