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
ChatLanguageModelund einChatMemoryDie 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 - 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 StandardoptionElasticsearchConfigurationKnnVerwendung der exakten, aber langsameren
script_score-Abfrage mit derElasticsearchConfigurationScript-Option
Im nächsten Schritt geht es darum, eine vollständige RAG-Anwendung zu entwickeln, basierend auf dem, was wir hier gelernt haben.

