Spring Boot + Vue 项目集成 AI 识图搜索功能:从零搭建多模态商品检索系统
摘要:在 Spring Boot + Vue 3 二手/电商项目中接入阿里云百炼视觉大模型,实现"上传图片 → AI 提取检索词 → 业务库模糊匹配"的完整链路。本文将带你从环境搭建、API 申请、后端开发、前端开发到测试调优,一步步落地 AI 识图搜索功能。含完整前后端代码与配置说明。
关键词:AI 识图、Spring Boot、Vue 3、百炼、多模态、二手交易、视觉大模型、语义搜索
目录
一、引言:为什么我们需要 AI 识图搜索
1.1 一个真实的场景
想象这样一个场景:你走在街上,看到有人背着一款你特别喜欢的背包,但你不知道品牌、不知道型号,甚至连它属于什么品类都说不清楚——"就是那个米色的、方形的、有个小logo的包"。你打开某电商 App,在搜索框里敲下这串描述,结果返回一堆不相关的东西。你尝试拍照,但 App 只有「扫码购」功能,拍背包根本没用。
这就是传统搜索的盲区:用户手里有图,脑子有印象,却无法用文字准确表达搜索意图。
1.2 传统搜索的三大痛点
在电商、内容平台、企业资产库、仓储盘点、社交市集等场景中,传统关键词搜索存在以下结构性缺陷:
| 痛点 | 具体表现 | 业务影响 |
|---|---|---|
| 认知门槛高 | 用户需知道品牌、型号、品类名称才能发起搜索 | 流失"只会看图、不会描述"的用户 |
| 表述偏差大 | 用户描述与商品标题/标签表述不一致时,返回零结果或大量噪声 | 搜索命中率低,用户满意度下降 |
| 分类依赖强 | 用户需手动点选品类、筛选条件,操作路径长 | 移动端体验差,跳出率高 |
1.3 AI 识图搜索能带来什么
AI 识图搜索将交互方式从「输入关键词」升级为「上传图片」,由视觉大模型理解画面内容,再自动映射到业务系统的分类体系与检索条件,在真实业务数据库中返回可点击、可交易、可管理的结果列表。
一句话总结:把「搜像素的困难」转交给 AI,把「搜业务的准确性」交给检索引擎与数据层。
1.4 适用场景
本方案适用于但不限于以下场景:
-
二手 / 闲置交易平台:用户拍旧物照片,AI 识别后在平台内匹配相似商品
-
综合电商与垂直类目商城:降低搜索门槛,提升长尾商品曝光率
-
企业内部物资、样品、备件查询:仓库管理员拍照即可查库存
-
图库、素材站、版权内容检索:设计师上传参考图,匹配素材库
-
本地生活、同城便民信息匹配:拍照找附近的餐厅、商铺、服务
核心命题不是「聊天识图」,而是 「看图 → AI 大模型理解 → 可检索 → 可落地」 的完整链路。
二、行业现状与技术选型
2.1 从关键词搜索到多模态搜索的演进
搜索引擎的发展大致经历了三个阶段:
| 阶段 | 代表技术 | 交互方式 | 局限 |
|---|---|---|---|
| 1.0 关键词匹配 | SQL LIKE、Elasticsearch BM25 | 输入文字关键词 | 依赖用户准确描述 |
| 2.0 语义搜索 | BERT、Sentence Embedding | 输入自然语言句子 | 仍局限于文本模态 |
| 2.5 以图搜图 | CNN 特征向量 + 向量相似度 | 上传图片 | 只能找"视觉相似"的图片,缺乏语义理解 |
| 3.0 多模态语义搜索 | 视觉大模型(VLM)+ 结构化检索 | 上传图片 → 语义理解 → 结构化搜索 | 本篇重点 |
2.2 为什么不直接用以图搜图?
以图搜图(如 ResNet + Faiss)做的是像素级别的相似度匹配:你上传一张红色运动鞋的照片,它返回一堆红色的鞋子图片。但它不知道你上传的是一双"Nike Air Max 90 红白配色 42码 九成新"——它只看像素,不做语义理解。
而视觉大模型(VLM)能做到:
-
识别物品品类(运动鞋 → 鞋类)
-
提取属性信息(品牌、颜色、成色、尺码)
-
生成结构化检索词("Nike", "Air Max 90", "红色", "42码")
-
与业务分类体系对齐(映射到平台类目 ID)
2.3 为什么选择阿里云百炼?
在多模态大模型选型时,我们对比了以下方案:
| 方案 | 优势 | 劣势 | 结论 |
|---|---|---|---|
| 阿里云百炼(Qwen-VL) | 国内合规、中文理解强、API 稳定、按量付费 | 需阿里云账号 | ✅ 首选 |
| OpenAI GPT-4V | 视觉理解能力顶尖 | 网络限制、中文场景不如百炼、数据出境风险 | ❌ 国内生产环境不推荐 |
| 本地部署(LLaVA 等) | 数据不出域、零 API 成本 | GPU 资源消耗大、部署运维复杂、效果弱于云端 | ❌ 中小企业不划算 |
| 百度文心 / 讯飞星火 | 国内合规 | 多模态能力弱于百炼、API 生态不如阿里云 | ⚠️ 备选 |
最终选择阿里云百炼的理由:
-
Qwen-VL 系列(如 qwen-vl-max、qwen3.6-flash)在中文场景的视觉理解评测中名列前茅
-
DashScope API 提供统一的调用入口,SDK 成熟,社区活跃
-
按量付费模式灵活,适合从 MVP 到规模化全阶段
-
数据安全:国内部署,符合等保和数据合规要求
2.4 为什么选 Spring Boot + Vue 3 架构?
| 考量维度 | Spring Boot + Vue 3 | 其他方案 | 优势 |
|---|---|---|---|
| 开发效率 | MyBatis-Plus 代码生成器 + Element Plus UI 组件库 | Django + React / Node.js + Next.js | 国内最主流,生态最完善 |
| 学习成本 | Java + Vue 是国内高校/培训机构主力技术栈 | Go / Python / Node | 团队招聘和交接成本低 |
| 部署运维 | JAR 包一键部署,Nginx 反向代理 | Docker 化部署 | 传统运维人员友好 |
| 社区生态 | 组件库(Element Plus)、工具链(MyBatis-Plus)成熟 | 各有优劣 | 问题排查资源丰富 |
三、核心架构设计
3.1 整体技术链路
本方案采用 "视觉理解"与"库存检索"分层架构,严格分离 AI 理解与业务检索:
客户端(Web / App / 小程序) 选择图片 → 预览 → 提交 multipart ↓ API 网关 / 业务服务 校验 → Base64/DataURL → 调用多模态 API ↓ 视觉大模型(阿里云百炼 Qwen-VL) 返回 JSON:名称、类目、关键词、摘要 ↓ 检索层(SQL LIKE / Elasticsearch / 向量库) 映射类目 ID + 文本条件 + 业务状态过滤 ↓ 统一分页结构 → 卡片列表 → 详情 / 下单 / 工单
3.2 架构分层详解
| 层 | 职责 | 关键技术 | 产出 |
|---|---|---|---|
| 客户端层 | 图片上传、预览、结果展示 | Vue 3 + Element Plus + Axios | 用户交互界面 |
| API 网关层 | 请求校验、文件接收、Base64 编码 | Spring Boot + MultipartFile | 标准化请求数据 |
| AI 理解层 | 图像 → 文本的语义转换 | 阿里云百炼 DashScope API | 结构化 JSON(itemName, categoryName, keywords, summary) |
| 检索层 | JSON → SQL 查询条件 | MyBatis-Plus LambdaQueryWrapper | 数据库查询结果 |
| 数据层 | 存储商品/物料结构化数据 | MySQL / PostgreSQL | 业务数据 |
3.3 为什么不做"端到端黑盒"?
业界常见误区是把大模型当成"直接生成商品列表"的黑盒——给一张图,让模型直接返回"这里有这些商品、价格分别是多少"。这会导致严重问题:
-
幻觉问题:模型可能编造不存在的商品、价格、库存
-
不可审计:结果无法追溯到数据库中的真实记录
-
无法交易:用户看到的"价格"是模型生成的,与实际订单系统脱节
本方案中,AI 只负责"看懂图"并输出结构化检索条件,数据库才是结果的唯一来源。每条返回结果都对应库表中的一条真实记录,可下单、可追溯、可统计。
3.4 数据流详解
以用户上传一张"机械键盘照片"为例,完整数据流如下:
1. 用户上传机械键盘照片
→ POST /api/ai-image/search (multipart/form-data, field: "file")
2. 后端接收 MultipartFile
→ 校验文件类型(image/jpeg, image/png 等)
→ 校验文件大小(建议 ≤ 10MB)
→ 将文件转为 Base64 字符串
3. 构建大模型请求
→ System Prompt: "你是一个商品识别助手..."
→ User Message: [text: "识别图中商品"] + [image: base64...]
→ POST https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
4. 大模型返回 JSON
{
"itemName": "机械键盘",
"categoryName": "电脑外设",
"keywords": ["机械键盘", "RGB背光", "键帽", "Cherry轴"],
"summary": "识别到一款带有RGB背光的机械键盘,疑似Cherry轴体..."
}
5. 后端解析 JSON
→ 提取 keywords 数组
→ 查询 category 表,将 "电脑外设" 映射为 category_id = 5
→ 构建 SQL: SELECT * FROM goods WHERE category_id = 5 AND (title LIKE '%机械键盘%' OR description LIKE '%RGB%' OR ...)
6. 返回分页结果
→ 封装为 Result<Page<Goods>> 返回前端
→ 前端渲染商品卡片列表
四、环境准备与依赖安装
4.1 基础环境要求
| 软件 | 版本要求 | 用途 | 下载地址 |
|---|---|---|---|
| JDK | 17+ (推荐 21 LTS) | Spring Boot 3.x 运行环境 | Home | Adoptium |
| Maven | 3.8+ | 项目构建与依赖管理 | Welcome to Apache Maven – Maven |
| Node.js | 18+ (推荐 20 LTS) | Vue 3 前端开发环境 | Node.js — Run JavaScript Everywhere |
| MySQL | 8.0+ | 业务数据库 | https://dev.mysql.com/downloads/ |
| IDEA / VS Code | 最新版 | IDE 开发工具 | JetBrains / Microsoft |
4.2 后端依赖(pom.xml 关键依赖)
以下是本项目涉及的核心 Maven 依赖及说明:
<!-- Spring Boot 父工程(3.x 版本) --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.0</version> </parent> <dependencies> <!-- Spring Boot Web Starter:提供 REST API 能力 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus:增强版 ORM,提供 LambdaQueryWrapper 等便捷 API --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Jackson:JSON 解析,Spring Boot 自带,无需额外引入 --> <!-- RestTemplate:HTTP 客户端,Spring Boot Web Starter 自带 --> <!-- Lombok:简化 Getter/Setter/日志代码(可选) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>
4.3 前端依赖(package.json 关键依赖)
{
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"element-plus": "^2.4.0",
"@element-plus/icons-vue": "^2.3.0",
"axios": "^1.6.0",
"pinia": "^2.1.0"
}
}
| 依赖 | 版本 | 用途 |
|---|---|---|
| vue | 3.4+ | 前端框架,Composition API |
| vue-router | 4.2+ | 前端路由管理 |
| element-plus | 2.4+ | UI 组件库,提供 el-button、el-alert 等 |
| @element-plus/icons-vue | 2.3+ | Element Plus 图标库 |
| axios | 1.6+ | HTTP 请求库,用于前后端通信 |
| pinia | 2.1+ | Vue 3 官方状态管理(可选,简单页面可不引入) |
4.4 项目初始化步骤
后端初始化:
# 方式一:使用 Spring Initializr 创建项目 # 访问 https://start.spring.io/ # 选择:Maven、Java 21、Spring Boot 3.2.0 # 添加依赖:Spring Web、MySQL Driver、MyBatis-Plus # 方式二:直接克隆配套项目 git clone <项目仓库地址> cd secondhand-mall-base/springboot mvn clean install -DskipTests
前端初始化:
# 进入前端目录 cd secondhand-mall-base/vue # 安装依赖 npm install # 启动开发服务器(默认 http://localhost:5173) npm run dev
五、阿里云百炼 API 申请与配置
5.1 开通百炼服务
步骤一:登录阿里云控制台
访问 阿里云百炼控制台,使用阿里云账号登录。如果没有账号,需要先注册并完成实名认证。
步骤二:开通 DashScope 服务
进入百炼控制台后,在左侧导航栏找到「模型广场」,搜索 "Qwen-VL" 或 "通义千问视觉"。点击进入模型详情页,按提示开通服务。
注意:百炼采用按量付费模式,开通后会获得一定的免费额度(通常为 100 万 Token),足够开发测试使用。正式上线前需要充值或绑定企业账户。
步骤三:创建 API Key
在控制台右上角点击头像 → API-KEY 管理,进入 API Key 管理页面。
-
点击「创建 API Key」
-
填写 Key 名称(如
secondhand-mall-ai-search) -
系统生成一串类似
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx的密钥 -
立即复制保存!关闭弹窗后将无法再次查看完整 Key
⚠️ 安全提示:API Key 等同于你的账户操作权限,切勿上传到 GitHub 或公开分享。建议使用环境变量或配置中心管理。
5.2 模型选型建议
百炼提供了多个 Qwen-VL 模型版本,按需选择:
| 模型 | 特点 | 建议场景 | 单价(参考) |
|---|---|---|---|
| qwen-vl-max | 最强视觉理解能力,支持高分辨率图片 | 复杂场景、精细化识别 | 较高 |
| qwen-vl-plus | 均衡性价比,日常识别场景足够 | 生产环境推荐 | 中等 |
| qwen3.6-flash | 轻量模型,响应速度快 | 开发测试、低延迟要求 | 较低 |
本项目示例使用 qwen3.6-flash,兼顾速度与效果。如果对识别精度有更高要求,可直接替换为 qwen-vl-plus 或 qwen-vl-max,代码无需改动。
5.3 测试 API 连通性
在正式开始编码之前,可以先通过 curl 命令测试 API 是否可用:
curl -X POST https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions \
-H "Authorization: Bearer sk-你的API-KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3.6-flash",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "这张图片里有什么?"},
{"type": "image_url", "image_url": {"url": "https://example.com/test.jpg"}}
]
}
]
}'
如果返回正常的 JSON 响应(包含 choices[0].message.content),说明 API Key 和网络均正常。
六、后端开发详解
6.1 项目结构
src/main/java/com/base/ ├── controller/ │ └── AIImageController.java # AI 识图搜索控制器 ├── entity/ │ ├── Goods.java # 商品实体 │ └── GoodsExt.java # 商品扩展实体 ├── mapper/ │ └── GoodsMapper.java # 商品 Mapper ├── common/ │ └── Result.java # 统一返回结果封装 └── exception/ └── BusinessException.java # 业务异常类
6.2 商品实体类(Goods.java)
package com.base.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品实体类 —— AI 识图搜索的主要检索对象
* 对应数据库 goods 表
*/
@Data
@TableName("goods")
public class Goods {
/** 商品主键 ID */
@TableId
private Long id;
/** 商品标题 —— AI 识图搜索的主要匹配字段之一 */
private String title;
/** 商品描述/详情 —— 辅助匹配字段 */
private String description;
/** 所属分类 ID —— 与 category 表关联,用于「分类+关键词」联合过滤 */
private Integer categoryId;
/** 商品价格 */
private BigDecimal price;
/** 商品主图 URL */
private String imageUrl;
/** 商品状态:1-在售 2-已售 3-下架 —— 检索时过滤非在售商品 */
private Integer status;
/** 卖家 ID */
private Long sellerId;
/** 创建时间 */
private LocalDateTime createTime;
/** 更新时间 */
private LocalDateTime updateTime;
}
6.3 AI 识图控制器(AIImageController.java)—— 完整代码逐段解析
package com.base.controller;
// ======================== 1. 导入依赖 ========================
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.base.common.Result;
import com.base.entity.Goods;
import com.base.entity.GoodsExt;
import com.base.exception.BusinessException;
import com.base.mapper.GoodsMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* AI 识图搜索控制器
* 接口:POST /api/ai-image/search
* 请求方式:multipart/form-data,字段名 "file"
*
* 核心流程:
* 1. 接收前端上传的图片文件
* 2. 将图片转为 Base64 编码
* 3. 调用阿里云百炼视觉大模型(Qwen-VL)进行图像理解
* 4. 解析大模型返回的 JSON,提取商品名称、类目、关键词
* 5. 将关键词转为数据库查询条件(多关键词 OR 匹配 + 类目过滤)
* 6. 分页返回命中的商品列表
*/
@RestController
@RequestMapping("/api/ai-image")
public class AIImageController {
// ======================== 2. 成员变量 ========================
private static final Logger log = LoggerFactory.getLogger(AIImageController.class);
/** 阿里云 DashScope API 地址 */
@Value("${dashscope.base-url}")
private String dashscopeBaseUrl;
/** 阿里云百炼 API Key —— 敏感信息,仅在服务端配置 */
@Value("${dashscope.api-key}")
private String apiKey;
/** 视觉模型名称,可通过配置文件切换 */
@Value("${dashscope.vision-model}")
private String visionModel;
private final GoodsMapper goodsMapper;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
/**
* 构造函数注入 —— Spring 推荐的依赖注入方式
*/
public AIImageController(GoodsMapper goodsMapper,
RestTemplate restTemplate,
ObjectMapper objectMapper) {
this.goodsMapper = goodsMapper;
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
}
// ======================== 3. 核心接口 ========================
/**
* AI 识图搜索入口
*
* @param file 前端上传的图片文件(multipart/form-data, field="file")
* @param page 当前页码,默认 1
* @param size 每页条数,默认 10
* @return Result 统一返回格式,data 中包含分页结果
*/
@PostMapping("/search")
public Result<?> aiImageSearch(
@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size) {
// ---- 3.1 入参校验 ----
if (file.isEmpty()) {
return Result.error("请选择一张图片");
}
// 校验文件类型:仅允许常见图片格式
String contentType = file.getContentType();
if (contentType == null || !contentType.startsWith("image/")) {
return Result.error("仅支持上传图片文件(jpg、png、webp 等)");
}
// 校验文件大小:建议不超过 10MB(百炼 API 对图片 Base64 长度有上限)
if (file.getSize() > 10 * 1024 * 1024) {
return Result.error("图片文件大小不能超过 10MB");
}
try {
// ---- 3.2 图片转 Base64 ----
// 大模型 API 需要 Base64 或 URL 格式的图片
byte[] imageBytes = file.getBytes();
String base64Image = Base64.getEncoder().encodeToString(imageBytes);
String dataUrl = "data:" + contentType + ";base64," + base64Image;
// ---- 3.3 调用视觉大模型 ----
String aiResponse = callVisionModel(dataUrl);
// ---- 3.4 解析大模型返回 JSON ----
JsonNode root = objectMapper.readTree(aiResponse);
String itemName = extractField(root, "itemName");
String categoryName = extractField(root, "categoryName");
List<String> keywords = extractKeywordsArray(root);
String summary = extractField(root, "summary");
log.info("AI 识图结果 —— 商品: {}, 类目: {}, 关键词: {}", itemName, categoryName, keywords);
// ---- 3.5 构建数据库查询条件 ----
LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>();
// 类目过滤:将 categoryName 映射为 categoryId
// 实际项目中应查询 category 表做映射,这里简化处理
// 如果类目名为空或无法映射,则跳过类目条件
if (StringUtils.hasText(categoryName)) {
Integer categoryId = mapCategoryNameToId(categoryName);
if (categoryId != null) {
wrapper.eq(Goods::getCategoryId, categoryId);
}
}
// 多关键词 OR 模糊匹配
// 策略:任一个关键词命中 title 或 description 即纳入候选
if (keywords != null && !keywords.isEmpty()) {
wrapper.and(w -> {
for (String keyword : keywords) {
// 忽略过短的关键词(单字符易产生大量噪声)
if (keyword.length() >= 2) {
w.or().like(Goods::getTitle, keyword)
.or().like(Goods::getDescription, keyword);
}
}
});
}
// 仅查询在售商品(业务状态过滤)
wrapper.eq(Goods::getStatus, 1);
// 按更新时间倒序排列
wrapper.orderByDesc(Goods::getUpdateTime);
// ---- 3.6 分页查询 ----
Page<Goods> resultPage = goodsMapper.selectPage(new Page<>(page, size), wrapper);
// ---- 3.7 构建响应 ----
// 附加 AI 识别信息,供前端展示
Map<String, Object> data = new LinkedHashMap<>();
data.put("records", resultPage.getRecords());
data.put("total", resultPage.getTotal());
data.put("current", resultPage.getCurrent());
data.put("size", resultPage.getSize());
data.put("aiInfo", Map.of(
"itemName", itemName != null ? itemName : "",
"categoryName", categoryName != null ? categoryName : "",
"keywords", keywords != null ? keywords : List.of(),
"summary", summary != null ? summary : ""
));
return Result.success(data);
} catch (BusinessException e) {
// 业务异常直接抛出
return Result.error(e.getMessage());
} catch (Exception e) {
log.error("AI 识图搜索异常", e);
return Result.error("识图搜索服务异常,请稍后重试");
}
}
// ======================== 4. 辅助方法 ========================
/**
* 调用阿里云百炼视觉大模型
*
* 使用 OpenAI 兼容接口格式调用 DashScope API
* 参考文档:https://help.aliyun.com/zh/model-studio/
*
* @param dataUrl 图片的 Data URL(data:image/jpeg;base64,...)
* @return 大模型返回的 JSON 文本
*/
private String callVisionModel(String dataUrl) {
// 4.1 构建 System Prompt —— 关键!约束模型输出格式
String systemPrompt = buildSystemPrompt();
// 4.2 构建请求体(OpenAI 兼容格式)
Map<String, Object> requestBody = new LinkedHashMap<>();
requestBody.put("model", visionModel);
// messages 数组
List<Map<String, Object>> messages = new ArrayList<>();
// System Message:定义模型角色和输出格式
Map<String, Object> systemMsg = new LinkedHashMap<>();
systemMsg.put("role", "system");
systemMsg.put("content", systemPrompt);
messages.add(systemMsg);
// User Message:包含文本指令和图片
Map<String, Object> userMsg = new LinkedHashMap<>();
userMsg.put("role", "user");
List<Map<String, Object>> userContent = new ArrayList<>();
// 文本部分
Map<String, Object> textPart = new LinkedHashMap<>();
textPart.put("type", "text");
textPart.put("text", "请识别这张图片中的商品,按照系统提示的 JSON 格式输出结果。");
userContent.add(textPart);
// 图片部分
Map<String, Object> imagePart = new LinkedHashMap<>();
imagePart.put("type", "image_url");
imagePart.put("image_url", Map.of("url", dataUrl));
userContent.add(imagePart);
userMsg.put("content", userContent);
messages.add(userMsg);
requestBody.put("messages", messages);
requestBody.put("temperature", 0.1); // 低温度,提高输出稳定性
// 4.3 构建 HTTP Headers
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + apiKey);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
// 4.4 发送请求
String url = dashscopeBaseUrl + "/compatible-mode/v1/chat/completions";
ResponseEntity<String> response = restTemplate.exchange(
url, HttpMethod.POST, entity, String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new BusinessException("调用视觉大模型失败,状态码: " + response.getStatusCode());
}
// 4.5 解析响应,提取 content
try {
JsonNode responseJson = objectMapper.readTree(response.getBody());
JsonNode choices = responseJson.get("choices");
if (choices == null || !choices.isArray() || choices.size() == 0) {
throw new BusinessException("大模型返回结果为空");
}
String content = choices.get(0).get("message").get("content").asText();
// 提取 JSON 块(可能包含在 ```json ... ``` 中)
return extractJsonBlock(content);
} catch (Exception e) {
log.error("解析大模型响应失败: {}", response.getBody(), e);
throw new BusinessException("解析AI识别结果失败");
}
}
/**
* 构建 System Prompt
*
* 核心策略:通过 Prompt 工程约束模型的输出格式和内容边界,
* 确保输出可直接被程序解析并使用。
*/
private String buildSystemPrompt() {
return """
你是一个商品识别助手。用户会上传商品图片,请识别图片中的商品。
**必须严格按照以下 JSON 格式输出(不要输出其他内容):**
{
"itemName": "商品主体名称",
"categoryName": "所属分类(如:手机数码、电脑办公、服饰鞋包、家居生活、图书教材、运动户外、美妆个护、食品饮料、母婴用品、其他)",
"keywords": ["关键词1", "关键词2", "关键词3"],
"summary": "一段简短的识别描述(50字以内)"
}
**重要约束:**
1. categoryName 必须从上面列举的分类中选择,不要自创分类
2. keywords 提取 3-5 个最关键的检索词(品牌、型号、材质、颜色等)
3. summary 用通俗的中文描述,让普通用户能看懂
4. 只输出 JSON,不要有任何额外的解释文字
""";
}
/**
* 从模型返回的文本中提取 JSON 块
* 处理可能包裹在 ```json ... ``` 中的情况
*/
private String extractJsonBlock(String content) {
// 尝试匹配 ```json ... ``` 或 ``` ... ```
Pattern pattern = Pattern.compile("```(?:json)?\\s*\\n?([\\s\\S]*?)\\n?```");
Matcher matcher = pattern.matcher(content);
if (matcher.find()) {
return matcher.group(1).trim();
}
// 如果没有代码块标记,直接返回原始内容
return content.trim();
}
/**
* 从 JSON 节点中安全提取字符串字段
*/
private String extractField(JsonNode root, String fieldName) {
JsonNode node = root.get(fieldName);
return (node != null && !node.isNull()) ? node.asText() : null;
}
/**
* 从 JSON 节点中提取 keywords 数组
*/
private List<String> extractKeywordsArray(JsonNode root) {
JsonNode keywordsNode = root.get("keywords");
if (keywordsNode == null || !keywordsNode.isArray()) {
return new ArrayList<>();
}
List<String> keywords = new ArrayList<>();
for (JsonNode node : keywordsNode) {
String kw = node.asText().trim();
if (!kw.isEmpty()) {
keywords.add(kw);
}
}
return keywords;
}
/**
* 将类目名称映射为类目 ID
*
* 实际项目中建议从 category 表查询,这里做简化映射
* 也可以使用 Map 常量或枚举来实现
*/
private Integer mapCategoryNameToId(String categoryName) {
Map<String, Integer> categoryMap = Map.ofEntries(
Map.entry("手机数码", 1),
Map.entry("电脑办公", 2),
Map.entry("服饰鞋包", 3),
Map.entry("家居生活", 4),
Map.entry("图书教材", 5),
Map.entry("运动户外", 6),
Map.entry("美妆个护", 7),
Map.entry("食品饮料", 8),
Map.entry("母婴用品", 9),
Map.entry("其他", 10)
);
return categoryMap.getOrDefault(categoryName, null);
}
// ======================== 5. 配置 RestTemplate Bean ========================
// 在 @Configuration 类中添加以下 Bean(也可以放在主启动类中)
/**
* 在项目的配置类(如 WebConfig.java)中注册 RestTemplate Bean:
*
* @Bean
* public RestTemplate restTemplate() {
* return new RestTemplate();
* }
*/
}
6.4 统一返回结果封装(Result.java)
package com.base.common;
import lombok.Data;
/**
* 统一响应体
* 所有 API 接口统一使用此类封装返回结果
*/
@Data
public class Result<T> {
/** 响应码:200-成功,其他-失败 */
private int code;
/** 响应消息 */
private String message;
/** 响应数据 */
private T data;
/** 成功返回(带数据) */
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("success");
result.setData(data);
return result;
}
/** 成功返回(无数据) */
public static <T> Result<T> success() {
return success(null);
}
/** 失败返回 */
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
/** 自定义状态码返回 */
public static <T> Result<T> of(int code, String message, T data) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
result.setData(data);
return result;
}
}
七、前端开发详解
7.1 项目结构
src/
├── views/
│ └── user/
│ └── AiImageSearch.vue # AI 识图搜索页面(单文件组件)
├── router/
│ └── index.js # 路由配置
├── api/
│ └── aiImage.js # AI 识图 API 封装(可选)
└── components/
└── layout/
└── FrontNav.vue # 前端导航组件(含菜单入口)
7.2 AI 识图搜索页面(AiImageSearch.vue)—— 完整代码逐段解析
<!--
AI 识图搜索页(单文件版)
路由:/user/ai-search
接口:POST /api/ai-image/search,字段 file
核心功能:
1. 用户点击「选择图片」按钮,从本地选择图片
2. 选中后显示本地预览
3. 点击「开始识图」,将图片上传到后端
4. 后端调用大模型识别 → 数据库检索 → 返回结果
5. 前端展示识别摘要 + 搜索结果商品列表
-->
<template>
<div class="ai-page">
<!-- ============ 7.2.1 页头标题区 ============ -->
<div class="ai-page__header">
<h2 class="ai-page__title">AI 识图搜索</h2>
<p class="ai-page__desc">
上传一张商品图片,AI 将自动识别商品信息并在商品库中为您匹配相似商品
</p>
</div>
<!-- ============ 7.2.2 上传操作区 ============ -->
<div class="ai-upload-bar">
<!--
隐藏的原生文件选择框
设置 accept="image/*" 限制只能选择图片文件
通过 ref 获取 DOM 引用,在 pickImage 方法中触发点击
-->
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="ai-upload-bar__input"
@change="onFileChange"
/>
<!-- 选择图片按钮:点击后间接触发隐藏的 input -->
<el-button type="primary" :loading="loading" @click="pickImage">
<el-icon><Picture /></el-icon>
{{ previewUrl ? '重新选择' : '选择图片' }}
</el-button>
<!--
开始识图按钮:
- 有图才能点击(:disabled="!selectedFile")
- loading 时显示加载动画
- 点击后调用 doSearch 方法
-->
<el-button
type="success"
:loading="loading"
:disabled="!selectedFile"
@click="doSearch"
>
<el-icon><Search /></el-icon>
开始识图
</el-button>
<!-- 清除选择按钮:重置当前选中的图片 -->
<el-button
v-if="selectedFile"
:disabled="loading"
@click="clearSelection"
>
清除
</el-button>
</div>
<!-- ============ 7.2.3 本地预览区 ============ -->
<!--
选中图片后,在本地生成 Blob URL 进行预览
此时图片尚未上传到服务器
-->
<div v-if="previewUrl" class="ai-preview-wrapper">
<img :src="previewUrl" class="ai-preview" alt="预览图" />
</div>
<!-- ============ 7.2.4 AI 识别摘要区 ============ -->
<!--
大模型识别完成后,展示摘要信息:
- AI 认为这是什么商品
- 提取了哪些关键词
- 匹配到哪个类目
-->
<div v-if="aiInfo" class="ai-summary">
<el-alert
title="AI 识别结果"
type="info"
show-icon
:closable="false"
>
<template #default>
<div class="ai-summary__content">
<p><strong>识别商品:</strong>{{ aiInfo.itemName }}</p>
<p><strong>匹配类目:</strong>{{ aiInfo.categoryName }}</p>
<p><strong>检索关键词:</strong>
<el-tag
v-for="kw in aiInfo.keywords"
:key="kw"
size="small"
class="ai-summary__tag"
>
{{ kw }}
</el-tag>
</p>
<p><strong>识别摘要:</strong>{{ aiInfo.summary }}</p>
</div>
</template>
</el-alert>
</div>
<!-- ============ 7.2.5 搜索结果统计 ============ -->
<div v-if="total > 0" class="ai-result-meta">
共找到 <strong>{{ total }}</strong> 件相关商品
</div>
<!-- 无结果提示 -->
<el-empty
v-if="searched && total === 0"
description="未找到匹配的商品,请尝试更换图片或使用关键词搜索"
/>
<!-- ============ 7.2.6 搜索结果列表 ============ -->
<div v-if="records.length > 0" class="ai-result-grid">
<div
v-for="item in records"
:key="item.id"
class="ai-result-card"
@click="goDetail(item.id)"
>
<!-- 商品图片 -->
<div class="ai-result-card__image">
<img :src="item.imageUrl" :alt="item.title" />
</div>
<!-- 商品信息 -->
<div class="ai-result-card__info">
<h4 class="ai-result-card__title">{{ item.title }}</h4>
<p class="ai-result-card__price">
<span class="price-symbol">¥</span>
{{ item.price }}
</p>
<p class="ai-result-card__desc">{{ truncateText(item.description, 60) }}</p>
</div>
</div>
</div>
<!-- ============ 7.2.7 分页组件 ============ -->
<div v-if="total > size" class="ai-page__pagination">
<el-pagination
v-model:current-page="currentPage"
:page-size="size"
:total="total"
layout="prev, pager, next"
@current-change="onPageChange"
/>
</div>
</div>
</template>
<script setup>
/**
* AI 识图搜索页面 —— 逻辑部分
*
* 使用 Vue 3 Composition API(<script setup> 语法糖)
* 相较于 Options API,代码更简洁,逻辑更清晰
*/
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Picture, Search } from '@element-plus/icons-vue'
import axios from 'axios'
// ==================== 1. 响应式状态 ====================
const router = useRouter()
/** 是否正在加载(上传 + AI 识别 + 搜索) */
const loading = ref(false)
/** 是否已经执行过搜索(用于控制空状态显示) */
const searched = ref(false)
/** 隐藏的 file input DOM 引用 */
const fileInputRef = ref(null)
/** 用户选择的文件对象 */
const selectedFile = ref(null)
/** 本地预览的 Blob URL */
const previewUrl = ref('')
/** AI 识别信息(后端返回的 aiInfo 字段) */
const aiInfo = ref(null)
/** 搜索结果商品列表 */
const records = ref([])
/** 搜索结果总数 */
const total = ref(0)
/** 当前页码 */
const currentPage = ref(1)
/** 每页条数 */
const size = ref(10)
// ==================== 2. 方法 ====================
/**
* 触发文件选择对话框
* 通过 ref 拿到隐藏的 input 元素,调用其 click() 方法
*/
function pickImage() {
fileInputRef.value?.click()
}
/**
* 文件选择变化回调
* 当用户在文件选择框中选中(或重新选中)图片时触发
*
* @param {Event} event - input change 事件
*/
function onFileChange(event) {
const file = event.target.files?.[0]
if (!file) return
// 前端二次校验:仅允许图片类型
if (!file.type.startsWith('image/')) {
ElMessage.warning('请选择图片文件(jpg、png、webp 等)')
return
}
// 前端二次校验:大小不超过 10MB
if (file.size > 10 * 1024 * 1024) {
ElMessage.warning('图片文件不能超过 10MB')
return
}
// 保存文件对象
selectedFile.value = file
// 生成本地预览 URL
// 使用 URL.createObjectURL 创建 Blob URL,注意在组件卸载时释放
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = URL.createObjectURL(file)
// 清除之前的搜索结果和 AI 信息
aiInfo.value = null
records.value = []
total.value = 0
searched.value = false
}
/**
* 发起识图搜索
* 核心流程:构建 FormData → 发送 POST 请求 → 处理响应
*/
async function doSearch() {
if (!selectedFile.value) {
ElMessage.warning('请先选择一张图片')
return
}
loading.value = true
searched.value = true
try {
// 构建 multipart/form-data 请求体
const formData = new FormData()
formData.append('file', selectedFile.value)
formData.append('page', currentPage.value.toString())
formData.append('size', size.value.toString())
// 发送 POST 请求
// 注意:axios 会自动识别 FormData 并设置 Content-Type 为 multipart/form-data
const response = await axios.post('/api/ai-image/search', formData)
if (response.data.code === 200) {
const data = response.data.data
// 更新 AI 识别信息
aiInfo.value = data.aiInfo || null
// 更新搜索结果
records.value = data.records || []
total.value = data.total || 0
currentPage.value = data.current || 1
if (total.value > 0) {
ElMessage.success(`AI 识图完成,找到 ${total.value} 件相关商品`)
} else {
ElMessage.info('AI 已识别图片内容,但未在商品库中找到匹配结果')
}
} else {
ElMessage.error(response.data.message || '识图搜索失败')
}
} catch (error) {
console.error('AI 识图搜索请求异常:', error)
if (error.response) {
// 服务器返回了错误响应
ElMessage.error(error.response.data?.message || '服务器异常,请稍后重试')
} else if (error.request) {
// 请求已发出但未收到响应(网络问题)
ElMessage.error('网络连接异常,请检查网络后重试')
} else {
ElMessage.error('请求发送失败,请稍后重试')
}
} finally {
loading.value = false
}
}
/**
* 清除当前选择
*/
function clearSelection() {
selectedFile.value = null
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = ''
aiInfo.value = null
records.value = []
total.value = 0
searched.value = false
// 重置 file input,以便再次选择同一文件时能触发 change 事件
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
/**
* 翻页回调
*/
function onPageChange(page) {
currentPage.value = page
// 翻页时重新发起搜索(携带新的页码参数)
doSearch()
}
/**
* 跳转到商品详情页
* @param {number} id - 商品 ID
*/
function goDetail(id) {
router.push(`/goods/detail/${id}`)
}
/**
* 截断文本(用于商品描述预览)
* @param {string} text - 原始文本
* @param {number} maxLen - 最大长度
* @returns {string} 截断后的文本
*/
function truncateText(text, maxLen) {
if (!text) return ''
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text
}
</script>
<style scoped>
/* ==================== 样式 ==================== */
.ai-page {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.ai-page__header {
text-align: center;
margin-bottom: 32px;
}
.ai-page__title {
font-size: 28px;
font-weight: 700;
color: #303133;
margin-bottom: 12px;
}
.ai-page__desc {
font-size: 14px;
color: #909399;
}
/* 上传操作区 */
.ai-upload-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.ai-upload-bar__input {
display: none; /* 隐藏原生文件选择框 */
}
/* 图片预览 */
.ai-preview-wrapper {
text-align: center;
margin-bottom: 24px;
}
.ai-preview {
max-width: 400px;
max-height: 400px;
border-radius: 8px;
border: 1px solid #e4e7ed;
object-fit: contain;
}
/* AI 识别摘要 */
.ai-summary {
margin-bottom: 24px;
}
.ai-summary__content p {
margin: 8px 0;
font-size: 14px;
color: #606266;
}
.ai-summary__tag {
margin-right: 6px;
margin-bottom: 4px;
}
/* 搜索结果统计 */
.ai-result-meta {
font-size: 14px;
color: #606266;
margin-bottom: 16px;
}
.ai-result-meta strong {
color: #409EFF;
font-size: 16px;
}
/* 搜索结果网格 */
.ai-result-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.ai-result-card {
border: 1px solid #ebeef5;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: box-shadow 0.3s, transform 0.3s;
background: #fff;
}
.ai-result-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.ai-result-card__image {
width: 100%;
height: 180px;
overflow: hidden;
background: #f5f7fa;
}
.ai-result-card__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ai-result-card__info {
padding: 12px;
}
.ai-result-card__title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
/* 最多显示两行 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ai-result-card__price {
font-size: 18px;
font-weight: 700;
color: #f56c6c;
margin-bottom: 6px;
}
.price-symbol {
font-size: 14px;
}
.ai-result-card__desc {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
/* 分页 */
.ai-page__pagination {
display: flex;
justify-content: center;
padding: 20px 0;
}
</style>
7.3 路由配置(router/index.js)
// 在路由配置文件中添加 AI 识图搜索路由
{
path: 'ai-search',
name: 'AiImageSearch',
// 使用路由懒加载,减小首屏加载体积
component: () => import('@/views/user/AiImageSearch.vue'),
meta: {
front: true, // 标记为前端页面(需要用户登录)
title: 'AI 识图' // 页面标题
}
}
路由注册要点:
-
懒加载:使用
() => import(...)语法,Vite 会将此页面打包为独立的 chunk,用户访问时才加载,减小首屏体积 -
meta.front:标记为需要登录的前端页面,在全局路由守卫中判断
-
meta.title:设置页面标题,可在路由守卫中通过
document.title动态设置
7.4 前端导航菜单入口
<!-- 在导航组件中添加 AI 识图入口 -->
<router-link
to="/user/ai-search"
class="front-nav__item"
:class="{ 'is-active': activeMenu === '/user/ai-search' }"
>
<el-icon><Camera /></el-icon>
AI识图
</router-link>
导航入口设计要点:
-
使用
<router-link>而非<a>标签,保持 SPA 导航体验(不刷新页面) -
通过
:class="{ 'is-active': ... }"动态绑定高亮样式 -
使用 Element Plus 的
<Camera />图标,直观表达"拍照/识图"功能 -
放在用户导航栏而非管理后台,因为这是面向终端用户的功能
7.5 AI 识图 API 封装(可选,api/aiImage.js)
/**
* AI 识图搜索 API 封装
* 将接口调用逻辑从组件中抽离,便于维护和复用
*/
import axios from 'axios'
/**
* AI 识图搜索
* @param {File} file - 用户选择的图片文件
* @param {number} page - 页码(默认 1)
* @param {number} size - 每页条数(默认 10)
* @returns {Promise} 搜索结果
*/
export function searchByImage(file, page = 1, size = 10) {
const formData = new FormData()
formData.append('file', file)
formData.append('page', page.toString())
formData.append('size', size.toString())
return axios.post('/api/ai-image/search', formData)
}
八、配置文件详解
8.1 后端配置文件(application.yml)
# ==================== 服务器配置 ====================
server:
port: 8080 # 服务端口
# ==================== Spring 配置 ====================
spring:
application:
name: secondhand-mall # 应用名称
# 数据源配置
datasource:
url: jdbc:mysql://localhost:3306/secondhand_mall?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
# 文件上传限制
servlet:
multipart:
max-file-size: 10MB # 单个文件最大 10MB
max-request-size: 20MB # 单次请求最大 20MB
# ==================== MyBatis-Plus 配置 ====================
mybatis-plus:
configuration:
map-underscore-to-camel-case: true # 下划线转驼峰命名
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境打印 SQL
global-config:
db-config:
id-type: auto # 主键自增
# ==================== 阿里云百炼 DashScope 配置 ====================
# 申请 API Key: https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key
dashscope:
api-key: 'sk-your-api-key-here' # ⚠️ 生产环境建议使用环境变量或配置中心
base-url: https://dashscope.aliyuncs.com
vision-model: qwen3.6-flash # 可选: qwen-vl-plus, qwen-vl-max
# ==================== 日志配置 ====================
logging:
level:
com.base: DEBUG # 项目包日志级别
org.springframework.web: INFO # Spring Web 日志级别
8.2 配置项详解
| 配置项 | 说明 | 注意事项 |
|---|---|---|
server.port |
服务端口 | 确保端口未被占用 |
spring.datasource.url |
数据库连接 | serverTimezone=Asia/Shanghai 解决时区问题 |
spring.servlet.multipart.max-file-size |
单文件大小限制 | 需与前端校验保持一致 |
dashscope.api-key |
百炼 API Key | 严禁提交到公开仓库,建议用 ${DASHSCOPE_API_KEY} 环境变量 |
dashscope.vision-model |
视觉模型名称 | qwen3.6-flash 适合开发测试,生产建议 qwen-vl-plus |
mybatis-plus.configuration.log-impl |
SQL 日志 | 生产环境务必关闭,避免日志膨胀 |
8.3 环境变量最佳实践
开发环境:在 application-dev.yml 中配置测试 API Key。
生产环境:通过环境变量注入敏感信息:
# Linux / macOS export DASHSCOPE_API_KEY="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # Windows PowerShell $env:DASHSCOPE_API_KEY="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # 启动命令 java -jar app.jar --dashscope.api-key=$env:DASHSCOPE_API_KEY
对应修改 application.yml:
dashscope:
api-key: ${DASHSCOPE_API_KEY:} # 从环境变量读取,未设置时为空(识图功能降级)
base-url: https://dashscope.aliyuncs.com
vision-model: qwen3.6-flash
九、核心亮点深度解析
亮点 1:多模态理解 + 业务检索闭环
传统的关键词搜索只能理解文本,而本方案接入支持 图像 + 文本 输入的视觉大模型(阿里云百炼 Qwen-VL 系列),对上传图片进行物体识别、场景理解与属性抽取。
输出不是自然语言闲聊,而是结构化 JSON:
{
"itemName": "机械键盘",
"categoryName": "电脑办公",
"keywords": ["机械键盘", "RGB背光", "Cherry轴", "键帽"],
"summary": "识别到一款带有RGB背光的机械键盘,疑似搭载Cherry轴体,键帽为PBT材质"
}
随后由服务端将上述字段转为数据库查询条件,返回业务库中的真实记录。AI 负责「看懂图」,系统负责「找对货」——职责清晰,便于扩展与运维。
亮点 2:「视觉理解」与「库存检索」分层架构
业界常见误区是把大模型当成"直接生成商品列表"的黑盒。这种做法在实际生产中存在严重问题:
| 问题 | 说明 |
|---|---|
| 幻觉 | 模型可能编造不存在的商品、错误的价格、虚构的卖家 |
| 不可溯源 | 结果没有对应的数据库主键,无法点击下单 |
| 不可控 | 模型输出不稳定,同一张图两次可能返回不同结果 |
本方案采用语义检索架构:
-
结果可审计:每条记录对应库表主键,可追溯
-
流程可解释:用户可见「AI 识别了什么、检索了哪些关键词」
-
架构可替换:模型可换(百炼 → GPT-4V),检索规则可换,数据层不变
亮点 3:多关键词 OR + 分级兜底,提升命中率
视觉模型给出的描述往往比卖家标题更"学术"或更"细碎"。例如:
| 来源 | 表述 |
|---|---|
| AI 识别 | "机械键盘、键帽、RGB 背光、樱桃轴" |
| 卖家标题 | "Keychron K2 茶轴 九成新 包邮" |
如果拼成整句做模糊匹配,大概率零结果。本方案的优化策略包括:
-
关键词拆分 OR:任一词命中标题或描述即纳入候选
-
分类 + 关键词联合过滤:先收窄类目,再文本匹配,大幅减少噪声
-
分级放宽策略:精准匹配 → 单词匹配 → 同类目浏览 → 全库关键词
-
业务状态过滤:仅展示「在售 / 已上架 / 审核通过」等有效状态
在召回率与相关性之间取得平衡,避免"认对了却搜不到"。
亮点 4:领域约束 Prompt,抑制分类幻觉
通过 Prompt 工程将模型输出约束在平台已有类目枚举内:
**categoryName 必须从下面列举的分类中选择:** 手机数码、电脑办公、服饰鞋包、家居生活、图书教材、 运动户外、美妆个护、食品饮料、母婴用品、其他
避免模型自创"智能家居""数码配件"等库中不存在的分类,保证后续 SQL 查询可执行。这是 RAG 之前的第一道闸门:用规则 + 结构化输出,把开放域视觉理解收束到封闭业务词典。
亮点 5:安全部署与模块解耦
-
API Key 仅部署在服务端:前端只传图片,不暴露密钥,符合密钥管理规范
-
独立识图 API:
POST /api/ai-image/search与商品、订单、用户等模块解耦 -
友好降级:未配置 API Key 时,识图接口明确报错,不影响其他业务模块
适合 SaaS 多租户、私有化交付、教学实训等多种部署形态。
亮点 6:轻量模块,易集成、易二次开发
本方案采用单页前端 + 单控制器后端的交付形态:
-
前端:上传、预览、结果网格、识别摘要——一个
.vue文件搞定 -
后端:调用大模型、解析 JSON、查库、分页返回——一个 Controller 类搞定
接入方只需已有商品(或物料)表、类目表、登录与列表 UI,即可快速集成。后续可平滑演进为:向量 embedding 真·以图搜图、搜索日志分析、点击率排序、A/B 测试等,无需推翻初版架构。
十、测试与调优经验
10.1 本地测试流程
步骤一:启动后端服务
cd springboot mvn spring-boot:run # 或直接运行主启动类
确认服务启动成功后,访问 http://localhost:8080 验证。
步骤二:启动前端服务
cd vue npm install npm run dev
默认在 http://localhost:5173 启动 Vite 开发服务器。
步骤三:准备测试图片
建议准备以下类型的测试图片,覆盖不同识别难度:
| 图片类型 | 示例 | 测试重点 |
|---|---|---|
| 清晰单品图 | 白色背景的商品正面照 | 验证基础识别能力 |
| 复杂场景图 | 桌面上堆放的物品 | 验证主体抽取能力 |
| 多物品图 | 多件不同商品合影 | 验证是否能识别主要物品 |
| 低分辨率图 | 压缩过的模糊图片 | 验证鲁棒性 |
| 非标准角度 | 侧面、斜角拍摄 | 验证泛化能力 |
步骤四:使用 Postman 测试后端接口
POST http://localhost:8080/api/ai-image/search Content-Type: multipart/form-data file: (选择测试图片) page: 1 size: 10
10.2 调优经验
Prompt 调优:
最初的 Prompt 没有约束类目枚举,模型有时会输出"智能家居"等库中不存在的分类,导致类目过滤失效。通过在 System Prompt 中显式列举可用类目并强调"必须从中选择",彻底解决了这个问题。
关键词拆分策略:
初期将所有关键词拼成一条 LIKE 语句(如 '%机械键盘 RGB 背光%'),导致命中率极低。改为每个关键词独立 OR 匹配后,召回率提升了 3 倍以上。
温度参数设置:
temperature 设置为 0.1(接近 0),确保模型输出稳定、可解析。实际测试中,同一张图在 temperature=0.1 时输出基本一致,而在 temperature=0.8 时关键词差异较大。
10.3 效果评估指标
| 指标 | 说明 | 参考值 |
|---|---|---|
| 识别准确率 | AI 识别的商品名称与实际商品一致的比例 | > 85% |
| 召回率 | 库中实际存在但被搜到的比例 | > 70%(含分级兜底) |
| 精确率 | 搜索结果中真正相关的比例 | > 60%(允许模糊匹配) |
| 平均响应时间 | 从上传图片到返回结果的总耗时 | < 5 秒(含大模型调用) |
十一、常见问题与排错指南
Q1:调用百炼 API 返回 401 错误
错误信息:401 Unauthorized 或 Invalid API Key
原因:
-
API Key 填写错误或已过期
-
Authorization Header 格式不正确(应为
Bearer sk-xxx)
解决方案:
-
检查
application.yml中dashscope.api-key是否正确 -
前往 百炼 API Key 管理页 确认 Key 状态
-
确认 API Key 前缀为
sk-,不要漏掉
Q2:大模型返回的结果不是标准 JSON
错误信息:解析AI识别结果失败
原因:
-
System Prompt 约束不够强,模型输出了额外的解释文字
-
temperature 参数过高,输出不稳定
解决方案:
-
确认 System Prompt 中包含"只输出 JSON,不要有任何额外的解释文字"
-
将
temperature设置为0.1或更低 -
在后端添加
extractJsonBlock()方法(已在上文提供),自动去除json包裹
Q3:搜索结果为空,但 AI 识别看起来是正确的
原因:
-
数据库中可能确实没有对应商品
-
关键词拆分不够细
-
类目映射错误
解决方案:
-
检查数据库 goods 表是否有对应类目的商品
-
确认
mapCategoryNameToId()方法的映射关系是否与数据库一致 -
建议从数据库 category 表动态查询映射,而非硬编码
-
开启分级兜底:类目不匹配时,降级为仅按关键词搜索
Q4:图片上传失败或超时
错误信息:413 Payload Too Large 或请求超时
原因:
-
图片文件过大,超过 Spring Boot 默认上传限制(1MB)
-
大模型处理大图片耗时过长
解决方案:
-
在
application.yml中配置spring.servlet.multipart.max-file-size: 10MB -
前端在上传前对图片进行压缩(可使用 Canvas API)
-
建议前端限制图片大小不超过 10MB,并在选择文件时即时校验
Q5:前端跨域问题(CORS)
错误信息:Access to XMLHttpRequest has been blocked by CORS policy
原因:前端开发服务器(localhost:5173)与后端(localhost:8080)端口不同
解决方案:在后端配置 CORS:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true);
}
}
或通过 Vite 开发服务器配置代理(推荐):
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
}
Q6:大模型调用耗时过长
原因:百炼 API 是远程调用,网络延迟 + 模型推理时间
解决方案:
-
设置合理超时:RestTemplate 默认无超时,需显式设置:
@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000); // 连接超时 5 秒
factory.setReadTimeout(30000); // 读取超时 30 秒
return new RestTemplate(factory);
}
-
前端增加加载动画:让用户感知到正在进行中
-
异步处理:对于耗时较长的图片,可考虑异步 + 轮询模式
-
图片预处理:上传前在前端压缩图片至合理分辨率(1024px 以内)
十二、性能优化建议
12.1 图片压缩与预处理
大模型 API 的图片 Base64 编码后体积直接影响请求耗时和费用。建议在前端上传前进行压缩:
/**
* 图片压缩函数(使用 Canvas API)
* @param {File} file - 原始图片文件
* @param {number} maxWidth - 最大宽度,默认 1024
* @param {number} quality - 压缩质量 0-1,默认 0.8
* @returns {Promise<File>} 压缩后的文件
*/
async function compressImage(file, maxWidth = 1024, quality = 0.8) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
let width = img.width
let height = img.height
if (width > maxWidth) {
height = (maxWidth / width) * height
width = maxWidth
}
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0, width, height)
canvas.toBlob(
(blob) => resolve(new File([blob], file.name, { type: 'image/jpeg' })),
'image/jpeg',
quality
)
}
img.onerror = reject
img.src = e.target.result
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
12.2 数据库查询优化
为检索字段建立索引:
-- 商品标题支持模糊搜索的前缀索引 ALTER TABLE goods ADD INDEX idx_title (title(100)); -- 商品描述全文索引(可选,数据库需要支持) ALTER TABLE goods ADD FULLTEXT INDEX ft_description (description); -- 类目查询索引 ALTER TABLE goods ADD INDEX idx_category_status (category_id, status);
避免全表扫描:
-
始终携带
category_id过滤条件(如果有的话) -
限制单次查询返回数量(分页)
-
避免
LIKE '%xxx%'的前缀通配(可用全文索引代替)
12.3 大模型调用优化
| 优化手段 | 说明 | 效果 |
|---|---|---|
| 图片预处理 | 前端压缩至 1024px 以内 | Base64 体积减少 60%+ |
| temperature 降低 | 设为 0.1 | 减少重复请求概率 |
| 选择 Flash 模型 | qwen3.6-flash 比 max 快 3-5 倍 | 响应时间大幅缩短 |
| 结果缓存 | 对相同图片的识别结果做缓存(MD5 去重) | 大幅减少 API 调用 |
| 超时控制 | RestTemplate 设置合理的连接和读取超时 | 避免请求阻塞 |
12.4 结果缓存方案
对相同图片的 AI 识别结果做缓存可以显著降低 API 调用成本:
/**
* 简单的内存缓存策略(可替换为 Redis)
* key: 图片 MD5
* value: AI 识别 JSON
*/
@Component
public class AiImageCache {
// 生产环境建议替换为 Redis Cache
private final Map<String, CacheEntry> cache = new LinkedHashMap<>() {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > 100; // 最多缓存 100 条
}
};
/**
* 获取缓存
* @param imageMd5 图片的 MD5 值
* @return 缓存的识别结果,不存在返回 null
*/
public String get(String imageMd5) {
CacheEntry entry = cache.get(imageMd5);
if (entry != null && !entry.isExpired()) {
return entry.getContent();
}
return null;
}
/**
* 存入缓存(TTL 1 小时)
*/
public void put(String imageMd5, String aiResponse) {
cache.put(imageMd5, new CacheEntry(aiResponse, 3600_000L));
}
private static class CacheEntry {
private final String content;
private final long expireAt;
CacheEntry(String content, long ttlMs) {
this.content = content;
this.expireAt = System.currentTimeMillis() + ttlMs;
}
boolean isExpired() {
return System.currentTimeMillis() > expireAt;
}
String getContent() {
return content;
}
}
}
十三、扩展方向与未来演进
13.1 真·以图搜图:向量 Embedding 方案
当前方案是"图片 → 文字 → 数据库检索",本质上是图文跨模态检索。更进一步可以做以图搜图:将图片和商品图片都转为向量,在向量空间中进行相似度搜索。
技术方案:
商品图片 → CLIP / 通义万相 Embedding → 向量存入 Milvus / Faiss 用户上传图片 → 同样 Embedding → 向量相似度搜索 → 返回 Top-K
13.2 接入 Elasticsearch 全文检索
当商品数量达到十万级以上时,MySQL LIKE 模糊搜索的性能会急剧下降。此时应迁移到 Elasticsearch:
// ES 查询示例:多关键词 OR 匹配
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
for (String keyword : keywords) {
boolQuery.should(QueryBuilders.matchQuery("title", keyword));
boolQuery.should(QueryBuilders.matchQuery("description", keyword));
}
boolQuery.filter(QueryBuilders.termQuery("categoryId", categoryId));
boolQuery.filter(QueryBuilders.termQuery("status", 1));
Elasticsearch 的优势:
-
全文搜索引擎,模糊匹配毫秒级响应
-
支持同义词、拼音搜索
-
可结合向量插件(如 elastiknn)实现混合搜索
13.3 多模态 RAG 增强
将识图搜索与 RAG(Retrieval-Augmented Generation)结合:
用户上传图片 → VLM 理解 → 关键词检索 → 召回候选商品
↓
LLM 汇总候选结果,生成自然语言推荐
"根据您上传的机械键盘照片,我们为您找到 3 款相似商品:..."
这样用户不仅能搜到商品,还能得到 AI 的智能推荐解读。
13.4 搜索日志与效果反馈闭环
在生产环境中,应记录每次识图搜索的日志:
| 日志字段 | 说明 |
|---|---|
image_md5 |
图片唯一标识(用于去重和缓存) |
ai_item_name |
AI 识别商品名 |
ai_keywords |
AI 提取关键词 |
result_count |
搜索结果数量 |
user_click_id |
用户点击的商品 ID(如有) |
search_time_ms |
搜索耗时 |
通过分析这些日志,可以:
-
优化 Prompt(哪些识别经常出错)
-
调整关键词权重(哪些词匹配度高)
-
A/B 测试不同模型版本
-
构建点击率排序模型
13.5 小程序与移动端适配
当前方案基于 Web 端,但核心逻辑可以无缝迁移到微信小程序、UniApp 等移动端:
// 小程序端调用 wx.chooseMedia 而非 input[type=file]
wx.chooseMedia({
count: 1,
mediaType: ['image'],
success(res) {
const tempFilePath = res.tempFiles[0].tempFilePath
// 转为 Base64 或直接上传
wx.getFileSystemManager().readFile({
filePath: tempFilePath,
encoding: 'base64',
success: (data) => {
// 调用后端 API
}
})
}
})
十四、总结与展望
14.1 核心收获
本文从零到一搭建了一套完整的 AI 识图搜索系统,核心要点回顾:
-
架构分层:视觉理解(大模型)与业务检索(数据库)严格分离,各司其职
-
Prompt 工程:通过精心设计的 System Prompt,约束模型输出结构化 JSON
-
多关键词 OR:将 AI 提取的多个关键词独立匹配,大幅提升命中率
-
分级兜底:类目过滤 → 关键词匹配 → 放宽条件,在召回率和精确率之间平衡
-
安全合规:API Key 仅存服务端,前端只传图片;独立接口,模块解耦
-
轻量易集成:单页前端 + 单控制器后端,快速接入任何有商品表的项目
14.2 适用场景总结
AI 识图搜索的价值,在于把多模态能力嵌入既有业务数据流,而非替代数据库:
-
降低搜索门槛——用图片作为统一入口,用户不再需要精确描述
-
提高可解释性——识别结果与检索条件对用户可见,过程透明
-
保证结果真实性——列表来自业务库,可溯源、可交易、可统计
-
工程可落地——密钥安全、模块解耦、分级兜底、类目对齐
适用于任何「用户手里是图,系统里是结构化商品(或物料)数据」的检索增强场景。
14.3 未来展望
随着多模态大模型能力的持续进步(更大分辨率、更强细节理解、更低延迟),AI 识图搜索将成为电商、资产管理、本地生活等领域的标配能力。从"以文搜文"到"以图搜文"再到"以图搜图",搜索范式的演进才刚刚开始。
技术标签:Multimodal LLM、Structured Output、Semantic Search、Fallback Retrieval、BFF API、前后端分离、Spring Boot、Vue 3、阿里云百炼。
更多推荐


所有评论(0)