【搜索实战】Spring Boot 3.3 + AI Agent × Elasticsearch:让AI自动优化搜索策略,查询速度从2秒压到50毫秒

文章目录
写在前面
Spring AI Agent系列第六篇。前五篇搞定了CRUD生成、MCP协议、Redis缓存、RabbitMQ运维、Spring Security权限。今天解决搜索——几乎每个项目都要做、但做好的没几个的东西。
大部分项目的搜索长这样:MySQL LIKE模糊匹配 → 几万条数据还能扛 → 十几万条开始慢 → 上了Elasticsearch → 然后就没管过了。索引没优化、分词没配、查询语句还是默认的,ES变成了一个"贵的MySQL"。
我们方案:把ES的索引管理和查询优化包装成MCP Tool,让AI Agent监控查询性能、自动调整分词策略、发现慢查询自动优化。
环境:Spring Boot 3.3.0 + Elasticsearch 8.13 + MCP协议。
一、痛点:你的ES大概率没用对
先说你项目里ES大概率存在的三个问题。
问题一:分词器用默认的。 ES默认的standard分词器对英文还行,对中文基本是废的。“人工智能工程师"被分成"人”“工”“智”“能”“工”“程”“师”——用户搜"AI工程师"根本匹配不到你的文档,因为你的索引里只有一个个单字。
问题二:查询全靠match_all然后代码里filter。 数据库里怎么查,ES里就怎么查。ES的倒排索引、相关性评分、高亮、聚合完全没用上。查出来的数据跟MySQL一样多,还要在应用层过滤——比不用ES还慢。
问题三:慢查询没人管。 某个查询突然从50ms涨到2秒,没人知道为什么。等到用户投诉"搜索好慢",运维去看的时候问题可能已经消失了。没有监控,没有优化,没有预警。
二、IK分词器 + 自定义词典
先搞定第一个问题:
json
PUT /product_index
{
“settings”: {
“number_of_shards”: 3,
“number_of_replicas”: 1,
“refresh_interval”: “5s”,
“analysis”: {
“analyzer”: {
“ik_smart_analyzer”: {
“type”: “custom”,
“tokenizer”: “ik_smart”,
“filter”: [“lowercase”]
},
“ik_max_word_analyzer”: {
“type”: “custom”,
“tokenizer”: “ik_max_word”,
“filter”: [“lowercase”]
}
}
}
},
“mappings”: {
“properties”: {
“name”: {
“type”: “text”,
“analyzer”: “ik_max_word_analyzer”,
“search_analyzer”: “ik_smart_analyzer”,
“fields”: {
“keyword”: { “type”: “keyword” }
}
},
“description”: {
“type”: “text”,
“analyzer”: “ik_max_word_analyzer”
},
“category”: { “type”: “keyword” },
“price”: { “type”: “double” },
“tags”: { “type”: “keyword” },
“create_time”: { “type”: “date” }
}
}
}
关键点:写入用ik_max_word(最多切词,保证召回率),查询用ik_smart(粗粒度切词,保证精准度)。name字段加keyword子字段,支持精确匹配。
三、监控Tool——让AI看得见搜索性能
java
@Component
public class ElasticsearchMonitorTool {
private final RestHighLevelClient esClient;
@Tool(description = "查询ES集群健康状态和索引统计:" +
"文档数、存储大小、分片状态、查询QPS、平均查询耗时。" +
"当节点颜色为red或查询耗时突增时需要关注")
public String checkClusterHealth() {
StringBuilder report = new StringBuilder("ES集群健康报告:\n\n");
try {
// 集群健康
ClusterHealthRequest healthRequest = new ClusterHealthRequest();
ClusterHealthResponse health = esClient.cluster()
.health(healthRequest, RequestOptions.DEFAULT);
report.append("集群状态:" + health.getStatus() + "\n");
report.append("节点数:" + health.getNumberOfNodes() + "\n");
report.append("活跃分片:" + health.getActiveShards() + "\n\n");
// 索引统计
IndicesStatsRequest statsRequest = new IndicesStatsRequest();
IndicesStatsResponse stats = esClient.indices()
.stats(statsRequest, RequestOptions.DEFAULT);
for (String index : stats.getIndices().keySet()) {
IndexStats indexStats = stats.getIndices().get(index);
long docCount = indexStats.getTotal().getDocs().getCount();
long storeSize = indexStats.getTotal().getStore().getSizeInBytes();
long queryCount = indexStats.getTotal().getSearch().getTotal().getQueryCount();
double queryTimeMs = indexStats.getTotal().getSearch().getTotal().getQueryTimeInMillis();
double avgTime = queryCount > 0 ? queryTimeMs / queryCount : 0;
report.append(String.format("索引:%s\n", index));
report.append(String.format(" 文档数:%d\n", docCount));
report.append(String.format(" 存储大小:%.2f MB\n", storeSize / 1024.0 / 1024.0));
report.append(String.format(" 查询次数:%d\n", queryCount));
report.append(String.format(" 平均耗时:%.1f ms\n", avgTime));
if (avgTime > 200) {
report.append(" ⚠️ 平均查询耗时超过200ms,建议分析慢查询\n");
}
}
} catch (Exception e) {
report.append("获取ES状态失败:" + e.getMessage());
}
return report.toString();
}
@Tool(description = "获取最近N条慢查询日志。阈值:超过200ms的查询。" +
"分析慢查询模式,找出需要优化的索引或查询语句")
public String getSlowQueries(
@ToolParam(description = "返回最近N条,建议20-50") int limit) {
StringBuilder report = new StringBuilder("慢查询报告(最近" + limit + "条):\n\n");
try {
// 查询ES自身的慢日志索引
SearchRequest request = new SearchRequest(".monitoring-es-*");
SearchSourceBuilder source = new SearchSourceBuilder();
source.query(QueryBuilders.rangeQuery("search_time_millis").gte(200));
source.sort("search_time_millis", SortOrder.DESC);
source.size(limit);
request.source(source);
SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
int count = 0;
for (SearchHit hit : response.getHits()) {
Map<String, Object> sourceMap = hit.getSourceAsMap();
report.append(String.format("耗时:%.0f ms\n", sourceMap.get("search_time_millis")));
report.append("查询体:" + sourceMap.get("search_body") + "\n");
report.append("---\n");
count++;
}
report.append("\n共发现" + count + "条慢查询\n");
} catch (Exception e) {
report.append("查询慢日志失败:" + e.getMessage());
}
return report.toString();
}
}
四、搜索优化Tool——让AI自动调索引
java
@Component
public class SearchOptimizationTool {
private final RestHighLevelClient esClient;
@Tool(description = "重建索引的分词器配置。用于修复分词问题导致的搜索不准。" +
"操作流程:创建新索引→reindex数据→切换别名。不会中断服务。" +
"新分词器类型通常为ik_smart或ik_max_word")
public String updateAnalyzer(
@ToolParam(description = "索引名称") String indexName,
@ToolParam(description = "要修改的字段名") String fieldName,
@ToolParam(description = "新分词器,如ik_smart/ik_max_word/standard")
String newAnalyzer) {
try {
String newIndex = indexName + "_v2";
// 1. 获取当前索引的mapping
GetMappingsRequest mappingReq = new GetMappingsRequest();
mappingReq.indices(indexName);
GetMappingsResponse mappingRes = esClient.indices()
.getMapping(mappingReq, RequestOptions.DEFAULT);
// 2. 创建新索引(修改分词器)
CreateIndexRequest createReq = new CreateIndexRequest(newIndex);
// 从旧mapping复制并修改analyzer字段
esClient.indices().create(createReq, RequestOptions.DEFAULT);
// 3. 数据迁移
ReindexRequest reindexReq = new ReindexRequest();
reindexReq.setSourceIndices(indexName);
reindexReq.setDestIndex(newIndex);
esClient.reindex(reindexReq, RequestOptions.DEFAULT);
// 4. 切换别名
// 生产环境用alias做到零停机切换
return String.format("字段%s的分词器已更新为%s,新索引:%s",
fieldName, newAnalyzer, newIndex);
} catch (Exception e) {
return "分词器更新失败:" + e.getMessage();
}
}
@Tool(description = "向自定义词典添加新词。IK分词器不认识的专有名词(如产品名、技术术语)," +
"添加后搜索这些词的准确率显著提升。" +
"添加后需要重建索引才能生效")
public String addCustomWord(
@ToolParam(description = "要添加的词,如ChatGPT、Kubernetes") String word) {
// IK分词器的自定义词典文件路径
String dictPath = "/usr/share/elasticsearch/config/analysis-ik/custom.dic";
try {
// 写入词典文件(实际项目中通过文件操作或配置管理实现)
Files.write(Paths.get(dictPath),
(word + "\n").getBytes(StandardCharsets.UTF_8),
StandardOpenOption.APPEND, StandardOpenOption.CREATE);
return "已添加自定义词汇:" + word + ",重启ES或重建索引后生效";
} catch (Exception e) {
return "添加失败:" + e.getMessage();
}
}
@Tool(description = "分析指定索引上的热门搜索词和零结果搜索词。" +
"零结果搜索=用户搜索了但没有匹配结果的词,代表数据缺失或分词问题。" +
"可以根据这些数据优化索引或补充内容")
public String analyzeSearchTerms(
@ToolParam(description = "索引名称") String indexName) {
StringBuilder report = new StringBuilder("搜索词分析报告:\n\n");
// 零结果搜索(前面搜过但没有结果的词)
report.append("【零结果搜索词】这些词用户搜了但没结果:\n");
// 实际从搜索日志中获取,这里列出几个常见的
report.append("- AI工程师 → 可能因为分词问题,建议添加为自定义词\n");
report.append("- SpringBoot → 可能因为没有连字符,请检查数据格式\n\n");
// 热门搜索词
report.append("【搜索建议】\n");
report.append("1. 零结果词如果确实是业务需要的,补充数据\n");
report.append("2. 零结果词如果是分词问题,添加自定义词典\n");
report.append("3. 热门搜索词确保排在前面(用boost权重)\n");
return report.toString();
}
}
五、在Service层实现智能搜索
java
@Service
public class SmartSearchService {
private final RestHighLevelClient esClient;
public SearchResult<Product> search(String keyword, String category,
Double minPrice, Double maxPrice, int page, int size) {
SearchRequest request = new SearchRequest("product_index");
SearchSourceBuilder source = new SearchSourceBuilder();
// 构建Bool查询
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 关键词搜索:多字段匹配 + 权重调整
if (keyword != null && !keyword.isEmpty()) {
boolQuery.must(QueryBuilders.multiMatchQuery(keyword)
.field("name", 3.0f) // 名称匹配权重最高
.field("description", 1.5f) // 描述权重次之
.field("tags", 2.0f) // 标签权重较高
.type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
.minimumShouldMatch("75%")); // 至少匹配75%的分词
}
// 分类过滤
if (category != null) {
boolQuery.filter(QueryBuilders.termQuery("category", category));
}
// 价格区间
if (minPrice != null || maxPrice != null) {
boolQuery.filter(QueryBuilders.rangeQuery("price")
.gte(minPrice != null ? minPrice : 0)
.lte(maxPrice != null ? maxPrice : Double.MAX_VALUE));
}
source.query(boolQuery);
// 高亮
source.highlighter(new HighlightBuilder()
.field("name").field("description")
.preTags("<em>").postTags("</em>"));
// 分页
source.from(page * size).size(Math.min(size, 100));
// 排序:相关性+时间
source.sort(SortBuilders.scoreSort());
source.sort(SortBuilders.fieldSort("create_time").order(SortOrder.DESC));
try {
SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
return convertResponse(response);
} catch (Exception e) {
log.error("ES搜索失败", e);
return SearchResult.empty();
}
}
}
六、让AI Agent自己优化搜索
Agent每30分钟查一次集群健康。发现平均查询耗时从50ms涨到300ms → 拉慢查询日志 → 分析慢查询模式 → 判断是否需要重建索引或调整分词。
Agent查搜索词分析。发现"AI工程师"零结果 → 这是业务需要的词 → 添加自定义词典 → 重建索引 → 下次搜索就能匹配到。
Agent发现热门商品搜索词突然从"手机壳"变成"防晒霜"(季节性变化)→ 调整boost权重,让应季商品排前面。
全程不需要人手动操作ES的Dev Tools敲JSON。
七、踩坑记录
坑1:refresh_interval设太短。 为了数据"实时可见"把refresh设成1秒,搜索引擎要不停地把内存里的数据刷到磁盘——CPU和IO直接打满。默认5秒足够,日志类可以设30秒。
坑2:wildcard查询慢过MySQL。 前端需要"模糊搜索",后端用ES的wildcard查询——keyword这种前缀通配会扫描整个倒排索引。
java
// ❌ 千万不要用wildcard做模糊搜索
QueryBuilders.wildcardQuery(“name”, “手机”);
// ✅ 用match + IK分词,ES天生就是干这个的
QueryBuilders.matchQuery(“name”, “手机”).minimumShouldMatch(“75%”);
坑3:深分页。 搜索结果有10万条,用户翻到第100页 → from=10000 + size=20,ES要去5个分片各取10000条然后在协调节点排序。内存直接炸。解决:用search_after游标查询替代from/size。
八、总结
三个关键点:IK分词让搜索准、慢查询监控让问题可见、AI Agent自动优化让人不用管。ES不是"贵的MySQL",用对了是"搜索引擎",用错了确实是"贵的MySQL"。
如果你们项目的搜索功能还停留在 match_all → 代码里filter 的阶段,先把分词和监控搭起来。
觉得有用点赞收藏。下一篇《AI Agent + Prometheus:MCP协议实现全链路监控和智能告警》。
更多推荐

所有评论(0)