Spring AI:利用 Lucene 与 RRF 的混合搜索实现更出色的 RAG 效果
在 Spring AI 检索器中将向量搜索与 Lucene BM25 结合,并通过互反排名融合算法整合排序结果,从而使 RAG 能同时匹配语义和精确术语。
向量搜索擅长理解查询的含义,而词法搜索则擅长找出实际输入的词汇。将两者结合为混合搜索,RAG应用就能同时发挥两者的优势。
基于嵌入的相似度搜索能够有效检索与问题在语义上相近的文档,但当查询包含重要的特定术语时,其可靠性可能会下降。BM25(最佳匹配25)则是一种词法排序算法,它的做法恰恰相反:在匹配术语的同时,会考虑这些术语出现的频率、稀有程度以及文档的长度。将这两种方法结合使用,可以弥补彼此的缺陷。
这里的方案是为 Spring AI 设计的混合式 DocumentRetriever,它同时查询向量存储和 BM25 索引,并将查询结果合并为单一的排序列表。BM25 源自 Apache Lucene,这是一个成熟且快速的搜索库,长期以来一直是 Java 搜索领域的核心技术。首要的改动在于数据导入流程,即每个数据块都会被同时写入向量存储和 Lucene 中。
将数据块写入向量存储和 Lucene
首先需要在构建过程中加入 Lucene:
implementation 'org.apache.lucene:lucene-core:10.5.1'
接下来是一个能够接收 Spring AI Document 对象并将其索引到 Lucene 中的组件,即 LuceneDocumentWriter:
public class LuceneDocumentWriter implements AutoCloseable {
private static final String ID_FIELD = "id";
private static final String CONTENT_FIELD = "content";
private static final String METADATA_FIELD = "metadata";
private final Analyzer analyzer;
private final Directory directory;
private final IndexWriter writer;
private final ObjectMapper objectMapper;
public LuceneDocumentWriter(Path indexPath) throws IOException {
this.analyzer = new StandardAnalyzer();
this.directory = FSDirectory.open(indexPath);
var config = new IndexWriterConfig(analyzer);
this.writer = new IndexWriter(directory, config);
this.objectMapper = new ObjectMapper();
}
public void add(List<Document> documents) throws IOException {
for (Document document : documents) {
var luceneDocument = new org.apache.lucene.document.Document();
luceneDocument.add(
new StringField(ID_FIELD, document.getId(), Field.Store.YES));
luceneDocument.add(
new TextField(CONTENT_FIELD, document.getText(), Field.Store.YES));
luceneDocument.add(
new StoredField(METADATA_FIELD,
objectMapper.writeValueAsString(document.getMetadata())));
writer.addDocument(luceneDocument);
}
writer.commit();
}
@Override
public void close() throws IOException {
writer.close();
directory.close();
analyzer.close();
}
}
当前的 Lucene 版本默认使用 BM25 作为相似度计算算法,因此无需特殊操作即可启用它。如果您希望明确说明这一选择,可在创建 IndexWriterConfig 后添加以下行:
config.setSimilarity(new BM25Similarity());
数据导入代码通过调用add()方法将文档放入Lucene中。该方法的List<Document>参数用于存储Spring AI文档(org.springframework.ai.document.Document),而Lucene则使用自己定义的文档类型(org.apache.lucene.document.Document)进行索引。因此,该方法的主要工作是将每个Spring AI文档的ID、文本及元数据复制到新的Lucene文档的对应字段中,之后再将该Lucene文档添加到索引中。
在准备好写入器后,需将其声明为Bean,并注入到负责数据导入的ApplicationRunner中,从而将每批数据同时写入这两个存储系统中:
@Bean
LuceneDocumentWriter luceneDocumentWriter(
@Value("${lucene.index.path}") Path luceneIndexPath) throws IOException {
return new LuceneDocumentWriter(luceneIndexPath);
}
@Bean
ApplicationRunner loadSpringBootDocumentation(
VectorStore vectorStore, LuceneDocumentWriter luceneDocumentWriter) {
return args -> {
// ...code snipped out for brevity
vectorStore.add(chunks);
luceneDocumentWriter.add(chunks);
// ...code snipped out for brevity
};
}
索引路径通过 @Value 设置,因此请在 application.properties 中进行配置:
lucene.index.path=/tmp/lucene/boot-docs
任何路径都可以,但请记住它,因为主要的RAG应用必须打开同一个Lucene索引。
如果您在同一个项目中已经完成过之前的HyDE步骤,请在再次运行数据导入之前清除现有的向量存储数据,否则将会存储重复的数据块。数据导入完成后,您应该会再次得到89份文档对应的300个数据块,这些数据块现在可以通过向量存储和Lucene同时访问。
查询两种数据源
主要的RAG应用需要Lucene Core以及Lucene的查询解析器:
implementation 'org.apache.lucene:lucene-core:10.5.1'
implementation 'org.apache.lucene:lucene-queryparser:10.5.1'
现在创建一个组件,用于搜索 Lucene 索引,并以 Spring AI Document 对象的形式返回匹配结果:
public class LuceneSearch implements AutoCloseable {
private static final String ID_FIELD = "id";
private static final String CONTENT_FIELD = "content";
private static final String METADATA_FIELD = "metadata";
private final Analyzer analyzer;
private final Directory directory;
private final DirectoryReader reader;
private final IndexSearcher searcher;
private final ObjectMapper objectMapper;
public LuceneSearch(Path indexPath) throws IOException {
this.analyzer = new StandardAnalyzer();
this.directory = FSDirectory.open(indexPath);
this.reader = DirectoryReader.open(directory);
this.searcher = new IndexSearcher(reader);
this.objectMapper = new ObjectMapper();
}
public List<Document> search(String queryText, int topK) {
try {
var parser = new QueryParser(CONTENT_FIELD, analyzer);
var query = parser.parse(
QueryParser.escape(queryText));
var hits = searcher.search(query, topK);
var storedFields = searcher.storedFields();
var results = new ArrayList<Document>();
for (var hit : hits.scoreDocs) {
var luceneDocument =
storedFields.document(hit.doc);
var metadata = objectMapper.readValue(
luceneDocument.get(METADATA_FIELD),
new TypeReference<Map<String, Object>>() {
});
results.add(new Document(
luceneDocument.get(ID_FIELD),
luceneDocument.get(CONTENT_FIELD),
metadata));
}
return results;
}
catch (IOException | ParseException e) {
throw new IllegalStateException("Failed to search Lucene index", e);
}
}
@Override
public void close() throws IOException {
reader.close();
directory.close();
analyzer.close();
}
}
无需深入研究 Lucene 的搜索 API,search() 函数会接收查询文本,并向 IndexSearcher 请求最匹配的文档,而 topK 参数则用于限制返回的文档数量。与索引时一样,BM25 是默认的相似度计算方法;如果希望显式设置该参数,可以明确配置搜索器:
searcher.setSimilarity(new BM25Similarity());
search() 的其余部分会将每个 Lucene Document 转换回 Spring AI Document,以便其能够参与 RAG 流水线。
目前有两种独立的文档检索方式。通过 VectorStore 进行的向量相似度搜索擅长查找语义相关的内容;而通过 LuceneSearch 进行的 BM25 搜索则擅长匹配查询中的关键术语。单独使用任一方式都能获得有用的结果,但将二者结合则既能保证语义相关性,又能提升词汇精确度。HybridDocumentRetriever 负责实现这种组合。
混合文档检索器
这就是其核心所在:
public class HybridDocumentRetriever implements DocumentRetriever {
private static final Logger logger =
LoggerFactory.getLogger(HybridDocumentRetriever.class);
private final VectorStore vectorStore;
private final LuceneSearch luceneSearch;
public HybridDocumentRetriever(
VectorStore vectorStore, LuceneSearch luceneSearch) {
this.vectorStore = vectorStore;
this.luceneSearch = luceneSearch;
}
@Override
public List<Document> retrieve(Query query) {
var vectorResults = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query.text())
.topK(10)
.build());
var bm25Results = luceneSearch.search(query.text(), 10);
var fusedResults =
reciprocalRankFusion(vectorResults, bm25Results, 10);
logResults("VECTOR", vectorResults);
logResults("BM25", bm25Results);
logResults("FUSED", fusedResults);
return fusedResults;
}
// ...
}
HybridDocumentRetriever实现了Spring AI的DocumentRetriever接口,该组件负责为RetrievalAugmentationAdvisor获取文档。之前的步骤中使用的是仅从向量存储中检索的VectorStoreDocumentRetriever,此处则被自定义实现所替代。
它的retrieve()方法会对一个查询执行两次检索。首先调用similaritySearch()获取排名前10的向量匹配结果,然后向Lucene请求排名前10的BM25匹配结果。最终会得到两个排序列表,有些文档会同时出现在两个列表中,而有些则只出现在其中一个列表中。如果直接将这两个列表拼接在一起,排序信息就会丢失,因此合并时必须保留每篇文档在各自列表中的位置。互反排名融合方法正是为此而设计的。
使用互反排名融合进行合并
该融合方法的具体实现如下:
private List<Document> reciprocalRankFusion(
List<Document> vectorResults,
List<Document> bm25Results,
int limit) {
Map<String, Double> scores = new HashMap<>();
Map<String, Document> documents = new HashMap<>();
addRrfScores(vectorResults, scores, documents);
addRrfScores(bm25Results, scores, documents);
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(limit)
.map(entry -> documents.get(entry.getKey()))
.toList();
}
private void addRrfScores(
List<Document> results,
Map<String, Double> scores,
Map<String, Document> documents) {
final int k = 60;
for (int i = 0; i < results.size(); i++) {
var document = results.get(i);
var rank = i + 1;
documents.putIfAbsent(document.getId(), document);
scores.merge(
document.getId(),
1.0 / (k + rank),
Double::sum);
}
}
互斥排名融合(RRF)将多个排序后的结果集合并为单一排序。它不会试图比较两种截然不同的检索算法产生的原始分数,而是根据文档在各个列表中的排名来为其评分。对于每个列表,文档会获得相应的评分:
1 / (k + rank)
此处,k 是一个常数,RRF中通常为60,而rank则表示文档在该列表中的位置。同时出现在两个列表中的文档,其两个得分会相加。其结果很直观:在任一搜索中排名靠前的文档表现良好,而在两种搜索中都排名靠前的文档往往能升至顶部,这正是结合语义检索与词汇检索时所期望的结果。
retrieve()函数执行完毕后会记录所有三种排名情况,以便直观查看效果:
private void logResults(String label, List<Document> documents) {
logger.debug("{}", label);
for (int i = 0; i < documents.size(); i++) {
var document = documents.get(i);
var text = document.getText()
.replaceAll("\\s+", " ")
.trim();
var snippet = text.substring(
0, Math.min(text.length(), 80));
logger.debug(
"{}. {} - {}",
i + 1,
document.getId(),
snippet);
}
}
混合搜索本身并不需要这种日志记录,但在实验过程中,它可以让您同时对比向量、BM25以及融合排序的结果。
配置混合检索
将 LuceneSearch 和 HybridDocumentRetriever 注册为 Bean:
@Bean
LuceneSearch luceneSearch(
@Value("${lucene.index.path}") Path luceneIndexPath) throws IOException {
return new LuceneSearch(luceneIndexPath);
}
@Bean
HybridDocumentRetriever hybridDocumentRetriever(
VectorStore vectorStore, LuceneSearch luceneSearch) {
return new HybridDocumentRetriever(vectorStore, luceneSearch);
}
与数据摄取类似,LuceneSearch也需要Lucene索引的位置:
lucene.index.path=/tmp/lucene/boot-docs
请确保此路径与数据摄取应用所使用的路径一致。接着修改配置 RetrievalAugmentationAdvisor 的 ChatClientBuilderCustomizer,使其使用 HybridDocumentRetriever 代替 VectorStoreDocumentRetriever:
@Bean
ChatClientBuilderCustomizer addRagAdvisor(
QueryTransformer[] queryTransformers,
HybridDocumentRetriever hybridDocumentRetriever) {
return builder -> {
var ragAdvisor = RetrievalAugmentationAdvisor.builder()
.queryTransformers(queryTransformers)
.documentRetriever(hybridDocumentRetriever)
.documentJoiner(documentsForQuery ->
documentsForQuery.values().stream()
.flatMap(List::stream)
.flatMap(List::stream)
.toList())
.build();
builder.defaultAdvisors(ragAdvisor);
};
}
除了将检索器传递给 documentRetriever() 外,定制器还会向 documentJoiner() 提供一个基于 lambda 的 DocumentJoiner。拼接器的任务是将多个检索到的文档列表合并为一个集合。默认的 ConcatenationDocumentJoiner 会按照每篇文档的得分重新排序,这会破坏 RRF 才生成的排序结果。因此,代码提供了一个几乎不起作用的拼接器,让合并后的排序保持不变。
你可能会认为在 Joiner 处进行 RRF 处理是理所当然的,从概念上来说确实如此。那样的设计也意味着需要将向量搜索和 Lucene 搜索拆分为两个独立的检索器。问题在于,Advisor 的构建器只接受一个 DocumentRetriever,因此这两种机制无法独立配置,也无法将各自的查询结果同时传递给 Joiner。更简单的做法是在 HybridDocumentRetriever 中完成两次搜索及结果融合,再使用一个直通式的 Joiner 来保持融合后的顺序。如果 Spring AI 后来支持多个检索器,那么这个设计就值得重新考虑了。
这就是所有的连接方式。RetrievalAugmentationAdvisor 不必知道存在两种搜索策略。对它而言,HybridDocumentRetriever 只是负责提供添加到提示上下文中的文档的组件而已。
查看差异
为 HybridDocumentRetriever 启用调试日志记录,以便对比三种排序结果:
logging.level.com.example.essentialrag.HybridDocumentRetriever=debug
启动应用程序,提出一个关于如何使用 @ConfigurationProperties 将外部配置映射到带有验证功能的 Java 对象的问题。调试输出将显示三个标记为 VECTOR、BM25 和 FUSED 的结果集。向量排序的结果如下所示:
VECTOR
1. d91bc5c8-... - If the annotation does not provide any prefix...
2. 049f6484-... - For YAML, you can use the following syntax...
3. 152dde59-... - In this setup, the presence of a single parameterized constructor...
...
BM25 生成的顺序不同:
BM25
1. 4c7128dc-... - There are spring-boot-starter-data-couchbase...
2. a3dbefae-... - fun main(args: Array<String>)...
3. 1ab4c223-... - Configuring System Environment Properties...
4. d91bc5c8-... - If the annotation does not provide any prefix...
...
RRF输出会将它们混合在一起:
FUSED
1. d91bc5c8-... - If the annotation does not provide any prefix...
2. 1ab4c223-... - Configuring System Environment Properties...
3. 049f6484-... - For YAML, you can use the following syntax...
4. 152dde59-... - In this setup, the presence of a single parameterized constructor...
5. 091acd83-... - The default unit is bytes...
...
这种对比恰恰说明了混合搜索为何能发挥作用。向量搜索擅长处理问题中的概念性部分:外部配置、绑定、Java对象以及验证。BM25则负责词汇匹配,并给予如ConfigurationProperties这样的特殊术语更高的权重。随后RRF会将这两种视角的结果合并。在两种搜索方式中都获得高分的文档会排在最前面,而仅通过某一种方式找到的高质量结果也能进入最终列表。
因此,该处理流程无需在理解用户意图与匹配其使用的词汇之间做出选择。借助混合搜索,它能够同时实现这两点。
相关阅读
- RAG的混合搜索:BM25、向量与互反排名融合的实际应用 —— 为何仅使用向量的RAG会丢失精确标识符,RRF如何将BM25与嵌入向量融合,混合搜索在何种情况下表现不佳,以及为何元数据过滤应先于融合步骤进行。
- 基于文档树的推理而非向量搜索 —— PageIndex构建了分层索引,使大语言模型能够直接在其中导航,从而无需进行分块处理或生成嵌入向量。其工作原理、在FinanceBench测试中的表现,以及适用场景。