Spring AI: лучшие результаты RAG благодаря гибридному поиску с использованием Lucene и RRF
Сочетайте векторный поиск с алгоритмом BM25 от Lucene в ретривере Spring AI и объединяйте рейтинги с методом Reciprocal Rank Fusion, чтобы RAG находил совпадения как по смыслу, так и по точным терминам.
Источники: Spring AI Recipe: Better RAG Results with Hybrid Search
Векторный поиск хорошо справляется с пониманием смысла запроса. Лексический поиск эффективен для нахождения тех слов, которые были фактически введены. Сочетание их в виде гибридного поиска позволяет приложению типа RAG использовать преимущества обоих подходов.
Поиск по сходству с использованием эмбеддингов хорошо работает для нахождения документов, семантически близких к вопросу, но может оказаться менее надежным, когда в запросе присутствует специфическая терминология, имеющая важное значение. BM25 (Best Match 25) — это алгоритм лексического ранжирования, который действует наоборот: он сопоставляет термины, учитывая частоту их встречаемости, редкость и длину документа. Используя их вместе, можно компенсировать слабые стороны каждого из них.
Здесь предусмотрен гибридный DocumentRetriever для Spring AI, который запрашивает данные из хранилища векторов и индекса BM25, а затем объединяет результаты в единый отсортированный список. BM25 взят из Apache Lucene — зрелой и быстрой библиотеки поиска, которая уже давно является основой поиска в Java. Первое изменение касается процесса ввода данных: теперь каждый фрагмент записывается как в хранилище векторов, так и в Lucene.
Запись фрагментов в хранилище векторов и в Lucene
Начните с добавления Lucene в процесс сборки:
implementation 'org.apache.lucene:lucene-core:10.5.1'
Далее идет компонент, который принимает объекты Document от Spring AI и индексирует их в 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). Поэтому основная часть метода копирует ID, текст и метаданные каждого документа Spring AI в поля нового документа Lucene, который затем добавляется в индекс.
После того как объект для записи готов, его нужно объявить в качестве бина, ввести в 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 в том же проекте, очистите существующие данные векторного хранилища перед повторной загрузкой, иначе будут сохраняться дублирующиеся фрагменты. После завершения загрузки у вас снова должно быть 300 фрагментов в 89 документах, доступных как через векторное хранилище, так и через Lucene.
Запросы к обоим источникам
Основное приложение RAG требует Lucene Core вместе с парсером запросов Lucene:
implementation 'org.apache.lucene:lucene-core:10.5.1'
implementation 'org.apache.lucene:lucene-queryparser:10.5.1'
Теперь создайте компонент, который будет искать в индексе Lucene и возвращать совпадения в виде объектов Document 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();
}
}
Не вдаваясь в детали API поиска Lucene, метод search() берет текст запроса и запрашивает у IndexSearcher документы с наилучшим совпадением, причем параметр topK определяет количество возвращаемых результатов. Как и при индексации, по умолчанию используется алгоритм сходства BM25, и если вы хотите, чтобы это было видно, можно явно настроить поисковик:
searcher.setSimilarity(new BM25Similarity());
Остальная часть метода search() преобразует каждый документ Lucene Document обратно в документ Spring AI Document, чтобы он мог участвовать в работе конвейера RAG.
Теперь существует два независимых способа получения документов. Поиск по векторному сходству с использованием VectorStore эффективен для нахождения семантически связанного контента. Поиск с использованием алгоритма BM25 через LuceneSearch хорошо справляется с поиском важных терминов в запросе. Каждый из этих методов может сам по себе давать полезные результаты, но их сочетание обеспечивает как семантическую точность, так и лексическую точность. 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 реализует интерфейс DocumentRetriever из Spring AI — компонент, отвечающий за загрузку документов для RetrievalAugmentationAdvisor. Ранее использовался VectorStoreDocumentRetriever, который работал исключительно с хранилищем векторов. Здесь его заменили на пользовательскую реализацию.
Метод retrieve() этого класса выполняет два поиска для одного запроса. Сначала он вызывает similaritySearch() для получения 10 наиболее похожих результатов из векторного хранилища, затем запрашивает у Lucene 10 наиболее подходящих результатов по алгоритму BM25. В результате получаются два отсортированных списка: некоторые документы присутствуют в обоих списках, а другие — только в одном. Простое объединение списков приведёт к потере информации о рангировании, поэтому при слиянии необходимо учитывать позицию каждого документа в каждом списке. Именно это и делает метод Reciprocal Rank Fusion.
Слияние с использованием Reciprocal Rank Fusion
Метод слияния выглядит следующим образом:
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 — это константа, обычно равная 60 для RRF, в то время как 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
Убедитесь, что этот путь совпадает с путем, используемым приложением для загрузки данных. Затем измените класс ChatClientBuilderCustomizer, который настраивает RetrievalAugmentationAdvisor, чтобы он использовал 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() объект DocumentJoiner, основанный на лямбда-функции. Задача такого объединителя — слиять несколько списков полученных документов в один набор. Стандартный ConcatenationDocumentJoiner сортирует документы заново по их оценкам, что сводит на нет порядок, только что установленный алгоритмом RRF. Поэтому в коде предусмотрен объединитель, который фактически ничего не делает и оставляет сформированный рейтинг без изменений.
Вы можете подумать, что компонент объединения — это естественное место для использования RRF, и концептуально это действительно так. Такой подход также предполагает разделение векторного поиска и поиска с использованием Lucene на два отдельных механизма поиска. Проблема в том, что конструктор советника принимает только один объект DocumentRetriever, поэтому эти два механизма нельзя настроить независимо, и оба набора результатов передаются в компонент объединения. Проще всего выполнять оба поиска и их объединение внутри HybridDocumentRetriever, а затем использовать компонент объединения для сохранения порядка объединенных результатов. Если позже 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 объединяет результаты обоих подходов. Документы, получившие высокие оценки по обоим критериям, поднимаются вверх списка, в то время как сильные результаты, найденные только с помощью одного из методов, тоже могут попасть в итоговый список.
Поэтому в такой системе не нужно выбирать между пониманием смысла запроса пользователя и совпадением используемых им слов. С помощью гибридного поиска можно делать и то, и другое.
Связанные материалы
- Гибридный поиск в RAG: практическое применение BM25, векторов и метода взаимного рангинга — почему RAG, основанный исключительно на векторах, теряет точные идентификаторы, как метод RRF объединяет BM25 с эмбеддингами, в каких случаях гибридный поиск дает худшие результаты и почему фильтры метаданных должны применяться до объединения данных.
- Расчеты на дереве документов вместо поиска в векторах — PageIndex создает иерархический индекс, позволяющий большой языковой модели навигировать по нему, избегая разбиения на чанки и использования эмбеддингов. Как это работает, результаты тестирования на FinanceBench и когда его следует применять.