博客

LangChain4j 使用 Elasticsearch 作为嵌入存储

LangChain4j(Java 版 LangChain)将 Elasticsearch 作为嵌入式存储。了解如何使用它在普通 Java 中构建 RAG 应用程序。

上一篇文章中,我们了解了什么是 LangChain4j 以及如何使用:

  • 与法律硕士进行讨论,实施ChatLanguageModelChatMemory

  • 在记忆中保留聊天记录,以便回忆起之前与一位法律硕士讨论的背景情况

本博文将介绍如何

  • 根据文本示例创建向量嵌入

  • 将向量嵌入存储在 Elasticsearch 嵌入存储中

  • 搜索类似载体

创建嵌入

要创建嵌入式,我们需要定义一个EmbeddingModel 。例如,我们可以使用上一篇文章中使用过的相同的 mistral 模型。它和奥拉马一起跑:

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

模型能够从文本中生成向量。在这里,我们可以检查模型生成的维数:

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

要从文本中生成向量,我们可以使用

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

或者,如果我们还想提供元数据,以便对文本、价格、发布日期等内容进行筛选,我们可以使用Metadata.from().NET。例如,我们在这里添加游戏名称作为元数据字段:

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

如果您想运行这段代码,请查看Step5EmbedddingsTest.java类。

添加 Elasticsearch 来存储向量

LangChain4j 提供内存嵌入存储。这对运行简单测试非常有用:

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

但是,这显然不能用于更大的数据集,因为该数据存储将所有内容都存储在内存中,而我们的服务器上没有无限的内存。因此,我们可以将嵌入式数据存储到 Elasticsearch 中,根据定义,Elasticsearch 是"elastic" ,可以随着数据的扩展而扩展。为此,让我们在项目中添加 Elasticsearch:

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

正如你所注意到的,我们还在项目中添加了 Elasticsearch TestContainers 模块,这样我们就可以从测试中启动 Elasticsearch 实例:

// 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", "/"));

要将 Elasticsearch 用作嵌入式存储,"," ,就必须从 LangChain4j 内存数据存储切换到 Elasticsearch 数据存储:

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

这将在 Elasticsearch 中以default 索引的形式存储向量。您还可以将索引名称改为更有意义的名称:

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

如果您想运行此代码,请查看Step6ElasticsearchEmbedddingsTest.java类。

搜索类似载体

要搜索相似向量,我们首先需要使用之前使用过的相同模型,将问题转换为向量表示。我们已经做到了,所以再做一次并不难。请注意,在这种情况下我们不需要元数据:

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

我们可以用问题的这种表示法建立一个搜索请求,并要求嵌入式存储空间找出最前面的向量:

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

现在,我们可以遍历结果并打印一些信息,如来自元数据的游戏名称和得分:

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

正如我们所预料的那样,"Out Run" 作为第一击:

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

如果您想运行这段代码,请查看Step7SearchForVectorsTest.java类。

幕后花絮

Elasticsearch 嵌入存储的默认配置是在后台使用近似 kNN 查询

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

但这可以通过向嵌入存储区提供默认配置 (ElasticsearchConfigurationKnn) 以外的另一种配置 (ElasticsearchConfigurationScript) 来改变:

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

ElasticsearchConfigurationScriptscript_score执行程序使用cosineSimilarity 函数 在后台运行 查询 。

基本上,打电话时

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

现在呼叫

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

在这种情况下,结果并不会因为"order" 而发生变化,只是分数会有所调整,因为cosineSimilarity 调用并不使用任何近似值,而是计算每个匹配向量的余弦值:

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

如果您想运行这段代码,请查看Step7SearchForVectorsTest.java类。

结论

我们已经介绍了如何从文本中轻松生成嵌入,以及如何使用两种不同的方法在 Elasticsearch 中存储和搜索近邻:

  • 使用ElasticsearchConfigurationKnn 默认选项进行近似和快速knn 查询

  • 使用ElasticsearchConfigurationScript 选项进行精确但较慢的script_score 查询

下一步将根据我们在这里学到的知识,构建一个完整的 RAG 应用程序。

相关内容

使用模拟和真实 Elasticsearch 测试 Java 代码

Piotr Przybyl

引入 LangChain4j 以简化 LLM 与 Java 应用程序的集成

David Pilato

准备好打造最先进的搜索体验了吗?

足够先进的搜索不是一个人的努力就能实现的。Elasticsearch 由数据科学家、ML 操作员、工程师以及更多和您一样对搜索充满热情的人提供支持。让我们联系起来,共同打造神奇的搜索体验,让您获得想要的结果。

亲自试用