Spring AI: Lepsze wyniki RAG dzięki hybrydowemu wyszukiwaniu wykorzystującemu Lucene i RRF
Połącz wyszukiwanie wektorowe z Lucene BM25 w retriewerze Spring AI oraz połącz rankingi za pomocą Reciprocal Rank Fusion, aby RAG odpowiadał zarówno znaczeniu, jak i dokładnym terminom.
Źródła: Spring AI Recipe: Better RAG Results with Hybrid Search
Wyszukiwanie wektorowe dobrze radzi sobie z zrozumieniem znaczenia zapytania. Wyszukiwanie leksykalne skutecznie znajduje słowa, które faktycznie zostały wpisane. Połączenie ich w formie wyszukiwania hybrydowego pozwala aplikacjom typu RAG wykorzystać zalety obu metod.
Wyszukiwanie podobieństwa na podstawie embeddingów dobrze sprawdza się przy znajdowaniu dokumentów semantycznie bliskich danemu pytaniu, ale może być mniej niezawodne, gdy zapytanie zawiera specyficzną terminologię o dużym znaczeniu. BM25 (Best Match 25) to algorytm rankingu leksykalnego, który postępuje odwrotnie: dopasowuje terminy, biorąc pod uwagę częstotliwość ich występowania, rzadkość oraz długość dokumentu. Stosowane razem, oba te podejścia kompensują swoje słabe strony.
Plan polega na stworzeniu hybrydowego DocumentRetriever dla Spring AI, który wyszukuje w magazynie wektorowym oraz indeksie BM25 i łączy wyniki w jedną uporządkowaną listę. BM25 pochodzi z Apache Lucene – dojrzałej, szybkiej biblioteki do wyszukiwania, która od dawna stanowi filar systemów wyszukiwania w Javie. Pierwszą zmianą jest proces wprowadzania danych, tak aby każdy fragment był zapisywany zarówno w magazynie wektorowym, jak i w Lucene.
Zapisywanie fragmentów do magazynu wektorowego i Lucene
Najpierw należy dodać Lucene do procesu budowania aplikacji:
implementation 'org.apache.lucene:lucene-core:10.5.1'
Następnie pojawia się komponent, który przyjmuje obiekty Document z Spring AI i indeksuje je w Lucene. Jest to 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();
}
}
Obecne wersje Lucene używają BM25 jako domyślnej miary podobieństwa, więc nie jest potrzebne nic specjalnego, aby ją włączyć. Jeśli wolisz jasno określić tę decyzję, dodaj tę linię po utworzeniu IndexWriterConfig:
config.setSimilarity(new BM25Similarity());
Kod zaimportowania wywołuje metodę add(), aby umieścić dokumenty w Lucene. Jego parametr typu List<Document> przechowuje dokumenty Spring AI (org.springframework.ai.document.Document), podczas gdy Lucene indeksuje swój własny typ dokumentu (org.apache.lucene.document.Document). Dlatego główna część metody kopiuje ID, tekst oraz metadane każdego dokumentu Spring AI do pól nowego dokumentu Lucene, który następnie jest dodawany do indeksu.
Gdy narzędzie do zapisu jest gotowe, należy je zadeklarować jako bean, wstrzyknąć je do klasy ApplicationRunner odpowiedzialnej za importowanie, a następnie zapisać każdą partię danych do obu magazynów:
@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
};
}
Ścieżka indeksu jest dostarczana za pomocą @Value, więc ustaw ją w pliku application.properties:
lucene.index.path=/tmp/lucene/boot-docs
Każda ścieżka będzie odpowiednia, ale pamiętaj o niej, ponieważ główne aplikacje RAG muszą otwierać ten sam indeks Lucene.
Jeśli pracowałeś nad wcześniejszym krokiem HyDE w tym samym projekcie, usuń istniejące dane z magazynu wektorowego przed ponownym uruchomieniem procesu pobierania, w przeciwnym razie zostaną zapisane duplikatyczne fragmenty. Po zakończeniu procesu pobierania powinno znów być 300 fragmentów w 89 dokumentach, dostępnych zarówno przez magazyn wektorowy, jak i Lucene.
Wyszukiwanie w obu źródłach
Główne aplikacje RAG wymagają Lucene Core wraz z parserem zapytań Lucene:
implementation 'org.apache.lucene:lucene-core:10.5.1'
implementation 'org.apache.lucene:lucene-queryparser:10.5.1'
Teraz utwórz komponent, który przeszukuje indeks Lucene i zwraca wyniki jako obiekty Document z 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();
}
}
Bez konieczności zgłębiania API wyszukiwania Lucene, metoda search() bierze tekst zapytania i prosi IndexSearcher o dostarczenie dokumentów najbardziej odpowiadających temu zapytaniu, przy czym parametr topK określa liczbę takich dokumentów. Podobnie jak przy indeksowaniu, domyślną miarą podobieństwa jest BM25, a jeśli chcesz to uwidocznić, możesz wyraźnie skonfigurować narzędzie wyszukiwania:
searcher.setSimilarity(new BM25Similarity());
Pozostała część metody search() przekształca każdy dokument Lucene Document z powrotem w dokument Spring AI Document, aby mógł brać udział w procesie RAG.
Obecnie istnieją dwa niezależne sposoby na odzyskiwanie dokumentów. Szukanie podobieństwa wektorowego za pomocą VectorStore doskonale radzi sobie z znajdowaniem treści semantycznie powiązanych. Szukanie BM25 za pomocą LuceneSearch skutecznie dopasowuje ważne terminy z zapytania. Każdy z tych metod sam w sobie może dostarczyć przydatnych wyników, ale ich połączenie zapewnia zarówno sens, jak i precyzję leksykalną. HybridDocumentRetriever zajmuje się tym połączeniem.
Hybrydowy odbieracz dokumentów
Oto jego istota:
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 implementuje klasę DocumentRetriever z Spring AI, komponent odpowiedzialny za pobieranie dokumentów dla RetrievalAugmentationAdvisor. W poprzednich krokach używano VectorStoreDocumentRetriever, który czerpie dane wyłącznie z magazynu wektorowego. Tutaj został on zastąpiony własną implementacją.
Jego metoda retrieve() przeprowadza dwie wyszukiwania na jeden zapytanie. Najpierw wywołuje similaritySearch() w celu uzyskania 10 najlepszych wyników wektorowych. Następnie prosi Lucene o 10 najlepszych wyników metodą BM25. Rezultatem są dwie uporządkowane listy – niektóre dokumenty pojawiają się w obu, a inne tylko w jednej. Połączenie tych list od końca do końca spowodowałoby utratę informacji o rankingu, dlatego fuzja musi uwzględniać pozycję każdego dokumentu w każdej z list. Właśnie to robi metoda Reciprocal Rank Fusion.
Fuzja za pomocą Reciprocal Rank Fusion
Metoda fuzji wygląda następująco:
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);
}
}
Fuzja wzajemnych rankingów (RRF) łączy kilka zestawów wyników posortowanych w jeden ranking. Zamiast próbować porównywać surowe oceny pochodzące z dwóch zupełnie różnych algorytmów wyszukiwania, ocenia dokumenty na podstawie ich pozycji w poszczególnych listach. Dla każdej listy dokument otrzymuje:
1 / (k + rank)
Tutaj k to stała, zwykle równa 60 dla RRF, natomiast rank oznacza pozycję dokumentu na tej liście. Dokument znajdujący się na obu listach otrzymuje sumę obu wyników. Wynik jest intuicyjny: dokumenty wysoko uplasowane w jednym z wyszukiwań radzą sobie dobrze, a te wysoko uplasowane w obu zazwyczaj awansują na czoło, co jest właśnie celem łączenia wyszukiwania semantycznego z leksykalnym.
Na końcu funkcji retrieve() zapisywane są wszystkie trzy rankingi, dzięki czemu efekt jest widoczny:
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);
}
}
To logowanie nie jest konieczne dla samego wyszukiwania hybrydowego, ale podczas eksperymentowania umożliwia porównanie układów wektorowych, BM25 oraz połączonych metod sortowania obok siebie.
Konfiguracja hybrydowego wyszukiwania
Zarejestruj LuceneSearch i HybridDocumentRetriever jako beany:
@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);
}
Tak jak w przypadku pobierania danych, LuceneSearch wymaga lokalizacji indeksu Lucene:
lucene.index.path=/tmp/lucene/boot-docs
Upewnij się, że odpowiada to ścieżce użytej przez aplikację do pobierania danych. Następnie zmień ChatClientBuilderCustomizer, który konfiguruje RetrievalAugmentationAdvisor, tak aby używał HybridDocumentRetriever zamiast 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);
};
}
Oprócz przekazania funkcji documentRetriever(), personalizator dostarcza również documentJoiner() w postaci DocumentJoiner opartego na lambda. Zadaniem tego narzędzia jest połączenie kilku list pozyskanych dokumentów w jedną kolekcję. Domyślny ConcatenationDocumentJoiner ponownie sortuje dokumenty według ich oceny, co unieważniałoby uporządkowanie właśnie utworzone przez RRF. Dlatego kod dostarcza narzędzie, które w zasadzie nic nie robi i pozostawia połączony ranking bez zmian.
Można by pomyśleć, że joiner to naturalne miejsce dla RRF, i koncepcyjnie rzeczywiście tak jest. Taki projekt sugerowałby również podzielenie wyszukiwania wektorowego i wyszukiwania za pomocą Lucene na dwa oddzielne mechanizmy odzyskiwania danych. Problem polega na tym, że konstruktor advisora przyjmuje tylko jeden DocumentRetriever, więc oba mechanizmy nie mogą być konfigurowane niezależnie, a oba zbiory wyników nie mogą być przekazywane do joinera. Prostszym rozwiązaniem jest przeprowadzenie obu wyszukiwań oraz fuzji wewnątrz HybridDocumentRetriever, a następnie użycie joinera typu pass-through, aby zachować porządek po fuzji. Jeśli Spring AI w przyszłości będzie obsługiwał kilka mechanizmów odzyskiwania danych, warto ponownie rozważyć taki projekt.
To wszystko dotyczy połączeń między komponentami. RetrievalAugmentationAdvisor nie musi wiedzieć, że używane są dwie strategie wyszukiwania. Dla niego HybridDocumentRetriever to po prostu komponent dostarczający dokumenty dodawane do kontekstu promptu.
Widzenie różnicy
Włącz logowanie debugowe dla HybridDocumentRetriever, aby móc porównać trzy rankingi:
logging.level.com.example.essentialrag.HybridDocumentRetriever=debug
Zainstaluj aplikację i zadaj pytanie dotyczące używania @ConfigurationProperties do mapowania konfiguracji z zewnątrz na obiekt Java z walidacją. Wyniki debugowania pokażą trzy zestawy wyników oznaczone jako VECTOR, BM25 i FUSED. Rankowanie wektorowe zaczyna się w następujący sposób:
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 wytwarza inny porządek:
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...
...
A wynik RRF łączy je ze sobą:
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...
...
Kontrast ten pokazuje, dlaczego wyszukiwanie hybrydowe jest skuteczne. Wyszukiwanie wektorowe dobrze radzi sobie z koncepcyjnymi aspektami pytania: konfiguracją zewnętrzną, powiązaniami, obiektami Java oraz weryfikacją. BM25 dodaje dopasowanie leksykalne i przywiązuje większą wagę do specyficznych terminów, takich jak ConfigurationProperties. Następnie RRF łączy te dwa podejścia. Dokumenty, które uzyskują wysokie wyniki w obu metodach, znajdują się na górze listy, natomiast mocne wyniki uzyskane tylko jedną strategią mogą nadal trafić do końcowej listy.
Dlatego ten proces nie musi wybierać między zrozumieniem intencji użytkownika a dopasowaniem użytych przez niego słów. Dzięki wyszukiwaniu hybrydowemu może robić jedno i drugie.
Literatura pokrewna
- Hybrydowe wyszukiwanie w RAG: praktyczne zastosowanie BM25, wektorów i fuzji ranku recyprokalnego — Dlaczego RAG oparte wyłącznie na wektorach traci dokładne identyfikatory, jak RRF łączy BM25 z embeddingami, w jakich przypadkach hybrydowe wyszukiwanie sprawdza się słabo i dlaczego filtry metadanych powinny być stosowane przed fuzją.
- Rozumowanie na podstawie drzewa dokumentów zamiast wyszukiwania wektorów — PageIndex tworzy hierarchiczną indeksację, która umożliwia modelom językowym nawigację po niej, pomijając proces dzielenia na fragmenty i użycie embeddingów. Jak to działa, wyniki w testach FinanceBench oraz kiedy należy go stosować.