티스토리 뷰

작은 프로젝트로 텍스트 기반 검색 기능을 구현할 일이 생겼다.

 

처음에는 너무 막연했는데 Elasticsearch에서 텍스트 유사도 검색이라는 기능을 제공하는 것을 알게되었다.

https://www.elastic.co/kr/blog/text-similarity-search-with-vectors-in-elasticsearch

 

벡터 필드를 사용한 Elasticsearch의 텍스트 유사도 검색

Elasticsearch 7.3 릴리즈에서는 벡터를 사용하여 문서 점수를 매기는 기능을 제공합니다. 이 게시물에서는 텍스트 임베딩과 벡터 필드를 사용하여 유사도를 검색하는 방법을 살펴봅니다.

www.elastic.co

 

이 문서를 기반으로 Elasticseach cloud + Springboot를 이용해 검색 기능을 구현해봤다.

 

가장 먼저 해야할 것은 문장을 임베딩(embedding)하는 것이다.

 

임베딩이란 무엇인지 위 문서에서 자세히 설명해놨다.

 

임베딩이란?

단어 임베딩 모델은 단어를 고밀도 숫자 벡터로 나타냅니다. 이러한 숫자 벡터의 목표는 단어의 의미론적 속성을 포착하는 것으로, 벡터가 비슷한 단어들은 의미론적 의미가 유사합니다. 우수한 임베딩에서는 벡터 공간의 방향이 단어 의미의 다양한 측면과 연결되어 있습니다. 예를 들어 ‘캐나다’의 벡터는 한쪽으로는 ‘프랑스’와 가깝고 다른 한쪽으로는 ‘토론토’와 가깝습니다.

NLP와 검색 커뮤니티는 꽤 오래전부터 단어의 벡터 표현에 관심을 두었는데, 지난 몇 년간 기존 작업에 신경망을 적용할 수 있게 되면서 단어 임베딩이 다시 주목받게 되었습니다. word2vec, GloVe를 비롯하여 일부 단어 임베딩 알고리즘이 성공적으로 개발되었습니다. 이러한 접근 방식에서는 대규모 텍스트 모음을 사용하고 각 단어가 나타나는 문맥을 검토하여 벡터 표현을 결정합니다.

 

임베딩을 하게되면 문장이 vector로 표현되게 된다.

 

예를 들어, 입력한 단어 혹은 문장이 [4, 3.4, -0.2] 이런식으로 변환된다. 

 

단어 임베딩에는 정말 다양한 라이브러리들이 있는데, 나는 openAI의 embedding API를 사용했다.

(이 포스팅에서는 임베딩을 어떻게 하는지는 따로 다루지 않겠다.)

 

openAI의 임베딩을 사용하면 1536 차원의 벡터가 생성되게 되는데 이 데이터를 기반으로 유사도 검사를 진행하게 된다.

 

코드로 가보자

 

데이터 저장

입출력 데이터 형식

@Data
public class ElasticSearchVo {
    private int took;
    private boolean timedOut;
    private ShardInfoVo _shards;
    private HitListVo hits;

    @Data
    public static class ShardInfoVo {
        private int total;
        private int successful;
        private int skipped;
        private int failed;
    }
    @Data
    public static class HitListVo {
        private TotalVo total;
        @JsonProperty("max_score")
        private double maxScore;
        private List<HitVo> hits;
    }

    @Data
    public static class TotalVo {
        private int value;
        private String relation;
    }

    @Data
    public static class HitVo {
        @JsonProperty("_index")
        private String index;
        @JsonProperty("_id")
        private String id;
        @JsonProperty("_score")
        private double score;
        @JsonProperty("_source")
        private SourceVo source;
    }

    @Data
    public static class SourceVo {
        private String title;
        @JsonProperty("title_vector")
        private float[] titleVector;
    }

데이터를 요청받으면 편하지만 ES에 저장된 데이터를 GET 해서 사용했기 때문에, 데이터가 저장된 객체가 불필요하게 커졌다.

 

일반적으로 사용할 거라면, title만 받아서 임베딩해서 사용하면 된다.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ElasticSearchResultVo  {
    private Integer took;
    @JsonProperty("timed_out")
    private Boolean timedOut;
    @JsonProperty("_shards")
    private ShardsVo shards;
    private HitsVo hits;

    @Data
    public static class ShardsVo {
        private Integer total;
        private Integer successful;
        private Integer skipped;
        private Integer failed;
    }

    @Data
    public static class HitsVo {
        private HitsTotalVo total;
        @JsonProperty("max_score")
        private Float maxScore;
        private List<HitsItemVo> hits;
    }

    @Data
    public static class HitsTotalVo {
        private Integer value;
        private String relation;
    }

    @Data
    public static class HitsItemVo {
        @JsonProperty("_index")
        private String index;
        @JsonProperty("_id")
        private String id;
        @JsonProperty("_score")
        private Float score;
        @JsonProperty("_ignored")
        private String[] ignored;
        @JsonProperty("_source")
        private HitsItemSourceVo source;
    }

    @Data
    public static class HitsItemSourceVo {
        private String title;
        // 검색 결과 데이터
        // ...
    }

}

데이터 저장 매서드 & Elasticsearch cloud 요청 매서드

public void insertCosineSearch(ElasticSearchVo.SourceVo elasticSearchVo) {

    String json = requestElasticSearch("GET", "[index URL]/_search", "");

    ElasticSearchVo elasticSearchVo = CommonUtils.JsonStringToObject(json, ElasticSearchVo.class);

    EmbeddingResponseDto embeddingResponseDto = openAiService.createEmbedding(hitVo.getSource().getProjectName());
    elasticSearchVo.getHits().getHits().getSource().setTitleVector(embeddingResponseDto.getData().get(0).getEmbedding());
        
    String endPoint = [인덱스 URL];

    if(!indexExists(endPoint)) {
        // dense_vector 컬럼 생성
        requestElasticSearch("PUT", COSINE_SEARCH_MAPPING);
    }
    
    // 데이터 삽입
    endPoint = [인덱스 URL] + [DOC ID];

    String responseBody = requestElasticSearch("PUT", endPoint, CommonUtils.objectToJsonString(elasticSearchVo));

    ResultData resultData = CommonUtils.JsonStringToObject(responseBody, ResultData.class);

    log.info("데이터 삽입 성공");
}

public String requestElasticSearch(String method, String endPoint, String dataJson) {

    Request request = new Request(method, endPoint);
    try {
        if(!ObjectUtils.isEmpty(dataJson)) {
            HttpEntity entity = new NStringEntity(dataJson, ContentType.APPLICATION_JSON);
            request.setEntity(entity);
        }

        Response response = restClient.performRequest(request);
        return EntityUtils.toString(response.getEntity());
    } catch(Exception e) {
        return null;
    }
}

1. GET METHOD를 이용해 ES에 저장된 데이터를 가져온다.

 

2. 스트링 형태의 json을 객체로 변환

 

3. 임베딩 후 객체에 삽입

 

4. 다시 저장, 인덱스가 비었다면 mapping 해준다. title_vector를 dense_vector로 지정 

 

순서로 코드가 진행된다.

 

commonUtils를 사용한 매서드들은 ObjectMapper를 이용한 json 파싱을 위한 매서드들이다.

 

[MAPPING_JSON]은 검색에 사용할 vector는 무조건 dense_vector여야하기 때문에, 그 내용을 지정해 준 것이다.

 

인덱스에 값을 삽입하기 전에 반드시 지정해줘야한다.

public String COSINE_SEARCH_MAPPING = """
                {
                  "mappings": {
                    "properties": {
                      "title_vector": {
                      "type": "dense_vector",
                      "dims": 1536
                      }
                    }
                  }
                }
                """;

openAI의 임베딩은 1536차원이라 차원수를 1536 으로 지정해야 한다. 차원 수와 벡터의 크기가 다르면 검색이 동작하지 않는다.

 

검색

public ElasticSearchResultVo cosineSimilarity(EmbeddingResponseDto embeddingResponseDto) {

    String vectorArray = CommonUtils.objectToJsonString(embeddingResponseDto.getData().get(0).getEmbedding());

    String requestJson = getCosineSearchQuery(vectorArray);

    String URL = "[인덱스 URL]/_search"

    String responseBody = requestElasticSearch("POST", URL , requestJson);

    return CommonUtils.JsonStringToObject(responseBody, ElasticSearchResultVo.class);
}

간단히 코드를 따라가보면,

 

1. 질문 문장을 임베딩한 embeddingResponseDto 를 파라미터로 받는다.

 

2. 임베딩한 vectorArray를 포함한 json 형태로 만든다.(getCosineSearchQuery)

 

3. _search URL로 검색 API를 POST 요청을 보낸다.

 

4. 응답데이터를 받아 json string을 객체로 변환한다.

 

아래는 cosine search를 위한 json string이다.(getCosineSearchQuery 매서드)

public String getCosineSearchQuery(String queryVector) {
    return """
            {
              "_source": {
                "excludes": [
                  "title_vector"
                ]
              },
              "size": 10,
              "query": {
                "script_score": {
                  "query": {
                    "match_all": {}
                  },
                  "script": {
                    "source": "cosineSimilarity(params.queryVector, 'project_name_vector') + 1.0",
                    "params": {
                      "queryVector": %s
                    }
                  }
                }
              }
            }
            """.formatted(queryVector);
}

 

""" """(Raw String Literals)과 .foramtted를 이용해 json을 표현했다.

 

안그랬으면 이스케이프 문자를 덕지덕지 붙여서 보기 싫은 스트링 형태의 json을 만들었어야 했다.

 

검색 결과

{
    "time": "20230816144241705",
    "code": "200000",
    "response": {
        "took": 2,
        "hits": {
            "total": {
                "value": 3,
                "relation": "eq"
            },
            "hits": [
                {
                    "_index": "amore_pacific",
                    "_id": "2",
                    "_score": 1.981503,
                    "_ignored": null,
                    "_source": {
                        "title": "IT SW 개발2"
                    }
                },
                {
                    "_index": "amore_pacific",
                    "_id": "1",
                    "_score": 1.9581885,
                    "_ignored": null,
                    "_source": {
                        "title": "IT SW 개발1"
                    }
                },
                {
                    "_index": "amore_pacific",
                    "_id": "3",
                    "_score": 1.9517823,
                    "_ignored": null,
                    "_source": {
                        "title": "IT SW 개발3"
                    }
                }
            ],
            "max_score": 1.981503
        },
        "timed_out": false,
        "_shards": {
            "total": 1,
            "successful": 1,
            "skipped": 0,
            "failed": 0
        }
    }
}

 

역시 openAI의 임베딩 기술 + elasticsearch의 검색 기능이라 그런지 꽤나 훌륭한 검색 기능을 보여줬다.

 

마치며 

Elasticsearch의 사용법부터 더듬더듬 알아가서 결국 검색 알고리즘까지 사용해봤다.

 

원래는 k-nn과 함께 정리하려고 했는데, 내용이 너무 길어져서 k-nn은 다음글에 정리해야할 것 같다.

 

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
글 보관함