第1839篇:混合检索的工程实践——BM25与向量检索融合,让RAG召回更准
第1839篇:混合检索的工程实践——BM25与向量检索融合,让RAG召回更准
适读人群:正在优化RAG系统召回效果的工程师 | 阅读时长:约18分钟 | 核心价值:理解混合检索的原理与实现,避免纯向量检索的常见缺陷
上个月一个做法律AI的朋友找我咨询,说他们的RAG系统有个让人抓狂的问题:用户问"《劳动合同法》第47条说了什么",系统给出的答案经常是相关的劳动法内容,但就是不是第47条的原文。
我问他用的什么检索方式。他说纯向量检索。
我说,那就是问题所在了。
"第47条"这个关键词是精确匹配的需求,向量检索不擅长干这件事。向量空间里,"第47条"和"第46条""第48条"的语义距离可能很近,模型根本区分不了你要的是哪一条。
这种情况需要混合检索(Hybrid Search)——把向量的语义理解能力,和传统全文检索的关键词精确匹配能力结合起来。
为什么单独用向量检索不够
向量检索的核心假设是:语义相似的文本,在向量空间里距离也近。
这个假设在大多数情况下成立,所以向量检索能处理"查询词和文档用了不同表达方式但意思一样"的情况,比如"如何提高工作效率"和"怎么让工作更高效"能被正确匹配。
但有些查询,语义相似性帮不上忙:
- 精确实体名称:法条编号、产品型号、人名、公司名
- 缩写和专有名词:RAG、LLM、GPT-4o、Spring Boot
- 特定数字:订单号、日期、金额
- 罕见词:偏专业的术语,Embedding模型可能对这类词的表示不够准确
反过来,BM25(或者说TF-IDF类的全文检索)在这些场景下更可靠,因为它是基于词频和逆文档频率的统计方法,对关键词完全匹配天然更敏感。
但BM25对同义词替换无能为力,"效率"和"高效"对它来说是完全不同的词。
两者的能力互补,混合起来才是最健壮的方案。
Hybrid Search的核心问题:怎么融合两路分数
两路检索各自返回了一组结果,每个结果有一个分数(向量相似度,或者BM25分值),现在要把它们融合成一个统一的排序。
这是混合检索里最关键也最容易被忽视的问题。
直接相加的问题: 向量相似度通常在0到1之间,BM25分值可能是0到几十甚至几百,量纲完全不同。直接相加,BM25会完全主导结果。
归一化相加: 把每路分数归一化到[0,1],然后加权求和。但归一化依赖于当前批次的最大值,如果某次查询的BM25最高分特别高,会压低其他所有结果的得分,不同查询间的分数不可比。
RRF(Reciprocal Rank Fusion)——当前最推荐的方案:
RRF不用分数本身,只用排名。每个文档的最终分数是:
RRF分数 = sum(1 / (k + rank_in_list_i))其中 k 是一个常数(通常取60),rank_in_list_i 是该文档在第i路检索结果中的排名(从1开始)。
public class RRFFusion {
private static final int K = 60; // RRF超参数
/**
* 融合多路检索结果
* @param rankedLists 每路检索的有序结果(每路已按相关性排好序)
* @param topK 最终返回多少条
*/
public List<FusedDocument> fuse(
List<List<RankedDocument>> rankedLists,
int topK) {
Map<String, Double> rrfScores = new HashMap<>();
Map<String, FusedDocument> docMap = new HashMap<>();
for (List<RankedDocument> rankedList : rankedLists) {
for (int rank = 0; rank < rankedList.size(); rank++) {
RankedDocument doc = rankedList.get(rank);
String docId = doc.getId();
// RRF公式:1 / (k + rank+1),rank从0开始所以+1
double rrfScore = 1.0 / (K + rank + 1);
rrfScores.merge(docId, rrfScore, Double::sum);
docMap.putIfAbsent(docId, new FusedDocument(doc));
}
}
// 按RRF分数降序排列,取前topK
return rrfScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(entry -> {
FusedDocument fusedDoc = docMap.get(entry.getKey());
fusedDoc.setFusedScore(entry.getValue());
return fusedDoc;
})
.collect(Collectors.toList());
}
}RRF的优点是:不需要对分数做归一化,计算简单,对"某路结果分数方差大"的情况鲁棒性强。
完整实现:Spring Boot里的混合检索
下面是一个可以直接落地的混合检索实现,向量库用Qdrant,全文检索用Elasticsearch:
依赖配置:
<!-- Elasticsearch Java客户端 -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.11.0</version>
</dependency>
<!-- Qdrant Java客户端 -->
<dependency>
<groupId>io.qdrant</groupId>
<artifactId>client</artifactId>
<version>1.7.0</version>
</dependency>混合检索服务:
@Service
@Slf4j
public class HybridSearchService {
private final QdrantClient qdrantClient;
private final ElasticsearchClient esClient;
private final EmbeddingModel embeddingModel;
private final RRFFusion rrfFusion;
private static final String QDRANT_COLLECTION = "knowledge_base";
private static final String ES_INDEX = "knowledge_base";
/**
* 混合检索入口
* @param query 用户查询
* @param topK 每路各取多少条候选,最终融合后返回topK条
*/
public List<Document> hybridSearch(String query, int topK) {
int candidateCount = topK * 3; // 每路多取一些,给融合留空间
// 并发执行两路检索
CompletableFuture<List<RankedDocument>> vectorFuture =
CompletableFuture.supplyAsync(() -> vectorSearch(query, candidateCount));
CompletableFuture<List<RankedDocument>> bm25Future =
CompletableFuture.supplyAsync(() -> bm25Search(query, candidateCount));
try {
List<RankedDocument> vectorResults = vectorFuture.get(5, TimeUnit.SECONDS);
List<RankedDocument> bm25Results = bm25Future.get(5, TimeUnit.SECONDS);
log.debug("向量检索返回{}条,BM25检索返回{}条",
vectorResults.size(), bm25Results.size());
// RRF融合
List<FusedDocument> fusedResults = rrfFusion.fuse(
Arrays.asList(vectorResults, bm25Results),
topK
);
return fusedResults.stream()
.map(FusedDocument::toDocument)
.collect(Collectors.toList());
} catch (TimeoutException e) {
log.warn("混合检索超时,降级为纯向量检索");
return vectorSearch(query, topK).stream()
.map(RankedDocument::toDocument)
.collect(Collectors.toList());
} catch (Exception e) {
log.error("混合检索失败", e);
throw new SearchException("检索失败:" + e.getMessage(), e);
}
}
private List<RankedDocument> vectorSearch(String query, int topK) {
// 1. 先向量化查询
float[] queryEmbedding = embeddingModel.embed(query);
// 2. 向量检索
List<ScoredPoint> points = qdrantClient.searchAsync(
SearchPoints.newBuilder()
.setCollectionName(QDRANT_COLLECTION)
.addAllVector(toFloatList(queryEmbedding))
.setLimit(topK)
.setWithPayload(WithPayloadSelector.newBuilder()
.setEnable(true).build())
.build()
).join();
List<RankedDocument> results = new ArrayList<>();
for (int i = 0; i < points.size(); i++) {
ScoredPoint point = points.get(i);
RankedDocument doc = RankedDocument.builder()
.id(point.getId().getUuid())
.content(point.getPayload().get("content").getStringValue())
.metadata(extractMetadata(point.getPayload()))
.score(point.getScore())
.rank(i)
.source("vector")
.build();
results.add(doc);
}
return results;
}
private List<RankedDocument> bm25Search(String query, int topK) {
try {
SearchResponse<Map> response = esClient.search(s -> s
.index(ES_INDEX)
.query(q -> q
.multiMatch(m -> m
.query(query)
.fields("content", "title^2") // title字段权重×2
.type(TextQueryType.BestFields)
.fuzziness("AUTO") // 允许一定的模糊匹配
)
)
.size(topK),
Map.class
);
List<RankedDocument> results = new ArrayList<>();
List<Hit<Map>> hits = response.hits().hits();
for (int i = 0; i < hits.size(); i++) {
Hit<Map> hit = hits.get(i);
Map source = hit.source();
RankedDocument doc = RankedDocument.builder()
.id(hit.id())
.content((String) source.get("content"))
.metadata(extractMetadata(source))
.score(hit.score() != null ? hit.score() : 0.0)
.rank(i)
.source("bm25")
.build();
results.add(doc);
}
return results;
} catch (IOException e) {
log.error("Elasticsearch检索失败", e);
return Collections.emptyList();
}
}
}文档索引:要同时往两个地方写
用混合检索,就需要在文档入库的时候,同时把数据写进向量库和ES:
@Service
public class DocumentIndexService {
private final QdrantClient qdrantClient;
private final ElasticsearchClient esClient;
private final EmbeddingModel embeddingModel;
@Transactional
public void indexDocument(DocumentToIndex doc) {
// 1. 生成向量
float[] embedding = embeddingModel.embed(doc.getContent());
// 2. 写入Qdrant(向量检索用)
indexToQdrant(doc, embedding);
// 3. 写入Elasticsearch(BM25检索用)
indexToElasticsearch(doc);
log.info("文档{}已写入向量库和ES索引", doc.getId());
}
private void indexToQdrant(DocumentToIndex doc, float[] embedding) {
qdrantClient.upsertAsync(
"knowledge_base",
Collections.singletonList(
PointStruct.newBuilder()
.setId(PointId.newBuilder().setUuid(doc.getId()).build())
.setVectors(buildVectors(embedding))
.putPayload("content", stringValue(doc.getContent()))
.putPayload("title", stringValue(doc.getTitle()))
.putPayload("doc_id", stringValue(doc.getDocumentId()))
.putPayload("source", stringValue(doc.getSource()))
.build()
)
).join();
}
private void indexToElasticsearch(DocumentToIndex doc) {
Map<String, Object> esDoc = new HashMap<>();
esDoc.put("content", doc.getContent());
esDoc.put("title", doc.getTitle());
esDoc.put("doc_id", doc.getDocumentId());
esDoc.put("source", doc.getSource());
esDoc.put("indexed_at", Instant.now().toString());
try {
esClient.index(i -> i
.index("knowledge_base")
.id(doc.getId())
.document(esDoc)
);
} catch (IOException e) {
throw new IndexException("ES索引失败: " + e.getMessage(), e);
}
}
public void deleteDocument(String docId) {
// 删除时也要两边同步删
qdrantClient.deleteAsync(
"knowledge_base",
PointsSelector.newBuilder()
.setFilter(Filter.newBuilder()
.addMust(matchFieldCondition("doc_id", docId))
.build())
.build()
).join();
try {
esClient.deleteByQuery(d -> d
.index("knowledge_base")
.query(q -> q.term(t -> t.field("doc_id").value(docId)))
);
} catch (IOException e) {
log.error("ES删除失败,可能造成数据不一致: docId={}", docId, e);
// 这里要有告警机制,数据不一致要及时修复
}
}
}按查询类型动态调整权重
有了两路检索之后,一个更高级的优化是:根据查询的性质,动态调整RRF中各路的权重。
比如,当查询里包含精确的编号、型号等关键词,BM25的权重应该更高;当查询是模糊的语义查询,向量检索的权重应该更高。
@Component
public class AdaptiveHybridSearchService {
private final HybridSearchService hybridSearchService;
private final QueryAnalyzer queryAnalyzer;
public List<Document> search(String query, int topK) {
QueryCharacteristics characteristics = queryAnalyzer.analyze(query);
// 根据查询特征确定检索策略
SearchStrategy strategy = determineStrategy(characteristics);
return switch (strategy) {
case EXACT_FIRST -> hybridSearchService.hybridSearch(
query, topK, 0.3, 0.7); // BM25权重更高
case SEMANTIC_FIRST -> hybridSearchService.hybridSearch(
query, topK, 0.7, 0.3); // 向量权重更高
case BALANCED -> hybridSearchService.hybridSearch(
query, topK, 0.5, 0.5); // 平衡
};
}
private SearchStrategy determineStrategy(QueryCharacteristics chars) {
// 包含法条编号、条款编号
if (chars.hasLegalReference()) return SearchStrategy.EXACT_FIRST;
// 包含产品型号、编码等精确标识符
if (chars.hasExactIdentifier()) return SearchStrategy.EXACT_FIRST;
// 纯自然语言的语义查询
if (chars.isPureSemanticQuery()) return SearchStrategy.SEMANTIC_FIRST;
return SearchStrategy.BALANCED;
}
}
@Component
public class QueryAnalyzer {
// 法条格式:第X条、§X、Article X
private static final Pattern LEGAL_REFERENCE =
Pattern.compile("第[零一二三四五六七八九十百千\\d]+条|§\\d+|Article\\s+\\d+");
// 精确标识符:大写字母+数字组合,或者包含连字符的编码
private static final Pattern EXACT_IDENTIFIER =
Pattern.compile("[A-Z]{1,5}[-_]?\\d{2,6}|\\b[A-Z0-9]{4,}\\b");
public QueryCharacteristics analyze(String query) {
return QueryCharacteristics.builder()
.hasLegalReference(LEGAL_REFERENCE.matcher(query).find())
.hasExactIdentifier(EXACT_IDENTIFIER.matcher(query).find())
.isPureSemanticQuery(query.length() > 15 && !query.matches(".*[A-Z0-9_-]{4,}.*"))
.queryLength(query.length())
.build();
}
}效果评估:怎么知道混合检索比单路检索好多少
引入混合检索之前和之后,用同一批测试查询跑对比,是必须做的事。
我常用的评估指标是 Recall@K(前K条结果里,有多少包含了正确答案):
@Service
public class RetrievalEvaluator {
public EvaluationReport evaluate(
List<EvaluationCase> cases,
SearchService searchService,
int k) {
int totalCases = cases.size();
int hitCount = 0;
List<String> missedCases = new ArrayList<>();
for (EvaluationCase testCase : cases) {
List<Document> results = searchService.search(testCase.getQuery(), k);
boolean hit = results.stream()
.anyMatch(doc -> testCase.getExpectedDocIds()
.contains(doc.getMetadata().get("doc_id")));
if (hit) {
hitCount++;
} else {
missedCases.add(testCase.getQuery());
}
}
double recall = (double) hitCount / totalCases;
return EvaluationReport.builder()
.totalCases(totalCases)
.hitCount(hitCount)
.recall(recall)
.missedCases(missedCases)
.build();
}
}混合检索不是银弹,维护两套索引有额外的运维成本,也需要保证两边数据的一致性。但对于召回质量要求高的RAG系统,这个投入是值得的。
从我的实际经验来看,相比纯向量检索,混合检索在精确实体查询上的召回率通常能提升20-40%,而在语义查询上基本持平甚至略有提升(因为BM25有时候能发现向量检索遗漏的精确匹配结果)。
