Strona główna / Artykuły / Spring AI kontra LangChain4j: ten sam RAG, różne ukryte opcje

Spring AI kontra LangChain4j: ten sam RAG, różne ukryte opcje

Dwa warianty Java RAG oparte na tym samym przewodniku pokazały, że różnice w liczbie wierszy zmniejszają się przy integracji z Springiem, a błędna odpowiedź dotycząca urlopu ujawniła domyślne ustawienia fragmentacji.

736 słów

Ta sama ścieżka RAG została zbudowana dwa razy. Pierwsze wyniki wydawały się decydujące. Jedno doświadczenie zmieniło te wnioski.

Usunięcie 85 linijek kodu w Javie z usługi RAG nie zmieniło niczego, co użytkownicy mogliby zobaczyć — i położyło kres dwutygodniowej kłótni zespołu na temat frameworków.

Fani Spring AI oraz LangChain4j przynieśli po swoich slajdach. Nikt jednak nie przygotował identycznej implementacji. Obie wersje zostały napisane przy identycznych warunkach.

Warunki pozostały niezmienne: jeden 240-stronicowy plik PDF z dziedziny HR, jeden model embeddingu, Postgres w połączeniu z pgvector, jeden model do czatowania oraz zestaw 200 pytań ustalony jeszcze przed stworzeniem obu baz kodu.

Cel: jeśli zachowanie się różniło, przyczyną powinien być framework — a nie różnice w architekturze pomiędzy dwoma ręcznie opracowanymi rozwiązaniami.

Podobna struktura ścieżki

Na ścieżce przetwarzania nie było nic egzotycznego:

Question
   |
   v
Embed Query
   |
   v
Vector Search
   |
   v
Top 4 Chunks
   |
   v
Build Context
   |
   v
Chat Model
   |
   v
Answer

Proces pobierania danych został celowo uczyniony równie nudnym:

PDF
 ↓
Extract Text
 ↓
Split Into Chunks
 ↓
Generate Embeddings
 ↓
Store In pgvector

Celem było porównanie ram, a nie nowoczesna architektura, która mogłaby zamieszać wyniki.

wersja Spring AI

Konfiguracja pozostała bardzo prosta:

spring:
  ai:
    openai.api-key: ${OPENAI_KEY}
    vectorstore.pgvector:
      initialize-schema: true

Przyjmowanie danych jako komponent Spring:

@Component
class Ingest {
  Ingest(VectorStore store) {
    var docs =
        new TikaDocumentReader(
            "classpath:/docs/handbook.pdf").get();

store.add(new TokenTextSplitter().apply(docs));
  }
}

Interfejs HTTP do zadawania pytań:

@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 ukrywał proces wyszukiwania informacji, budowania kontekstu oraz wstrzykiwania promptów. Ścieżka wyszukiwania ledwo wymagała specjalnego kodu — co wydawało się zaletą, dopóki nie nadeszła późniejsza faza.

wersja LangChain4j

Jasne przedstawienie procesu budowy pokazywało od razu więcej elementów:

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));

Budowa mechanizmu wyszukiwania była równie przejrzysta:

var retriever =
    EmbeddingStoreContentRetriever.builder()
        .embeddingStore(store)
        .embeddingModel(embed)
        .maxResults(4)
        .minScore(0.6)
        .build();

Czy jest czytelna? Tak. Czy jest bardziej zwięzła w porównaniu z Spring AI? Nie od razu. Liczba linii kodu w aplikacji w Javie wyniosła około 41 vs 126 — mniej więcej trzy razy więcej w wersji LangChain4j. Wydawało się, że kwestia zwięzłości jest rozstrzygnięta.

Następnie system odpowiedział na pytanie dotyczące urlopu ojcowskiego.

Odpowiedź, której nie było w podręczniku

Gdy zapytano o liczbę dni urlopu ojcowskiego przewidzianych w podręczniku, model odpowiedział z pewnością siebie:

Employees are entitled to 12 days of paternity leave,
subject to the conditions listed in the policy.

W podręczniku podano 15 dni. Liczba 12 nigdy nie występuje w tym dokumencie. Pobrany kontekst ujawnił prawdziwą sytuację: tabele z zasadami zostały rozerwane przez domyślny narzędzie rozdzielania. Powiązane wiersze zostały rozdzielone na fragmenty; narzędzie pobierania zwróciło pojedyncze, wiarygodne fragmenty; model połączył je w spójną, błędną odpowiedź.

Błąd istniał przed modelem do rozmów. Liczba wierszy w ramowaniu nie przewidywała jakości.

Różnica 3× nie wynikała faktycznie z ramowania

Najważniejszy wiersz:

new TokenTextSplitter().apply(docs)

Jedno połączenie przekazało decyzje, które nie zostały przeanalizowane: obsługa tabeli, granice fragmentów, nakładanie się danych, przetrwałe metadane oraz to, co faktycznie widzi mechanizm wyszukiwania. Spring AI ułatwił ignorowanie tych wyborów. LangChain4j sprawił, że większa liczba z nich stała się widoczna w kodzie aplikacji.

Pierwsze porównanie pokazało również różnice pomiędzy Spring AI zintegrowanym z Spring Boot a bardziej ręczną konfiguracją LangChain4j. Przebudowa LangChain4j z integracją Spring sprowadziła liczbę linii kodu aplikacji do 58. Różnica zmniejszyła się z około 3× do około 1,4×. Złożoność przeniosła się do samego frameworka, a nie zniknęła z systemu.

Co wybrać w praktyce

  • Istniejące usługi Spring Boot → Spring AI dla standardowych zastosowań RAG bez zbędnych formalności.
  • Samodzielny Java lub zaawansowane, spersonalizowane metody pobierania danych → LangChain4j, gdy istotne są wyraźne elementy pipeline’u oraz możliwość dostosowania rozdzielania danych na fragmenty i procesu pobierania.
  • Nie decyduj się wyłącznie na podstawie liczby wierszy. Po stworzeniu obu rozwiązań przydatnym kryterium stało się: ile opcji pipeline’u jesteś skłonny pozostawić przy domyślnych ustawieniach frameworku? Zacznij od tego — zanim ktoś usunie osiemdziesiąt pięć wierszy i ogłosi zwycięstwo.