Blog

LangChain4j mit Elasticsearch als Einbettungsspeicher

LangChain4j (LangChain für Java) verwendet Elasticsearch als eingebetteten Speicher. Erfahren Sie, wie Sie damit Ihre RAG-Anwendung in reinem Java erstellen können.

Im vorherigen Beitrag haben wir herausgefunden, was LangChain4j ist und wie es funktioniert:

  • Führen Sie eine Diskussion mit LLMs, indem Sie ein ChatLanguageModel und ein ChatMemory

  • Die Chat-Historie wird im Speicher gehalten, um den Kontext einer früheren Diskussion mit einem LLM wieder abrufen zu können.

Dieser Blogbeitrag erklärt, wie man:

  • Vektor-Einbettungen aus Textbeispielen erstellen

  • Vektoreinbettungen im Elasticsearch-Einbettungsspeicher speichern

  • Suche nach ähnlichen Vektoren

Einbettungen erstellen

Um Einbettungen zu erstellen, müssen wir ein EmbeddingModel definieren, das verwendet werden soll. Wir können beispielsweise dasselbe Mistral-Modell verwenden, das wir im vorherigen Beitrag verwendet haben. Es lief mit ollama:

EmbeddingModel model = OllamaEmbeddingModel.builder()
  .baseUrl(ollama.getEndpoint())
  .modelName(MODEL_NAME)
  .build();

Ein Modell ist in der Lage, aus Text Vektoren zu generieren. Hier können wir die Anzahl der vom Modell generierten Dimensionen überprüfen:

Logger.info("Embedding model has {} dimensions.", model.dimension());
// This gives: Embedding model has 4096 dimensions.

Um Vektoren aus einem Text zu generieren, können wir Folgendes verwenden:

Response<Embedding> response = model.embed("A text here");

Oder wenn wir auch Metadaten bereitstellen möchten, um nach Dingen wie Text, Preis, Veröffentlichungsdatum oder Ähnlichem filtern zu können, können wir Metadata.from() verwenden. Zum Beispiel fügen wir hier den Spielnamen als Metadatenfeld hinzu:

TextSegment game1 = TextSegment.from("""
    The game starts off with the main character Guybrush Threepwood stating "I want to be a pirate!"
    To do so, he must prove himself to three old pirate captains. During the perilous pirate trials, 
    he meets the beautiful governor Elaine Marley, with whom he falls in love, unaware that the ghost pirate 
    LeChuck also has his eyes on her. When Elaine is kidnapped, Guybrush procures crew and ship to track 
    LeChuck down, defeat him and rescue his love.
""", Metadata.from("gameName", "The Secret of Monkey Island"));
Response<Embedding> response1 = model.embed(game1);
TextSegment game2 = TextSegment.from("""
    Out Run is a pseudo-3D driving video game in which the player controls a Ferrari Testarossa 
    convertible from a third-person rear perspective. The camera is placed near the ground, simulating 
    a Ferrari driver's position and limiting the player's view into the distance. The road curves, 
    crests, and dips, which increases the challenge by obscuring upcoming obstacles such as traffic 
    that the player must avoid. The object of the game is to reach the finish line against a timer.
    The game world is divided into multiple stages that each end in a checkpoint, and reaching the end 
    of a stage provides more time. Near the end of each stage, the track forks to give the player a 
    choice of routes leading to five final destinations. The destinations represent different 
    difficulty levels and each conclude with their own ending scene, among them the Ferrari breaking 
    down or being presented a trophy.
""", Metadata.from("gameName", "Out Run"));
Response<Embedding> response2 = model.embed(game2);

Wenn Sie diesen Code ausführen möchten, sehen Sie sich bitte die Klasse Step5EmbedddingsTest.java an.

Elasticsearch hinzufügen, um unsere Vektoren zu speichern

LangChain4j bietet einen In-Memory-Embedding-Speicher. Dies ist nützlich, um einfache Tests durchzuführen:

EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
embeddingStore.add(response1.content(), game1);
embeddingStore.add(response2.content(), game2);

Bei deutlich größeren Datensätzen funktioniert das natürlich nicht, da dieser Datenspeicher alles im Arbeitsspeicher ablegt und wir auf unseren Servern nicht über unbegrenzten Speicherplatz verfügen. Wir könnten unsere Einbettungen also stattdessen in Elasticsearch speichern, das per Definition "elastisch" ist und mit Ihren Daten skalieren kann. Dazu fügen wir Elasticsearch zu unserem Projekt hinzu:

<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-elasticsearch</artifactId>
  <version>${langchain4j.version}</version>
</dependency>

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>elasticsearch</artifactId>
  <version>1.20.1</version>
  <scope>test</scope>
</dependency>

Wie Sie bemerkt haben, haben wir dem Projekt auch das Elasticsearch TestContainers-Modul hinzugefügt, sodass wir eine Elasticsearch-Instanz aus unseren Tests heraus starten können:

// Create the elasticsearch container
ElasticsearchContainer container =
  new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.15.0")
    .withPassword("changeme");

// Start the container. This step might take some time...
container.start();

// As we don't want to make our TestContainers code more complex than
// needed, we will use login / password for authentication.
// But note that you can also use API keys which is preferred.
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("elastic", "changeme"));

// Create a low level Rest client which connects to the elasticsearch container.
client = RestClient.builder(HttpHost.create("https://" + container.getHttpHostAddress()))
  .setHttpClientConfigCallback(httpClientBuilder -> {
    httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
    httpClientBuilder.setSSLContext(container.createSslContextFromCa());
    return httpClientBuilder;
  })
  .build();

// Check the cluster is running
client.performRequest(new Request("GET", "/"));

Um Elasticsearch als Einbettungsspeicher zu verwenden, müssen Sie „nur“ vom LangChain4j-In-Memory-Datenspeicher zum Elasticsearch-Datenspeicher wechseln:

EmbeddingStore<TextSegment> embeddingStore =
  ElasticsearchEmbeddingStore.builder()
    .restClient(client)
    .build();
embeddingStore.add(response1.content(), game1);
embeddingStore.add(response2.content(), game2);

Dadurch werden Ihre Vektoren in Elasticsearch in einem default -Index gespeichert. Sie können den Indexnamen auch in einen aussagekräftigeren Namen ändern:

EmbeddingStore<TextSegment> embeddingStore =
  ElasticsearchEmbeddingStore.builder()
    .indexName("games")
    .restClient(client)
    .build();
embeddingStore.add(response1.content(), game1);
embeddingStore.add(response2.content(), game2);

Wenn Sie diesen Code ausführen möchten, sehen Sie sich bitte die Klasse Step6ElasticsearchEmbedddingsTest.java an.

Suche nach ähnlichen Vektoren

Um nach ähnlichen Vektoren zu suchen, müssen wir zunächst unsere Frage mithilfe des gleichen Modells, das wir zuvor verwendet haben, in eine Vektordarstellung umwandeln. Das haben wir schon gemacht, also ist es nicht schwer, das noch einmal zu tun. Beachten Sie, dass wir in diesem Fall die Metadaten nicht benötigen:

String question = "I want to pilot a car";
Embedding questionAsVector = model.embed(question).content();

Wir können mit dieser Repräsentation unserer Frage eine Suchanfrage erstellen und den Embedding-Speicher bitten, die ersten Top-Vektoren zu finden:

EmbeddingSearchResult<TextSegment> result = embeddingStore.search(
  EmbeddingSearchRequest.builder()
    .queryEmbedding(questionAsVector)
    .build());

Wir können die Ergebnisse nun durchlaufen und einige Informationen ausgeben, wie zum Beispiel den Spielnamen, der aus den Metadaten stammt, und die Punktzahl:

result.matches().forEach(m -> Logger.info("{} - score [{}]",
  m.embedded().metadata().getString("gameName"), m.score()));

Wie zu erwarten, ist „Out Run“ der erste Treffer:

Out Run
Out Run - score [0.86672974]
The Secret of Monkey Island - score [0.85569763]

Wenn Sie diesen Code ausführen möchten, sehen Sie sich bitte die Klasse Step7SearchForVectorsTest.java an.

Hinter den Kulissen

Die Standardkonfiguration für den Elasticsearch Embedding Store verwendet im Hintergrund die approximative kNN-Abfrage .

POST games/_search
{
  "query" : {
    "knn": {
      "field": "vector",
      "query_vector": [-0.019137882, /* ... */, -0.0148779955]
    }
  }
}

Dies könnte jedoch geändert werden, indem dem Embedding-Speicher eine andere Konfiguration (ElasticsearchConfigurationScript) als die Standardkonfiguration (ElasticsearchConfigurationKnn) bereitgestellt wird:

EmbeddingStore<TextSegment> embeddingStore =
  ElasticsearchEmbeddingStore.builder()
    .configuration(ElasticsearchConfigurationScript.builder().build())
    .indexName("games")
    .restClient(client)
    .build();

Die ElasticsearchConfigurationScript -Implementierung führt im Hintergrund eine script_score -Abfrage mit einer cosineSimilarity -Funktion aus.

Grundsätzlich gilt beim Aufruf:

EmbeddingSearchResult<TextSegment> result = embeddingStore.search(
  EmbeddingSearchRequest.builder()
    .queryEmbedding(questionAsVector)
    .build());

Dies nennt man nun:

POST games/_search
{
  "query": {
    "script_score": {
      "script": {
        "source": "(cosineSimilarity(params.query_vector, 'vector') + 1.0) / 2",
        "params": {
          "queryVector": [-0.019137882, /* ... */, -0.0148779955]
        }
      }
    }
  }
}

In diesem Fall ändert sich das Ergebnis hinsichtlich der "Ordnung" nicht, sondern nur die Punktzahl wird angepasst, da der cosineSimilarity -Aufruf keine Näherung verwendet, sondern den Kosinus für jeden der übereinstimmenden Vektoren berechnet:

Out Run - score [0.871952]
The Secret of Monkey Island - score [0.86380446]

Wenn Sie diesen Code ausführen möchten, sehen Sie sich bitte die Klasse Step7SearchForVectorsTest.java an.

Fazit

Wir haben behandelt, wie einfach Sie Einbettungen aus Ihrem Text generieren können und wie Sie die nächsten Nachbarn in Elasticsearch speichern und suchen können, und zwar mit zwei verschiedenen Ansätzen:

  • Verwendung der approximativen und schnellen knn -Abfrage mit der Standardoption ElasticsearchConfigurationKnn

  • Verwendung der exakten, aber langsameren script_score -Abfrage mit der ElasticsearchConfigurationScript -Option

Im nächsten Schritt geht es darum, eine vollständige RAG-Anwendung zu entwickeln, basierend auf dem, was wir hier gelernt haben.

Zugehörige Inhalte

Testen Ihres Java-Codes mit Mocks und echtem Elasticsearch

Piotr Przybyl

Wir stellen LangChain4j vor, um die LLM-Integration in Java-Anwendungen zu vereinfachen.

David Pilato

Sind Sie bereit, hochmoderne Sucherlebnisse zu schaffen?

Eine ausreichend fortgeschrittene Suche kann nicht durch die Bemühungen einer einzelnen Person erreicht werden. Elasticsearch wird von Datenwissenschaftlern, ML-Ops-Experten, Ingenieuren und vielen anderen unterstützt, die genauso leidenschaftlich an der Suche interessiert sind wie Sie. Lasst uns in Kontakt treten und zusammenarbeiten, um das magische Sucherlebnis zu schaffen, das Ihnen die gewünschten Ergebnisse liefert.

Probieren Sie es selbst aus