Accueil / Articles

Spring AI : Meilleurs résultats RAG grâce à une recherche hybride utilisant Lucene et RRF

Combinez la recherche vectorielle avec Lucene BM25 dans un retriever Spring AI et fusionnez les classements à l’aide de Reciprocal Rank Fusion afin que RAG corresponde à la fois au sens et aux termes exacts.

Éditeur : REACTAPP.TOP10 min de lecture2185 mots

Sources: Spring AI Recipe: Better RAG Results with Hybrid Search

La recherche vectorielle excelle à comprendre le sens d’une requête. La recherche lexicale, quant à elle, est efficace pour trouver les mots qui ont réellement été tapés. En les combinant en une recherche hybride, une application RAG bénéficie des forces des deux approches.

La recherche de similarité sur des embeddings fonctionne bien pour récupérer des documents semantiquement proches d’une question, mais elle peut être moins fiable lorsque la requête contient une terminologie spécifique importante. BM25 (Best Match 25) est un algorithme de classement lexicale qui fait l’inverse : il compare les termes en tenant compte de leur fréquence d’apparition, de leur rareté et de la longueur du document. Utilisés ensemble, ces deux méthodes compensent leurs points faibles respectifs.

Le plan ici consiste en un DocumentRetriever hybride pour Spring AI qui interroge un stockage de vecteurs et un index BM25, puis fusionne les résultats en une seule liste classée. BM25 provient d’Apache Lucene, la bibliothèque de recherche mature et rapide qui constitue depuis longtemps un pilier des recherches en Java. La première modification concerne l’ingestion, de sorte que chaque morceau de données soit enregistré à la fois dans le stockage de vecteurs et dans Lucene.

Enregistrement des morceaux de données dans le stockage de vecteurs et dans Lucene

Commencez par ajouter Lucene à la configuration de compilation :

implementation 'org.apache.lucene:lucene-core:10.5.1'

Viens ensuite un composant qui accepte des objets Document de Spring AI et les indexe dans Lucene. Il s’agit de 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();
  }

}

Les versions actuelles de Lucene utilisent BM25 comme similarité par défaut, il n’est donc pas nécessaire de faire quoi que ce soit de spécial pour l’activer. Si vous préférez indiquer clairement ce choix, ajoutez cette ligne après avoir créé IndexWriterConfig :

config.setSimilarity(new BM25Similarity());

Le code d’ingestion appelle la méthode add() pour insérer des documents dans Lucene. Son paramètre List<Document> contient des documents Spring AI (org.springframework.ai.document.Document), tandis que Lucene indexe un type propre à lui (org.apache.lucene.document.Document). Ainsi, la majeure partie de la méthode copie l’ID, le texte et les métadonnées de chaque document Spring AI dans les champs d’un nouveau document Lucene, qui est ensuite ajouté à l’index.

Lorsque l’écriture est prête, déclarez-la en tant que bean, injectez-la dans le ApplicationRunner qui effectue l’ingestion, et écrivez chaque lot de fragments dans les deux bases de données :

@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

  };
}

Le chemin de l’index est défini via @Value ; il faut donc le spécifier dans application.properties :

lucene.index.path=/tmp/lucene/boot-docs

N’importe quel chemin conviendra, mais gardez-le en mémoire, car l’application principale RAG doit ouvrir le même index Lucene.

Si vous avez déjà effectué une étape HyDE précédente dans le même projet, videz les données existantes du stockage vectoriel avant de relancer l’ingestion, sinon des fragments dupliqués seront stockés. Une fois l’ingestion terminée, vous devriez à nouveau disposer de 300 fragments répartis sur 89 documents, accessibles désormais tant via le stockage vectoriel que via Lucene.

Interrogation des deux sources

L’application principale RAG a besoin de Lucene Core ainsi que du analyseur de requêtes de Lucene :

implementation 'org.apache.lucene:lucene-core:10.5.1'
implementation 'org.apache.lucene:lucene-queryparser:10.5.1'

Créez maintenant un composant qui recherche dans l’index Lucene et retourne les résultats correspondants sous forme d’objets Document de Spring AI :

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();
  }
}

Sans avoir à explorer en profondeur l’API de recherche de Lucene, la méthode search() prend le texte de la requête et demande à un IndexSearcher les documents les plus pertinents, topK servant à limiter le nombre de résultats retournés. Tout comme pour l’indexation, BM25 est la similarité par défaut, et si vous souhaitez que cela soit visible, vous pouvez configurer explicitement le moteur de recherche :

searcher.setSimilarity(new BM25Similarity());

Le reste de la fonction search() convertit chaque Document Lucene en un Document Spring AI afin qu’il puisse participer au pipeline RAG.

Il existe désormais deux méthodes indépendantes pour récupérer des documents. La recherche par similarité vectorielle via VectorStore est efficace pour trouver du contenu sémantiquement pertinent. La recherche BM25 via LuceneSearch est efficace pour correspondre aux termes importants de la requête. Chacune peut fournir des résultats utiles seule, mais leur combinaison permet d’obtenir à la fois le sens et la précision lexicale. HybridDocumentRetriever gère cette combinaison.

Le récupérateur de documents hybride

C’est là l’essentiel :

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 met en œuvre DocumentRetriever de Spring AI, le composant qui récupère les documents pour RetrievalAugmentationAdvisor. Les étapes précédentes utilisaient VectorStoreDocumentRetriever, qui ne s’appuie que sur un stockage vectoriel. Ici, il est remplacé par une implémentation personnalisée.

Sa méthode retrieve() effectue deux recherches pour une même requête. D’abord, elle appelle similaritySearch() pour obtenir les 10 meilleurs résultats vectoriels. Ensuite, elle demande à Lucene les 10 meilleurs résultats BM25. Le résultat est deux listes classées, dont certains documents apparaissent dans les deux tandis que d’autres n’apparaissent que dans l’une d’elles. Associer ces listes bout à bout entraînerait la perte d’informations sur le classement, il faut donc que la fusion respecte la position de chaque document dans chaque liste. La fusion par rang réciproque fait précisément cela.

Fusion par rang réciproque

La méthode de fusion est la suivante :

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);
  }
}

La Fusion de rangs réciproques (RRF) transforme plusieurs ensembles de résultats classés en un seul classement. Au lieu de tenter de comparer des scores bruts provenant de deux algorithmes de recherche très différents, elle évalue les documents en fonction de leur position dans chaque liste. Pour chaque liste, un document reçoit :

1 / (k + rank)

Ici, k est une constante, généralement égale à 60 pour RRF, tandis que rank indique la position du document dans cette liste. Un document présent dans les deux listes voit ses deux scores additionnés. Le résultat est intuitif : les documents bien classés par l’une ou l’autre recherche se comportent bien, et ceux qui sont bien classés par les deux ont tendance à monter en tête, ce qui est souhaitable lors du mélange de la recherche sémantique et de la recherche lexicale.

À la fin de la fonction retrieve(), les trois classements sont enregistrés afin que l’effet soit visible :

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);
  }
}

Cette journalisation n’est pas nécessaire pour la recherche hybride elle-même, mais elle vous permet, lors des expérimentations, de comparer les résultats vectoriels, ceux basés sur BM25 et les classements fusionnés côte à côte.

Mise en place de la récupération hybride

Enregistrez LuceneSearch et HybridDocumentRetriever en tant que beans :

@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);
}

Comme pour l’ingestion, LuceneSearch a besoin de l’emplacement de l’index Lucene :

lucene.index.path=/tmp/lucene/boot-docs

Vérifiez que cela correspond au chemin utilisé par l’application d’ingestion. Ensuite, modifiez le ChatClientBuilderCustomizer qui configure RetrievalAugmentationAdvisor afin qu’il utilise HybridDocumentRetriever à la place de 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);
  };
}

En plus de passer le récupérateur par l’intermédiaire de documentRetriever(), le personnalisateur transmet également à documentJoiner() un DocumentJoiner basé sur des lambdas. La tâche d’un jointeur est de fusionner plusieurs listes de documents récupérés en une seule collection. Le ConcatenationDocumentJoiner par défaut réordonne les documents en fonction de leur score, ce qui annulerait l’ordre généré par RRF. Par conséquent, le code fournit un jointeur qui ne fait essentiellement rien et laisse le classement fusionné inchangé.

On pourrait penser que le joiner est l’endroit naturel pour RRF, et conceptuellement c’est le cas. Ce design suggérerait également de séparer la recherche vectorielle de la recherche Lucene en deux récupérateurs distincts. Le problème, c’est que le constructeur de l’advisor n’accepte qu’un seul DocumentRetriever ; par conséquent, ces deux mécanismes ne peuvent pas être configurés indépendamment et les deux ensembles de résultats ne peuvent pas être transmis au joiner. La solution la plus simple consiste à effectuer les deux recherches ainsi que leur fusion à l’intérieur de HybridDocumentRetriever, puis à utiliser un joiner de type pass-through pour conserver l’ordre fusionné. Si Spring AI prend ultérieurement en charge plusieurs récupérateurs, ce design mériterait d’être réexaminé.

C’est tout pour les connexions. RetrievalAugmentationAdvisor n’a pas besoin de savoir qu’il existe deux stratégies de recherche en jeu. Pour lui, HybridDocumentRetriever n’est rien de plus que le composant qui fournit les documents ajoutés au contexte de la requête.

Voir la différence

Activez le journalage de débogage pour HybridDocumentRetriever afin de pouvoir comparer les trois classements :

logging.level.com.example.essentialrag.HybridDocumentRetriever=debug

Démarrez l’application et posez une question concernant l’utilisation de @ConfigurationProperties pour mapper des configurations externes vers un objet Java avec validation. La sortie de débogage affichera trois ensembles de résultats étiquetés VECTOR, BM25 et FUSED. Le classement vectoriel commence comme suit :

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 produit un ordre différent :

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...
...

Et la sortie RRF les mélange :

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...
...

Le contraste montre pourquoi la recherche hybride reste pertinente. La recherche vectorielle gère bien les aspects conceptuels de la question : configuration externe, liaison, objets Java et validation. BM25 ajoute une correspondance lexicale et accorde de l’importance aux termes distinctifs tels que ConfigurationProperties. RRF fusionne ensuite ces deux approches. Les documents qui obtiennent de bons scores dans les deux méthodes se hissent en tête, tandis que des résultats solides obtenus uniquement par une stratégie peuvent tout de même figurer dans la liste finale.

Ainsi, le pipeline n’a pas besoin de choisir entre comprendre ce que l’utilisateur veut dire et correspondre aux mots qu’il a utilisés. Avec la recherche hybride, il peut faire les deux.

Lectures complémentaires

  • Recette Spring AI : Filtrage des résultats RAG avec des métadonnées par titre de jeu — Une présentation pas à pas de Spring AI qui met des étiquettes sur les documents en fonction de leur titre, utilise un LLM pour sélectionner la bonne valeur parmi les questions, et filtre la recherche vectorielle en conséquence.
  • RAG sensible à la conversation dans Spring AI avec CompressionQueryTransformer — Correction des questions de suivi vagues dans une conversation RAG Spring AI en réécrivant les requêtes à partir de l’historique de la conversation avant qu’elles n’atteignent le stockage vectoriel, en utilisant deux constructeurs ChatClient.
  • Recherche hybride pour un homelab : Qdrant RRF, BM25 et chunking personnalisé — Un assistant homelab auto-hébergé qui divise en blocs des notes, des feuilles de calcul, des diagrammes Draw.io et des journaux Loki, puis récupère les données à l’aide de vecteurs denses et sparses, de RRF ainsi que de réclassement via un cross-encoder.