Spring AI: Bessere RAG-Ergebnisse durch hybrischen Suchalgorithmus unter Verwendung von Lucene und RRF
Kombinieren Sie die Vektorsuche mit Lucene BM25 in einem Spring AI Retriever und fügen Sie die Ranglisten mithilfe von Reciprocal Rank Fusion zusammen, damit RAG sowohl Bedeutung als auch exakte Begriffe berücksichtigt.
Quellen: Spring AI Recipe: Better RAG Results with Hybrid Search
Die Vektorsuche ist gut darin, zu verstehen, was eine Abfrage bedeutet. Die lexikalische Suche hingegen ist gut darin, die tatsächlich eingegebenen Wörter zu finden. Wenn man beides zu einer hybriden Suche kombiniert, nutzt eine RAG-Anwendung die Stärken beider Ansätze.
Die Ähnlichkeitssuche anhand von Embeddings funktioniert gut, um Dokumente zu finden, die semantisch einer Frage nahekommen, kann jedoch weniger zuverlässig sein, wenn die Abfrage spezifische, wichtige Fachbegriffe enthält. BM25 (Best Match 25) ist ein lexikalischer Ranking-Algorithmus, der das Gegenteil tut: Er vergleicht Begriffe und berücksichtigt dabei, wie oft sie vorkommen, wie selten sie sind und wie lang das Dokument ist. Wenn beide Methoden nebeneinander verwendet werden, ergänzen sie sich gegenseitig bei ihren Schwachstellen.
Der Plan hier ist ein hybrider DocumentRetriever für Spring AI, der einen Vektor-Speicher sowie einen BM25-Index abfragt und die Ergebnisse zu einer einzigen, gerankten Liste zusammenführt. BM25 stammt von Apache Lucene, der ausgereifte, schnelle Suchbibliothek, die seit Langem eine Schlüsselrolle bei Suchfunktionen in Java spielt. Die erste Änderung betrifft die Eingabe, sodass jeder Datenblock sowohl im Vektor-Speicher als auch in Lucene gespeichert wird.
Datenblöcke in den Vektor-Speicher und in Lucene schreiben
Beginnen Sie damit, Lucene zur Projektkonfiguration hinzuzufügen:
implementation 'org.apache.lucene:lucene-core:10.5.1'
Danach kommt ein Komponente, die Spring AI Document-Objekte entgegennimmt und sie in Lucene indiziert. Das ist 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();
}
}
Die aktuellen Lucene-Versionen verwenden BM25 als Standard-Similarity-Methode, sodass nichts Besonderes erforderlich ist, um sie zu aktivieren. Wenn Sie die Wahl ausdrücklich angeben möchten, fügen Sie nach der Erstellung von IndexWriterConfig diese Zeile hinzu:
config.setSimilarity(new BM25Similarity());
Der Einpflegungscode ruft add() auf, um Dokumente in Lucene einzufügen. Sein Parameter List<Document> enthält Spring AI-Dokumente (org.springframework.ai.document.Document), während Lucene eine eigene Dokumenttypenstruktur indiziert (org.apache.lucene.document.Document). Daher kopiert der Methodeninhalt in erster Linie die ID, den Text sowie die Metadaten jedes Spring AI-Dokuments in die Felder eines neuen Lucene-Dokuments, das anschließend dem Index hinzugefügt wird.
Sobald der Writer bereit ist, deklariert man ihn als Bean, injiziert ihn in den ApplicationRunner, der die Einpflegung durchführt, und schreibt jede Batch von Datenblöcken in beide Speicher:
@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
};
}
Der Index-Pfad wird über @Value übergeben, daher geben Sie ihn in application.properties ein:
lucene.index.path=/tmp/lucene/boot-docs
Jeder Pfad ist geeignet, aber merken Sie ihn sich, denn die Hauptanwendung von RAG muss denselben Lucene-Index öffnen.
Falls Sie bereits in einem früheren HyDE-Schritt desselben Projekts gearbeitet haben, löschen Sie die vorhandenen Daten des Vektorlagers, bevor Sie die Eingabe erneut ausführen – andernfalls werden doppelte Blöcke gespeichert. Nach Abschluss der Eingabe sollten wieder 300 Blöcke in 89 Dokumenten vorhanden sein, die nun sowohl über den Vektorlager als auch über Lucene zugänglich sind.
Abfragen aus beiden Quellen
Die Hauptanwendung von RAG benötigt Lucene Core sowie Lucenes Abfragesyntaxparser:
implementation 'org.apache.lucene:lucene-core:10.5.1'
implementation 'org.apache.lucene:lucene-queryparser:10.5.1'
Erstellen Sie nun eine Komponente, die den Lucene-Index durchsucht und die Treffer als Spring AI Document-Objekte zurückgibt:
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();
}
}
Ohne tief in die Such-API von Lucene einzutauchen, nimmt search() den Abfragetext und bittet einen IndexSearcher um die am besten passenden Dokumente, wobei topK bestimmt, wie viele zurückgegeben werden. Wie bei der Indizierung ist BM25 die Standardähnlichkeitsmessung, und falls Sie dies sichtbar machen möchten, können Sie den Sucher explizit konfigurieren:
searcher.setSimilarity(new BM25Similarity());
Der Rest von search() wandelt jedes Lucene Document wieder in ein Spring AI Document um, damit es am RAG-Pipeline teilnehmen kann.
Es gibt nun zwei unabhängige Möglichkeiten, Dokumente abzurufen. Die Vektorähnlichkeitssuche über VectorStore ist gut darin, semantisch verwandte Inhalte zu finden. Die BM25-Suche über LuceneSearch eignet sich hervorragend, um die wichtigen Begriffe in der Abfrage zu erkennen. Jede Methode kann für sich genommen nützliche Ergebnisse liefern, doch ihre Kombination bringt sowohl Bedeutung als auch lexikalische Präzision mit sich. HybridDocumentRetriever kümmert sich um diese Kombination.
Der hybride Dokumentabrufmechanismus
Das ist der Kern davon:
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 implementiert DocumentRetriever von Spring AI, den Komponenten, der Dokumente für RetrievalAugmentationAdvisor abruft. Frühere Schritte nutzten VectorStoreDocumentRetriever, der ausschließlich aus einem Vektorlagern zieht. Hier wird er durch eine eigene Implementierung ersetzt.
Seine retrieve()-Methode führt für eine Abfrage zwei Suchvorgänge durch. Zuerst ruft sie similaritySearch() für die 10 besten Vektor-Ergebnisse auf. Anschließend bittet sie Lucene um die 10 besten BM25-Ergebnisse. Das Ergebnis sind zwei gerankte Listen, wobei einige Dokumente in beiden auftauchen und andere nur in einer. Ein einfaches Verknüpfen der Listen würde die Ranginformationen verlieren, daher muss die Fusion die Position jedes Dokuments in jeder Liste berücksichtigen. Reciprocal Rank Fusion tut genau das.
Verknüpfung mit Reciprocal Rank Fusion
Die Fusionsmethode sieht wie folgt aus:
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);
}
}
Reciprocal Rank Fusion (RRF) wandelt mehrere gerankte Ergebnismengen in eine einzige Rangliste um. Anstatt zu versuchen, Rohwerte von zwei sehr unterschiedlichen Suchalgorithmen zu vergleichen, bewertet sie Dokumente anhand ihrer Platzierung in jeder Liste. Für jede Liste erhält ein Dokument:
1 / (k + rank)
Hier ist k eine Konstante, die in der Regel 60 für RRF ist, während rank angibt, an welcher Stelle das Dokument in dieser Liste steht. Ein Dokument, das in beiden Listen vorkommt, erhält die beiden Bewertungen addiert. Das Ergebnis ist intuitiv: Dokumente, die bei einer der Suchmethoden hoch eingestuft werden, schneiden gut ab, und Dokumente, die bei beiden Methoden hoch eingestuft werden, neigen dazu, an die Spitze zu gelangen – genau das ist das Ziel bei der Kombination semantischer und lexikalischer Suche.
Am Ende von retrieve() werden alle drei Ranglisten protokolliert, sodass der Effekt sichtbar wird:
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);
}
}
Diese Protokollierung ist für die hybride Suche an sich nicht notwendig, ermöglicht es Ihnen aber beim Experimentieren, Vektor-, BM25- und kombinierte Sortierungen nebeneinander zu vergleichen.
Einrichtung der hybriden Suche
Registrieren Sie LuceneSearch und HybridDocumentRetriever als 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);
}
Genau wie bei der Einholung benötigt LuceneSearch die Lage des Lucene-Index:
lucene.index.path=/tmp/lucene/boot-docs
Stellen Sie sicher, dass dies mit dem Pfad übereinstimmt, den die Einpflegungsanwendung verwendet hat. Ändern Sie anschließend den ChatClientBuilderCustomizer, der RetrievalAugmentationAdvisor konfiguriert, sodass er anstelle von VectorStoreDocumentRetriever HybridDocumentRetriever verwendet:
@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);
};
}
Neben der Übermittlung des Retrievers über documentRetriever() übergibt der Anpasser auch an documentJoiner() einen auf Lambda basierenden DocumentJoiner. Die Aufgabe eines Joiners besteht darin, mehrere Listen mit abgerufenen Dokumenten in eine einzige Sammlung zu verschmelzen. Der Standard-ConcatenationDocumentJoiner sortiert die Dokumente erneut nach deren Score, was die gerade von RRF erzeugte Sortierung rückgängig machen würde. Deshalb stellt der Code einen im Grunde nutzlosen Joiner bereit, der die zusammengeführte Rangliste unverändert lässt.
Man könnte denken, dass der Joiner der natürliche Ort für RRF ist – und konzeptionell stimmt das auch. Ein solches Design würde außerdem vorschlagen, die Vektorabfrage und die Lucene-Abfrage in zwei separate Retriever aufzuteilen. Das Problem ist jedoch, dass der Builder des Advisors nur einen DocumentRetriever akzeptiert, wodurch die beiden Mechanismen nicht unabhängig konfiguriert und beide Ergebnismengen dem Joiner übergeben werden können. Der einfachere Ansatz besteht darin, beide Abfragen sowie die Fusion innerhalb von HybridDocumentRetriever durchzuführen und anschließend einen Pass-Through-Joiner zu verwenden, um die gefusionierte Reihenfolge beizubehalten. Sollte Spring AI später mehrere Retriever unterstützen, lohnt es sich, das Design erneut zu überdenken.
Das ist die gesamte Verkabelung. RetrievalAugmentationAdvisor muss nicht wissen, dass zwei Suchstrategien zum Einsatz kommen. Für ihn ist HybridDocumentRetriever einfach nur der Komponente, die die Dokumente liefert, die dem Prompt-Kontext hinzugefügt werden.
Der Unterschied erkennen
Aktivieren Sie das Debug-Logging für HybridDocumentRetriever, damit Sie die drei Ranglisten vergleichen können:
logging.level.com.example.essentialrag.HybridDocumentRetriever=debug
Starten Sie die Anwendung und stellen Sie eine Frage zur Verwendung von @ConfigurationProperties, um externe Konfigurationen mit Validierung auf ein Java-Objekt abzubilden. Die Debug-Ausgabe zeigt drei Ergebnismengen mit den Bezeichnungen VECTOR, BM25 und FUSED. Die Vektor-Rangliste beginnt wie folgt:
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 erzeugt eine andere Reihenfolge:
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...
...
Und die RRF-Ausgabe mischt sie zusammen:
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...
...
Der Kontrast zeigt, warum die hybride Suche ihre Berechtigung hat. Die Vektorsuche bewältigt die konzeptionellen Aspekte der Frage gut: externe Konfiguration, Bindung, Java-Objekte und Validierung. BM25 fügt lexikale Abgleiche hinzu und legt Wert auf charakteristische Begriffe wie ConfigurationProperties. RRF verschmilzt anschließend die beiden Ansätze. Dokumente, die in beiden Methoden gute Ergebnisse erzielen, rücken an die Spitze, während starke Treffer, die nur durch eine Strategie gefunden werden, dennoch in die endgültige Liste gelangen können.
Daher muss der Prozess nicht zwischen dem Verstehen dessen, was der Benutzer meint, und dem Abgleich der von ihm verwendeten Wörter wählen. Mit der hybriden Suche kann er beides gleichzeitig erledigen.
Verwandte Literatur
- Hybride Suche für RAG: Praktische Anwendung von BM25, Vektoren und der rekursiven Rangfunktion — Warum RAG, das nur Vektoren verwendet, genaue Identifikatoren verliert, wie RRF BM25 mit Embeddings verbindet, wo die hybride Suche schlechter abschneidet und warum Metadatenfilter vor der Fusion kommen sollten.
- Logisches Arbeiten anhand eines Dokumentbaums anstelle der Suche in Vektoren — PageIndex erstellt einen hierarchischen Index, der es einem LLM ermöglicht, diesen zu durchqueren, wodurch das Aufteilen in Blöcke sowie die Verwendung von Embeddings entfällt. Wie es funktioniert, die Ergebnisse im FinanceBench-Test sowie wann es eingesetzt werden sollte.