请输入密码以查看内容。
检索增强生成(RAG)已经成为构建智能问答、知识库助手的核心技术。然而,一个基础的 RAG 系统在面对复杂业务场景时,其检索模块的准确性往往成为瓶颈。
混合检索是提升召回率和相关性的第一道防线。我们将在 Elasticsearch 中同时执行 BM25 关键词搜索和向量搜索,并融合其结果。
首先,在你的 pom.xml
中加入 Elasticsearch Java 客户端的依赖:
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.14.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
</dependency>
假设我们的索引 documents
中包含 title
、content
(text类型) 和 content_vector
(dense_vector类型) 字段。
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch._types.query_dsl.QueryBuilders;
import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse;
// ... 假设 esClient 已经注入
public SearchResponse<MyDocument> hybridSearch(String userQuery, float[] queryVector) throws IOException {
// 1. 构建关键词查询 (BM25)
Query keywordQuery = QueryBuilders.multiMatch(m -> m
.query(userQuery)
.fields("title^2", "content") // 标题权重加倍
.fuzziness("AUTO")
);
// 2. 构建向量查询 (k-NN)
Query vectorQuery = QueryBuilders.knn(k -> k
.field("content_vector")
.queryVector(queryVector)
.k(10)
.numCandidates(50)
);
// 3. 将两者组合在一个查询中
// 注意: Elasticsearch 8.4+ 直接支持混合检索。对于旧版本,可能需要两次查询后在客户端融合。
// 这里展示的是 8.4+ 的原生混合查询方式。
SearchRequest request = new SearchRequest.Builder()
.index("documents")
.query(keywordQuery) // BM25 作为主查询
.knn(k -> k // k-NN 作为补充
.field("content_vector")
.queryVector(queryVector)
.k(10)
.numCandidates(50)
.boost(0.5f) // 可选:给向量搜索结果一个权重
)
.build();
// 对于更灵活的融合(如 RRF),你也可以分开执行两个查询,然后在Java代码中融合结果。
// RRF (Reciprocal Rank Fusion) 是一种更高级的客户端融合策略。
return esClient.search(request, MyDocument.class);
}
代码解析:
multiMatch
查询来进行关键词搜索,并使用 title^2
实现了标题加权。knn
查询来进行向量搜索。queryVector
需要通过一个 Embedding 模型服务(如 OpenAI、Hugging Face)提前生成。query
和 knn
放在同一个请求中,ES 会自动进行分数融合。混合搜索召回了 Top-N 个候选文档,现在我们用 Reranker 模型给它们打一个更准的分数。Reranker 通常作为一个独立的微服务存在。
我们将模拟一个 API 调用,将查询和候选文档发送给 Reranker 服务。
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.stream.Collectors;
// ...
public List<MyDocument> rerank(String userQuery, List<MyDocument> initialResults) {
// Reranker 服务地址
String rerankerApiEndpoint = "http://your-reranker-service.com/rerank";
// 构建请求体
// 格式: [{"query": "user query", "text": "document content 1"}, {"query": "...", "text": "..."}]
String requestBody = initialResults.stream()
.map(doc -> String.format("{\"query\": \"%s\", \"text\": \"%s\"}",
escapeJson(userQuery),
escapeJson(doc.getContent())))
.collect(Collectors.joining(",", "[", "]"));
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(rerankerApiEndpoint))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
// 解析 Reranker 返回的分数,并对 initialResults 进行重排序
// 假设返回格式: [0.98, 0.23, 0.75, ...]
List<Double> scores = parseScores(response.body());
for (int i = 0; i < initialResults.size(); i++) {
initialResults.get(i).setRerankScore(scores.get(i));
}
// 按 rerankScore 降序排序
initialResults.sort((d1, d2) -> Double.compare(d2.getRerankScore(), d1.getRerankScore()));
return initialResults;
} catch (Exception e) {
// 异常处理:可选择返回原始结果或抛出异常
e.printStackTrace();
return initialResults;
}
}
// 辅助方法
private String escapeJson(String text) {
return text.replace("\"", "\\\"");
}
private List<Double> parseScores(String jsonResponse) {
// 使用 Jackson 或 Gson 等库来解析 JSON 数组
// ... 实现略
}
代码解析:
除了 Reranker,我们还可以利用 Elasticsearch 的内置功能进一步优化排序和用户体验。
对于新闻、日志等时效性强的文档,新发布的应该排名更靠前。
// ...
public SearchResponse<MyDocument> searchWithDateDecay(String userQuery) throws IOException {
Query matchQuery = QueryBuilders.match(m -> m.field("content").query(userQuery));
// 构建 Function Score Query
Query functionScoreQuery = QueryBuilders.functionScore(fs -> fs
.query(matchQuery) // 基础查询
.functions(f -> f
.gauss(g -> g // 使用高斯衰减函数
.field("publish_date") // 作用于日期字段
.origin("now") // 以当前时间为中心
.scale("30d") // 30天后,分数衰减到约0.6
.offset("7d") // 7天内,分数不衰减
.decay(0.5)
)
)
.boostMode("multiply") // 将衰减分数与原始分数相乘
);
SearchRequest request = SearchRequest.of(s -> s
.index("documents")
.query(functionScoreQuery)
);
return esClient.search(request, MyDocument.class);
}
代码解析:
function_score
查询,它允许我们修改由主查询 matchQuery
计算出的 _score
。gauss
函数定义了一个衰减曲线:离 now
(当前时间)越远的文档,其分数衰减得越厉害。让用户快速定位到文档中的匹配项。
// ...
public SearchResponse<MyDocument> searchWithHighlight(String userQuery) throws IOException {
SearchRequest request = SearchRequest.of(s -> s
.index("documents")
.query(q -> q.match(m -> m.field("content").query(userQuery)))
.highlight(h -> h
.fields("content", f -> f // 对 content 字段进行高亮
.preTags("<mark>") // 设置高亮前缀
.postTags("</mark>") // 设置高亮后缀
)
)
);
SearchResponse<MyDocument> response = esClient.search(request, MyDocument.class);
// 从 response 中提取高亮片段并附加到结果对象上
response.hits().hits().forEach(hit -> {
if (hit.highlight() != null && hit.highlight().containsKey("content")) {
hit.source().setHighlightedContent(hit.highlight().get("content").get(0));
}
});
return response;
}
代码解析:
.highlight()
部分,指定要高亮的字段和包裹的 HTML 标签。hit.highlight()
中返回,你需要将其取出并附加到你的数据对象上,以便前端渲染。根据场景提供不同精度的搜索。
// ...
// 宽松匹配 (Match)
public Query createMatchQuery(String userQuery) {
return QueryBuilders.match(m -> m
.field("content")
.query(userQuery)
);
}
// 短语匹配 (Match Phrase)
public Query createMatchPhraseQuery(String userQuery) {
return QueryBuilders.matchPhrase(mp -> mp
.field("content")
.query(userQuery)
.slop(1) // 允许词之间有1个词的间隔,增加灵活性
);
}
代码解析:
match
查询会将 “quick brown fox” 分词,并查找包含 quick
或 brown
或 fox
的文档。match_phrase
查询会严格查找 “quick brown fox” 这个连续的短语。slop
参数可以增加一些灵活性。动态词库的实现偏向于架构设计。一个常见的模式是:
_reload_search_analyzers
API 让索引加载最新的同义词典,无需停机。在 Java 端,我们主要关注如何使用这个已经配置好的同义词分析器。当你为字段配置了同义词分析器后,无需在查询时做任何特殊操作,ES 会自动进行同义词扩展。
例如,如果词库里有 ai -> 人工智能
,那么搜索 ai
时,ES 会自动扩展为搜索 (ai OR 人工智能)
。
现在,我们将所有部分串联起来,形成一个完整的检索流程。
public class AdvancedRagService {
private ElasticsearchClient esClient;
private EmbeddingServiceClient embeddingClient; // 模拟的向量生成服务
private RerankerClient rerankerClient; // 模拟的Reranker服务
private LlmClient llmClient; // 模拟的大模型服务
// ... 构造函数注入依赖
public String answer(String userQuery) {
// 1. 生成查询向量
float[] queryVector = embeddingClient.generateVector(userQuery);
// 2. 执行混合搜索 (包含标题加权、日期衰减等)
SearchResponse<MyDocument> searchResponse = searchWithOptimizations(userQuery, queryVector);
List<MyDocument> initialDocs = searchResponse.hits().hits().stream()
.map(Hit::source).collect(Collectors.toList());
// 3. Reranker 精排
List<MyDocument> rerankedDocs = rerankerClient.rerank(userQuery, initialDocs);
// 4. 提取 Top-K 文档作为上下文
List<String> contextSnippets = rerankedDocs.stream()
.limit(3) // 取最相关的3个文档
.map(MyDocument::getContent)
.collect(Collectors.toList());
// 5. 构建 Prompt 并调用 LLM
String context = String.join("\n---\n", contextSnippets);
String prompt = String.format(
"基于以下信息回答问题: \"%s\". 信息: %s",
userQuery,
context
);
return llmClient.generateAnswer(prompt);
}
private SearchResponse<MyDocument> searchWithOptimizations(String userQuery, float[] queryVector) {
// ... 此处整合 Section 2 和 Section 4 的查询构建逻辑 ...
// 返回一个包含混合搜索、日期衰减、高亮等功能的复杂查询结果
}
}