搜索补全(autocomplete)是外卖平台最敏感的交互环节——用户每敲一个字,系统就得在几十毫秒内返回最相关的建议。传统做法靠人工规则排序,规则越堆越难维护,效果也容易停滞。Swiggy 最近分享了他们在 OpenSearch 上搭建的实时 ML 排序系统:把候选生成和排序解耦,引入特征仓库供给实时信号,用学习排序(LTR)模型替代启发式规则,同时守住延迟红线、支持从用户行为持续更新模型。这套思路对任何做搜索补全的团队都有参考价值。
两阶段架构:先召回、再精排
Swiggy 的核心设计决策是把补全拆成两步:
- 候选生成(Candidate Generation):基于用户输入的前缀,从索引中快速拉出一批候选词条。这一步追求召回率和速度,排序可以粗糙。
- 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),改 host 为 localhost 即可测试。这段代码演示了特征仓库的核心读写模式: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_weight 和 rescore_query_weight 控制原始匹配分和模型分的混合比例。这样既利用了模型排序能力,又把计算量限制在可控范围。
持续迭代:从用户行为到模型更新
Swiggy 系统的最后一块拼图是闭环迭代:用户在补全中的点击和下单行为被采集为标注数据,定期(或近实时)重新训练模型,推送到线上。
实操建议:
- 标注采集:记录每次补全展示的候选列表和用户最终点击的条目,展示未点击的作为负样本。
- 采样策略:正负样本比例严重倾斜,需要做负样本采样或使用 pairwise/listwise 目标函数。
- 模型灰度:新模型先在部分流量上 A/B 测试,对比 NDCG 和点击率指标,确认提升后再全量上线。
- 回退机制:如果新模型线上表现异常,能一键回退到上一版本模型。
落地检查清单
如果你也在做搜索补全的 ML 排序改造,可以对照这几项:
| 检查项 | 说明 |
|---|---|
| 延迟预算 | 补全总延迟上限是多少?模型推理 + 特征读取 + 候选召回各自分多少? |
| 候选集大小 | LTR 精排的 window_size 设多少?太大延迟炸,太小召回不够 |
| 特征分层 | 哪些特征可以预计算存索引?哪些必须实时读取?实时特征的 TTL 多长? |
| 标注管线 | 用户行为数据从采集到可训练数据的延迟是多少?正负样本比例如何? |
| 模型更新频率 | 每天更新?每小时?更新时是否灰度?回退机制是否就绪? |
| 监控指标 | 线上监控 NDCG、点击率、p99 延迟,模型版本变更时指标是否自动对比? |
Swiggy 的这套方案本质上是一个经典的搜索排序升级路径:从规则到模型、从静态到实时、从手工到闭环。架构上最值得借鉴的是候选生成与排序的解耦——这让每一层可以独立优化、独立扩展,也让 LTR 模型的引入不会把整个系统拖慢。