블로그

Elasticsearch를 임베딩 저장소로 활용한 LangChain4j

LangChain4j(Java용 LangChain)에는 임베딩 저장소로 Elasticsearch가 있습니다. 이를 사용하여 일반 Java로 RAG 애플리케이션을 빌드하는 방법을 알아보세요.

이전 포스트에서 LangChain4j가 무엇이며 어떻게 사용하는지 알아보았습니다:

  • ChatLanguageModel 를 구현하여 LLM과 토론하고 ChatMemory

  • 메모리에 채팅 기록을 유지하여 LLM과의 이전 토론의 맥락을 기억할 수 있습니다.

이 블로그 게시물에서는 그 방법을 다룹니다:

  • 텍스트 예제에서 벡터 임베딩 만들기

  • Elasticsearch 임베딩 스토어에 벡터 임베딩 저장하기

  • 유사한 벡터 검색

임베딩 만들기

임베딩을 만들려면 사용할 EmbeddingModel 을 정의해야 합니다. 예를 들어 이전 게시물에서 사용한 것과 동일한 미스트랄 모델을 사용할 수 있습니다. 올라마와 함께 실행 중이었습니다:

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() 을 사용할 수 있습니다. 예를 들어, 여기에 게임 이름을 메타데이터 필드로 추가합니다:

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에 저장하는 대신 "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 - 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();

ElasticsearchConfigurationScript 구현은 script_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]
        }
      }
    }
  }
}

이 경우 "주문" 결과는 변경되지 않지만 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

Java 애플리케이션에 LLM 통합을 간소화하는 LangChain4j 소개

David Pilato

최첨단 검색 환경을 구축할 준비가 되셨나요?

충분히 고급화된 검색은 한 사람의 노력만으로는 달성할 수 없습니다. Elasticsearch는 여러분과 마찬가지로 검색에 대한 열정을 가진 데이터 과학자, ML 운영팀, 엔지니어 등 많은 사람들이 지원합니다. 서로 연결하고 협력하여 원하는 결과를 얻을 수 있는 마법 같은 검색 환경을 구축해 보세요.

직접 사용해 보세요