Spring AI: Mejores resultados RAG con búsqueda híbrida que utiliza Lucene y RRF
Combine la búsqueda vectorial con Lucene BM25 en un recuperador de Spring AI y fusione las clasificaciones mediante Reciprocal Rank Fusion para que RAG coincida tanto con el significado como con los términos exactos.
Fuentes: Spring AI Recipe: Better RAG Results with Hybrid Search
La búsqueda vectorial es eficaz para comprender el significado de una consulta. La búsqueda léxica es buena para encontrar las palabras que se escribieron realmente. Al combinarlas como búsqueda híbrida, una aplicación RAG aprovecha las ventajas de ambas.
La búsqueda de similitud mediante embeddings funciona bien para obtener documentos semánticamente cercanos a una pregunta, pero puede ser menos fiable cuando la consulta contiene terminología específica importante. BM25 (Best Match 25) es un algoritmo de clasificación léxica que hace lo opuesto: compara términos teniendo en cuenta la frecuencia con la que aparecen, su rareza y la longitud del documento. Al usarlos juntos, se compensan sus puntos ciegos.
El plan aquí es utilizar un DocumentRetriever híbrido para Spring AI que consulta un almacén de vectores y un índice BM25, y fusiona los resultados en una única lista ordenada. BM25 proviene de Apache Lucene, la biblioteca de búsqueda madura y rápida que ha sido un pilar de las búsquedas en Java durante mucho tiempo. El primer cambio se aplica a la fase de ingestión, de modo que cada fragmento se escribe tanto en el almacén de vectores como en Lucene.
Escribir fragmentos en el almacén de vectores y en Lucene
Comience agregando Lucene al proceso de compilación:
implementation 'org.apache.lucene:lucene-core:10.5.1'
A continuación se presenta un componente que acepta objetos Document de Spring AI y los indexa en Lucene. Se trata 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();
}
}
Las versiones actuales de Lucene utilizan BM25 como similitud predeterminada, por lo que no se necesita nada especial para activarla. Si prefiere indicar claramente la elección, agregue esta línea después de crear IndexWriterConfig:
config.setSimilarity(new BM25Similarity());
El código de ingestión llama a add() para insertar documentos en Lucene. Su parámetro List<Document> contiene documentos de Spring AI (org.springframework.ai.document.Document), mientras que Lucene indexa un tipo propio (org.apache.lucene.document.Document). Por lo tanto, la mayor parte del método copia el ID, el texto y los metadatos de cada documento de Spring AI a los campos de un nuevo documento de Lucene, el cual luego se agrega al índice.
Una vez listo el escritor, declárelo como un bean, inyéctelo en el ApplicationRunner que realiza la ingestión, y escriba cada lote de fragmentos en ambos almacenamientos:
@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
};
}
La ruta del índice se especifica mediante @Value; por lo tanto, úsela en application.properties:
lucene.index.path=/tmp/lucene/boot-docs
Cualquier ruta servirá, pero recuérdela, ya que la aplicación principal de RAG debe abrir el mismo índice Lucene.
Si ya trabajaste en una etapa anterior de HyDE en el mismo proyecto, borra los datos existentes del almacén de vectores antes de volver a ejecutar la ingestión, de lo contrario almacenarás fragmentos duplicados. Una vez finalizada la ingestión, deberías volver a tener 300 fragmentos distribuidos en 89 documentos, ahora accesibles tanto a través del almacén de vectores como de Lucene.
Consultar ambas fuentes
La aplicación principal de RAG necesita Lucene Core además del analizador de consultas de Lucene:
implementation 'org.apache.lucene:lucene-core:10.5.1'
implementation 'org.apache.lucene:lucene-queryparser:10.5.1'
Ahora cree un componente que busque en el índice de Lucene y devuelva las coincidencias como objetos 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();
}
}
Sin adentrarnos en la API de búsqueda de Lucene, search() toma el texto de la consulta y solicita a un IndexSearcher los documentos que mejor se ajustan, siendo topK el parámetro que limita la cantidad de resultados devueltos. Al igual que en el proceso de indexación, BM25 es la similitud predeterminada, y si se desea que esto sea visible, se puede configurar explícitamente al buscador:
searcher.setSimilarity(new BM25Similarity());
El resto de search() convierte cada Document de Lucene de nuevo en un Document de Spring AI para que pueda formar parte del pipeline RAG.
Actualmente existen dos formas independientes de recuperar documentos. La búsqueda por similitud vectorial a través de VectorStore es eficaz para encontrar contenido semánticamente relacionado. La búsqueda BM25 a través de LuceneSearch es útil para coincidir con los términos importantes de la consulta. Cada una de ellas puede proporcionar resultados útiles por sí sola, pero al combinarlas se obtiene tanto el significado como la precisión léxica. HybridDocumentRetriever se encarga de esa combinación.
El recuperador híbrido de documentos
Esto es lo esencial:
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 implementa DocumentRetriever de Spring AI, el componente que obtiene documentos para RetrievalAugmentationAdvisor. En pasos anteriores se utilizó VectorStoreDocumentRetriever, que solo accede a un almacén de vectores. Aquí es reemplazado por una implementación personalizada.
Su método retrieve() realiza dos búsquedas para una misma consulta. Primero llama a similaritySearch() para obtener los 10 resultados más similares en formato vectorial. Luego solicita a Lucene los 10 resultados más relevantes según el algoritmo BM25. El resultado son dos listas ordenadas, donde algunos documentos aparecen en ambas y otros solo en una. Unir las listas directamente perdería la información de clasificación, por lo que la fusión debe respetar la posición de cada documento en cada lista. La Fusión de Rangos Recíprocos hace exactamente eso.
Fusión mediante Fusión de Rangos Recíprocos
El método de fusión se ve así:
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 Fusión de Rangos Recíprocos (RRF) convierte varios conjuntos de resultados clasificados en un único ranking. En lugar de intentar comparar las puntuaciones brutas de dos algoritmos de recuperación muy diferentes, califica a los documentos según su posición en cada lista. Para cada lista, un documento recibe:
1 / (k + rank)
Aquí k es una constante, que suele ser 60 para RRF, mientras que rank indica la posición del documento en esa lista. Un documento que aparece en ambas listas recibe la suma de las dos puntuaciones. El resultado es intuitivo: los documentos con una alta clasificación en cualquiera de las búsquedas tienen buen rendimiento, y aquellos con alta clasificación en ambas tienden a ascender a la cima, lo cual es lo que se desea al combinar la recuperación semántica y la léxica.
Al finalizar retrieve() se registran las tres clasificaciones para que el efecto sea 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);
}
}
Ese registro de datos no es necesario para la búsqueda híbrida en sí, pero al experimentar con ella permite comparar los métodos de ordenamiento vectorial, BM25 y fusionado uno al lado del otro.
Configuración de la recuperación híbrida
Registre LuceneSearch y HybridDocumentRetriever como 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);
}
Al igual que con la ingestión, LuceneSearch necesita la ubicación del índice de Lucene:
lucene.index.path=/tmp/lucene/boot-docs
Asegúrese de que esto coincida con la ruta que utilizó la aplicación de ingestión. Luego modifique ChatClientBuilderCustomizer, que configura RetrievalAugmentationAdvisor, para que utilice HybridDocumentRetriever en lugar 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);
};
}
Además de pasar el recuperador por documentRetriever(), el personalizador también le entrega a documentJoiner() un DocumentJoiner basado en lambda. La función del unificador es combinar varias listas de documentos recuperados en una sola colección. El ConcatenationDocumentJoiner predeterminado vuelve a ordenar según la puntuación de cada documento, lo que anularía el orden que acaba de generar RRF. Por eso, el código proporciona un unificador que, en esencia, no hace nada y deja intacto el ranking fusionado.
Puede que piense que el joiner es el lugar natural para RRF, y conceptualmente así es. Ese diseño también sugeriría dividir la búsqueda vectorial y la búsqueda con Lucene en dos recuperadores separados. El problema es que el constructor del advisor solo acepta un DocumentRetriever, por lo que los dos mecanismos no pueden configurarse de forma independiente ni ambos conjuntos de resultados enviarse al joiner. La opción más sencilla es realizar ambas búsquedas y la fusión dentro de HybridDocumentRetriever, y luego utilizar un joiner de paso directo para mantener el orden fusionado. Si Spring AI llega a soportar múltiples recuperadores, vale la pena revisar ese diseño.
Eso es todo en cuanto a la configuración. RetrievalAugmentationAdvisor no necesita saber que se están utilizando dos estrategias de búsqueda. Para él, HybridDocumentRetriever es simplemente el componente que suministra los documentos añadidos al contexto de la solicitud.
Observando la diferencia
Active el registro de depuración para HybridDocumentRetriever para poder comparar las tres clasificaciones:
logging.level.com.example.essentialrag.HybridDocumentRetriever=debug
Inicie la aplicación y haga una pregunta sobre el uso de @ConfigurationProperties para mapear configuraciones externas a un objeto Java con validación. La salida de depuración mostrará tres conjuntos de resultados etiquetados como VECTOR, BM25 y FUSED. El ranking vectorial comienza de la siguiente manera:
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 produce un orden diferente:
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...
...
Y la salida de RRF las combina:
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...
...
El contraste muestra por qué la búsqueda híbrida es eficaz. La búsqueda vectorial maneja bien las partes conceptuales de la consulta: configuración externa, enlace, objetos Java y validación. BM25 añade coincidencias léxicas y otorga mayor importancia a términos distintivos como ConfigurationProperties. Luego, RRF fusiona estas dos perspectivas. Los documentos que obtienen buenas puntuaciones en ambos métodos ascienden hacia la parte superior, mientras que los resultados sólidos obtenidos solo con una estrategia también pueden llegar a la lista final.
Por lo tanto, el proceso no necesita elegir entre comprender lo que quiere decir el usuario y coincidir con las palabras que utilizó. Con la búsqueda híbrida puede hacer ambas cosas.
Leer más
- Búsqueda híbrida para RAG: Fusión en la práctica de BM25, vectores y rango recíproco — Por qué el RAG basado únicamente en vectores pierde identificadores exactos, cómo RRF fusiona BM25 con embeddings, en qué casos la búsqueda híbrida rinde peor y por qué los filtros de metadatos deben aplicarse antes de la fusión.
- Razonamiento sobre un árbol de documentos en lugar de buscar vectores — PageIndex crea un índice jerárquico que permite a un LLM navegar por él, omitiendo el procesamiento en bloques y los embeddings. Cómo funciona, su resultado en FinanceBench y cuándo utilizarlo.