Articles de blog

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 ChatLanguageModel et un ChatMemory

  • Conserver 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 :

Sortie de route
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 knn avec l'option par défaut ElasticsearchConfigurationKnn

  • Utilisation de la requête exacte mais plus lente script_score avec l'option ElasticsearchConfigurationScript

La prochaine étape consistera à créer une application RAG complète, sur la base de ce que nous avons appris ici.

Pour aller plus loin

Tester votre code Java avec des mocks et du vrai Elasticsearch

Piotr Przybyl

Présentation de LangChain4j pour simplifier l'intégration de LLM dans les applications Java

David Pilato

Prêt à créer des expériences de recherche d'exception ?

Une recherche suffisamment avancée ne se fait pas avec les efforts d'une seule personne. Elasticsearch est alimenté par des data scientists, des ML ops, des ingénieurs et bien d'autres qui sont tout aussi passionnés par la recherche que vous. Mettons-nous en relation et travaillons ensemble pour construire l'expérience de recherche magique qui vous permettra d'obtenir les résultats que vous souhaitez.

Jugez-en par vous-même