Spring AI проти LangChain4j: один і той самий RAG, різні приховані варіанти вибору
Та сама додаток Java RAG, створена двічі: Spring AI проти LangChain4j — різниця у кількості рядків зменшується завдяки налаштуванням Spring, а неправильна відповідь виявила стандартні налаштування чанкінгу.
Джерела: Spring AI Vs LangChain4j: Same RAG Pipeline, One Was 3× Less Code
Ту саму схему RAG було створено двічі. Перші результати здавалися вирішальними. Одне експериментування змінило висновок.
Видалення 85 рядків коду на Java з сервісу RAG не змінило нічого того, що могли б побачити користувачі — і поклало край двотижневим суперечкам команди щодо фреймворків.
Шанувальники Spring AI та LangChain4j принесли по своїх слайд-презентаціях. Але ніхто не приніс дубльованої реалізації. Обидві версії були написані за ідентичних умов.
Умови залишилися незмінними: один PDF з 240 сторінками про HR, одна модель ембеддингу, Postgres разом із pgvector, одна модель чату та набір з 200 запитань, визначений ще до створення будь-якого кодового базису.
Мета: якщо поведінка відрізнятиметься, причиною має бути фреймворк — а не відмінності в архітектурі між двома ручно створеними рішеннями.
Спільна структура схеми
У процесі обробки інформації не було нічого незвичайного:
Question
|
v
Embed Query
|
v
Vector Search
|
v
Top 4 Chunks
|
v
Build Context
|
v
Chat Model
|
v
Answer
Процес отримання даних також був навмисно одноманітним:
PDF
↓
Extract Text
↓
Split Into Chunks
↓
Generate Embeddings
↓
Store In pgvector
Метою було порівняння фреймворків, а не створення нової архітектури, яка б ускладнила результати.
Версія Spring AI
Конфігурація залишилась дуже простою:
spring:
ai:
openai.api-key: ${OPENAI_KEY}
vectorstore.pgvector:
initialize-schema: true
Обробка даних як компонент Spring:
@Component
class Ingest {
Ingest(VectorStore store) {
var docs =
new TikaDocumentReader(
"classpath:/docs/handbook.pdf").get();
store.add(new TokenTextSplitter().apply(docs));
}
}
HTTP-інтерфейс для запитань:
@RestController
class AskApi {
private final ChatClient ai;
AskApi(ChatClient.Builder b, VectorStore store) {
this.ai = b.defaultAdvisors(
new QuestionAnswerAdvisor(store)).build();
}
@GetMapping("/ask")
String ask(@RequestParam String q) {
return ai.prompt().user(q).call().content();
}
}
QuestionAnswerAdvisor приховував процес пошуку інформації, формування контексту та вставку запитів. Для обробки пошуку майже не знадобився власний код — що спочатку здавалося перевагою, але потім виявилося інакше.
Версія LangChain4j
У цій версії з самого початку було видно більше складових:
var embed =
OpenAiEmbeddingModel.builder()
.apiKey(key)
.build();
var store =
PgVectorEmbeddingStore.builder()
.host("localhost")
.port(5432)
.database("rag")
.user("app")
.password(pw)
.table("chunks")
.dimension(1536)
.build();
EmbeddingStoreIngestor.builder()
.documentSplitter(
DocumentSplitters.recursive(500, 60))
.embeddingModel(embed)
.embeddingStore(store)
.build()
.ingest(
FileSystemDocumentLoader.loadDocument(path));
Створення механізму пошуку також було відкритим для перегляду:
var retriever =
EmbeddingStoreContentRetriever.builder()
.embeddingStore(store)
.embeddingModel(embed)
.maxResults(4)
.minScore(0.6)
.build();
Читабельно — так. Але чи компактніше, ніж у Spring AI? Спочатку — ні. Кількість рядків коду в додатку на Java становила майже 41 проти 126 — приблизно в три рази більше у версії LangChain4j. Здавалося, що питання короткості вирішене на користь останньої.
Потім система відповіла на запитання щодо батьківської відпустки.
Відповідь, якої не було в посібнику
Коли запитали, скільки днів батьківської відпустки передбачено посібником, модель впевнено відповіла:
Employees are entitled to 12 days of paternity leave,
subject to the conditions listed in the policy.
У посібнику сказано 15 днів. Число 12 ніколи не згадується в цьому документі. Аналіз контексту розкрив справжню причину: таблиці з правилами були розрізані за замовчуванням. Відповідні рядки розподілилися на частини; система збирала окремі, правдоподібні фрагменти та склеювала їх у некоректну, але логічну відповідь.
Ця помилка існувала до появи моделі для чату. Кількість рядків у фреймворку не дозволяла прогнозувати якість.
Різниця в 3 рази насправді була не через фреймворк
Найважливіший рядок:
new TokenTextSplitter().apply(docs)
Одне лише викликання призводило до ухвалення рішень, які не були перевірені: обробка таблиць, межі блоків даних, перекриття, збереження метаданих та те, що насправді бачить механізм пошуку. Spring AI ускладнював ігнорування цих рішень. LangChain4j робив більше з них видимими у коді додатку.
У першому порівнянні також виявилася різниця між Spring AI, інтегрованим у Spring Boot, та більш ручно налаштовуваним LangChain4j. Перебудова LangChain4j із його інтеграцією в Spring зменшила кількість рядків коду додатку до 58. Розбіжність скоротилася з приблизно 3× до приблизно 1,4×. Складність перемістилася всередину фреймворку, а не зникла з системи.
Що обирати на практиці
- Існуючі сервіси Spring Boot → Spring AI для дотримання стандартів та меншої кількості формальностей у звичайних сценаріях RAG.
Не обирайте лише за кількістю рядків. Після створення обох варіантів корисним критерієм стає: скільки варіантів конвеєра ви готові залишити у стандартних налаштуваннях фреймворку? Почніть з цього — перш ніж хтось видалить вісімдесят п’ять рядків та проголосить перемогу.