首页 / 文章

Spring AI:利用 Lucene 与 RRF 的混合搜索实现更出色的 RAG 效果

在 Spring AI 检索器中将向量搜索与 Lucene BM25 结合,并通过互反排名融合算法整合排序结果,从而使 RAG 能同时匹配语义和精确术语。

发布者:REACTAPP.TOP阅读约 10 分钟2185 词

来源: Spring AI Recipe: Better RAG Results with Hybrid Search

向量搜索擅长理解查询的含义,而词法搜索则擅长找出实际输入的词汇。将两者结合为混合搜索,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会将这两种视角的结果合并。在两种搜索方式中都获得高分的文档会排在最前面,而仅通过某一种方式找到的高质量结果也能进入最终列表。

因此,该处理流程无需在理解用户意图与匹配其使用的词汇之间做出选择。借助混合搜索,它能够同时实现这两点。

相关阅读

  • Spring AI 实用技巧:按游戏标题利用元数据筛选 RAG 结果 — 该指南介绍了如何为文档添加标题标签,使用大语言模型从问题中筛选出正确信息,进而对向量搜索结果进行过滤。
  • 利用 CompressionQueryTransformer 在 Spring AI 中实现具备对话感知能力的 RAG — 通过两种 ChatClient 构建方式,在查询进入向量存储之前结合对话历史重新编写查询语句,从而解决 Spring AI RAG 聊天中出现的模糊后续问题。
  • 家庭实验室的混合搜索方案:Qdrant RRF、BM25与自定义分块技术 — 一款可自主部署的家庭实验室辅助工具,它能将笔记、电子表格、Draw.io绘图以及Loki日志进行分块处理,随后通过密集向量、稀疏向量、RRF算法以及交叉编码器重排技术来检索相关内容。