最近阿里云千问的"点奶茶"技能很火,碾压了微信红包的 AI 玩法。这波 AI 热潮下,我们在 APP 里也做了一个 AI 找货功能。
用户搜"温润的手镯",传统搜索只认"温润"和"手镯"两个字。但用户真正想要的是和田玉——因为"温润"是和田玉的特征。
这篇讲讲怎么用向量搜索 + AI 对话,让系统理解用户真正想要什么。淘宝、京东、美团、携程都有类似功能,我们这个还比较粗浅,讲个大概,体验看起来还有待优化,还请包涵。

| 概念 | 说明 |
|---|---|
| Dify | 开源 LLM 应用开发平台,我们用它做工作流编排。Dify 部署成独立服务,Rails 后端通过 HTTP API 调用。 |
| SSE | Server-Sent Events,服务器主动推送数据给前端,比 WebSocket 简单,适合这种单向实时推送的场景。 |
| 向量搜索 | 把文本转成 1024 维向量,算向量相似度来找商品。比如"温润的手镯"能匹配到"和田玉手镯"。 |
| KNN | K 最近邻算法,在向量空间里找最相似的 K 个商品。K 越大结果越多,但也越慢。 |
| Embedding | 把文本转成向量的过程,语义相近的文本向量也相近。 |
| HNSW | 一种高效的向量索引算法,ES 8.x 原生支持,用来加速 KNN 搜索。 |

整体流程
用户在前端输入需求 → SSE 建立长连接 → Rails 把请求转给 Dify → Dify 识别意图、提取参数 → Dify 调用 Rails 的搜索接口 → Rails 生成查询向量 → ES 做 KNN 向量搜索 → 结果沿路返回 → 前端展示。
几个关键点
核心配置
| 配置项 | 环境变量 | 作用 |
|---|---|---|
| Dify API 地址 | DIFY_API_BASE_URL |
Dify 服务的 URL |
| Dify API Key | DIFY_CHATFLOW_API_KEY |
调用工作流的认证密钥 |
核心接口
| 接口 | 路径 | 谁调用 |
|---|---|---|
| SSE 对话接口 | POST /api/v1/chat/stream |
前端 |
| 商品语义搜索接口 | POST /api/v1/products/ai_search |
Dify 工作流 |

GET /api/v1/products 这个接口支持两种搜索方式:普通搜索(传 q、category_id、price_min/max 等参数),和 ai_search_id 搜索(传之前搜索的 ID,复用搜索参数)。
使用场景是这样的:用户在对话里看到商品列表,想看更多,点"查看更多"按钮,前端把之前的 ai_search_id 传过来,后端直接从数据库捞出之前的搜索参数,做分页查询。

两个关键点
| 组件 | 选型 | 说明 |
|---|---|---|
| 工作流引擎 | Dify | 最新版,做 AI 流程编排 |
| LLM | 通义千问 qwen-plus-latest | 意图理解、参数提取 |
| 搜索引擎 | Elasticsearch 8.x | 商品索引 + KNN 向量搜索 |
| 后端框架 | Ruby on Rails 8.x | 业务逻辑 |

| 事件类型 | 触发时机 | 作用 |
|---|---|---|
system_message |
连接建立后、stream_end 前 | 系统提示,比如"正在匹配货品中",或者错误提示 |
message_start |
第一次收到 Dify 的 message | 告诉前端准备接收消息 |
message |
收到 Dify 的 message | 真正推送内容,可能拆成多个片段 |
thought |
Dify 节点开始处理 | 显示"正在处理:xxx",让用户知道进度 |
message_end |
收到 Dify 的 message_end | 一条消息发送完成 |
stream_end |
整个流程结束 | 前端可以关闭连接了 |
msg_type 的取值
message_start 和 message 的 msg_type:
PRODUCT_CARD → product_card(商品卡片)CLARIFY_CARD → clarify_card(追问卡片)TEXT → text(普通文本)text,结构化内容可能是 blocks
system_message 的 msg_type:
show_thinking:显示思考过程hide_thinking:隐藏思考过程text:普通系统文本retry:可重试的错误message_start
{
"event": "message_start",
"data": {
"msg_id": "uuid",
"msg_type": "text",
"role": "assistant",
"task_id": "uuid"
}
}
message
{
"event": "message",
"data": {
"msg_id": "uuid",
"content": "这是消息内容",
"task_id": "uuid"
}
}
message_end
{
"event": "message_end",
"data": {
"msg_id": "uuid",
"status": "success",
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150
}
}
}
stream_end
{
"event": "stream_end",
"data": {
"conversation_id": "uuid",
"status": "success"
}
}
system_message
{
"event": "system_message",
"data": {
"id": "uuid",
"msg_type": "text",
"role": "system",
"content": "正在匹配货品中",
"user_message_id": 123
}
}
thought
{
"event": "thought",
"data": {
"msg_id": "uuid",
"content": "正在处理:属性提取",
"task_id": "uuid"
}
}
标准顺序
system_message(show_thinking)告诉用户"正在匹配"message_start,然后发 message(可能多次)node_started 时可以发 thought
message_end 时发 message_end
system_message
stream_end
错误处理
system_message
msg_type 是 retry 表示可重试的错误stream_end
前端注意事项
message 是流式的,前端要累积内容直到收到 message_end
msg_type 决定怎么渲染stream_end 就关闭连接我们用 Dify 的节点式编排来做对话流,核心节点如下:
| 节点类型 | 作用 |
|---|---|
| Start | 接收用户输入和认证信息 |
| Question Classifier | 意图识别,分成 4 种意图 |
| LLM | 属性提取,把用户输入转成结构化参数 |
| If-Else | 判断要不要追问用户 |
| LLM | 生成追问话术和选项按钮 |
| HTTP Request | 调用后端搜索接口 |
| Code | 结果合并,各分支互斥,只返回其中一个结果 |
工作流里的变量:环境变量 HTTP_DOMAIN 存 API 域名。
起点,接收前端传来的变量:
| 变量 | 类型 | 说明 |
|---|---|---|
| intent | string | 用户意图标识 |
| authorization | string | Bearer Token |
| version_date | string | 版本日期(可选) |
用 Dify 的 Question Classifier 节点,把用户请求分成 4 类:
| 分类 ID | 名称 | 例子 |
|---|---|---|
| 1 | 找货意图 | "找翡翠手镯"、"想买和田玉" |
| 2 | 知识问答 | "怎么鉴别翡翠 A 货"、"如何保养和田玉" |
| 3 | App 问答 | "怎么注册账号"、"如何发布商品" |
| 4 | 闲聊 | "你好"、"谢谢"、"再见"、"今天天气怎么样" |
模型配置:通义千问 qwen-plus-latest,Temperature 设 0.3 保证分类稳定,对话记忆保留最近 10 轮。
从用户输入里提取搜索参数,用 LLM 节点,模型也是通义千问,Temperature 0.3。
q 字段(核心搜索描述)
价格范围
price_min 只有满足"品类 + 高价值信号 + 高价值形态 + 无瑕疵"时才推断。比如用户说"高品质玻璃种翡翠手镯,无纹无裂",才可能推断最低价。
price_max 按用户字面描述来,用户说"5000 元左右"就设 5000。
分类映射
1=>翡翠,2=>玉石,3=>钻石,4=>彩宝,39=>书画,40=>黄金,107=>黄金饰品,109=>文玩古玩,110=>钱币邮票
输出字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| q | string | 是 | 核心搜索描述 |
| price_min/max | number/null | 否 | 价格范围(元) |
| category_id | number/null | 否 | 商品分类 ID |
| inner_circle_size_min/max | number/null | 否 | 圈口尺寸(mm) |
| heat_min/max | number/null | 否 | 参与热度范围 |
| is_uncertain | boolean | 否 | 是否模糊需追问 |
| has_discount | boolean | 否 | 是否要优惠商品 |
| negative_filters | array[string] | 否 | 排除关键词 |
检查 is_uncertain 字段,true 就进入追问分支,false 直接搜索。
触发追问的例子
当需要追问时,用 LLM 生成话术和选项。Temperature 设 0.7 高一点,让回答更有创造性。
生成规则:message 字段要共情 + 归因 + 引导,suggested_questions 至少 3 个肯定式选项。
{
"message": "追问话术",
"suggested_questions": [
{ "title": "问题标题", "intent": "find_item" }
]
}
真正调用 Rails 搜索接口的地方:
{{#env.HTTP_DOMAIN#}}/api/v1/products/ai_search
X-Authorization(从 Start 节点取)超时和重试:连接/读取/写入都设 10 秒,失败自动重试 1 次。
根据条件判断走不同的分支,各分支互斥,只返回其中一个:追问分支返回追问话术,搜索分支返回商品列表结果,无结果时返回默认话术。
传统搜索的局限:
| 搜索方式 | 问题 |
|---|---|
| 关键词搜索 | 无法理解语义,"温润的手镯"匹配不到和田玉 |
| 短语匹配 | 要求太精确,用户不会打完整短语 |
| 布尔搜索 | AND/OR/NOT 组合太复杂,用户不会用 |
向量搜索能理解语义:
| 搜什么 | 关键词搜索 | 向量搜索 |
|---|---|---|
| "温润的手镯" | 匹配含"温润"和"手镯"的商品 | 匹配和田玉手镯 |
| "高货" | 匹配含"高货"的商品 | 匹配翡翠高品 |
| "送妈妈的礼物" | 几乎没结果 | 匹配适合送长辈的手镯 |
流程:文本 → Embedding 模型 → 1024 维向量 → KNN 搜索 → 返回结果。
步骤:1) 把用户输入转成向量;2) 在 ES 里用余弦相似度找最相似的 20 个商品;3) 用 min_score 阈值过滤。
我们用阿里云百炼的 Embedding 接口。一次请求把文本转成 1024 维向量,耗时大概 100-200ms。
缓存策略:相同文本 10 分钟内重复查询直接返回缓存。
错误处理:向量生成失败了返回空数组,后面的 ES 查询就不做了,避免浪费资源。
vector = EmbeddingService.encode("翡翠手镯,种水细腻,色泽温润")
# => [0.023, -0.156, 0.089, ..., 0.012] (1024个浮点数)
商品上架后,AI 总结生成完成时自动触发向量生成。流程:
ai_summary_vector 字段vector_generated_at
注意事项:版本冲突自动重试 3 次,失败了要报警。向量是预先生成的,搜索时直接用,不用实时生成。
核心方法:
| 方法 | 作用 |
|---|---|
build_ai_knn_search_body |
构建 KNN 查询 Body |
extract_knn_filters |
提取过滤条件(排除关键词、圈口尺寸等) |
apply_filters_to_knn_query |
应用过滤条件 |
关键参数
k:返回多少条结果。翡翠设 30,玉石 25,钻石和彩宝设 20。翡翠商品多,设大一点有足够候选。min_score:相似度阈值。翡翠设 0.85,玉石 0.8。翡翠商品描述比较标准化,设高一点不容易跑偏。核心词加权:如果提取到核心词(如"翡翠"),生成向量时重复 3 次,让这个词权重更高。"翡翠手镯" → "翡翠 翡翠 翡翠 手镯" → 生成向量。
召回流程
knn.filter 过滤条件min_score 的排序:目前只用 _score(相似度分数)降序,简单的做法,后续可以加其他排序维度。
重排:没有额外的重排逻辑,ES 返回什么顺序就是什么顺序。
{
"knn": {
"field": "ai_summary_vector",
"query_vector": [0.023, -0.156, 0.089, 0.234, -0.067, 0.178, ..., 0.012],
"k": 20,
"filter": {
"bool": {
"filter": [
{ "term": { "category_id": 1 } },
{ "term": { "status": "onsale" } },
{ "term": { "hide_in_miniprogram": false } },
{ "range": { "confirmed_price": { "gte": 5000, "lte": 7000 } } },
{ "range": { "inner_circle_size": { "gte": 55, "lte": 58 } } },
{ "exists": { "field": "ai_summary_vector" } }
],
"must_not": [
{ "match_phrase": { "goods_description_text": { "query": "镶嵌", "analyzer": "ik_max_word" } } }
]
}
}
},
"min_score": 0.8,
"from": 0,
"size": 10,
"sort": [
{ "_score": { "order": "desc" } },
{ "updated_at": { "order": "desc" } }
]
}
说明:过滤条件都在 knn.filter 里处理,在向量搜索阶段就过滤,减少计算量。
AiSearch 表记录每次搜索的完整参数,搜索条件用 JSONB 存。为什么要冗余存一份 keywords_text?因为运营同学要统计数据,JSONB 查起来麻烦。每条记录关联 ai_chat_id 和 ai_chat_message_id,方便回溯"这条搜索结果是谁发的"。
主要字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| msg_id | string | 消息 ID(唯一索引) |
| search_params | jsonb | 完整搜索参数 |
| keywords_text | string | 搜索关键词文本(冗余字段) |
| category | string | 商品分类(冗余字段) |
| price_min/max | decimal | 价格范围(冗余字段) |
| ai_chat_id | bigint | 关联的对话 ID |
| ai_chat_message_id | bigint | 关联的消息 ID |
使用场景:记录搜索参数支持"再次搜索",通过 ai_search_id 复用参数实现"查看更多",也支持搜索行为分析和统计。
POST /api/v1/products/ai_search
处理流程
成功返回
{
"success": true,
"data": {
"ai_search_id": "uuid",
"total": 15,
"products": [
{ "id": 12345, "title": "翡翠手镯", "price": 6000, ... }
]
}
}
错误返回
{
"success": false,
"error": "参数错误:q 或 category_id 不能为空"
}
curl 调用示例
curl -X POST "https://api.example.com/api/v1/products/ai_search" \
-H "X-Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d '{"is_ai_search": true, "q": "翡翠手镯,5000-8000元", "category_id": 1, "price_min": 5000, "price_max": 8000}'
错误处理
| 错误类型 | 处理方式 |
|---|---|
| 参数校验失败 | 返回 400,提示具体错误信息 |
| 向量生成失败 | 记录日志,返回空结果 |
| ES 查询失败 | 返回 500,提示"搜索失败,请稍后重试" |
配置表:ai_search_knn_category_config
| 分类 ID | 分类名称 | k | min_score |
|---|---|---|---|
| 1 | 翡翠 | 30 | 0.85 |
| 2 | 玉石(和田玉等) | 25 | 0.8 |
| 3 | 钻石 | 20 | 0.82 |
| 4 | 彩宝 | 20 | 0.8 |
k:返回多少条结果。翡翠商品多,设 30;其他品类设 20-25。
min_score:相似度阈值。翡翠设 0.85 因为商品描述标准化,不容易跑偏;其他品类设 0.8。
调参建议:搜索结果太少就降 min_score 或增 k;结果不相关就提高 min_score。如果某个分类经常没结果,可以把 min_score 降 0.05-0.1。
现象:SSE 连接建立失败或频繁断开
排查步骤
Accept: text/event-stream
X-Authorization: Bearer <token>
常见原因:token 过期或无效、网络超时(默认 60 秒)、服务器主动关闭连接。
现象:搜索返回空结果
排查步骤
解决:向量失败会降级为返回空结果,检查 Embedding 服务配置和网络,清理缓存后重试。
现象:调用超时或返回错误
排查步骤
DIFY_API_BASE_URL 环境变量DIFY_CHATFLOW_API_KEY 是否有效常见错误:ConnectionError(连不上 Dify)、ResponseError(Dify 返回错误)、ParseError(响应解析失败)。
现象:查询超时或延迟高
排查步骤
k 值是否过大(建议不超过 50)优化:适当降低 k、缩小 knn.filter 范围、检查 ES 集群资源。
现象:返回的商品和用户需求不匹配
排查步骤
q 字段是否正确ai_summary_vector 是否正常min_score 阈值是否合适解决:提高 min_score、检查商品 AI 总结质量、优化属性提取规则。
┌─────────────────────────────────────────────────────────────
│ AI 找货助手技术架构
├─────────────────────────────────────────────────────────────
│ 前端层 → SSE 对话接口 → Dify 工作流 → 商品语义搜索接口
│ ↓
│ Elasticsearch KNN 搜索
│ ↓
│ 商品向量同步 + 查询向量生成
├─────────────────────────────────────────────────────────────
│ 核心流程:Dify 对话流 → 意图识别 → 属性提取 → 追问 → 搜索
│ 核心技术:SSE 实时推送 + 向量语义搜索 + HNSW 高性能索引
└─────────────────────────────────────────────────────────────