🚀 引言:搜索的下一个十年
2023 年,ChatGPT 爆火之后,"AI 会替代搜索引擎"的声音此起彼伏。但两年过去,最前沿的实践给出了截然不同的答案:LLM 不会替代 ES,LLM 会让 ES 变得更聪明。
原因很简单:LLM 的幻觉问题、知识截止日期、无法访问私有数据——这些根本性的局限,只有通过检索增强才能解决。而检索增强的核心,就是以 ES 为代表的向量+关键词混合检索系统。
但 ES + LLM 的结合,远不止第08篇讲的基础 RAG。2024-2025 年,这个领域迭代极快:
- Text2DSL:用户说"找价格 3000 以内、评分 4.5 分以上的降噪耳机",LLM 自动生成 ES 查询语句
- 多轮对话搜索:用户说"再便宜点的",系统记住上下文,知道是在上一次搜索基础上追加过滤
- 搜索 Agent:LLM 自主决定"先搜索、再过滤、再汇总",完成复杂的多步骤信息检索任务
- GraphRAG:ES + 知识图谱,让检索具备推理能力
本篇把这些前沿技术逐一落地,给出可运行的 Spring AI + ES 代码。
一、Text2DSL——让 LLM 把自然语言转成 ES 查询
1.1 为什么需要 Text2DSL?
传统搜索要求用户"会用关键词",而大多数用户更习惯说自然语言。Text2DSL 让用户直接说需求,LLM 负责把需求翻译成精确的 ES 查询:
用户输入:
"找一个支持 5G、价格在 2000-4000 元之间、评分高于 4.5 分、
有 5000 毫安以上大电池、最近一周上架的手机,按评分从高到低排"
期望的 ES DSL 输出:
{
"query": { "bool": {
"must": [{ "match": { "tags": "5G" } }],
"filter": [
{ "range": { "price": { "gte": 2000, "lte": 4000 } } },
{ "range": { "rating": { "gte": 4.5 } } },
{ "range": { "battery_capacity": { "gte": 5000 } } },
{ "range": { "created_at": { "gte": "now-7d" } } }
]
}},
"sort": [{ "rating": { "order": "desc" } }],
"size": 20
}
1.2 Text2DSL 实现
// Text2DslService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class Text2DslService {
private final ChatClient chatClient;
private final ElasticsearchClient esClient;
private final ObjectMapper objectMapper;
/**
* 把用户自然语言查询转换为 ES DSL,然后执行
*
* 设计关键:
* 1. 系统提示(System Prompt)里要描述索引的 Mapping,
* 让 LLM 知道有哪些字段可用,字段类型是什么
* 2. 要求 LLM 只输出 JSON,不要任何解释
* 3. 要对 LLM 输出做校验和兜底(LLM 有时会生成非法 DSL)
*/
public ProductSearchResponse searchByNaturalLanguage(String userQuery,
String tenantId) throws IOException {
// Step 1: 构建包含 Mapping 信息的系统提示
String systemPrompt = buildSystemPrompt(tenantId);
// Step 2: 让 LLM 生成 ES DSL
String generatedDsl = chatClient.prompt()
.system(systemPrompt)
.user(buildUserPrompt(userQuery))
.call()
.content();
log.info("Text2DSL 生成: query='{}' → dsl='{}'",
userQuery, generatedDsl.substring(0, Math.min(200, generatedDsl.length())));
// Step 3: 解析和校验 DSL
Map<String, Object> dsl = parseDsl(generatedDsl);
if (dsl == null) {
log.warn("LLM 生成的 DSL 解析失败,降级到关键词搜索: {}", generatedDsl);
return fallbackKeywordSearch(userQuery, tenantId);
}
// Step 4: 安全过滤(强制注入租户 ID 过滤,防止 LLM 生成跨租户查询)
injectTenantFilter(dsl, tenantId);
// Step 5: 执行查询
return executeRawDslSearch(dsl, tenantId);
}
private String buildSystemPrompt(String tenantId) {
return """
你是一个 Elasticsearch 查询专家。
用户会输入自然语言查询,你需要将其转换为有效的 ES Query DSL(JSON 格式)。
索引名称: tenant_%s_products
可用字段(Mapping):
– name: text(商品名称,全文检索)
– brand: keyword(品牌,精确匹配)
– category: keyword(分类,精确匹配)
– price: scaled_float(价格,单位:元)
– rating: scaled_float(用户评分,0-5分)
– stock: integer(库存数量)
– tags: keyword(标签数组,如 "5G", "折叠屏", "降噪")
– battery_capacity: integer(电池容量,单位:毫安)
– screen_size: float(屏幕尺寸,单位:英寸)
– is_active: boolean(是否上架)
– created_at: date(上架时间,ISO格式)
– description: text(商品描述,全文检索)
规则:
1. 只返回 JSON 格式的 ES Query DSL,不要任何解释
2. 全文检索用 multi_match(name 和 description),精确条件用 term/range 放 filter
3. 默认 size 为 20,除非用户指定
4. 价格、评分、电池等数值条件用 range query
5. 排序如果用户未指定,默认按 _score 降序
6. 不要在 DSL 中包含 tenant_id 过滤(系统会自动注入)
输出格式示例:
{"query":{…},"sort":[…],"size":20}
""".formatted(tenantId);
}
private String buildUserPrompt(String userQuery) {
return "将以下查询转换为 ES Query DSL:\\n" + userQuery;
}
/**
* 解析 LLM 返回的 DSL,处理 LLM 可能带 markdown 代码块的情况
*/
private Map<String, Object> parseDsl(String rawResponse) {
try {
// 去除 markdown 代码块(LLM 有时会包裹 ```json … ```)
String cleaned = rawResponse
.replaceAll("```json\\\\s*", "")
.replaceAll("```\\\\s*", "")
.trim();
// 解析 JSON
Map<String, Object> dsl = objectMapper.readValue(
cleaned, new TypeReference<Map<String, Object>>() {});
// 基础校验:必须有 query 字段
if (!dsl.containsKey("query")) {
log.warn("LLM 生成的 DSL 缺少 query 字段");
return null;
}
return dsl;
} catch (Exception e) {
log.warn("LLM DSL 解析失败: {}", e.getMessage());
return null;
}
}
/**
* 安全:强制注入租户 ID 过滤,防止 LLM 生成的查询越界
*/
@SuppressWarnings("unchecked")
private void injectTenantFilter(Map<String, Object> dsl, String tenantId) {
Map<String, Object> query = (Map<String, Object>) dsl.get("query");
Map<String, Object> tenantFilter = Map.of(
"term", Map.of("tenant_id", tenantId)
);
if (query.containsKey("bool")) {
Map<String, Object> bool = (Map<String, Object>) query.get("bool");
List<Object> filters = (List<Object>) bool.computeIfAbsent("filter", k -> new ArrayList<>());
filters.add(tenantFilter);
} else {
// 把原始查询包裹在 bool 里,加上 filter
dsl.put("query", Map.of(
"bool", Map.of(
"must", List.of(query),
"filter", List.of(tenantFilter)
)
));
}
}
}
1.3 Text2DSL 的风险控制
LLM 生成的 DSL 不可盲目信任,必须有防御层:
// DslSafetyValidator.java
@Component
public class DslSafetyValidator {
// 禁止的操作(防止恶意 Prompt 注入生成危险查询)
private static final List<String> FORBIDDEN_KEYS = List.of(
"script", // 禁止 Script 查询(可执行任意代码)
"pinned", // 禁止固定置顶(可能被利用)
"_all" // 禁止全字段查询(性能风险)
);
private static final int MAX_TERMS_SIZE = 100; // terms 查询最多 100 个值
private static final int MAX_SIZE = 100; // 单次最多返回 100 条
public ValidationResult validate(Map<String, Object> dsl) {
List<String> errors = new ArrayList<>();
// 1. 检查禁止的 key
String dslStr = dsl.toString();
for (String forbidden : FORBIDDEN_KEYS) {
if (dslStr.contains("\\"" + forbidden + "\\"")) {
errors.add("不允许使用 " + forbidden + " 查询");
}
}
// 2. 限制返回数量
Object size = dsl.get("size");
if (size instanceof Number && ((Number) size).intValue() > MAX_SIZE) {
dsl.put("size", MAX_SIZE); // 强制限制
}
// 3. 深度限制(防止无限嵌套查询)
int depth = calculateQueryDepth(dsl);
if (depth > 10) {
errors.add("查询嵌套深度超过限制");
}
return new ValidationResult(errors.isEmpty(), errors);
}
private int calculateQueryDepth(Object obj) {
if (!(obj instanceof Map)) return 0;
Map<?, ?> map = (Map<?, ?>) obj;
return 1 + map.values().stream()
.mapToInt(this::calculateQueryDepth)
.max()
.orElse(0);
}
}
二、多轮对话搜索——记忆上下文的会话式搜索
2.1 会话状态管理
// SearchSession.java —— 多轮对话的搜索会话
@Data
@Builder
public class SearchSession {
private String sessionId;
private String userId;
private String tenantId;
// 对话历史(LLM 需要这些来理解上下文)
private List<ConversationTurn> history;
// 当前搜索状态(上一轮的查询条件,用于追加过滤)
private String lastKeyword;
private Map<String, Object> lastFilters;
private String lastCategory;
private int lastResultCount;
// 用户偏好(从对话中推断出来的)
private Set<String> preferredBrands;
private Double maxBudget;
private Set<String> mustHaveFeatures;
@Data
@Builder
public static class ConversationTurn {
private String role; // "user" | "assistant"
private String content;
private long timestamp;
private Map<String, Object> searchContext; // 这一轮的搜索条件(用于上下文关联)
}
}
// ConversationalSearchService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class ConversationalSearchService {
private final ChatClient chatClient;
private final Text2DslService text2DslService;
private final RedisTemplate<String, SearchSession> sessionStore;
private final ElasticsearchClient esClient;
private static final Duration SESSION_TTL = Duration.ofMinutes(30);
/**
* 多轮对话搜索的核心方法
*
* 处理流程:
* 1. 恢复/创建会话
* 2. 让 LLM 理解当前输入(是新查询、追加过滤还是引用上文)
* 3. 生成 ES 查询(融合历史上下文)
* 4. 执行查询
* 5. 生成自然语言回复
* 6. 更新会话状态
*/
public ConversationalSearchResponse chat(String sessionId,
String userMessage,
String tenantId) throws IOException {
// 恢复会话
SearchSession session = sessionStore.opsForValue().get("session:" + sessionId);
if (session == null) {
session = SearchSession.builder()
.sessionId(sessionId)
.tenantId(tenantId)
.history(new ArrayList<>())
.build();
}
// Step 1: 用 LLM 分析用户意图(区分"新搜索"vs"追加过滤"vs"提问")
SearchIntent intent = analyzeIntent(userMessage, session);
log.info("意图分析: message='{}', intent={}", userMessage, intent.getType());
ProductSearchResponse searchResult = null;
String esQuery = null;
switch (intent.getType()) {
case NEW_SEARCH -> {
// 全新的搜索,清除上一轮的过滤条件
searchResult = text2DslService.searchByNaturalLanguage(userMessage, tenantId);
updateSessionFromSearch(session, userMessage, searchResult);
}
case REFINE_FILTER -> {
// 在上一轮基础上追加过滤(如"再便宜一点"、"只看有货的")
String refinedQuery = buildRefinedQuery(userMessage, session);
searchResult = text2DslService.searchByNaturalLanguage(refinedQuery, tenantId);
updateSessionFromSearch(session, refinedQuery, searchResult);
}
case COMPARE -> {
// 用户想比较上一轮结果中的几个商品
searchResult = getComparisonProducts(intent.getProductIds(), tenantId);
}
case QUESTION -> {
// 用户在问关于上一轮搜索结果的问题(走 RAG)
// 不需要新的 ES 查询,用上一轮的结果做 RAG 回答
}
}
// Step 2: 生成自然语言回复
String assistantReply = generateReply(userMessage, searchResult, session, intent);
// Step 3: 更新会话历史
session.getHistory().add(SearchSession.ConversationTurn.builder()
.role("user")
.content(userMessage)
.timestamp(System.currentTimeMillis())
.build());
session.getHistory().add(SearchSession.ConversationTurn.builder()
.role("assistant")
.content(assistantReply)
.timestamp(System.currentTimeMillis())
.searchContext(session.getLastFilters())
.build());
// 保留最近 10 轮对话(防止 Context 过长)
if (session.getHistory().size() > 20) {
session.setHistory(session.getHistory().subList(
session.getHistory().size() – 20, session.getHistory().size()
));
}
// 持久化会话
sessionStore.opsForValue().set("session:" + sessionId, session, SESSION_TTL);
return ConversationalSearchResponse.builder()
.sessionId(sessionId)
.reply(assistantReply)
.searchResult(searchResult)
.intent(intent.getType().name())
.build();
}
/**
* LLM 分析用户意图
* 区分:NEW_SEARCH / REFINE_FILTER / COMPARE / QUESTION
*/
private SearchIntent analyzeIntent(String userMessage, SearchSession session) {
String historyContext = session.getHistory().stream()
.filter(t -> "user".equals(t.getRole()))
.map(t -> "用户: " + t.getContent())
.collect(Collectors.joining("\\n"));
String intentJson = chatClient.prompt()
.system("""
分析用户的搜索意图,返回 JSON 格式。
对话历史:
%s
上一次搜索结果数量:%d
上一次搜索关键词:%s
意图类型:
– NEW_SEARCH: 完全新的搜索请求
– REFINE_FILTER: 在上一次搜索基础上追加/修改过滤条件(如"便宜一点"、"只看华为"、"有货的")
– COMPARE: 想对比上一次结果中的某些商品
– QUESTION: 询问关于上一次搜索结果的问题
只返回 JSON:{"type": "NEW_SEARCH|REFINE_FILTER|COMPARE|QUESTION", "refinement": "追加的条件(如果是REFINE_FILTER)"}
""".formatted(historyContext, session.getLastResultCount(), session.getLastKeyword())
)
.user(userMessage)
.call()
.content();
try {
return objectMapper.readValue(intentJson, SearchIntent.class);
} catch (Exception e) {
return SearchIntent.builder().type(SearchIntent.Type.NEW_SEARCH).build();
}
}
/**
* 构建追加过滤后的完整查询
*
* 例如:
* 上一轮:搜索"手机",过滤:category=手机
* 这一轮:用户说"便宜一点"
* 合并后:搜索"手机",过滤:category=手机 AND price < 上一轮最低价
*/
private String buildRefinedQuery(String userMessage, SearchSession session) {
return chatClient.prompt()
.system("""
用户正在对上一次搜索结果进行精化过滤。
上一次的搜索条件:
– 关键词:%s
– 过滤条件:%s
请将用户的新需求合并到原有条件中,生成完整的自然语言搜索描述。
只返回合并后的查询描述,不要解释。
""".formatted(session.getLastKeyword(),
session.getLastFilters())
)
.user("在原有条件基础上,追加:" + userMessage)
.call()
.content();
}
private String generateReply(String userMessage,
ProductSearchResponse result,
SearchSession session,
SearchIntent intent) {
String context = result != null
? "找到 %d 个商品,Top 3:%s".formatted(
result.getTotal(),
result.getProducts().stream().limit(3)
.map(p -> p.getName() + "(¥" + p.getPrice() + ")")
.collect(Collectors.joining("、"))
)
: "没有找到相关商品";
return chatClient.prompt()
.system("""
你是一个友好的购物助手。
根据搜索结果,用简洁自然的语言回复用户。
如果有结果:简要介绍Top商品,提示还有更多选项
如果无结果:解释原因,建议调整搜索条件
回复限制在 100 字以内。
""")
.user("用户问:%s\\n搜索结果:%s".formatted(userMessage, context))
.call()
.content();
}
}
三、搜索 Agent——让 LLM 自主完成复杂搜索任务
Agent 的核心是 Function Calling:LLM 可以决定调用哪些工具、以什么顺序调用、用什么参数,最终完成用户的复杂意图。
3.1 定义搜索工具集
// SearchToolDefinitions.java —— 定义 LLM 可以调用的搜索工具
@Component
@RequiredArgsConstructor
public class SearchToolDefinitions {
private final ElasticsearchClient esClient;
private final EmbeddingService embeddingService;
/**
* 工具1:关键词搜索
* LLM 调用时机:用户有明确关键词,需要精确匹配
*/
@Bean
public Function<KeywordSearchRequest, SearchResult> keywordSearch() {
return req -> {
try {
SearchResponse<Product> response = esClient.search(s -> s
.index("tenant_" + req.getTenantId() + "_products")
.query(q -> q.bool(b -> {
b.must(m -> m.multiMatch(mm -> mm
.query(req.getKeyword())
.fields("name^3", "description", "tags^2")
));
if (req.getMaxPrice() != null) {
b.filter(f -> f.range(r -> r.field("price")
.lte(JsonData.of(req.getMaxPrice()))));
}
if (req.getMinRating() != null) {
b.filter(f -> f.range(r -> r.field("rating")
.gte(JsonData.of(req.getMinRating()))));
}
return b;
}))
.size(req.getSize() != null ? req.getSize() : 5),
Product.class
);
return SearchResult.fromEsResponse(response);
} catch (IOException e) {
throw new RuntimeException("搜索执行失败", e);
}
};
}
/**
* 工具2:语义搜索
* LLM 调用时机:用户用描述性语言表达需求,无法用关键词精确匹配
*/
@Bean
public Function<SemanticSearchRequest, SearchResult> semanticSearch() {
return req -> {
try {
float[] queryVector = embeddingService.generateEmbedding(req.getQuery());
SearchResponse<Product> response = esClient.search(s -> s
.index("tenant_" + req.getTenantId() + "_products")
.knn(knn -> knn
.field("product_vector")
.queryVector(floatArrayToList(queryVector))
.k((long) (req.getK() != null ? req.getK() : 5))
.numCandidates(100L)
),
Product.class
);
return SearchResult.fromEsResponse(response);
} catch (IOException e) {
throw new RuntimeException("语义搜索失败", e);
}
};
}
/**
* 工具3:聚合分析
* LLM 调用时机:用户问"哪些品牌的手机在 3000-5000 价位比较多"
*/
@Bean
public Function<AggregationRequest, AggregationResult> aggregateProducts() {
return req -> {
try {
SearchResponse<Void> response = esClient.search(s -> s
.index("tenant_" + req.getTenantId() + "_products")
.size(0)
.query(q -> q.range(r -> r.field("price")
.gte(JsonData.of(req.getMinPrice()))
.lte(JsonData.of(req.getMaxPrice()))
))
.aggregations("by_brand", a -> a
.terms(t -> t.field("brand").size(10))
),
Void.class
);
List<BrandCount> brands = response.aggregations().get("by_brand")
.sterms().buckets().array().stream()
.map(b -> new BrandCount(b.key().stringValue(), b.docCount()))
.collect(Collectors.toList());
return new AggregationResult(brands);
} catch (IOException e) {
throw new RuntimeException("聚合分析失败", e);
}
};
}
/**
* 工具4:获取商品详情(单个商品的完整信息)
*/
@Bean
public Function<GetProductRequest, Product> getProductDetail() {
return req -> {
try {
GetResponse<Product> response = esClient.get(g -> g
.index("tenant_" + req.getTenantId() + "_products")
.id(req.getProductId()),
Product.class
);
return response.found() ? response.source() : null;
} catch (IOException e) {
throw new RuntimeException("获取商品失败", e);
}
};
}
}
3.2 搜索 Agent 调度器
// SearchAgentService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class SearchAgentService {
private final ChatClient chatClient;
private static final String AGENT_SYSTEM_PROMPT = """
你是一个专业的商品搜索助手,可以使用以下工具帮助用户找到合适的商品:
1. keywordSearch: 关键词搜索,适合有明确品牌/型号的查询
参数: keyword(必填), maxPrice, minRating, size
2. semanticSearch: 语义搜索,适合描述性需求(如"适合老人用的手机")
参数: query(必填), k
3. aggregateProducts: 品牌分布统计,了解某价位段的品牌分布
参数: minPrice(必填), maxPrice(必填)
4. getProductDetail: 获取单个商品完整信息,用于详细对比
参数: productId(必填)
工作原则:
1. 先理解用户意图,再选择合适的工具组合
2. 如果用户需求模糊,先用关键词搜索,再根据结果调整
3. 对比类需求:先搜索找到候选商品,再 getProductDetail 获取详情
4. 统计类需求:使用 aggregateProducts
5. 最终用自然语言总结结果,突出与用户需求最匹配的推荐
""";
/**
* Agent 执行搜索任务
*
* Spring AI 的 Function Calling 会自动:
* 1. 把工具定义发给 LLM
* 2. LLM 决定调用哪个工具、什么参数
* 3. 执行工具函数,把结果返回给 LLM
* 4. LLM 根据工具结果生成最终回复
* 5. 如果 LLM 认为还需要更多信息,重复 2-4
*/
public String agentSearch(String userTask, String tenantId) {
// 把租户 ID 注入到工具调用上下文(通过 ThreadLocal 或请求头)
TenantContext.setTenantId(tenantId);
try {
return chatClient.prompt()
.system(AGENT_SYSTEM_PROMPT)
.user(userTask)
// 注册所有搜索工具(Spring AI 会自动处理 Function Calling)
.functions("keywordSearch", "semanticSearch",
"aggregateProducts", "getProductDetail")
.call()
.content();
} finally {
TenantContext.clear();
}
}
}
3.3 Agent 实战演示
// 复杂任务示例
String task = """
我想买一台适合摄影爱好者的手机,预算 5000 元以内。
帮我:
1. 找出这个价位段摄影功能强的手机(前 5 名)
2. 分析这个价位段各品牌的产品数量
3. 对比最贵和最便宜的两款,说说性价比
""";
String agentReply = searchAgentService.agentSearch(task, "T001");
// Agent 内部执行过程(LLM 自主决策):
// Round 1: 调用 semanticSearch("摄影功能强 手机", k=5)
// Round 2: 调用 aggregateProducts(minPrice=0, maxPrice=5000)
// Round 3: 调用 getProductDetail("P001") // 最贵的
// Round 4: 调用 getProductDetail("P005") // 最便宜的
// Round 5: 汇总所有结果,生成综合推荐
四、GraphRAG——让检索具备推理能力
普通 RAG 的局限:只能检索直接相关的文档,无法跨文档推理关联关系。
GraphRAG 把知识图谱(实体 + 关系)存入 ES,让检索能沿着关系边推理:
问题:
"有哪些手机在摄影圈口碑好、并且与职业摄影师有合作的品牌出品的?"
普通 RAG:
搜索"摄影口碑好手机" → 返回直接匹配文档
GraphRAG:
1. 识别实体:[手机] [摄影圈口碑] [职业摄影师] [品牌合作]
2. 查知识图谱:哪些品牌与职业摄影师有合作关系?→ [哈苏×一加, 莱卡×华为, 蔡司×vivo]
3. 再查这些品牌的手机:摄影评分 > 4.5 的型号
4. 返回带推理链的结果
// GraphRagService.java
@Service
@RequiredArgsConstructor
public class GraphRagService {
private final ElasticsearchClient esClient;
private final ChatClient chatClient;
/**
* GraphRAG 搜索:利用知识图谱扩展检索范围
*
* 知识图谱存储方式:
* – 实体索引(brand_entities):存品牌、型号、人物等实体
* – 关系索引(brand_relations):存实体间的关系(合作、竞争、衍生等)
*/
public String graphRagSearch(String question, String tenantId) throws IOException {
// Step 1: 用 LLM 提取问题中的关键实体
String entitiesJson = chatClient.prompt()
.system("""
从用户问题中提取关键实体(品牌名、人名、产品类型等)。
只返回 JSON 数组:["实体1", "实体2"]
""")
.user(question)
.call()
.content();
List<String> entities = parseEntities(entitiesJson);
// Step 2: 查知识图谱,沿关系边扩展实体集合
Set<String> expandedEntities = expandEntitiesViaGraph(entities, tenantId);
// Step 3: 用扩展后的实体集合查商品索引
SearchResponse<Product> products = esClient.search(s -> s
.index("tenant_" + tenantId + "_products")
.query(q -> q.bool(b -> {
// 用扩展实体查询(brand 字段)
b.should(sh -> sh.terms(t -> t.field("brand")
.terms(tv -> tv.value(expandedEntities.stream()
.map(FieldValue::of)
.collect(Collectors.toList())))
));
// 同时做语义检索(覆盖实体提取不到的语义)
b.should(sh -> sh.multiMatch(mm -> mm
.query(question)
.fields("name^2", "description")
));
b.minimumShouldMatch("1");
return b;
}))
.size(5),
Product.class
);
// Step 4: 把图谱关系和商品信息一起给 LLM,生成推理回答
String graphContext = buildGraphContext(entities, expandedEntities);
String productContext = buildProductContext(products);
return chatClient.prompt()
.system("""
你是一个商品推荐专家,可以利用品牌关系知识进行推理。
请基于以下知识图谱关系和商品信息,回答用户问题。
在回答中说明推理过程。
""")
.user("""
用户问题:%s
知识图谱关系:
%s
相关商品:
%s
""".formatted(question, graphContext, productContext))
.call()
.content();
}
/**
* 通过关系索引扩展实体集合
* 例如:["哈苏"] → 查关系索引 → ["哈苏", "一加", "OnePlus"](哈苏合作伙伴)
*/
private Set<String> expandEntitiesViaGraph(List<String> entities,
String tenantId) throws IOException {
Set<String> expanded = new HashSet<>(entities);
for (String entity : entities) {
SearchResponse<Map> relations = esClient.search(s -> s
.index("brand_relations")
.query(q -> q.bool(b -> b
.should(sh -> sh.term(t -> t.field("from_entity").value(entity)))
.should(sh -> sh.term(t -> t.field("to_entity").value(entity)))
.minimumShouldMatch("1")
))
.size(10),
(Class<Map>) (Class<?>) Map.class
);
relations.hits().hits().forEach(hit -> {
Map<String, Object> rel = hit.source();
String relationType = (String) rel.get("relation_type");
// 只扩展"合作"类关系(不扩展竞争关系)
if (List.of("COOPERATION", "TECHNOLOGY_LICENSE", "CO_BRAND").contains(relationType)) {
expanded.add((String) rel.get("from_entity"));
expanded.add((String) rel.get("to_entity"));
}
});
}
return expanded;
}
}
五、技术演进路线图(2024-2026)
当前成熟技术(2024)
├── 混合检索(BM25 + kNN)→ 生产主流
├── 基础 RAG → 大规模落地
└── Text2DSL → 快速普及中
近期演进(2024-2025)
├── 多轮对话搜索 → 进入生产
├── 搜索 Agent → 早期落地
├── Reranker(精排模型)→ 搜索精度显著提升
└── 多模态搜索(图文混合)→ 电商场景试点
中期演进(2025-2026)
├── GraphRAG → 复杂推理场景
├── 个性化向量(用户画像 + 实时向量)→ 推荐与搜索融合
├── ES 内置 LLM Inference(不需要外部 API)→ 架构简化
└── Agentic RAG → 自主多步推理搜索
ES 自身路线
├── 8.x: dense_vector + kNN(已成熟)
├── 9.x 预期: 内置 Inference API(直接在 ES 层做 Embedding)
├── 语义重排(Semantic Reranking)→ 召回后精排
└── Learned Sparse Encoding(ELSER)→ 稀疏向量,更快的语义检索
六、系列完整技术栈总结
基础设施层
├── Elasticsearch 8.x(核心引擎)
├── Kibana(可视化 + 管理)
└── Docker Compose / K8s(容器化部署)
Java 技术栈
├── Spring Boot 3.x
├── Spring Data Elasticsearch 5.x
├── Spring AI 1.x(LLM 集成)
├── ElasticsearchClient(新官方客户端)
└── Spring Security(权限控制)
数据管道
├── Canal(MySQL binlog 捕获)
├── Apache Kafka(消息队列)
├── Apache Flink(实时流计算)
└── Logstash(ETL 同步)
AI 层
├── OpenAI / Ollama(Embedding + Chat 模型)
├── BGE-M3(中文向量模型)
├── RAG 架构(检索增强生成)
├── Function Calling(搜索 Agent)
└── Text2DSL(自然语言 → ES DSL)
监控运维
├── Prometheus + Grafana(指标监控)
├── Gatling(压测)
└── ES Rally(ES 基准测试)
📋 系列终篇总结
历经 15 篇,这个系列覆盖了 ES 从入门到前沿 AI 融合的完整知识体系:
| 01-02 | 架构理解 + 环境搭建 | 避开 90% 的入门坑 |
| 03-04 | Mapping + Query DSL | 正确设计数据模型和查询 |
| 05-06 | 中文搜索 + SaaS 多租户 | 商业化落地的核心能力 |
| 07 | 性能调优 | 把系统性能发挥到极限 |
| 08 | AI 向量检索 + RAG | 搜索进入 AI 时代 |
| 09-10 | 数据同步 + 运维 | 生产环境跑稳的保障 |
| 11 | 30 个易错点 | 避免重复踩坑 |
| 12 | 权限体系 | 企业级安全合规 |
| 13 | Kafka + Flink 数据飞轮 | 搜索自我进化 |
| 14 | 压测 + 容量规划 | 用数据驱动基础设施决策 |
| 15 | Text2DSL + Agent + GraphRAG | 搜索智能化的下一站 |
❓ 高频面试 & AI 问答
Q: 什么是 Text2DSL,如何保证 LLM 生成的 DSL 安全可用? A: Text2DSL 是用 LLM 把用户自然语言查询自动转换为 ES Query DSL 的技术。安全保障需要三层:(1)在 Prompt 里明确描述允许的字段和查询类型;(2)解析 LLM 输出后做校验(禁止 script、限制 terms 数量等);(3)强制注入租户 ID 过滤(不依赖 LLM 生成),防止越权查询。
Q: 搜索 Agent 和普通 RAG 有什么区别? A: 普通 RAG 是固定流程(检索 → 生成),搜索 Agent 是动态决策——LLM 自主决定调用哪些工具、以什么顺序、重复几次,直到完成复杂任务。Agent 适合多步骤、需要推理的复杂查询;普通 RAG 适合单次检索 + 生成的标准场景。
Q: 2025 年 ES 在 AI 时代还有价值吗? A: 价值不降反升。大语言模型的核心局限(幻觉、知识截止、私有数据)只能通过检索增强解决,而 ES 是最成熟的混合检索(关键词 + 向量)引擎。未来的趋势是"LLM 是大脑,ES 是记忆"——两者深度融合,而不是谁替代谁。
感谢陪伴这 15 篇的每一位读者。搜索技术的演进不会停歇,欢迎关注持续更新。如果本系列对你有帮助,评论、收藏、分享是对作者最大的鼓励 🙏




