Spring AI: лепшыя вынікі RAG з гібрыдным пошукам на Lucene і RRF
Спалучаем вектарны пошук з Lucene BM25 у рэтрыверы Spring AI і зліваем рэйтынгі праз Reciprocal Rank Fusion, каб RAG улічваў і сэнс, і дакладныя тэрміны.
Крыніцы: Spring AI Recipe: Better RAG Results with Hybrid Search
Вектарный пошук добра ў розумеўцы значэння запіту. Лексычны пошук добра ў пошуку тых слоў, якія былі фактычна надрукаваны. Якша ўжыць іх адночасна як гібрыдны пошук, тады прыкладнэ рашэнне на аднойчыні выкарыстоўвае перавагі обох.
Пошук за сэмантычным супаданнем па эмбедынгах добра працуе для выявлення дакументаў, якія сэмантычна блізкія да запіту, але можа быць менш надзеяным, калі запіт чыніць упор на спецыфічную тэрміналогію. BM25 (Best Match 25) — гэта лексычны алгорытм ранжыравання, які робіць працоўнае працоўнае: ён падбірае тэрміны, вазуячы частоту ўпынку, ўзнікласць і дзейсны тымп дакумента. Якша ўжыць іх адночасна, яны падкрываюць адна другую ў своіх слепых пунктах.
У даным плане выкорыстоўваецца гібрыдны DocumentRetriever для Spring AI, які запытвае сховішча вектараў і індэкс BM25, а пасля спаўнюе рэзультаты ў адны рангаваны спіс. BM25 ўзяты з Apache Lucene — зрэлай і шырока вжыванай бібліятакі для пошуку, якая ўжо давга ёсць основай для пошуку ў Java. Першая змяна стосуецца процэсу прыемкі дадзеных, так што кожны фрагмент запісваецца як у сховішча вектараў, так і ў Lucene.
Запіс фрагментаў у сховішча вектараў і ў Lucene
Пачніце з дадавання Lucene да працэсу стварэння праграмы:
implementation 'org.apache.lucene:lucene-core:10.5.1'
Далей ідзе компанент, які прымае об’екты Spring AI Document і індэксуе іх у 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, які пасля таго дадаецца ў індэкс.
Калі рэйтар готавы, заявіце яго як bean, увядзіце яго ў 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. Таму код прадае клас-з’еднавальнік, які практычна нічога не рабіць і залішае фузаваны рангінг без змян.
Можна думаць, што элемент joiner ёсць наявным месца для RRF, і канцэптуальна ён такі. Такія дыяграма таксама радзіць роздзеліць вектарны пошук і пошук за дапамою Lucene на два адзінольныя механізмы аднаходжэння дакументаў. Проблема заключаецца ў тым, што кампонент будоўніцы-кансалтанта прымае толькі аднаго DocumentRetriever, таму гэтыя два механізмы не можна настаўіць незалежна, і оба наборы рэзультатаў не можна перадаць элементу joiner. Простыяй спосаб — адрабатваць оба пошуку і ўзаўме з’еднанне ўнутры HybridDocumentRetriever, а пасля выкарыстоўваць элемент joiner для падтрымкі правільнага парадку з’еднаных рэзультатаў. Якшча 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...
...
Контраст паказвае, чаму гібрыдны пошук застаецца практычным. Векторны пошук добра працуе з канцэптуальныяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяя
- Гібрыдныя пошукавыя методы для RAG: практычны выкарыстоўванне BM25, вектараў і методу взаімнага рангу — Чаму RAG, які выкарыстоўвае толькі вектары, трапляецца без точных ідэнтыфікатораў, як метод RRF спаўнае BM25 з эмбедынгамі, дзе гібрыдныя пошукавыя методы паспелі гірша і чаму фільтры метадаў павінны выконвацца раней за спаўненне.
- Расчыненне задач на дрэве дакументаў заместо пошуку вектараў — PageIndex стварае іерархічны індэкс, які дазволяе LLM навігаваць у яму, адмовіваючыся ад разбівання на часткі і вікорыстоўвання эмбедынгаў. Як гэта працюе, рэзультаты на тэстах FinanceBench і калі ўжываць гэты падход.