LangChain4j avec Elasticsearch comme magasin de plongements
LangChain4j (LangChain pour Java) a Elasticsearch comme magasin intégré. Découvrez comment l'utiliser pour construire votre application RAG en Java simple.
Dans l'article précédent, nous avons découvert ce qu'est LangChain4j et comment l'utiliser :
Discutez avec les LLM en mettant en place un
ChatLanguageModelet unChatMemoryConserver l'historique du chat en mémoire pour se rappeler le contexte d'une discussion précédente avec un LLM
Cet article de blog traite de la manière de procéder :
Création d'encastrements vectoriels à partir d'exemples de textes
Stocker les embeddings vectoriels dans le magasin d'embedding d'Elasticsearch
Recherche de vecteurs similaires
Créer des embeddings
Pour créer des embeddings, nous devons définir un EmbeddingModel à utiliser. Par exemple, nous pouvons utiliser le même modèle de mistral que celui utilisé dans le billet précédent. Il s'agissait de courir avec l'ollama :
EmbeddingModel model = OllamaEmbeddingModel.builder()
.baseUrl(ollama.getEndpoint())
.modelName(MODEL_NAME)
.build();Un modèle est capable de générer des vecteurs à partir d'un texte. Nous pouvons ici vérifier le nombre de dimensions générées par le modèle :
Logger.info("Embedding model has {} dimensions.", model.dimension());
// This gives: Embedding model has 4096 dimensions.Pour générer des vecteurs à partir d'un texte, nous pouvons utiliser :
Response<Embedding> response = model.embed("A text here");Si nous voulons également fournir des métadonnées pour nous permettre de filtrer des éléments tels que le texte, le prix, la date de sortie ou autre, nous pouvons utiliser Metadata.from(). Par exemple, nous ajoutons ici le nom du jeu comme champ de métadonnées :
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);Si vous souhaitez exécuter ce code, veuillez consulter la classe Step5EmbedddingsTest.java.
Ajouter Elasticsearch pour stocker nos vecteurs
LangChain4j fournit un magasin d'intégration en mémoire. Cette fonction est utile pour exécuter des tests simples :
EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
embeddingStore.add(response1.content(), game1);
embeddingStore.add(response2.content(), game2);Mais il est évident que cela ne pourrait pas fonctionner avec un ensemble de données beaucoup plus important car ce datastore stocke tout en mémoire et nous ne disposons pas d'une mémoire infinie sur nos serveurs. Ainsi, nous pourrions plutôt stocker nos enregistrements dans Elasticsearch, qui est par définition "élastique" et peut évoluer avec vos données. Pour cela, ajoutons Elasticsearch à notre projet :
<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>Comme vous l'avez remarqué, nous avons également ajouté le module Elasticsearch TestContainers au projet, afin de pouvoir démarrer une instance Elasticsearch à partir de nos tests :
// 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", "/"));Pour utiliser Elasticsearch comme magasin d'intégration, il suffit de "" passer du datastore LangChain4j en mémoire au datastore Elasticsearch :
EmbeddingStore<TextSegment> embeddingStore =
ElasticsearchEmbeddingStore.builder()
.restClient(client)
.build();
embeddingStore.add(response1.content(), game1);
embeddingStore.add(response2.content(), game2);Cela permettra de stocker vos vecteurs dans Elasticsearch dans un index default. Vous pouvez également changer le nom de l'index en quelque chose de plus significatif :
EmbeddingStore<TextSegment> embeddingStore =
ElasticsearchEmbeddingStore.builder()
.indexName("games")
.restClient(client)
.build();
embeddingStore.add(response1.content(), game1);
embeddingStore.add(response2.content(), game2);Si vous souhaitez exécuter ce code, veuillez consulter la classe Step6ElasticsearchEmbedddingsTest.java.
Recherche de vecteurs similaires
Pour rechercher des vecteurs similaires, nous devons d'abord transformer notre question en une représentation vectorielle en utilisant le même modèle que celui utilisé précédemment. Nous l'avons déjà fait, il n'est donc pas difficile de le faire à nouveau. Notez que nous n'avons pas besoin des métadonnées dans ce cas :
String question = "I want to pilot a car";
Embedding questionAsVector = model.embed(question).content();Nous pouvons construire une requête de recherche avec cette représentation de notre question et demander au magasin d'intégration de trouver les premiers vecteurs :
EmbeddingSearchResult<TextSegment> result = embeddingStore.search(
EmbeddingSearchRequest.builder()
.queryEmbedding(questionAsVector)
.build());Nous pouvons maintenant itérer sur les résultats et imprimer certaines informations, comme le nom du jeu qui provient des métadonnées et le score :
result.matches().forEach(m -> Logger.info("{} - score [{}]",
m.embedded().metadata().getString("gameName"), m.score()));Comme on pouvait s'y attendre, cela nous donne "Out Run" comme premier résultat :

Out Run - score [0.86672974]
The Secret of Monkey Island - score [0.85569763]Si vous souhaitez exécuter ce code, consultez la classe Step7SearchForVectorsTest.java.
L'envers du décor
La configuration par défaut du magasin Elasticsearch Embedding utilise la requête kNN approximative en arrière-plan.
POST games/_search
{
"query" : {
"knn": {
"field": "vector",
"query_vector": [-0.019137882, /* ... */, -0.0148779955]
}
}
}Mais cela peut être modifié en fournissant une autre configuration (ElasticsearchConfigurationScript) que celle par défaut (ElasticsearchConfigurationKnn) au magasin d'intégration :
EmbeddingStore<TextSegment> embeddingStore =
ElasticsearchEmbeddingStore.builder()
.configuration(ElasticsearchConfigurationScript.builder().build())
.indexName("games")
.restClient(client)
.build();L'implémentation de ElasticsearchConfigurationScript exécute en coulisse une requête script_score à l'aide d'une fonction cosineSimilarity.
En principe, lors d'un appel :
EmbeddingSearchResult<TextSegment> result = embeddingStore.search(
EmbeddingSearchRequest.builder()
.queryEmbedding(questionAsVector)
.build());Il s'agit maintenant d'un appel :
POST games/_search
{
"query": {
"script_score": {
"script": {
"source": "(cosineSimilarity(params.query_vector, 'vector') + 1.0) / 2",
"params": {
"queryVector": [-0.019137882, /* ... */, -0.0148779955]
}
}
}
}
}Dans ce cas, le résultat ne change pas en termes d'ordre "" mais le score est simplement ajusté parce que l'appel cosineSimilarity n'utilise pas d'approximation mais calcule le cosinus pour chacun des vecteurs correspondants :
Out Run - score [0.871952]
The Secret of Monkey Island - score [0.86380446]Si vous souhaitez exécuter ce code, consultez la classe Step7SearchForVectorsTest.java.
Conclusion
Nous avons vu comment vous pouvez facilement générer des embeddings à partir de votre texte et comment vous pouvez stocker et rechercher les voisins les plus proches dans Elasticsearch en utilisant deux approches différentes :
Utilisation de la requête approximative et rapide
knnavec l'option par défautElasticsearchConfigurationKnnUtilisation de la requête exacte mais plus lente
script_scoreavec l'optionElasticsearchConfigurationScript
La prochaine étape consistera à créer une application RAG complète, sur la base de ce que nous avons appris ici.

