在这里插入图片描述

文章目录

写在前面
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协议实现全链路监控和智能告警》。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐