Building Neo4j-Powered Applications with LLMs · Part III — Neo4j·Spring AI·LangChain4j로 짓는 지능형 추천 시스템

MATCH (:Chapter {n: 9})-[:AUGMENTS {embedding: [...]}]->(:Graph)

LangChain4jSpring AI
Neo4j에 통합하다

그래프가 거래의 사슬을 내주면, LLM이 그 사슬을 세 문장의 취향으로 요약하고, 임베딩 모델이 그 요약을 3,072차원의 벡터로 새겨 계절 관계 위에 되돌려 놓는다. 같은 증강 루프를 두 Java 프레임워크로 두 번 짓는다 — 그리고 그 벡터로 닮은 고객과 닮은 옷을 찾아 추천을 완성한다.

Ravindranatha Anthapu · Siddhant Agarwal Packt Publishing, 2025 Chapter 9 · pp. 161–203

데이터를 그래프에 적재했으니, 이 장에서는 LangChain4j 또는 Spring AI로 그래프를 증강해 그 능력을 강화하고 지식그래프를 짓는 방법을 본다. 그래프를 LLM과 통합해 고객 구매의 요약을 생성하고, 그 요약의 임베딩을 만들어 고객 구매 이력을 표현한다. 이 임베딩은 기계학습과 그래프 알고리즘이 그래프 데이터를 이해하고 처리하게 하는 데 결정적이다. 구매 행동의 이해를 통해 고객에게 더 개인적인 추천을 제공하는 지식그래프를 짓도록 우리를 돕는 것이다. 데이터셋에 든 각 품목(article)의 상세 설명에 대한 임베딩을 만드는 방법도 함께 본다.

기술 요건

LangChain4j와 Spring AI 프로젝트 작업에는 Java IDE 환경을 쓴다. 다음이 설치되어 있어야 하고, 다루는 법도 알아야 한다.

플러그인 설치 방법은 이렇다(그림 9.1). Neo4j Desktop에서 DBMS를 선택하면 오른쪽에 상세 정보가 나타난다. Plugins 탭을 클릭해 필요한 플러그인을 고르고, 상세 창에서 Install and Restart 버튼을 클릭한다.

Note

필요한 모든 코드는 GitHub 저장소의 ch9 폴더에 있다. IDE에서 바로 실행할 수 있는 완결된 프로젝트들이다. 이 장에서는 사용법을 보여주기 위한 코드 조각만 싣는다. 이 장의 단계들을 따라가려면 코드를 내려받아 두는 편이 좋다.

(:Section {n: 1})-[:INITIALIZES]->(:StarterProject)

LangChain4j와 Spring AI 설정

spring initializr 웹사이트로 Spring AI와 LangChain4j 프로젝트를 설정한다. 두 기술은 각각 독립적으로 살펴본다. LangChain4j와 Spring AI는 같은 과업을 수행하는 두 선택지다. GenAI 프로젝트를 짓는 데는 둘 중 하나만 있으면 된다. LangChain4j가 Spring AI보다 조금 더 오래 존재해 왔고, 둘은 API와 통합의 측면에서 꽤 비슷하게 작동한다. 우리는 같은 애플리케이션을 두 프레임워크로 각각 지어 얼마나 닮았는지 보고, 차이점도 식별할 것이다. 스타터 프로젝트를 만드는 단계는 다음과 같다.

  1. LangChain4j 프로젝트 설정. start.spring.io에서 Project는 Maven, Language는 Java를 선택한다. Project Metadata에 다음 값을 채운다 — Group: com.packt.genai.hnm.springai, Artifact: springai_graphaugment, Name: springai_graphaugment, Description: Graph Augmenting with Spring AI, Package Name: com.packt.genai.hnm.springai.graphaugment, Packaging: Jar, Java: 17. Dependencies 섹션에서 Add Dependencies 버튼을 클릭해 Spring Web 의존성을 선택한다. 이니셜라이저가 현재 나열하는 다른 의존성은 없으므로, LangChain4j 의존성은 뒤에서 수동으로 프로젝트에 더한다. 생성된 ZIP 파일을 내려받아 저장한다.
  2. Spring AI 프로젝트 설정. 같은 사이트에서 Maven·Java를 선택하고 Metadata에 다음 값을 채운다 — Group: com.packt.genai.hnm.langchain, Artifact: langchain_graphaugment, Name: langchain_graphaugment, Description: Graph Augmenting with Langchain4J, Package Name: com.packt.genai.hnm.langchain.graphaugment, Packaging: Jar, Java: 17. Dependencies 섹션에서 Spring Web, OpenAI, Neo4j Vector Database 세 의존성을 선택한다. 생성된 ZIP 파일을 내려받아 저장한다.

이것으로 얻는 것은 애플리케이션을 지어 올릴 뼈대(skeleton) 프로젝트뿐이다. 뼈대 프로젝트는 그 위에 논리를 더해 갈 기본 구조를 준다. 짓기 전에, 애플리케이션에서 무엇을 원하는지부터 정리하자. 앞 장에서 H&M 거래 데이터를 그래프 데이터베이스에 적재했다. 지금 그래프는 고객, 품목, 거래와 함께, 주어진 계절·연도의 첫 거래를 표시하는 도우미 관계들을 담고 있다. 개인 추천 시스템을 지으려는 우리는, 고객 행동을 이해하고 추천을 제공하도록 그래프를 강화하고자 한다. 접근은 이렇다.

  1. 구매 행동을 이해할 계절을 고른다. 이를테면 2019년 여름과 2019년 가을에 구매한 고객을 찾고, 그 계절 사이의 거래로 고객 행동을 이해한다. 2019년 여름에 거래했더라도 2019년 가을에 거래하지 않은 고객이 있을 수 있는데, 단순화를 위해 이 연습에서는 그런 고객을 무시한다.
  2. 이 거래들에서 구매된 품목을 가져온다. 품목은 조건(2019년 여름·가을의 구매)에 부합해야 하며 구매된 순서대로 나와야 한다. 그런 다음 LLM으로 이 구매들을 요약한다. 이 요약은 품목 구매의 순서를 보존한다.
  3. 이 요약 텍스트의 임베딩을 LLM으로 생성한다. 이 부분에는 OpenAI LLM을 지렛대로 쓴다.
  4. 임베딩을 저장한다. 임베딩이 생성된 계절 관계 위에 저장한다. 2019년 여름의 요약을 생성하고 있다면, 결과 임베딩을 SUMMER_2019 관계에 저장하는 식이다. 임베딩 생성에는 OpenAI LLM이 쓰인다.

(:Section {n: 2})-[:BUILDS_WITH]->(:LangChain4j)

LangChain4j로 짓는 추천 엔진

이 절에서는 LangChain4j를 지렛대 삼는 그래프 증강 애플리케이션을 짓는다. 이 프로젝트에서 우리는 GraphRAG 접근으로, 요구 조건에 부합하는 거래 사슬의 임베딩을 생성한다. Neo4j 그래프 리트리버로 조건에 맞는 거래 사슬을 가져오고, LLM으로 그 거래들의 요약 — 고객 구매 행동의 서술 — 을 생성해 임베딩을 만든다. 생성된 임베딩은 텍스트 요약을 기계학습이나 Graph Data Science 알고리즘이 활용할 수 있는 방식으로 서술하는 벡터 표현이며, 벡터 검색 용도로도 쓸 수 있다. LLM 맥락에서의 임베딩은 이 글이 잘 설명한다.

앞 절에서 내려받은 ZIP 파일에서 출발한다. 압축을 푼 뒤, 다음 단계로 IntelliJ 플랫폼에 프로젝트를 적재한다(그림 9.2–9.5). IntelliJ IDE를 실행하고, File | New | Project from Existing Sources…를 클릭한다. 압축을 푼 디렉터리의 pom.xml 파일을 선택하고, Trust Project를 클릭해 프로젝트를 적재한 뒤, 창을 물으면 New Window를 선택한다. 프로젝트가 적재되면 다음으로 넘어간다.

LangChain4j — 프로젝트 의존성 갱신

Spring 스타터로 프로젝트를 준비할 때는 그 도구가 식별하는 의존성만 더할 수 있었다. 이제 pom.xml 파일을 편집해 의존성을 더해야 한다. 프로젝트에 더할 의존성은 넷이다.

pom.xml — LangChain4j 의존성 4종xml
<!-- Langchain Springboot integration: Spring Boot 통합 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-spring-boot-starter</artifactId>
    <version>0.36.0</version>
</dependency>

<!-- Open AI integration: OpenAI 통합 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
    <version>0.36.0</version>
</dependency>

<!-- Neo4j Vector Store integration: Neo4j 통합 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-neo4j</artifactId>
    <version>0.35.0</version>
</dependency>

<!-- LLM embeddings API -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
    <version>0.35.0</version>
</dependency>

최신 통합 옵션과 상세는 LangChain4j 통합 문서에서 확인할 수 있다.

Note

pom.xml 파일을 고쳤다면, IDE가 모든 의존성을 올바르게 갱신하도록 프로젝트를 다시 적재해야 할 수 있다. IntelliJ IDEA로 Maven 프로젝트를 다루는 법은 공식 도움말에서 더 읽는다.

LangChain4j — 애플리케이션 프로퍼티 갱신

앞 절에서 더한 의존성들이 API를 활용할 수 있도록 애플리케이션 프로퍼티를 갱신해야 한다. 프로젝트의 application.properties 파일에 다음 구성을 더한다. 채팅 모델과 임베딩에 OpenAI LLM을 쓸 것이므로 API 키가 필요하며, OpenAI 플랫폼에서 발급받는다.

application.properties — LangChain4j 구성properties
# Open AI LLM Integration for Generating Summary using Chat Model.
langchain4j.open-ai.chat-model.api-key=<OPEN_AI_KEY>
langchain4j.open-ai.chat-model.model-name=gpt-4o-mini
langchain4j.open-ai.chat-model.log-requests=true
langchain4j.open-ai.chat-model.log-responses=true

# Open AI LLM Integration for Generating Embeddings
langchain4j.open-ai.embedding-model.api-key=<OPEN_AI_KEY>
langchain4j.open-ai.embedding-model.model-name=text-embedding-3-large

# Neo4j Integration — 벡터 데이터베이스가 아닌 기본 통합
neo4j.uri=bolt://localhost:7687
neo4j.user=neo4j
neo4j.password=test1234
neo4j.database=hmreco
config.batchSize=5

구성 프로퍼티를 살펴보았으니 애플리케이션을 짓기 시작한다. Neo4j 데이터베이스 통합부터 시작해, 거래를 요약하고 요약의 임베딩을 생성하는 채팅 모델 통합을 더하고, 마지막으로 필요할 때 그 요청들을 호출하는 REST 엔드포인트를 짓는다.

LangChain4j — Neo4j 통합

Neo4j 통합을 먼저 본다. 다음 과업을 수행하려면 데이터베이스와 통합할 수단이 필요하기 때문이다. 첫째, 읽기·쓰기 트랜잭션을 수행할 수 있는 연결의 설정. 둘째, 지정된 계절에 발생한 거래의 품목 읽기. 셋째, 임베딩이 생성되면 지속(저장)하기. 이 논리를 짓기 전에 Neo4j 연결을 위한 구성 빈(bean)부터 만들어야 한다. application.properties에서 읽어 오도록 이렇게 정의한다.

Neo4jConfiguration — 구성 빈java
@ConfigurationProperties(prefix = "neo4j")
public class Neo4jConfiguration {
    private String uri;
    private String user ;
    private String password ;
    private String database ;
   /** Getter/Setters **/
}

클래스 정의 위의 ConfigurationProperties 어노테이션이 application.properties를 읽어 빈의 프로퍼티들을 초기화한다. prefix 옵션은 그 접두어로 시작하는 프로퍼티만 읽으라는 뜻이다. uri 필드를 채우고 싶다면 구성에 neo4j.uri 프로퍼티를 더해야 하는 식이다. 이 빈에서 프로퍼티를 읽는 데 필요한 게터·세터 코드는 여기 싣지 않았다.

이제 품목과 고객 거래 데이터를 읽고 필요에 따라 임베딩을 갱신하는, Neo4j 데이터베이스와의 통합을 제공하는 서비스를 정의한다.

Neo4jService 1·2 — 서비스 정의와 접속 설정java
// 1) @Service 어노테이션으로 서비스 클래스를 정의하고 Neo4jConfiguration을 주입한다
@Service
@Configuration
@EnableConfigurationProperties(Neo4jConfiguration.class)
public class Neo4jService {
    @Autowired
    private Neo4jConfiguration configuration ;
    private Driver driver ;

    // 2) Neo4j 데이터베이스 연결을 초기화하는 setup 메서드
    public synchronized void setup() {
        if( driver == null ) {
            driver = GraphDatabase.driver(
                    configuration.getUri(),
                    AuthTokens.basic(
                            configuration.getUser(),
                            configuration.getPassword()));
            driver.verifyConnectivity();
        }
    }

다음은 계절 시작·끝 값을 받아 고객 거래 데이터를 가져오는 메서드다. 제공된 값에 기초해 계절 시작 관계의 elementId 값과, 구매된 순서대로의 품목 설명을 가져온다. 이 elementId 값은 나중에 임베딩을 저장하는 데 필요하다. 설명만이 아니라 품목 속성에서 더 많은 관련 데이터를 얻으려 한다는 점도 눈여겨보자. 색상 같은 속성을 요약에 포함시켜, 임베딩으로 더 정확하게 표현하기 위함이다.

Neo4jService 3 — getDataFromDBjava
public List<EncodeRequest> getDataFromDB(String startSeason, String endSeason) {
    setup();
    String cypherTemplate = """
        --- Cypher query to get the transactions
    """;
    String cypher = String.format(cypherTemplate, startSeason, endSeason);
    SessionConfig config = SessionConfig.builder()
        .withDatabase(configuration.getDatabase())
        .build();
    try (Session session = driver.session(config)) {
        List<EncodeRequest> data = session.executeRead(tx -> {
            List<EncodeRequest> out = new ArrayList<>();
            var records = tx.run(cypher);
            while (records.hasNext()) {
                var record = records.next();
                String id = record.get("elementId").asString();
                String articles = record.get("articles").asString();
                out.add(new EncodeRequest(articles, id));
            }
            return out;
        });
        return data;
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}

같은 얼개로, 데이터베이스에서 품목들을 가져오는 getArticlesFromDB() 메서드도 더한다. 내장된 Cypher가 각 품목의 elementId와 품목 텍스트(article)를 돌려준다는 점만 다르다. 다음은 고객의 선택된 계절에 대한 임베딩을 저장하는 메서드다. 임베딩이 무엇을 표현하는지 이해하기 위해 요약을 그래프에 함께 저장한다. 이 측면이 이해되고 나면 요약을 데이터베이스에 저장할 필요는 없다.

Neo4jService 5 — saveEmbeddings (관계 벡터 프로퍼티)java
public void saveEmbeddings(List<Map<String, Object>> embeddings) {
    setup();
    String cypher = """
        UNWIND $data as row
        WITH row
        MATCH ()-[r]->()
        WHERE elementId(r) = row.id
        SET r.summary = row.summary
        WITH row, r
        CALL db.create.setRelationshipVectorProperty(r, 'embedding', row.embedding)
    """;
    SessionConfig config = SessionConfig.builder()
        .withDatabase(configuration.getDatabase())
        .build();
    try (Session session = driver.session(config)) {
        session.executeWriteWithoutResult(tx -> {
            tx.run(cypher, Map.of("data", embeddings));
        });
    } catch (Exception e) {
        e.printStackTrace();
    }
}
Neo4jService 6 — saveArticleEmbeddings (노드 벡터 프로퍼티)java
public void saveArticleEmbeddings(List<Map<String, Object>> embeddings) {
    setup();
    String cypher = """
        UNWIND $data as row
        WITH row
        MATCH (a:Article)
        WHERE elementId(a) = row.id
        CALL db.create.setNodeVectorProperty(a, 'embedding', row.embedding)
    """;
    SessionConfig config = SessionConfig.builder()
        .withDatabase(configuration.getDatabase())
        .build();
    try (Session session = driver.session(config)) {
        session.executeWriteWithoutResult(tx -> {
            tx.run(cypher, Map.of("data", embeddings));
        });
    } catch (Exception e) {
        e.printStackTrace();
    }
}

코드에서 보듯 이 서비스는 Neo4jConfiguration에 의존하며 위의 메서드들을 제공한다. 코드 흐름은 단순하고, Neo4j 데이터베이스와 상호작용하는 유틸리티 메서드들을 제공한다. 데이터를 가져오고 저장하는 메서드에는 Cypher 질의가 코드에 내장되어 있다. 다음은 품목 목록의 요약을 생성할 수 있는 OpenAI 채팅 모델 통합이다.

LangChain4j — OpenAI 채팅 통합

채팅을 통합하려면 AiService를 정의해야 한다. Java 애플리케이션을 짓기 위해 LangChain4j가 노출하는 API다. AiService를 정의하면 LangChain4j Spring Framework가 그 구현을 물밑에서 제공하므로, 채팅 서비스 호출이 매우 쉬워진다. 시스템 메시지는 다섯 부분으로 짜인다 — 역할(Role), 목표(Goal), 데이터 서술(Data Description), 응답 형식(Response), 그리고 입력이 치환되는 데이터({text}) 변수다.

ChatAssistant — @AiService 정의 (시스템 메시지 전문)java
@AiService
public interface ChatAssistant {
    @SystemMessage("""
             ---Role--
             You are an helpful assistant with expertise in fashion
             for a clothing company.

             ---Goal--
             Your goal is to generate a summary of the products purchased
             by the customers and descriptions of each of the products.
             Your summary should contain two sections -
             Section 1 - Overall summary outlining the fashion preferences
             of the customer based on the purchases. Limit the summary to
             3 sentences
             Section 2 - highlight 3-5 individual purchases.

             You should use the data provided in the section below as the
             primary context for generating the response.
             If you don't know the answer or if the input data tables do not
             contain sufficient information to provide an answer, just say so.
             Do not make anything up.

             Data Description:
             - Each Customer has an ID. Customer ID is a numeric value.
             - Each Customer has purchased more than one clothing articles
               (products). Products have descriptions.
             - The order of the purchases is very important. You should take
               into account the order when generating the summary.

             Response:
             ---
             # Overall Fashion Summary:

             \\n\\n

             # Individual Purchase Details:

             -
             Data:
             {text}
    """)
    String chat(String text);
}

@AiService 어노테이션이 붙은 인터페이스를 정의하고, 그 안에 chat 메서드를 정의한다. 여기서는 System Message 옵션을 갖춘 단순한 AI 채팅 서비스를 쓴다. AIServices가 제공하는 공통 연산과 고급 연산은 공식 문서에서 읽을 수 있다. 우리는 LLM에게 패션 전문가처럼 행동하기를, 구매 순서를 유념하며 고객의 패션 선호 요약과 상위 구매 하이라이트를 달라고 요청하고 있다. 텍스트 입력 파라미터가 채팅 어시스턴트의 입력 데이터로 쓰인다. 이 채팅 요청의 호출은 이렇게 한다.

OpenAIChatService — 채팅 호출java
@Service
public class OpenAIChatService {
    private ChatAssistant assistant ;
    public OpenAIChatService(ChatAssistant assistant) {
        this.assistant = assistant;
    }
    public String getSummaryText(String input) {
        String out = assistant.chat(input) ;
        return out ;
    }
}

사용법이 꽤 단순함을 알 수 있다. 채팅 어시스턴트는 Spring 초기화를 통해 이 서비스에 결속되고, getSummaryText 메서드가 채팅 요청을 호출한다. 채팅 서비스를 애플리케이션에 통합하는 일이 이토록 단순하다.

LangChain4j — OpenAI 임베딩 모델 통합

채팅 서비스에서 이미 AiService를 켜 두었으므로 임베딩 모델 통합은 꽤 단순하다.

OpenAIEmbeddingModelService — 임베딩 생성java
@Service
public class OpenAIEmbeddingModelService {
    EmbeddingModel embeddingModel ;
    public OpenAIEmbeddingModelService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }
    Embedding generateEmbedding(String text) {
        Response<Embedding> response = embeddingModel.embed(text) ;
        return  response.content() ;
    }
}

클래스에 EmbeddingModel을 더하고 생성자로 초기화하는 것이 전부다. Spring Boot 애플리케이션이 시작될 때 프로퍼티에 기초한 적절한 임베딩 모델 구현이 인스턴스화되어 이 변수에 배정된다. 이 서비스는 주어진 텍스트의 임베딩을 생성하는 메서드를 제공한다. 정의된 서비스들을 모두 보았으니, 이들을 모두 써서 고객 거래 그래프를 증강하는 애플리케이션을 짓는 방법을 본다.

LangChain4j — 최종 애플리케이션

최종 애플리케이션으로, 증강 수행을 요청하는 REST 엔드포인트를 짓는다. 과정 자체가 시간이 걸릴 수 있어 두 부분으로 나뉜다. 먼저 증강 과정을 시작하는 요청을 발행하면 요청 ID가 반환되고, 그 요청 ID로 요청의 진행 상황을 확인하는 방식이다.

GET/augment/{startSeason}/{endSeason}고객 거래 증강 과정을 시작한다. 계절 시작·끝 값을 받아 ProcessRequest 객체를 만들고 처리 스레드를 시작하며, 이 요청의 UUID를 반환한다. UUID와 ProcessRequest의 매핑을 보관해 상태를 제공한다.
GET/augmentArticles품목 텍스트 증강 과정을 시작한다. 역시 UUID를 반환한다.
GET/augment/status/{requestId}지정된 요청 ID의 상태를 가져온다. 완료된 요청은 맵에서 제거되며, 없는 ID에는 "Request Not Found."를 돌려준다.
LangchainGraphAugmentController — REST 컨트롤러java
@Configuration
@EnableConfigurationProperties(RunConfiguration.class)
@RestController
public class LangchainGraphAugmentController {
    // Autowired 지시어로 개별 서비스들을 주입한다
    @Autowired private OpenAIEmbeddingModelService embeddingModelService ;
    @Autowired private Neo4jService neo4jService ;
    @Autowired private OpenAIChatService chatService ;
    @Autowired private RunConfiguration configuration ;

    // 처리 중인 요청들을 담는 전역 변수
    private HashMap<String, IRequest> currentRequests = new HashMap<>() ;

    @GetMapping("/augment/{startSeason}/{endSeason}")
    public String processAugment(
            @PathVariable (value="startSeason") String startSeason,
            @PathVariable (value="endSeason") String endSeason
    ) {
        String uuid = UUID.randomUUID().toString() ;
        ProcessRequest request = new ProcessRequest(
                chatService, embeddingModelService, neo4jService,
                configuration, startSeason, endSeason) ;
        currentRequests.put(uuid, request) ;
        Thread t = new Thread(request) ;
        t.start();
        return uuid ;
    }

    @GetMapping("/augmentArticles")
    public String processAugmentArticles() {
        String uuid = UUID.randomUUID().toString() ;
        ProcessArticles request = new ProcessArticles(
                embeddingModelService, neo4jService, configuration) ;
        currentRequests.put(uuid, request) ;
        Thread t = new Thread(request) ;
        t.start();
        return uuid ;
    }

    @GetMapping("/augment/status/{requestId}")
    public String getStatus(
            @PathVariable (value="requestId") String requestId) {
        IRequest request = currentRequests.get(requestId) ;
        if( request != null ) {
            if( request.isComplete() ) {
                currentRequests.remove(requestId) ;
            }
            return request.getCurStatus() ;
        } else {
            return "Request Not Found." ;
        }
    }
}
Note — 처리 시간

그래프 증강 과정은 많은 시간이 걸릴 수 있다. 특히 LLM 채팅 API를 쓰는 요약 생성 부분이 시간을 잡아먹어, 조건에 맞는 모든 고객 — 이를테면 2019년 여름 구매 고객 전부 — 을 증강하는 데 꽤 오랜 시간이 걸릴 수 있다. 그런 이유로, 완전한 증강을 담은 데이터베이스 덤프는 약 10,000명의 고객만 커버한다.

이제 과정 요청의 구현을 본다. 다양한 API를 모두 묶어 필요한 과정을 수행하는 곳이 여기다. Runnable 인터페이스를 구현하는 ProcessRequest 클래스를 정의한다. 오래 걸리는 요청이므로 스레드를 시작하며, 채팅 서비스·임베딩 모델 서비스·Neo4j 서비스와 여타 파라미터가 요청 생성 시 입력으로 전달된다. 이 클래스는 현재 처리 상태를 추적한다.

ProcessRequest — run 메서드의 실제 과정java
public class ProcessRequest implements Runnable, IRequest {
    // chatService, embeddingModelService, neo4jService, configuration,
    // startSeson, endSeason, curStatus = "0 %", isComplete = false ... 생성자 생략

    @Override
    public void run() {
        try {
            // 2) Neo4j에서 고객 거래 데이터를 가져온다. 각 레코드는 계절 시작 관계의
            //    ID(컨텍스트)와, 구매 순서대로의 품목 설명을 담는다
            System.out.println("Retrieving Data from Graph");
            List<EncodeRequest> dbData = neo4jService.getDataFromDB(startSeson, endSeason);
            System.out.println("Retrieved Data from Graph");
            int i = 0;
            int processingSize = dbData.size();
            List<Map<String, Object>> embeddings = new ArrayList<>();
            for( EncodeRequest request: dbData ) {
                // 배치 크기에 도달하면 Neo4j에 저장한다
                if (i > 0 && i % configuration.getBatchSize() == 0) {
                    System.out.println("Saving Embeddings to Graph : " + i);
                    neo4jService.saveEmbeddings(embeddings);
                    embeddings.clear();
                    curStatus = ( ( i * 100.0 ) / processingSize ) + " %";
                }
                i++;
                Map<String, Object> embedMap = new HashMap<>();
                // 3) 그래프에서 가져온 거래 목록을 넘겨 LLM 채팅 서비스로 요약을 얻는다
                String id = request.getId();
                System.out.println("Retrieving Summary");
                String summary = chatService.getSummaryText(request.getText());
                System.out.println("Retrieving embedding");
                // 4) 요약에 대해 임베딩 서비스를 지렛대 삼아 임베딩을 만든다
                Embedding embedding = embeddingModelService.generateEmbedding(summary);
                // 5) 관계 컨텍스트 ID와 함께 요약·임베딩을 레코드에 담아 배치에 저장한다
                embedMap.put("id", id);
                embedMap.put("embedding", embedding.vector());
                embedMap.put("summary", summary);
                embeddings.add(embedMap);
            }
            // 6) 배치에 남은 데이터가 있으면 Neo4j에 저장한다
            if( embeddings.size() > 0 ) {
                System.out.println("Saving Embeddings to Graph");
                neo4jService.saveEmbeddings(embeddings);
                embeddings.clear();
            }
            curStatus = "100 %";
        }catch (Exception e) {
            e.printStackTrace();
        }
        isComplete = true;
    }
}

이 접근으로 그래프를 증강하면, 고객 구매 행동을 이해해 더 나은 추천을 제공하는 다음 단계를 수행할 수 있게 된다. 품목 증강을 처리하는 ProcessArticles 클래스의 코드는 ProcessRequest와 거의 같으므로 차이만 본다. run 메서드가 Neo4j에서 데이터를 읽어 배치로 쪼갠 뒤 배치 임베딩 요청(generateEmbeddingBatch)을 호출하고, 배치가 모이면 임베딩 서비스에 넘겨 임베딩을 얻어 saveArticleEmbeddings로 Neo4j에 저장하며, 남은 품목 텍스트도 같은 방식으로 처리한다. 계절 구매 임베딩에는 단일 요청 모드를 썼지만, 품목 임베딩에는 배치 모드를 쓴다. 단일 요청 모드(API 사용)로는 한 번에 하나의 요약만 생성할 수 있었지만, 배치 모드는 임베딩 생성이 훨씬 빠르다.

처음부터 짓는 대신 최신 프로젝트를 ch9/langchain_graphaugment에서 내려받아 다뤄 볼 수도 있다. 프로젝트를 실행하려면 LangchainGraphaugmentApplication.java 파일을 오른쪽 클릭하고 Run 메뉴를 선택한다.

Note

실행 옵션과 여타 측면의 커스터마이징에 관심이 있다면 IDE가 제공하는 Run/Debug 구성을 쓸 수 있다. 공식 도움말에서 더 읽는다.

(:Section {n: 3})-[:BUILDS_WITH]->(:SpringAI)

Spring AI로 짓는 추천 엔진

이 절에서는 Spring AI를 지렛대 삼는 그래프 증강 애플리케이션을 짓는다. 프로젝트의 접근은 LangChain4j로 지은 것과 유사하다. 역시 GraphRAG 접근으로, 요구 조건에 부합하는 거래 사슬의 임베딩을 생성한다. 앞 절에서 내려받은 ZIP 파일에서 출발해 압축을 풀고, IntelliJ 플랫폼에 프로젝트를 적재한다. 이전 절과 같은 절차이므로, LangChain4j로 짓는 추천 엔진 절 서두에 나열된 단계를 따라 프로젝트를 임포트하면 된다.

LangChain4j와 대조적으로, Spring AI 프로젝트에는 의존성 갱신에 특별한 단계가 없다. 왜 그런지 보자.

Spring AI — 프로젝트 의존성 갱신

LangChain4j 프로젝트와 달리 어떤 의존성도 갱신할 필요가 없다. 필요한 모든 의존성을 Spring 스타터 프로젝트에서 이미 더할 수 있었기 때문이다. 다음으로 애플리케이션 프로퍼티 갱신을 본다.

Spring AI — 애플리케이션 프로퍼티 갱신

API를 활용할 수 있도록 애플리케이션 프로퍼티를 갱신해야 한다. 프로젝트의 application.properties 파일에 다음 구성을 더한다. 채팅 모델과 임베딩에 OpenAI LLM을 쓸 것이므로 OpenAI 플랫폼에서 API 키를 발급받는다. 채팅 모델 통합에는 OpenAI API 키만 더하면 되고, 임베딩 통합에는 키를 다시 더할 필요가 없다 — LLM 채팅 구성과 같은 구성을 쓰기 때문이다.

application.properties — Spring AI 구성properties
# Open AI LLM Integration for Generating Summary using Chat Model.
spring.ai.openai.api-key=<OPEN_AI_KEY>

# Open AI LLM Integration for Generating Embeddings
spring.ai.openai.embedding.options.model=text-embedding-3-large

# Neo4j Integration — 벡터 데이터베이스가 아닌 기본 통합
neo4j.uri=bolt://localhost:7687
neo4j.user=neo4j
neo4j.password=test1234
neo4j.database=hmreco
config.batchSize=5

구성 프로퍼티를 보았으니 애플리케이션을 짓기 시작한다. 순서는 같다 — Neo4j 데이터베이스 통합, 거래 요약·임베딩 생성을 위한 채팅 모델 통합, 그리고 요청을 호출할 REST 엔드포인트다.

Spring AI — Neo4j 통합

데이터베이스와 통합할 수단이 필요하므로 Neo4j 통합을 먼저 본다. 과업은 셋이다 — 읽기·쓰기 트랜잭션을 위한 연결 설정, 지정된 기간의 거래 품목 조회, 생성된 임베딩의 지속. 여기의 구현은 앞 절의 LangChain4j — Neo4j 통합에서 논의한 LangChain4j 프로젝트와 정확히 같다. 곧바로 품목 목록의 요약을 생성하는 OpenAI 채팅 모델 통합으로 넘어간다.

Spring AI — OpenAI 채팅 통합

채팅 통합은 LangChain4j와 조금 다르다. Service를 정의하고 ChatClient를 초기화해야 한다. 이 클라이언트를 지렛대 삼아 채팅 API로 요청을 만드는 것이다. LangChain4j처럼 추상화되어 있지 않다. LLM에 프롬프트를 제공하는 방식도 Spring AI 프레임워크에서는 살짝 다르다. LangChain4j 프레임워크에서는 LLM이 맡을 역할, 응답의 목표, 그리고 파라미터로서의 데이터가 하나의 시스템 메시지에 담겼다. 여기서는 역할과 목표를 시스템 프롬프트 템플릿으로 나누고, 데이터 파라미터는 사용자 메시지로 전달해야 한다. 결과는 두 경우 모두 같다.

OpenAIChatService — 시스템 프롬프트 템플릿과 사용자 메시지java
@Service
public class OpenAIChatService {
    private final ChatClient chatClient;

    private final String SYSTEM_PROMPT_TEMPLATE = """
             ---Role--
             You are an helpful assistant with expertise in fashion
             for a clothing company.

             ---Goal--
             Your goal is to generate a summary of the products purchased
             by the customers and descriptions of each of the products.
             Your summary should contain two sections -
             Section 1 - Overall summary outlining the fashion preferences
             of the customer based on the purchases. Limit the summary to
             3 sentences
             Section 2 - highlight 3-5 individual purchases.

             You should use the data provided in the section below as the
             primary context for generating the response.
             If you don't know the answer or if the input data tables do not
             contain sufficient information to provide an answer, just say so.
             Do not make anything up.

             Data Description:
             - Each Customer has an ID. Customer ID is a numeric value.
             - Each Customer has purchased more than one clothing articles
               (products). Products have descriptions.
             - The order of the purchases is very important. You should take
               into account the order when generating the summary.

             Response:
             ---
             # Overall Fashion Summary:

             \\n\\n

             # Individual Purchase Details:
    -    """ ;

    // 데이터는 사용자 메시지로 전달된다. {text} 변수가 입력으로 치환된다
    private final String userMessage = """
            Data:
            {text}
            """ ;
OpenAIChatService — ChatClient 초기화와 호출java
// ChatClient.Builder가 Spring Framework에 의해 생성자로 주입된다
    public OpenAIChatService(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    public String getSummaryText(String input) {
        // 시스템 템플릿으로 프롬프트를 만들고, 데이터 치환과 함께 사용자 메시지를
        // 전달하고, chatResponse 메서드를 호출한다
        ChatResponse response = chatClient
                .prompt()
                .system(SYSTEM_PROMPT_TEMPLATE)
                .user(p -> p.text(userMessage).param("data", input))
                .call()
                .chatResponse() ;
        return response.getResult().getOutput().getContent() ;
    }
}

사용법이 LangChain4j 프레임워크와 다름을 볼 수 있다. 여기서는 시스템 템플릿으로 프롬프트를 만들고, 데이터 치환을 담은 사용자 메시지를 넘긴 뒤, chatResponse 메서드를 호출해야 한다. 다음은 임베딩 모델 통합이다.

Spring AI — OpenAI 임베딩 모델 통합

임베딩 모델 통합은 꽤 단순하다. Autowired로 임베딩 모델 인스턴스를 초기화할 수 있다.

OpenAIEmbeddingModelService — 단건 + 배치 임베딩java
@Service
public class OpenAIEmbeddingModelService {
    private EmbeddingModel embeddingModel ;

    @Autowired
    public OpenAIEmbeddingModelService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    float[] generateEmbedding(String text) {
        float[] response = embeddingModel.embed(text) ;
        return  response ;
    }

    List<float[]> generateEmbeddingBatch(List<String> textList) {
        List<float[]> responses = embeddingModel.embed(textList) ;
        return responses ;
    }
}

클래스에 EmbeddingModel을 더하고 생성자로 초기화하는 것이 전부다. Spring Boot 애플리케이션이 시작될 때 프로퍼티에 기초한 적절한 임베딩 모델 구현이 인스턴스화되어 이 변수에 배정된다. 이 서비스는 주어진 텍스트의 임베딩을 생성하는 메서드를 제공한다. 정의된 서비스를 모두 보았으니, 이들로 그래프 증강 애플리케이션을 짓는 방법을 본다.

Spring AI — 최종 애플리케이션

애플리케이션 흐름은 LangChain4j — 최종 애플리케이션에서 논의한 것과 거의 같다. 코드가 유사하므로 여기 다시 싣지 않는다. 유일한 차이라면 Java 패키지 이름 정도다. 기록을 위해 애플리케이션 흐름을 짚는다. 증강 수행을 요청하는 REST 엔드포인트를 짓되, 과정이 시간이 걸릴 수 있으므로 둘로 나눈다 — 증강 과정을 시작하는 요청이 요청 ID를 반환하고, 그 ID로 진행 상황을 확인한다. 첫 단계는 스레드를 시작해 전체 데이터의 처리를 개시한다. 요청 과정은 이렇게 진행된다.

  1. 계절 시작 관계의 ID(컨텍스트)와, 구매된 순서대로의 품목 설명을 가져온다. 레코드 리스트가 응답으로 반환된다.
  2. Neo4j에서 가져온 각 레코드에 대해 — 채팅 요청을 실행해 요약을 생성하고, 반환된 요약에 대해 LLM 임베딩 API로 임베딩을 생성하고, 관계 ID·요약·임베딩을 맵에 담아 배치를 쌓고, 배치 크기가 구성에 지정된 크기에 이르면 관계 ID가 가리키는 관계에 요약과 임베딩을 기록한다.

이 접근으로 그래프를 증강하면, 고객 구매 행동을 이해해 더 나은 추천을 제공하는 다음 단계를 수행할 수 있게 된다. 처음부터 짓는 대신 최신 프로젝트를 ch9/springai_graphaugment에서 내려받아 다뤄 볼 수도 있다. 실행하려면 SpringaiGraphAugmentApplication.java 파일을 오른쪽 클릭하고 Run 메뉴를 선택한다.

Note

실행 옵션의 커스터마이징에 관심이 있다면 IDE의 Run/Debug 구성을 쓸 수 있다. 공식 도움말에서 더 읽는다.

이제 우리가 지은 이 애플리케이션으로 그래프를 증강하고, 거기서 추천을 제공하는 방법을 본다.

(:Section {n: 4})-[:FINE_TUNES]->(:Recommendation)

추천 시스템의 미세조정

프로젝트가 준비되었으니, 애플리케이션을 IDE에서 실행하거나 실행 가능한 JAR 파일로 빌드할 수 있다. 여기서는 IDE에서 직접 실행한다. 테스트에는 LangChain4j 애플리케이션을 쓴다. Spring AI 애플리케이션도 같은 원리를 따른다. 앞 장에서 만든 데이터베이스에서 출발하되, 처음부터 시작하고 싶지 않다면 데이터베이스 덤프를 내려받아 그것으로 데이터베이스를 만들 수 있다.

LangchainGraphaugmentApplication.java 파일을 더블클릭해 IDE에 적재하고, 클래스 이름을 오른쪽 클릭해 Run 메뉴 항목을 클릭하면 애플리케이션이 시작된다(그림 9.6). 애플리케이션이 준비되면 IDE 콘솔에 다음이 보인다.

console — 애플리케이션 기동 로그log
2024-12-12T14:52:30.075+05:30  INFO 5296 --- [langchain_graphaugment]
[           main] w.s.c.ServletWebServerApplicationContext : Root
WebApplicationContext: initialization completed in 1271 ms
2024-12-12T14:52:31.347+05:30  INFO 5296 --- [langchain_graphaugment]
[           main] o.neo4j.driver.internal.DriverFactory    : Direct driver
instance 1567253519 created for server address localhost:7687
2024-12-12T14:52:31.388+05:30  INFO 5296 --- [langchain_graphaugment]
[           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat
started on port 8080 (http) with context path '/'
2024-12-12T14:52:31.398+05:30  INFO 5296 --- [langchain_graphaugment]
[           main] g.h.l.g.LangchainGraphaugmentApplication : Started
LangchainGraphaugmentApplication in 3.146 seconds (process running for 3.746)

애플리케이션이 가동되면 브라우저를 열어 http://localhost:8080/augment/SUMMER_2019/FALL_2019 URL을 입력해, 고객들의 SUMMER_2019 구매에 대한 증강 과정을 시작할 수 있다. 이 요청을 발행하면 aff867bd-08fb-42fb-8a27-3917e0ce83d1 같은 UUID가 응답으로 돌아온다. 과정이 도는 동안 http://localhost:8080/augment/status/aff867bd-08fb-42fb-8a27-3917e0ce83d1 URL로 현재 완료율을 물을 수 있다.

Note — UUID는 동적이다

앞서 언급한 UUID 값 aff867bd-08fb-42fb-8a27-3917e0ce83d1은 동적인 값이다. 위에 보인 것과 같은 UUID를 받는다는 보장이 없다. 이 UUID는 이 예제의 실행에 특정된 것으로, 자신의 실행에서 요청이 돌려준 UUID를 확인해 그것으로 상태를 조회해야 한다.

요약과 임베딩의 생성에는 시간이 걸린다. 이 과정이 끝나면 다음으로 품목에 대한 임베딩을 만들어야 한다. 과정은 앞 단계와 유사하다. 브라우저에 http://localhost:8080/augmentArticles URL을 입력하면 역시 UUID가 응답으로 돌아오고, 완료될 때까지 완료율을 계속 확인하면 된다. 이 전체 과정이 끝나기를 기다리고 싶지 않다면 데이터베이스 덤프를 내려받을 수 있다.

증강을 수행했으니, 이 임베딩들이 얼마나 좋은지, 추천 제공에 어떻게 도움이 되는지 본다. 이를 위해 생성한 임베딩에 벡터 인덱스를 만들어 그래프를 한층 더 강화할 수 있다. 품목 임베딩의 벡터 인덱스는 다음 Cypher로 만든다.

vector index — Article 노드 임베딩 (3,072차원)cypher
CREATE VECTOR INDEX `article-embeddings` IF NOT EXISTS
FOR (a:Article)
ON a.embedding
OPTIONS { indexConfig: {
`vector.dimensions`: 3072,
`vector.similarity_function`: 'cosine'
}}

이것이 Article 노드에 article-embeddings라는 이름의 벡터 인덱스를 만든다. 2019년 여름 구매 임베딩의 벡터 인덱스는 다음 Cypher 코드로 만들 수 있다.

vector index — SUMMER_2019 관계 임베딩cypher
CREATE VECTOR INDEX `summer-2019-embeddings` IF NOT EXISTS
FOR ()-[r:SUMMER_2019]->() ON (r.embedding)
OPTIONS { indexConfig: {
`vector.dimensions`: 3072,
`vector.similarity_function`: 'cosine'
}}

이것이 SUMMER_2019 관계에 summer-2019-embeddings라는 이름의 벡터 인덱스를 만든다. 먼저 Article 벡터 인덱스의 사용부터 보자. 다음 Cypher는 ID가 0748579001인 품목에 대한 상위 다섯 개의 매치를 찾으려 시도한다.

query — 주어진 품목과 유사한 품목 검색cypher
MATCH (a:Article {id:'0748579001'})
WITH a
CALL db.index.vector.queryNodes('article-embeddings', 5, a.embedding)
YIELD node, score
RETURN score, node.id as id, node.desc as desc

결과(그림 9.7)에서, 첫 매치가 최선의 매치이며 그것이 우리가 찾던 바로 그 Article 노드임을 볼 수 있다.

Article 유사도 — id: 0748579001top 5 · cosine
1.0
0748579001 "Ankle-length, A-line beach dress in an airy, patterned weave with a deep V-neck with gold-coloured studs, and long dolman sleeves with elastication and a decorative drawstring at the cuffs. Unlined."
0.882
0748033001 "Long beach dress in airy, crinkled chiffon with narrow, tie-top shoulder straps and an opening at the back of the neck. Unlined."
0.873
0748582008 "Short beach dress in an airy cotton weave with broderie anglaise. V-neck front and back with a crocheted lace trim, horizontal, tasselled straps at the back of the neck, dropped shoulders and short sleeves. Seam at the waist and a gently flared skirt. Unlined."
0.866
0748025004 "Kaftan in an airy weave with buttons down the front and high slits in the sides."
0.866
0747737004 "Sarong in an airy weave. Size 130x150 cm."

그림 9.7 — 주어진 품목과 유사한 품목들

서로 덜 유사한 품목일수록 점수 값이 낮은 것도 볼 수 있다. 이 결과에서 ID가 0748582008인 품목을 골라 무엇을 찾을 수 있는지 보자. 같은 Cypher를 이 ID로 실행하면 이런 결과가 나온다.

Article 유사도 — id: 0748582008top 5 · cosine
1.0
0748582008 "Short beach dress in an airy cotton weave with broderie anglaise. V-neck front and back with a crocheted lace trim, horizontal, tasselled straps at the back of the neck, dropped shoulders and short sleeves. Seam at the waist and a gently flared skirt. Unlined."
0.969
0748582001 "Short beach dress in an airy cotton weave with broderie anglaise. V-neck front and back with a crocheted lace trim, horizontal, tasselled straps at the back of the neck, dropped shoulders and short sleeves. Seam at the waist and a gently flared skirt. Unlined."
0.893
0848082001 "Short beach kaftan in an airy weave with lace trims. Short, wide sleeves, and a drawstring at the waist."
0.884
0854784001 "Short beach dress in an airy cotton weave containing glittery threads. Round neckline with a V-neck opening and narrow ties at the front, dropped shoulders and long balloon sleeves with narrow, buttoned cuffs. Gathered tiers at the hem for added width."
0.884
0850893001 "Calf-length lace kaftan with a crocheted trim around the opening, a drawstring and twisted ties at the waist, and long sleeves. Scalloped trim around the cuffs and hem."

그림 9.8 — 주어진 품목과 유사한 품목들

결과에서, 점수가 0.9에 가까울수록 품목들이 꽤 비슷함을 볼 수 있다. 고객이 이미 구매한 품목에 기초해 유사한 품목을 추천으로 제공하는 데 이 정보를 쓸 수 있다.

이제 ID가 92f0으로 끝나는 고객의 여름 구매 행동을 보자. 이 고객을 고객 A라 부른다.

query — 고객 A와 유사한 고객들의 구매 요약cypher
MATCH (c:Customer)-[r:SUMMER_2019]->() WHERE
c.id='0002b7a7ab270a638fcb2eb5899c58696db24d9d954ddb43683dd6b0ffa292f0'
WITH r
CALL db.index.vector.queryRelationships('summer-2019-embeddings', 5, r.embedding)
YIELD relationship, score
MATCH (oc)-[relationship]->()
WITH oc, score, relationship
WITH oc, score, split(relationship.summary, '\n') as s
WITH oc, score, CASE when s[2] <> '' THEN s[2] ELSE s[3] end as desc
WITH score, oc.id as id, desc
RETURN round(score,3) as score,
substring(id,0,4)+".."+substring(id,size(id)-4) as id, desc
고객 유사도 — 고객 A (0002..92f0)top 5 · SUMMER_2019
1.0
"0002..92f0" "The customer exhibits a strong preference for vibrant colors and comfortable, casual styles, particularly in swimwear and denim. Their purchases suggest a love for playful yet practical clothing, suitable for both beach outings and everyday wear. The mix of swimwear, shorts, and casual tops indicates a versatile wardrobe focused on both style and comfort."
0.968
"044d..d47e" "The customer exhibits a strong preference for swimwear, particularly in vibrant colors like light orange and dark red, indicating a fun and playful style. Their choices also reflect an inclination towards high-waisted designs and supportive tops, suggesting a desire for both comfort and fashion. Additionally, the purchase of a versatile playsuit and tailored jacket indicates an appreciation for stylish yet practical everyday wear."
0.967
"07fe..a87f" "The customer exhibits a strong preference for swimwear, particularly in vibrant colors like orange and black, indicating a love for beach and poolside activities. There is also a notable inclination towards basic wardrobe staples, such as tank tops and shorts, suggesting a desire for comfortable yet stylish casual wear. The blend of swimwear and basic clothing reflects a versatile fashion sense suitable for both leisure and everyday wear."
0.966
"0247..74b3" "The customer demonstrates a preference for vibrant colors and versatile clothing items suitable for various occasions, including casual wear and swimwear. The repeated purchases of swimwear suggest a keen interest in beach or poolside activities. Additionally, the inclusion of dresses and accessories indicates a desire for stylish yet comfortable outfits."
0.965
"0686..5220" "The customer displays a strong preference for vibrant colors, particularly orange and white, as seen in their selection of swimwear and casual attire. Their purchases indicate a blend of comfort and style, with a focus on versatile pieces that can be worn for various occasions. The inclusion of both swimwear and everyday clothing suggests a lifestyle that appreciates both leisure and fashion."

그림 9.9 — 주어진 고객과 유사한 다른 고객들의 구매 요약

기본 요약만 봐도 고객 구매 행동들이 꽤 비슷함을 알 수 있다. 이 목록에서 다른 고객(고객 B)을 하나 골라, 같은 질의를 실행했을 때 같은 고객들이 반환되는지 보자. ID가 74b3으로 끝나는 고객을 고른다. 같은 질의를 0247b7b564909181b2e552fe3d5cec01056ebc1b3d61d38f1ff0658db69174b3으로 실행한 결과다.

고객 유사도 — 고객 B (0247..74b3)top 5 · SUMMER_2019
1.0
"0247..74b3" "The customer demonstrates a preference for vibrant colors and versatile clothing items suitable for various occasions, including casual wear and swimwear. The repeated purchases of swimwear suggest a keen interest in beach or poolside activities. Additionally, the inclusion of dresses and accessories indicates a desire for stylish yet comfortable outfits."
0.968
"05de..29df" "The customer's fashion preferences indicate a strong inclination towards swimwear and dresses, particularly in vibrant and playful colors such as pink, orange, and blue. The selection of both swimwear and dresses suggests a versatile style that embraces both casual beachwear and stylish everyday attire. Additionally, the repeated purchases of high-waisted bikini bottoms showcase a preference for flattering and functional swimwear options."
0.967
"0322..3e92" "The customer exhibits a strong preference for swimwear, as evidenced by multiple purchases of bikini tops and bottoms, showcasing a desire for stylish beach attire. Additionally, the selection of dresses and blouses reflects an inclination towards fashionable yet comfortable everyday wear. The use of vibrant colors and unique design elements indicates a taste for contemporary and eye-catching pieces."
0.966
"0002..92f0" "The customer exhibits a strong preference for vibrant colors and comfortable, casual styles, particularly in swimwear and denim. Their purchases suggest a love for playful yet practical clothing, suitable for both beach outings and everyday wear. The mix of swimwear, shorts, and casual tops indicates a versatile wardrobe focused on both style and comfort."
0.965
"0863..c454" "The customer displays a strong preference for swimwear, particularly in vibrant colors like dark red and orange, indicating a fondness for beachwear and summer styles. Additionally, their choices in everyday clothing, such as airy dresses and denim skirts, suggest an inclination towards comfortable yet stylish casual wear. The repetition of specific items also reflects a desire for consistency and reliability in their fashion selections."

그림 9.10 — 주어진 고객과 유사한 다른 고객들의 구매 요약

고객 B의 구매 요약이 고객 A의 상위 5개 유사 구매 안에 있었음에도, 고객 B의 상위 5개 매치는 고객 A의 것과 매우 다르다는 사실을 볼 수 있다. 이 접근으로 고객 구매 행동에 기초해 구매할 품목을 추천할 수 있다. 우리는 구매의 순서를 포착하고 있지만, 그 구매들의 요약이 임베딩에 어떻게 포착되는가가 누구를 유사한 고객으로 볼지를 결정한다. 그 질의가 어떤 모습일지 보자. 질의는 부분별로 짚는다.

recommendation 1 — 유사 고객 5인 수집cypher
MATCH (c:Customer)-[r:SUMMER_2019]->() WHERE
c.id='0247b7b564909181b2e552fe3d5cec01056ebc1b3d61d38f1ff0658db69174b3'
WITH c, r
-- 구매에 기초해 이 고객과 유사한 다른 고객들을 찾는다.
-- 벡터 인덱스로 상위 5인의 유사 고객을 얻는다
CALL db.index.vector.queryRelationships('summer-2019-embeddings', 5, r.embedding)
YIELD relationship, score
MATCH (oc)-[relationship]->()
WITH c, collect(oc) as others
recommendation 2 — 고객 본인의 구매 품목 수집cypher
CALL {
    WITH c
    MATCH (c)-[:SUMMER_2019]->(start)
    MATCH (c)-[:FALL_2019]->(end)
    WITH start, end
    MATCH p=(start)-[:NEXT*]->(end)
    WITH p
    WITH nodes(p) as txns
    UNWIND txns as tx
    MATCH (tx)-[:HAS_ARTICLE]->(a)
    RETURN collect(a) as customerPurchases
}
WITH others, customerPurchases
recommendation 3 — 유사 고객들의 구매 품목 수집cypher
CALL {
    WITH others
    UNWIND others as a
    MATCH (a:Customer)-[:SUMMER_2019]->(start)
    MATCH (a)-[:FALL_2019]->(end)
    WITH start, end
    MATCH p=(start)-[:NEXT*]->(end)
    WITH nodes(p) as txns
    UNWIND txns as tx
    MATCH (tx)-[:HAS_ARTICLE]->(a)
    WITH DISTINCT a
    RETURN collect(a) as otherPurchases
}
WITH customerPurchases, otherPurchases
recommendation 4 — 본인 구매 제외 후 10개 추천cypher
-- 유사 고객들이 구매한 품목에서 원 고객이 구매한 품목을 제거한다
WITH apoc.coll.subtract(otherPurchases, customerPurchases) as others
UNWIND others as other
RETURN other.id as id, other.desc as desc
LIMIT 10

이 Cypher는 먼저 고객이 수행한 구매를 수집하고, 유사한 구매 행동을 지닌 다른 고객들을 찾고, 그 고객들의 구매를 가져와, 원래 고객이 이전에 구매하지 않은 품목 10개를 추천한다. 이 질의의 출력은 이렇다.

추천 결과 — 고객 0247..74b310 articles · 미구매
0471714036"Knee-length shorts in a cotton weave with a button fly, side pockets and a welt back pocket with a button."
0699923078"T-shirt in soft, printed cotton jersey."
0786663001"Short, off-the-shoulder dress in an airy, plumeti weave with elastication and a small frill trim at the top. Long sleeves with elastication at the cuffs, an elasticated seam at the waist and a flounce at the hem. Jersey lining."
0728473001"Triangle bikini top with laser-cut scalloped edges and lightly padded cups with removable inserts. Narrow, adjustable shoulder straps that can be fastened in different ways and an adjustable metal hook fastening at the back."
0689040001"Fully lined bikini bottoms with a mid waist, wide sides, a wide tie at one side and medium coverage at the back."
0736046001"Metal hoop earrings in different sizes, three with charms in various designs. Diameter 1-2 cm."
0713200006"Fully lined, waist-high bikini bottoms with wide sides and medium coverage at the back."
0674606026"Short, A-line skirt with a high waist and buttons down the front."
0562245064"5-pocket jeans in washed, superstretch denim with a regular waist, zip fly and button, and skinny legs."
0557247005"Oversized top in sturdy sweatshirt fabric with dropped shoulders and ribbing around the neckline, cuffs and hem. Soft brushed inside."

그림 9.11 — 유사 고객들의 구매에 기초한 고객 추천

그래프의 미세조정을 위해 설명한 단계들을 따르면, 이제 유사 고객과 그들의 구매를 찾거나 고객이 산 것에 기초해 유사 품목을 찾음으로써, 고객 구매 행동에 기초한 추천을 제공할 수 있다. 이 접근은 단순하고 잘 작동한다. 그러나 누가 유사 고객인지 등을 우리가 직접 결정하고 있다. 더 나은 추천을 제공하려면 Graph Data Science 알고리즘이나 기계학습으로 고객을 더 잘 그룹화하고 싶어질 것이다. 그 측면은 다음 장에서 들여다본다.

요약

이 장에서 우리는 LangChain4j와 Spring AI를 지렛대 삼아 지능형 애플리케이션을 짓는 방법을 보았다. 이 애플리케이션들로, LLM의 채팅과 임베딩 능력을 활용해 앞 장에서 적재한 H&M 거래 그래프를 증강했다. 그래프가 증강된 뒤에는 벡터 인덱스를 활용해 그래프를 한층 더 강화했고, 이 인덱스들이 구매에 기초해 유사한 품목이나 고객을 찾도록 어떻게 돕는지 보았다.

다음 장에서는 Graph Data Science 알고리즘으로 걸어 들어가, 이 추천들을 어떻게 한층 더 강화할 수 있는지 본다.

NEXT → Chapter 10 · Creating an Intelligent Recommendation System