混合检索架构:向量检索 + BM25关键词融合完整方案
混合检索架构:向量检索 + BM25关键词融合完整方案
适读人群:正在做RAG系统、对检索质量不满意的Java工程师 阅读时长:约20分钟
小李的RAG检索翻车事故
小李做了一个法律文书RAG系统,向量检索用的是text-embedding-3-small,milvus存向量,看起来挺像回事。
上线后第一个反馈就让他傻眼了:用户搜"第三百二十一条",什么都没搜到。再试"321条款",还是没有。改成语义化描述"关于财产继承的相关规定",倒是出来了几条,但用户要的根本不是那几条。
小李去查原因:向量模型对精确数字和法条编号的理解很弱,"三百二十一"和"321"在语义空间里距离并不近。同样的问题在产品文档、合同编号、专有名词上都会出现。
然后他来找我,我告诉他:纯向量检索天生就有这个缺陷,解决方案叫混合检索(Hybrid Search)。
这篇文章,我把我们团队打磨了大半年的混合检索方案,完整拆给你看。
向量检索 vs 关键词检索:各自的命门
先把两种检索方式的优缺点说清楚,才能理解为什么要混合。
| 维度 | 向量检索(语义检索) | BM25关键词检索 |
|---|---|---|
| 优势 | 理解同义词、上下文、模糊语义 | 精确匹配、数字/专名/缩写 |
| 劣势 | 对精确词汇不敏感 | 无法理解语义,需要完全匹配 |
| 适合场景 | "帮我找关于退款流程的内容" | "找第321条款" / "找SKU-88776" |
| 召回率 | 语义相关的都能召回 | 只召回包含关键词的文档 |
| 实现复杂度 | 高(需要Embedding模型) | 低(倒排索引即可) |
| 计算开销 | 高(向量距离计算) | 低(TF-IDF统计) |
两种方法互补,混合起来就能覆盖双方的盲区。
混合检索架构全貌
整个流程:双路检索 → RRF融合 → 精排 三阶段。
核心实现代码
依赖配置
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<!-- Milvus向量库 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-milvus-store-spring-boot-starter</artifactId>
</dependency>
<!-- Elasticsearch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<!-- 用于精排的sentence-transformers封装 -->
<dependency>
<groupId>ai.djl.huggingface</groupId>
<artifactId>tokenizers</artifactId>
<version>0.27.0</version>
</dependency>
</dependencies>BM25 Elasticsearch 端的文档索引
@Document(indexName = "knowledge_docs")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KnowledgeDocument {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String content;
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String title;
@Field(type = FieldType.Keyword)
private String documentId;
@Field(type = FieldType.Keyword)
private String chunkIndex;
@Field(type = FieldType.Object)
private Map<String, Object> metadata;
}@Repository
@RequiredArgsConstructor
public class BM25SearchRepository {
private final ElasticsearchOperations elasticsearchOperations;
private final ElasticsearchClient elasticsearchClient;
/**
* BM25关键词检索
* ES默认使用BM25作为相似度算法
*/
public List<ScoredDocument> search(String query, int topK) {
// 构建多字段查询,title权重高于content
Query searchQuery = NativeQuery.builder()
.withQuery(q -> q
.multiMatch(mm -> mm
.query(query)
.fields("title^3", "content^1") // title权重3倍
.type(TextQueryType.BestFields)
.minimumShouldMatch("1")
)
)
.withPageable(PageRequest.of(0, topK))
.withSourceFilter(FetchSourceFilter.of(b -> b.withIncludes("id", "content", "title", "metadata")))
.build();
SearchHits<KnowledgeDocument> hits =
elasticsearchOperations.search(searchQuery, KnowledgeDocument.class);
return hits.stream()
.map(hit -> ScoredDocument.builder()
.id(hit.getId())
.content(hit.getContent().getContent())
.score(hit.getScore() != null ? hit.getScore().doubleValue() : 0.0)
.metadata(hit.getContent().getMetadata())
.source("bm25")
.build())
.collect(Collectors.toList());
}
/**
* 短语精确匹配(处理编号、SKU等精确查询)
*/
public List<ScoredDocument> phraseSearch(String query, int topK) {
Query searchQuery = NativeQuery.builder()
.withQuery(q -> q
.matchPhrase(mp -> mp
.field("content")
.query(query)
.slop(2) // 允许2个词的位置偏移
)
)
.withPageable(PageRequest.of(0, topK))
.build();
SearchHits<KnowledgeDocument> hits =
elasticsearchOperations.search(searchQuery, KnowledgeDocument.class);
return hits.stream()
.map(hit -> ScoredDocument.builder()
.id(hit.getId())
.content(hit.getContent().getContent())
.score(hit.getScore() != null ? hit.getScore().doubleValue() : 0.0)
.metadata(hit.getContent().getMetadata())
.source("phrase")
.build())
.collect(Collectors.toList());
}
}RRF 融合排序算法
RRF(Reciprocal Rank Fusion)是混合检索里最常用的融合方法,原理简单但效果很好。核心公式:
RRF_score = Σ(1 / (k + rank_i)),其中 k 通常取60。
@Component
@Slf4j
public class ReciprockalRankFusion {
/**
* RRF融合多路检索结果
*
* @param resultSets 多路检索结果,key为来源标识
* @param k RRF参数,通常60,值越小对排名靠前的结果越敏感
* @return 融合后的排序结果
*/
public List<FusedDocument> fuse(Map<String, List<ScoredDocument>> resultSets, int k) {
// id -> 融合分数
Map<String, Double> fusedScores = new HashMap<>();
// id -> 文档内容(取第一次出现的)
Map<String, ScoredDocument> docMap = new HashMap<>();
for (Map.Entry<String, List<ScoredDocument>> entry : resultSets.entrySet()) {
String source = entry.getKey();
List<ScoredDocument> results = entry.getValue();
for (int rank = 0; rank < results.size(); rank++) {
ScoredDocument doc = results.get(rank);
String docId = doc.getId();
// RRF核心公式:1/(k+rank+1),rank从0开始所以+1
double rrfScore = 1.0 / (k + rank + 1);
fusedScores.merge(docId, rrfScore, Double::sum);
docMap.putIfAbsent(docId, doc);
log.debug("RRF计算: source={}, docId={}, rank={}, rrfScore={:.4f}",
source, docId, rank + 1, rrfScore);
}
}
// 按融合分数降序排列
return fusedScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.map(entry -> FusedDocument.builder()
.document(docMap.get(entry.getKey()))
.fusedScore(entry.getValue())
.build())
.collect(Collectors.toList());
}
/**
* 带权重的RRF融合(不同来源权重不同)
* 当你对某一路检索更信任时使用
*/
public List<FusedDocument> fuseWithWeights(
Map<String, List<ScoredDocument>> resultSets,
Map<String, Double> weights,
int k) {
Map<String, Double> fusedScores = new HashMap<>();
Map<String, ScoredDocument> docMap = new HashMap<>();
for (Map.Entry<String, List<ScoredDocument>> entry : resultSets.entrySet()) {
String source = entry.getKey();
List<ScoredDocument> results = entry.getValue();
double weight = weights.getOrDefault(source, 1.0);
for (int rank = 0; rank < results.size(); rank++) {
ScoredDocument doc = results.get(rank);
String docId = doc.getId();
double rrfScore = weight * (1.0 / (k + rank + 1));
fusedScores.merge(docId, rrfScore, Double::sum);
docMap.putIfAbsent(docId, doc);
}
}
return fusedScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.map(entry -> FusedDocument.builder()
.document(docMap.get(entry.getKey()))
.fusedScore(entry.getValue())
.build())
.collect(Collectors.toList());
}
}混合检索服务总装
@Service
@RequiredArgsConstructor
@Slf4j
public class HybridSearchService {
private final VectorStore vectorStore; // Spring AI Milvus
private final BM25SearchRepository bm25Repo; // Elasticsearch
private final ReciprockalRankFusion rrfFusion;
private final QueryAnalyzer queryAnalyzer;
private final CrossEncoderRerankService reranker;
// RRF参数,通常60
private static final int RRF_K = 60;
// 召回阶段各路取多少
private static final int RECALL_TOP_K = 20;
// 最终返回给LLM的数量
private static final int FINAL_TOP_K = 5;
/**
* 混合检索主入口
*/
public List<Document> search(String query) {
log.info("开始混合检索: query={}", query);
long startTime = System.currentTimeMillis();
// 1. 查询分析:判断查询意图,决定权重
QueryIntent intent = queryAnalyzer.analyze(query);
log.debug("查询意图分析: {}", intent);
// 2. 并行执行双路检索
CompletableFuture<List<ScoredDocument>> vectorFuture =
CompletableFuture.supplyAsync(() -> doVectorSearch(query, RECALL_TOP_K));
CompletableFuture<List<ScoredDocument>> bm25Future =
CompletableFuture.supplyAsync(() -> doBM25Search(query, RECALL_TOP_K));
List<ScoredDocument> vectorResults;
List<ScoredDocument> bm25Results;
try {
vectorResults = vectorFuture.get(5, TimeUnit.SECONDS);
bm25Results = bm25Future.get(5, TimeUnit.SECONDS);
} catch (Exception e) {
log.error("检索超时或失败", e);
// 降级:只用向量检索
vectorResults = doVectorSearch(query, FINAL_TOP_K);
bm25Results = Collections.emptyList();
}
log.debug("向量检索返回{}条,BM25检索返回{}条", vectorResults.size(), bm25Results.size());
// 3. RRF融合(带权重)
Map<String, List<ScoredDocument>> resultSets = new HashMap<>();
resultSets.put("vector", vectorResults);
resultSets.put("bm25", bm25Results);
// 根据查询意图调整权重
Map<String, Double> weights = buildWeights(intent);
List<FusedDocument> fusedResults = rrfFusion.fuseWithWeights(resultSets, weights, RRF_K);
log.debug("RRF融合后{}条候选文档", fusedResults.size());
// 4. 取Top20送精排
List<FusedDocument> candidatesForRerank = fusedResults.stream()
.limit(20)
.collect(Collectors.toList());
// 5. Cross-Encoder精排
List<Document> finalResults = reranker.rerank(
query,
candidatesForRerank.stream()
.map(fd -> fd.getDocument().toDocument())
.collect(Collectors.toList()),
FINAL_TOP_K
);
long elapsed = System.currentTimeMillis() - startTime;
log.info("混合检索完成: query={}, 返回{}条, 耗时{}ms", query, finalResults.size(), elapsed);
return finalResults;
}
private List<ScoredDocument> doVectorSearch(String query, int topK) {
try {
SearchRequest request = SearchRequest.query(query)
.withTopK(topK)
.withSimilarityThreshold(0.3); // 过滤相似度太低的
return vectorStore.similaritySearch(request).stream()
.map(doc -> ScoredDocument.fromSpringAiDocument(doc, "vector"))
.collect(Collectors.toList());
} catch (Exception e) {
log.error("向量检索失败: {}", e.getMessage());
return Collections.emptyList();
}
}
private List<ScoredDocument> doBM25Search(String query, int topK) {
try {
List<ScoredDocument> results = bm25Repo.search(query, topK);
// 如果查询包含精确模式(数字、编号等),追加短语精确搜索
if (queryAnalyzer.hasExactPattern(query)) {
List<ScoredDocument> phraseResults = bm25Repo.phraseSearch(query, topK / 2);
// 合并,精确匹配结果排在前面
phraseResults.addAll(results);
return phraseResults.stream()
.distinct()
.limit(topK)
.collect(Collectors.toList());
}
return results;
} catch (Exception e) {
log.error("BM25检索失败: {}", e.getMessage());
return Collections.emptyList();
}
}
private Map<String, Double> buildWeights(QueryIntent intent) {
return switch (intent.getType()) {
case EXACT_MATCH -> // 精确数字/编号查询,BM25更重要
Map.of("vector", 0.3, "bm25", 0.7);
case SEMANTIC -> // 语义查询,向量检索更重要
Map.of("vector", 0.7, "bm25", 0.3);
default -> // 混合查询,均等权重
Map.of("vector", 0.5, "bm25", 0.5);
};
}
}查询意图分析:让系统更智能
不同类型的查询,最优权重是不一样的。我们用一个简单的规则引擎来分析意图:
@Component
public class QueryAnalyzer {
// 精确匹配模式:数字编号、SKU、日期等
private static final Pattern EXACT_PATTERN = Pattern.compile(
"[A-Z]{2,}-?\\d{3,}|第[零一二三四五六七八九十百千]+条|\\d{4}-\\d{2}-\\d{2}"
);
// 语义查询特征词
private static final List<String> SEMANTIC_KEYWORDS = List.of(
"相关", "类似", "关于", "有没有", "怎么", "如何", "为什么", "是什么"
);
public QueryIntent analyze(String query) {
QueryIntent intent = new QueryIntent();
intent.setOriginalQuery(query);
// 判断是否包含精确匹配模式
if (EXACT_PATTERN.matcher(query).find()) {
intent.setType(QueryIntent.Type.EXACT_MATCH);
intent.setConfidence(0.9);
return intent;
}
// 判断是否语义查询
long semanticCount = SEMANTIC_KEYWORDS.stream()
.filter(query::contains)
.count();
if (semanticCount >= 1) {
intent.setType(QueryIntent.Type.SEMANTIC);
intent.setConfidence(0.7 + semanticCount * 0.05);
return intent;
}
// 默认混合查询
intent.setType(QueryIntent.Type.MIXED);
intent.setConfidence(0.5);
return intent;
}
public boolean hasExactPattern(String query) {
return EXACT_PATTERN.matcher(query).find();
}
}效果对比与评估
上线混合检索后,我们做了一次系统评估,结果如下:
| 检索方式 | 精确查询召回率 | 语义查询召回率 | 综合MRR | 响应耗时(P95) |
|---|---|---|---|---|
| 纯向量检索 | 47% | 83% | 0.61 | 320ms |
| 纯BM25检索 | 89% | 51% | 0.68 | 85ms |
| 混合检索(无精排) | 91% | 85% | 0.87 | 480ms |
| 混合检索+精排 | 93% | 88% | 0.91 | 650ms |
对于法律、医疗、产品手册这类对精确度要求高的场景,混合检索的收益非常显著。
我踩过的几个坑
坑1:两路结果的分数不可比
向量检索的cosine相似度在01之间,BM25的TF-IDF分数可能是020甚至更高,直接加权平均是错的。RRF只用排名不用分数值,天然解决了这个问题。
坑2:ES中文分词配置坑
一定要安装ik分词器,而且索引时用ik_max_word(细粒度),搜索时用ik_smart(粗粒度),不然精确匹配效果很差。
坑3:精排模型的冷启动问题
Cross-Encoder模型第一次加载很慢(几秒),在生产环境要做预热。我们在应用启动时用一个dummy查询触发模型加载。
坑4:向量维度与模型不匹配
换Embedding模型时一定要重建索引,不同模型输出的维度不一样,混着用会报错。这个问题我被坑了整整一天。
