第2117篇:混合检索的工程实践——当向量搜索和关键词搜索结合在一起
第2117篇:混合检索的工程实践——当向量搜索和关键词搜索结合在一起
适读人群:构建RAG和搜索系统的工程师 | 阅读时长:约19分钟 | 核心价值:理解向量搜索和BM25的互补性,掌握混合检索的实现方案和分数融合策略
向量搜索很强,但有一个明显的弱点:它对精确匹配不敏感。
比如用户搜索"GPT-4o-mini的价格",向量搜索可能返回很多关于"大模型定价"的通用文章,而不是那篇明确写了"GPT-4o-mini: $0.00015/1K input tokens"的文档。这是因为向量表达的是语义,而"GPT-4o-mini"这个具体的产品名,在向量空间里可能和其他模型距离都差不多。
BM25(传统关键词搜索)在这种场景里表现很好:它会精确匹配"GPT-4o-mini"这个词,给包含这个词的文档更高权重。
混合检索就是把两种方法的优势结合起来。
为什么需要混合检索
/**
* 向量搜索 vs BM25 各自的强弱项
*
* ===== 向量搜索擅长 =====
*
* ✓ 语义理解:
* 查询"心情不好"能找到关于"情绪管理"的文章
* 即使文章里完全没有"心情不好"这个词
*
* ✓ 同义词/近义词:
* 查询"汽车"能找到说"轿车"的文档
*
* ✓ 跨语言查询(多语言模型):
* 中文查询能找到英文文档里的相关内容
*
* ===== 向量搜索的弱项 =====
*
* ✗ 精确词匹配:
* 型号名、专有名词、代码片段、数字
* "Python 3.11.2的新特性"——版本号很重要,但向量可能忽略
*
* ✗ 稀有词汇:
* 领域专业术语在训练数据里出现次数少,向量质量差
*
* ===== BM25擅长 =====
*
* ✓ 精确关键词匹配(以上都是BM25的强项)
* ✓ 对罕见词/专有名词有很好的权重
*
* ===== BM25的弱项 =====
*
* ✗ 语义理解:不能处理同义词、近义词
* ✗ 问答形式的查询:
* "如何解决Java的内存泄漏" 可能找不到标题是"JVM调优实践"的文章
*
* 结论:混合检索 = 取长补短
*/混合检索架构
/**
* 混合检索服务
*
* 同时进行向量搜索和BM25搜索
* 然后用RRF(Reciprocal Rank Fusion)合并结果
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class HybridSearchService {
private final VectorStore vectorStore;
private final BM25SearchService bm25Service;
private final EmbeddingModel embeddingModel;
// 混合比例(可以根据场景调整)
// 0 = 纯BM25,1 = 纯向量,0.5 = 各占一半
@Value("${search.hybrid.vector-weight:0.6}")
private double vectorWeight;
@Value("${search.hybrid.bm25-weight:0.4}")
private double bm25Weight;
/**
* 混合检索
*/
public List<HybridSearchResult> search(
String query, int topK, HybridSearchConfig config) {
long startMs = System.currentTimeMillis();
// 1. 并行执行两种搜索
CompletableFuture<List<VectorSearchResult>> vectorFuture =
CompletableFuture.supplyAsync(() -> vectorSearch(query, topK * 2));
CompletableFuture<List<BM25SearchResult>> bm25Future =
CompletableFuture.supplyAsync(() -> bm25Search(query, topK * 2));
List<VectorSearchResult> vectorResults;
List<BM25SearchResult> bm25Results;
try {
vectorResults = vectorFuture.get(5, java.util.concurrent.TimeUnit.SECONDS);
bm25Results = bm25Future.get(5, java.util.concurrent.TimeUnit.SECONDS);
} catch (Exception e) {
log.warn("并行搜索超时,使用已有结果: {}", e.getMessage());
vectorResults = List.of();
bm25Results = List.of();
}
// 2. 使用RRF合并两个排名列表
List<HybridSearchResult> merged = mergeWithRRF(vectorResults, bm25Results, topK);
long latencyMs = System.currentTimeMillis() - startMs;
log.debug("混合检索: query='{}', vectorHits={}, bm25Hits={}, merged={}, latency={}ms",
query, vectorResults.size(), bm25Results.size(), merged.size(), latencyMs);
return merged;
}
private List<VectorSearchResult> vectorSearch(String query, int topK) {
float[] queryVector = embeddingModel.embed(query).content().vector();
return vectorStore.search(queryVector, topK, null).stream()
.map(r -> new VectorSearchResult(r.getId(), r.getContent(), r.getScore()))
.toList();
}
private List<BM25SearchResult> bm25Search(String query, int topK) {
return bm25Service.search(query, topK);
}
/**
* RRF(Reciprocal Rank Fusion)分数融合
*
* RRF的核心思想:不直接用原始分数(因为向量分数和BM25分数量纲不同),
* 而是用排名(rank)来计算融合分数
*
* 公式:RRF(d) = Σ 1/(k + rank_i(d))
* 其中 k 通常取60(这是个经验值,影响对高排名的关注程度)
*
* 优点:对各自的分数量纲不敏感,简单有效
*/
private List<HybridSearchResult> mergeWithRRF(
List<VectorSearchResult> vectorResults,
List<BM25SearchResult> bm25Results,
int topK) {
int K = 60; // RRF的k参数
Map<String, Double> docScores = new LinkedHashMap<>();
Map<String, String> docContents = new HashMap<>();
Map<String, String> docSources = new HashMap<>();
// 向量搜索的RRF得分(加权)
for (int rank = 0; rank < vectorResults.size(); rank++) {
VectorSearchResult result = vectorResults.get(rank);
double rrfScore = vectorWeight * (1.0 / (K + rank + 1));
docScores.merge(result.docId(), rrfScore, Double::sum);
docContents.put(result.docId(), result.content());
docSources.put(result.docId(), "vector");
}
// BM25搜索的RRF得分(加权)
for (int rank = 0; rank < bm25Results.size(); rank++) {
BM25SearchResult result = bm25Results.get(rank);
double rrfScore = bm25Weight * (1.0 / (K + rank + 1));
docScores.merge(result.docId(), rrfScore, Double::sum);
if (!docContents.containsKey(result.docId())) {
docContents.put(result.docId(), result.content());
}
// 如果文档在两个结果中都出现,标记来源为both
docSources.merge(result.docId(), "bm25",
(existing, bm25) -> existing.equals("vector") ? "both" : bm25);
}
// 按RRF分数排序,取前topK
return docScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(e -> new HybridSearchResult(
e.getKey(),
docContents.getOrDefault(e.getKey(), ""),
e.getValue(),
docSources.getOrDefault(e.getKey(), "unknown")
))
.toList();
}
record VectorSearchResult(String docId, String content, double score) {}
record BM25SearchResult(String docId, String content, double bm25Score) {}
@Data
@Builder
public static class HybridSearchResult {
private final String docId;
private final String content;
private final double rrfScore;
private final String source; // "vector", "bm25", 或 "both"
public HybridSearchResult(String docId, String content, double rrfScore, String source) {
this.docId = docId;
this.content = content;
this.rrfScore = rrfScore;
this.source = source;
}
}
@Data
@Builder
public static class HybridSearchConfig {
@Builder.Default
private double vectorWeight = 0.6;
@Builder.Default
private double bm25Weight = 0.4;
@Builder.Default
private double minScore = 0.0;
}
}BM25服务实现
/**
* BM25搜索服务
*
* BM25是TF-IDF的改进版本,是经典的信息检索算法
*
* 在Java中,可以用Lucene或Elasticsearch实现BM25
* 这里用Lucene内嵌实现(无需额外服务器)
*/
@Service
@Slf4j
public class BM25SearchService {
private final org.apache.lucene.store.Directory directory;
private final org.apache.lucene.analysis.Analyzer analyzer;
private org.apache.lucene.search.IndexSearcher searcher;
private org.apache.lucene.index.IndexReader indexReader;
// 文档内容存储(Lucene不直接存原始内容,需要另外存)
private final Map<String, String> docStore = new ConcurrentHashMap<>();
public BM25SearchService() {
// 使用内存索引(生产中用文件系统索引)
this.directory = new org.apache.lucene.store.ByteBuffersDirectory();
// SmartChineseAnalyzer对中文分词支持更好
this.analyzer = new org.apache.lucene.analysis.cn.smart.SmartChineseAnalyzer();
}
/**
* 添加文档到BM25索引
*/
public void addDocument(String docId, String content) {
try {
org.apache.lucene.index.IndexWriterConfig config =
new org.apache.lucene.index.IndexWriterConfig(analyzer);
config.setSimilarity(new org.apache.lucene.search.similarities.BM25Similarity(1.2f, 0.75f));
try (org.apache.lucene.index.IndexWriter writer =
new org.apache.lucene.index.IndexWriter(directory, config)) {
org.apache.lucene.document.Document doc = new org.apache.lucene.document.Document();
doc.add(new org.apache.lucene.document.StringField("id", docId,
org.apache.lucene.document.Field.Store.YES));
doc.add(new org.apache.lucene.document.TextField("content", content,
org.apache.lucene.document.Field.Store.NO));
writer.addDocument(doc);
}
docStore.put(docId, content);
refreshSearcher();
} catch (Exception e) {
throw new RuntimeException("文档添加到BM25索引失败", e);
}
}
/**
* 批量添加文档(效率更高)
*/
public void addDocuments(Map<String, String> docs) {
try {
org.apache.lucene.index.IndexWriterConfig config =
new org.apache.lucene.index.IndexWriterConfig(analyzer);
config.setSimilarity(new org.apache.lucene.search.similarities.BM25Similarity());
try (org.apache.lucene.index.IndexWriter writer =
new org.apache.lucene.index.IndexWriter(directory, config)) {
for (Map.Entry<String, String> entry : docs.entrySet()) {
org.apache.lucene.document.Document doc = new org.apache.lucene.document.Document();
doc.add(new org.apache.lucene.document.StringField("id", entry.getKey(),
org.apache.lucene.document.Field.Store.YES));
doc.add(new org.apache.lucene.document.TextField("content", entry.getValue(),
org.apache.lucene.document.Field.Store.NO));
writer.addDocument(doc);
docStore.put(entry.getKey(), entry.getValue());
}
writer.commit();
}
refreshSearcher();
log.info("BM25索引添加文档: count={}", docs.size());
} catch (Exception e) {
throw new RuntimeException("批量添加文档失败", e);
}
}
/**
* BM25搜索
*/
public List<HybridSearchService.BM25SearchResult> search(String query, int topK) {
if (searcher == null) return List.of();
try {
org.apache.lucene.queryparser.classic.QueryParser parser =
new org.apache.lucene.queryparser.classic.QueryParser("content", analyzer);
// 转义特殊字符防止查询解析失败
String escapedQuery = org.apache.lucene.queryparser.classic.QueryParser
.escape(query);
org.apache.lucene.search.Query luceneQuery = parser.parse(escapedQuery);
org.apache.lucene.search.TopDocs topDocs = searcher.search(luceneQuery, topK);
List<HybridSearchService.BM25SearchResult> results = new ArrayList<>();
for (org.apache.lucene.search.ScoreDoc scoreDoc : topDocs.scoreDocs) {
org.apache.lucene.document.Document doc = searcher.doc(scoreDoc.doc);
String docId = doc.get("id");
String content = docStore.getOrDefault(docId, "");
results.add(new HybridSearchService.BM25SearchResult(
docId, content, scoreDoc.score));
}
return results;
} catch (Exception e) {
log.warn("BM25搜索失败: query='{}', error={}", query, e.getMessage());
return List.of();
}
}
private void refreshSearcher() throws Exception {
if (indexReader != null) {
indexReader.close();
}
indexReader = org.apache.lucene.index.DirectoryReader.open(directory);
searcher = new org.apache.lucene.search.IndexSearcher(indexReader);
searcher.setSimilarity(new org.apache.lucene.search.similarities.BM25Similarity());
}
}查询理解和扩展
/**
* 查询理解服务
*
* 在混合检索前,对查询进行预处理:
* 1. 意图识别
* 2. 关键词提取(用于增强BM25)
* 3. 查询改写(用于增强向量搜索)
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class QueryUnderstandingService {
private final ChatLanguageModel llm;
/**
* 分析查询,提取对检索有用的信息
*/
public QueryAnalysis analyze(String rawQuery) {
// 对短查询或简单查询,直接使用,不需要LLM分析(节省成本)
if (rawQuery.length() < 10 && !rawQuery.contains("?") && !rawQuery.contains("?")) {
return QueryAnalysis.simple(rawQuery);
}
String prompt = """
请分析以下查询,提取用于搜索的关键信息。
查询:%s
返回JSON:
{
"intent": "FACTUAL_LOOKUP/CONCEPTUAL/PROCEDURAL/COMPARISON",
"keywords": ["关键词1", "关键词2"],
"entities": ["实体名1", "实体名2"],
"rewrittenQuery": "改写后更适合语义搜索的查询",
"bm25Query": "适合关键词搜索的简短查询"
}
intent说明:
- FACTUAL_LOOKUP:查找具体事实(某个产品的参数、某个API的用法)
- CONCEPTUAL:理解概念(什么是X,X和Y的区别)
- PROCEDURAL:操作方法(如何做X,怎么配置X)
- COMPARISON:对比选择(A和B哪个好)
只返回JSON。
""".formatted(rawQuery);
try {
String response = llm.generate(prompt);
String json = extractJson(response);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(json);
return QueryAnalysis.builder()
.originalQuery(rawQuery)
.intent(root.path("intent").asText("CONCEPTUAL"))
.keywords(toStringList(root.path("keywords")))
.entities(toStringList(root.path("entities")))
.rewrittenQuery(root.path("rewrittenQuery").asText(rawQuery))
.bm25Query(root.path("bm25Query").asText(rawQuery))
.build();
} catch (Exception e) {
log.warn("查询分析失败,使用原始查询: {}", e.getMessage());
return QueryAnalysis.simple(rawQuery);
}
}
/**
* 对FACTUAL_LOOKUP类型的查询,增强关键词权重
*
* 比如查询"GPT-4o定价",把"GPT-4o"作为必须出现的词
*/
public String enhanceBm25Query(QueryAnalysis analysis) {
if (analysis.getEntities().isEmpty()) {
return analysis.getBm25Query();
}
// 对实体名加权(Lucene的boost语法)
String boostedEntities = analysis.getEntities().stream()
.map(e -> "\"" + e + "\"^2") // 精确匹配,权重2倍
.collect(Collectors.joining(" "));
return boostedEntities + " " + analysis.getBm25Query();
}
private List<String> toStringList(JsonNode node) {
List<String> list = new ArrayList<>();
if (node.isArray()) {
node.forEach(item -> list.add(item.asText()));
}
return list;
}
private String extractJson(String s) {
int start = s.indexOf('{'); int end = s.lastIndexOf('}');
return (start >= 0 && end > start) ? s.substring(start, end + 1) : s;
}
@Data
@Builder
public static class QueryAnalysis {
private String originalQuery;
private String intent;
private List<String> keywords;
private List<String> entities;
private String rewrittenQuery;
private String bm25Query;
public static QueryAnalysis simple(String query) {
return QueryAnalysis.builder()
.originalQuery(query).intent("CONCEPTUAL")
.keywords(List.of()).entities(List.of())
.rewrittenQuery(query).bm25Query(query)
.build();
}
}
}完整混合检索+重排流程
/**
* 完整的检索链路
*
* 查询理解 → 混合检索 → Reranking → 返回结果
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AdvancedSearchOrchestrator {
private final QueryUnderstandingService queryUnderstanding;
private final HybridSearchService hybridSearch;
// LocalRerankerService 参考 article-2094
public SearchResponse search(String userQuery, int topK) {
// 1. 查询理解
QueryUnderstandingService.QueryAnalysis analysis =
queryUnderstanding.analyze(userQuery);
// 2. 根据意图调整检索策略
HybridSearchService.HybridSearchConfig config;
if ("FACTUAL_LOOKUP".equals(analysis.getIntent())) {
// 精确查找场景:提高BM25权重
config = HybridSearchService.HybridSearchConfig.builder()
.vectorWeight(0.3).bm25Weight(0.7).build();
} else if ("CONCEPTUAL".equals(analysis.getIntent())) {
// 概念理解场景:提高向量权重
config = HybridSearchService.HybridSearchConfig.builder()
.vectorWeight(0.7).bm25Weight(0.3).build();
} else {
config = HybridSearchService.HybridSearchConfig.builder()
.vectorWeight(0.6).bm25Weight(0.4).build();
}
// 3. 混合检索(用改写后的查询)
List<HybridSearchService.HybridSearchResult> results =
hybridSearch.search(analysis.getRewrittenQuery(), topK * 3, config);
// 4. Reranking(用CrossEncoder精确排序)
// 实际实现参考 article-2094 的 LocalRerankerService
// 这里简化为直接返回topK
List<HybridSearchService.HybridSearchResult> finalResults =
results.stream().limit(topK).toList();
return SearchResponse.builder()
.query(userQuery)
.intent(analysis.getIntent())
.results(finalResults)
.build();
}
@Data
@Builder
public static class SearchResponse {
private String query;
private String intent;
private List<HybridSearchService.HybridSearchResult> results;
}
}实践建议
混合检索不是默认选项,要先分析你的查询类型
如果你的用户查询都是自然语言问句("如何解决XX问题"),纯向量搜索可能就够了。如果查询里有大量专有名词、产品型号、代码片段,混合检索才有明显价值。花半天时间分析50个最近的真实查询日志,看看有多少是"精确匹配"类型,能帮你决定是否值得引入BM25。
RRF比手动调权重更稳健
一开始我试过直接给向量分和BM25分加权平均,但两个分数的量纲完全不同(向量分0-1,BM25分可能是0-10+),很难找到稳定的权重。RRF用排名而不是原始分数,天然解决了量纲问题,而且效果通常更稳定。如果你有数据可以调参,RRF的k参数(通常60)也可以针对你的场景微调。
中文搜索要特别关注分词器的选择
Lucene的SmartChineseAnalyzer对中文分词效果还不错,但对于高度专业化的领域术语(比如医疗、法律),可能需要自定义词典。如果有条件,接入Jieba或哈工大分词器,允许添加领域词典,效果会更好。"北京协和医院"如果被分成"北京"+"协和"+"医院",BM25匹配效果会大打折扣。
