知识库-向量化功能-混合查询
一、功能目标
基于 Elasticsearch 8.8+ 实现 BM25(关键词检索)+ 向量相似度(kNN) 混合检索,通过 Rank Fusion(RRF)算法融合两路结果,解决单一检索方式的局限性:
- 纯关键词检索(BM25):易遗漏语义相关但关键词不匹配的文档;
- 纯向量检索(kNN):对关键词精准匹配场景效果弱;
最终提升召回文档的准确率,优化 RAG(检索增强生成)输出质量。
二、核心原理
| 检索方式 | 算法核心 | 适用场景 | 局限性 |
|---|
| BM25 | 基于词频、文档长度的关键词相关性计算(改进版 TF-IDF) | 关键词精准匹配、结构化文本检索 | 无法理解语义,易受同义词/近义词影响 |
| kNN(向量检索) | 基于向量空间距离(余弦/欧氏)的语义相似度计算 | 语义相似检索、模糊匹配 | 对关键词精准匹配不敏感,计算成本较高 |
| RRF 融合 | 无需调参的排序融合算法,公式:1/(k + rank)(k 为常数) | 平衡关键词与语义检索结果 | 自动加权,两路结果排名越靠前,融合得分越高 |
三、核心实现代码
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class HybridSearchService {
private final ElasticsearchClient client;
private final EmbeddingModel embeddingModel;
private static final int K = 20;
private static final int FINAL_K = 5;
private static final int RRF_RANK_CONSTANT = 60;
private static final int KNN_NUM_CANDIDATES = 100;
public HybridSearchService(ElasticsearchClient client, EmbeddingModel embeddingModel) {
this.client = client;
this.embeddingModel = embeddingModel;
}
public static class DocFragment {
private String text;
private String docId;
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public String getDocId() { return docId; }
public void setDocId(String docId) { this.docId = docId; }
}
private static class HitWithScore {
String id;
Double score;
DocFragment doc;
HitWithScore(String id, Double score, DocFragment doc) {
this.id = id;
this.score = score;
this.doc = doc;
}
}
public List<DocFragment> hybridSearch(String index, String queryText) throws Exception {
SearchResponse<DocFragment> bm25Resp = client.search(
s -> s.index(index)
.query(q -> q.match(m -> m
.field("text")
.query(queryText)
.fuzziness("AUTO")
))
.size(K),
DocFragment.class
);
float[] queryVector = embeddingModel.embed(queryText);
List<Float> floatList = new ArrayList<>(queryVector.length);
for (float f : queryVector) {
floatList.add(f);
}
SearchResponse<DocFragment> knnResp = client.search(
s -> s.index(index)
.knn(k -> k
.field("vector")
.queryVector(floatList)
.k(K)
.numCandidates(100)
)
.size(K),
DocFragment.class
);
Map<String, Double> rrfScores = new HashMap<>();
Map<String, DocFragment> allDocs = new HashMap<>();
List<HitWithScore> bm25Hits = convertToHitWithScore(bm25Resp);
for (int rank = 0; rank < bm25Hits.size(); rank++) {
HitWithScore hit = bm25Hits.get(rank);
double rrfScore = 1.0 / (RRF_RANK_CONSTANT + rank + 1);
rrfScores.merge(hit.id, rrfScore, Double::sum);
allDocs.putIfAbsent(hit.id, hit.doc);
}
List<HitWithScore> knnHits = convertToHitWithScore(knnResp);
for (int rank = 0; rank < knnHits.size(); rank++) {
HitWithScore hit = knnHits.get(rank);
double rrfScore = 1.0 / (RRF_RANK_CONSTANT + rank + 1);
rrfScores.merge(hit.id, rrfScore, Double::sum);
allDocs.putIfAbsent(hit.id, hit.doc);
}
return rrfScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(FINAL_K)
.map(entry -> allDocs.get(entry.getKey()))
.collect(Collectors.toList());
}
private List<HitWithScore> convertToHitWithScore(SearchResponse<DocFragment> response) {
if (response == null || response.hits() == null || response.hits().hits() == null) {
return Collections.emptyList();
}
return response.hits().hits().stream()
.map(hit -> new HitWithScore(hit.id(), hit.score(), hit.source()))
.collect(Collectors.toList());
}
}
四、混合查询 vs vectorStore.similaritySearch()
4.1 核心差异对比表
| 维度 | 混合查询(BM25 + kNN + RRF) | vectorStore.similaritySearch()(Spring AI 内置) |
|---|
| 检索方式 | 多策略融合(关键词+语义) | 单一向量检索(仅kNN) |
| 算法核心 | BM25 + kNN + RRF 融合排序 | 仅向量相似度排序(cosine/l2_norm/dot_product) |
| 适用场景 | 通用检索、复杂语义+关键词混合需求、高召回率场景 | 纯语义检索、简单相似性匹配、快速开发场景 |
| 召回精度 | 高(兼顾关键词和语义,减少漏检/误检) | 中(易遗漏关键词精准匹配的文档) |
| 性能开销 | 较高(两次检索+融合计算) | 较低(单次kNN检索) |
| 灵活性 | 高(可调整K值、RRF常数、是否开启模糊匹配) | 低(封装度高,仅支持少量参数配置) |
| ES版本要求 | 8.8+(需支持RRF/knn查询) | 8.x均可(仅依赖向量字段) |
| 代码复杂度 | 较高(需手动处理两路检索+融合) | 极低(一行代码调用,无需关注底层) |
4.2 详细说明
(1)vectorStore.similaritySearch() 特点
Spring AI 封装的向量检索方法,核心逻辑:
List<Document> results = vectorStore.similaritySearch(
Query.query(queryText)
.withTopK(5)
);
- 优势:开箱即用,无需关注ES底层语法,适合快速开发;
- 局限:
- 仅基于向量相似度检索,无法利用关键词匹配;
- 对“语义相似但关键词不匹配”的场景友好,但对“关键词精准匹配但语义模糊”的场景(如专业术语检索)效果差;
- 无结果融合能力,召回结果单一。
(2)混合查询特点
- 优势:
- 兼顾“关键词精准匹配”和“语义相似”,召回结果更全面;
- RRF融合无需手动调参(如加权系数),适配性强;
- 可灵活扩展(如增加模糊匹配、过滤条件、自定义排序);
- 局限:
- 需手动编写ES检索逻辑,代码量较大;
- 两次检索增加ES查询开销,需合理设置K值(避免过大);
- 依赖ES 8.8+版本的kNN和RRF特性。
4.3 选型建议
| 场景 | 推荐方案 |
|---|
| 快速原型开发、纯语义检索需求 | vectorStore.similaritySearch() |
| 生产环境、通用检索、高召回率要求 | 混合查询(BM25 + kNN + RRF) |
| 专业术语检索、关键词精准匹配为主 | 混合查询(可加大BM25权重) |
| 模糊语义检索、无明确关键词 | vectorStore.similaritySearch()(或混合查询加大kNN权重) |
五、核心配置说明
| 配置项 | 取值建议 | 作用 |
|---|
| K(单路召回数量) | 10-50 | 单路召回越多,融合结果越全面,但性能开销越大 |
| FINAL_K(最终返回数量) | 3-10 | 适配LLM上下文窗口大小(避免输入过长) |
| RRF_RANK_CONSTANT | 50-100 | 经验值,数值越小,排名靠前的文档得分权重越高 |
| KNN_NUM_CANDIDATES | 100-200 | kNN候选集数量,越大召回精度越高,但查询耗时越长 |
| 模糊匹配(fuzziness) | AUTO | 可选开启,容错拼写错误(如“检索”→“搜素”) |
六、使用示例
6.1 基础调用
@Autowired
private HybridSearchService hybridSearchService;
public void testHybridSearch() {
try {
String indexName = "ai_vector_index";
String queryText = "Spring AI 向量检索使用方法";
List<HybridSearchService.DocFragment> results = hybridSearchService.hybridSearch(indexName, queryText);
for (HybridSearchService.DocFragment doc : results) {
System.out.printf("文档ID:%s,内容:%s%n", doc.getDocId(), doc.getText());
}
} catch (Exception e) {
System.err.println("混合检索失败:" + e.getMessage());
}
}
6.2 结合RAG场景
@Autowired
private HybridSearchService hybridSearchService;
@Autowired
private ChatClient chatClient;
public String ragAnswer(String userQuestion) {
try {
List<HybridSearchService.DocFragment> docs = hybridSearchService.hybridSearch("ai_vector_index", userQuestion);
String context = docs.stream()
.map(HybridSearchService.DocFragment::getText)
.collect(Collectors.joining("\n"));
String prompt = String.format("基于以下上下文回答问题:%n%s%n问题:%s", context, userQuestion);
return chatClient.call(prompt);
} catch (Exception e) {
return "检索失败:" + e.getMessage();
}
}
七、注意事项
- ES索引配置:需确保向量字段
vector的dimension与嵌入模型一致(如768),similarity设置为cosine;
- 性能优化:
- 合理设置K值(建议20),避免单路召回过多;
- 为ES索引的
text字段配置分词器(如IK分词器),提升BM25检索效果;
- 开启ES缓存,减少重复检索开销;
- 异常处理:增加ES连接超时、模型调用失败的容错逻辑;
- 监控指标:统计BM25/kNN召回文档的重合率、融合后Top-N的准确率,便于调参;
- 扩展能力:可增加过滤条件(如按文档ID/时间筛选)、自定义加权(如对重要文档提升分数)。
八、扩展建议
- 多字段检索:BM25检索可扩展为多字段(如
text+docId+title),提升关键词匹配维度;
- 动态权重:根据检索场景动态调整RRF常数(如专业术语检索减小RRF常数,强化BM25权重);
- 缓存优化:对高频检索词的混合结果进行缓存,减少重复计算;
- 异步检索:BM25和kNN检索异步执行,减少总耗时;
- 结果去重:融合前对BM25/kNN结果去重(如按文档内容相似度),避免重复文档。