从关键词到语义:用 Elasticsearch 打造真正“懂你”的推荐系统
最近在做推荐系统的重构,团队里有个很现实的拷问:“为什么用户刷了半天,看到的还是那些热门商品?”——这背后,其实是传统推荐架构的瓶颈。
我们过去依赖基于规则的筛选和协同过滤模型,虽然稳定,但面对冷启动、长尾内容曝光不足、语义理解缺失等问题时显得力不从心。更麻烦的是,为了实现“个性化”,不得不额外引入 Faiss 或 Milvus 这类向量数据库,运维复杂度陡增。
直到我们将目光转向Elasticsearch 的向量检索能力—— 没想到,这个原本只用来做日志搜索和商品全文匹配的“老伙计”,如今已经悄然进化成了一个支持语义级推荐的利器。
今天就想和大家分享:如何利用现有的 Elasticsearch 集群,构建一套高效、灵活、可落地的个性化推荐系统。全程无需新增向量库,也不必推倒重来,关键路径我们都已跑通。
当推荐遇上向量:一次范式的转变
先说清楚一个问题:为什么非得上“向量”?
想象一下,用户刚看完一篇《iPhone 15 Pro Max 深度评测》,紧接着你想推荐相关产品。如果靠关键词匹配,“MacBook Pro”可能根本不会出现结果中——它既不含“iPhone”,也没有“手机”字样。但你知道,这两个产品在语义上高度相关。
这就是传统方法的盲区:只能匹配字面,无法理解意图。
而通过 embedding 技术,我们可以把“iPhone 15 Pro Max”和“MacBook Pro”都映射成 128 维的稠密向量。它们虽然文字不同,但在向量空间中的距离却很近。只要计算余弦相似度,就能自动发现这种潜在关联。
向量化不是噱头,它是让机器学会“类比思维”的第一步 —— 就像人类会说:“喜欢 A 的人往往也喜欢 B”。
Elasticsearch 自7.10 版本起支持dense_vector字段类型,并在后续版本中集成了 HNSW 图索引和原生 KNN 查询(8.0+),正式具备了处理这类语义检索的能力。
这意味着什么?意味着你现在可以用一套系统,同时搞定:
- 商品标题的关键词搜索
- 用户兴趣的语义匹配
- 多条件组合过滤(价格、类目、库存)
不再需要拼接多个服务,也不用担心数据一致性问题。ES 成了真正的“统一检索入口”。
核心武器:Elasticsearch 是怎么做到快速找“相似”的?
dense_vector + HNSW:让亿级向量也能毫秒响应
当你往 ES 写入一条商品记录时,除了 title、price 这些常规字段,还可以加一个embedding字段:
{ "title": "无线蓝牙降噪耳机", "category": "electronics", "price": 599, "embedding": [0.12, -0.34, 0.78, ..., 0.56] }这个embedding就是经过 Sentence-BERT 或 Item2Vec 模型生成的语义向量。它的长度固定(比如 128 或 384 维),类型为dense_vector。
关键来了:你可以在该字段上建立 HNSW 索引,大幅提升向量相似性搜索的速度。
什么是 HNSW?
HNSW(Hierarchical Navigable Small World)是一种图结构索引算法。简单来说,它通过构建多层导航图,实现“从粗到细”的跳跃式查找。
你可以把它想象成城市地铁系统:
- 最高层是“快线”,覆盖范围广但站点少,快速定位大致区域;
- 越往下站台越密集,最终精准抵达目标站点。
相比暴力遍历(O(n)),HNSW 可以将查询时间降到 O(log n),即使面对千万甚至上亿条向量,也能做到亚秒级返回 Top-K 结果。
如何配置 HNSW 索引?
创建索引时指定index_options.type = "hnsw"即可:
PUT /products { "mappings": { "properties": { "title": { "type": "text" }, "category": { "type": "keyword" }, "price": { "type": "integer" }, "embedding": { "type": "dense_vector", "dims": 384, "index": true, "similarity": "cosine", "index_options": { "type": "hnsw", "m": 16, "ef_construction": 100 } } } } }几个核心参数解释一下:
| 参数 | 作用 | 建议值 |
|---|---|---|
dims | 向量维度 | 根据模型定,常见 128/384 |
similarity | 相似度函数 | 推荐"cosine" |
m | 每个节点连接数 | 16~32,越大越准越占内存 |
ef_construction | 构建时候选集大小 | 100 左右,影响建索引速度 |
ef_search | 查询时候选集大小 | 可运行时调整,越大越准 |
实践建议:初期可用默认值试跑,再根据 P99 延迟和召回率调优。
混合查询:不只是“最像”,还要“最合适”
很多人误以为向量检索就是“找个最像的”。其实真正有用的推荐,是在满足业务约束的前提下找最相关的。
举个例子:你想给一位预算 1000 元以下、偏好电子产品的用户推荐耳机。这时候不能只看向量相似度,还得考虑:
- 是否属于 electronics 类目?
- 价格是否 ≤ 1000?
- 是否有货?
幸运的是,Elasticsearch 完美支持这种“混合打分”模式。
方式一:script_score(兼容旧版)
适用于 7.x ~ 8.7 版本:
{ "size": 10, "query": { "script_score": { "query": { "bool": { "must": [ { "term": { "category": "electronics" } } ], "filter": [ { "range": { "price": { "lte": 1000 } } }, { "term": { "in_stock": true } } ] } }, "script": { "source": "cosineSimilarity(params.vec, 'embedding') + 1.0", "params": { "vec": [0.1, 0.5, ..., 0.9] } } } } }这里做了三件事:
1.bool.query先做硬性条件筛选;
2.script计算余弦相似度作为打分依据;
3.+1.0是因为cosineSimilarity返回 [-1,1),而评分必须 ≥0。
方式二:原生 KNN 查询(推荐!)
从Elasticsearch 8.8 开始,官方推出了简洁高效的knn查询语法:
{ "knn": { "field": "embedding", "query_vector": [0.1, 0.5, ..., 0.9], "k": 10, "num_candidates": 100, "filter": [ { "term": { "category": "electronics" } }, { "range": { "price": { "lte": 1000 } } } ] }, "query": { "term": { "in_stock": true } }, "size": 10 }优势非常明显:
- 更清晰的语义划分:knn负责语义匹配,query/filter控制业务逻辑;
- 性能更好:底层优化了预筛选与向量搜索的执行顺序;
- 支持num_candidates控制精度与性能平衡。
✅新项目强烈建议直接使用原生 KNN 查询。
推荐质量的命门:嵌入向量到底怎么来?
很多人以为上了向量检索就万事大吉,其实不然。检索只是“腿”,走得快不快看算法;但方向对不对,全靠嵌入质量。
换句话说:垃圾向量进,垃圾推荐出。
所以我们花了很多精力打磨两个核心模块:物品嵌入生成和用户兴趣建模。
物品向量:内容 + 行为双驱动
我们采用“两条腿走路”的策略生成 item embedding:
1. 内容嵌入(Content-based)—— 解决冷启动
对于新上架的商品,还没有用户行为怎么办?靠内容!
我们用Sentence-BERT对商品标题、描述、标签进行编码:
from sentence_transformers import SentenceTransformer model = SentenceTransformer('paraphrase-MiniLM-L6-v2') def get_content_embedding(title: str) -> list: return model.encode(title).tolist() # 示例 vec = get_content_embedding("苹果 MacBook Pro 笔记本电脑")这类轻量级模型推理快(单条 <10ms),可以直接部署在边缘服务器或 API 网关旁,实现实时向量化。
2. 协同嵌入(Collaborative)—— 捕捉群体偏好
当商品积累了一定交互数据后,我们会用Item2Vec或GraphSAGE在用户-物品二分图上训练,得到更具行为感知能力的向量。
例如,若大量用户同时点击“降噪耳机”和“AirPods Pro”,即便两者名字无关,模型也会让它们在向量空间靠近。
我们的做法是:新商品先用 content embedding 上线曝光 → 积累行为后切换为 collaborative embedding → 实现平滑过渡。
用户向量:动态捕捉兴趣漂移
用户的兴趣不是静态的。昨天爱看数码,今天可能就在搜母婴用品。因此用户向量必须近实时更新。
我们的方案如下:
简单有效:加权平均池化
将用户近期(如最近 30 条)点击/购买的商品向量做加权平均,时间越近权重越高:
import numpy as np def build_user_vector(click_history): vectors = [] weights = [] for item_id, timestamp in click_history: vec = item_embeddings[item_id] # 预加载的商品向量 age_in_hours = (now - timestamp).total_seconds() / 3600 weight = np.exp(-age_in_hours / 24) # 按天衰减 vectors.append(vec * weight) weights.append(weight) return np.sum(vectors, axis=0) / sum(weights)优点是实现简单、延迟低,适合中小规模系统。
高阶玩法:序列模型(BERT4Rec)
如果你有足够的训练数据,可以尝试用Transformer 结构建模用户行为序列,输出综合兴趣向量。
这类模型能识别“会话内偏好”、“跨品类跳转”等复杂模式,表达能力更强,但需要离线训练 + 在线 Serving。
目前我们在主站用平均池化,在信息流场景试点 BERT4Rec,CTR 提升约 7%。
我们的系统架构:轻量接入,渐进演进
不想一次性重构整个推荐链路?没关系,我们也是从小处切入的。
这是目前线上运行的简化架构图:
[前端埋点] ↓ (Kafka) [Fluentd/Flink 流处理] ↓ [Embedding Service] ├── 批量生成商品向量 → 写入 ES └── 实时聚合用户行为 → 更新用户向量(Redis) ↓ [Elasticsearch Cluster] ├── 存储商品向量 + HNSW 索引 └── 提供 knn 查询接口 ↓ [Recommendation API] ├── 获取 user vector from Redis └── 发起 knn 查询 → 返回 Top-N ↓ [App/Web 展示]关键设计思路
复用现有 ES 集群
初期直接在原有商品索引上增加embedding字段,避免新建集群带来的资源浪费和运维负担。读写分离,避免干扰
向量检索较耗 CPU 和内存,我们在协调节点层面做了分流:
- 普通搜索走 A 组协调节点;
- KNN 请求定向到 B 组专用节点,防止相互拖慢。冷启动友好策略
- 新用户:使用“城市平均向量”或“热门商品聚类中心”作为初始兴趣;
- 新商品:强制启用 content embedding,并设置 higher boost 分数提升曝光机会。监控不可少
我们重点关注几个指标:
- P99 查询延迟:<100ms(否则影响用户体验)
- recall@10:定期抽样验证 top-10 是否合理
- 向量更新延迟:用户行为→向量生效控制在 5 分钟内
落地效果与避坑指南
上线两个月后,我们对比了 AB 实验数据:
| 指标 | 提升幅度 |
|---|---|
| 页面点击率(CTR) | +12.3% |
| 平均停留时长 | +18.7% |
| GMV(成交额) | +9.5% |
尤其是长尾商品的曝光占比提升了近 3 倍,说明系统真的开始“挖掘潜力股”了。
但也踩过不少坑,总结几点血泪经验:
❌ 坑点一:没归一化的向量用了 cosine 相似度
一开始我们直接把原始 embedding 写进去,结果发现某些向量特别“长”,导致点积远大于其他,排序严重失衡。
✅解决方案:写入前务必做 L2 归一化!
import numpy as np vec = np.array(raw_vec) vec = vec / np.linalg.norm(vec) # L2 normalize这样所有向量长度均为 1,cosine 相似度才真正反映“夹角”差异。
❌ 坑点二:HNSW 参数设得太激进
曾把ef_construction=500,想着“越高越准”。结果建索引慢得像蜗牛,每天凌晨任务拖到早上还没完。
✅建议:生产环境ef_construction不超过 100,m控制在 16~32。追求精度不如多做几轮召回+精排。
❌ 坑点三:忽略了向量维度与存储成本的关系
384 维 vs 128 维,看着只差 3 倍,但乘以亿级文档量,磁盘和内存压力完全不同。
✅建议:优先测试低维模型(如 MiniLM),在精度损失可控前提下尽量压缩维度。我们最终选择了 128 维,节省了 60% 存储。
写在最后:这不是终点,而是起点
现在回头看,当初决定用 Elasticsearch 做向量推荐,最大的收益不是技术先进性,而是落地效率。
没有引入新组件,没有重建 pipeline,两周内就在现有架构上完成了原型验证。而且由于 ES 本身高可用、易监控、支持热扩容,运维同学也没抱怨。
更重要的是,我们终于实现了“一人一策”的语义推荐。用户不再被框死在“看了又看”里,而是能自然地从“手机”跳到“支架”,从“咖啡机”延伸到“滤纸”——这才是真实世界中的兴趣迁移。
未来我们计划进一步探索:
- 使用 ES 内置的稀疏向量支持(sparse_vector)结合 BM25,打造 hybrid ranking;
- 试验ELSER(Elastic Learned Sparse Encoder)做纯语义检索,摆脱对第三方 embedding 模型的依赖;
- 探索多向量交叉检索,比如分别编码标题、图片、评论,再融合打分。
Elasticsearch 正在从“搜索引擎”蜕变为“智能语义中枢”。而对于大多数团队而言,这或许是一条最具性价比的智能化升级之路。
如果你也在纠结要不要上向量推荐,我的建议是:别等完美方案,先在一个小场景试起来。用你手头的 ES 集群,跑通一次 knn 查询,看到第一条“意料之外却又情理之中”的推荐结果时,你会明白:这场进化,值得投入。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考