티스토리 뷰

이전 글에서 사용했던 Cosine Search는 dense vector 하나만을 이용한 방법이었다.

 

그래서 내가 조금 더 다양한 정보를 갖고 있을 때, 이를 전부 활용하기 어렵다는 단점이 있다.

 

예를 들어, 내가 알고 있는게 게시물의 제목, 내용, 작성자, 작성 시간 등 몇 가지의 정보가 있음에도

 

게시물의 제목만 활용할 수 있다는 것이다.

 

모든 내용을 한문장에 섞어서 하나의 벡터화를 할 수도 있겠지만, 이럴 경우 엄밀히 말하면 각각을 비교한 결과가 아니게 된다.

 

이렇게 다양한 정보를 활용하기 위해서 Elasticsearch에서는 K-NN search 기능을 제공한다.

 

이를 이용해 검색 기능을 Springboot로 구현해봤다.

 

대략적인 구현은 cosine search에서 구현한 방식을 따라가려고 한다.

 

그 전에 K-NN Search에 대해 짚고 넘어가자

 

K-NN Search이란?

CS를 공부했다면, 대략적으로 알만한 내용이다. 

 

간단히 설명하면, 검색하고자 하는 예시와 가장 가까운 k를 뽑아주는 검색 방법이다.

 

출처 : https://ai.plainenglish.io/introduction-to-k-nearest-neighbors-knn-algorithm-e8617a448fa8

여기서 예시가 2개 3개 4개... 계속 늘어나게 되면 차원수가 계속 늘어나게 되어 비교 연산에 많은 시간을 소요하게 된다.

 

그럴 경우, 데이터를 특정 영역을 나눈 후 계산하는 대략적인(Approixmate) K-NN 방법을 사용할 것을 권장한다.

 

이 글에서도 비교하려는 데이터가 많아서 대략적인 K-NN 방법을 사용했다.

 

하나씩 알아보자.

 

구현

데이터 저장

@Data
@Builder
public class KnnDataInsertVo {
private String id;
    private String title;
    private String content;
    private String creator;
    private String createdDate;
    
    @JsonProperty("title_vector")
    private float[] titleVector;
    @JsonProperty("content_vector")
    private float[] contentVector;
    @JsonProperty("creator_vector")
    private float[] creatorVector;
    @JsonProperty("created_vector")
    private float[] createdVector;
}

cosine search와 다르게 검색하고자하는 요소가 많기 때문에 입력데이터도 많다.

 

id는 단순히 저장 위치를 지정하기 위해 받는 정보이다.

 

임베딩 데이터를 함께 갖고 있게 했다.

 

데이터 저장 매서드

저장하기 전에 임베딩을 해줬는데, 이 부분은 간단하게만 첨부한다.

EmbeddingResponseDto titleResponseDto = createEmbeddingIfNotNull(confluenceKnnSearchRequestDto.getTitle());

private EmbeddingResponseDto createEmbeddingIfNotNull(String text) {
    if (!ObjectUtils.isEmpty(text)) {
        return openAiService.createEmbedding(text);
    } else {
        return null;
    }
}

코사인 검색 방법과 같이 임베딩은 openAI의 API를 이용했다.

 

추가적으로 입력 정보가 없을 경우를 대비해 null체크를 해줬다.

 

다음은 데이터 저장 매서드이다.

public ResultData insertKnnSearchData(KnnDataInsertVo knnDataInsertVo) {
    String json = CommonUtils.objectToJsonString(knnDataInsertVo);

    String endPoint = "/knn-test";

    // space 별 index 생성
    if(!indexExists(endPoint)) {
        // dense_vector 컬럼 생성
        requestElasticSearch("PUT", "/knn-test/_mapping", ElasticSearchIndexMapping.ES_KNN_SEARCH_MAPPING);
    }

    String docEndPoint = "/knn-test/_doc/" + knnDataInsertVo.getId();

    String responseBody = requestElasticSearch("POST", docEndPoint, json);

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

여기서도 검색하고자하는 벡터들을 dense_vector로 지정해야한다.

 

때문에 인덱스를 만들 때 maaping 정보를 심어줬다.

public static final String ES_KNN_SEARCH_MAPPING = """
            {
              "mappings": {
                "properties": {
                  "title_vector": {
                    "type": "dense_vector",
                    "dims": 1536,
                    "index": true,
                    "similarity": "l2_norm"
                  },
                  "content_vector": {
                    "type": "dense_vector",
                    "dims": 1536,
                    "index": true,
                    "similarity": "l2_norm"
                  },
                  "creator_vector": {
                    "type": "dense_vector",
                    "dims": 1536,
                    "index": true,
                    "similarity": "l2_norm"
                  },
                  "created_vector": {
                    "type": "dense_vector",
                    "dims": 1536,
                    "index": true,
                    "similarity": "l2_norm"
                  },
                  "title": {
                    "type": "text"
                  },
                  "content": {
                    "type": "text"
                  },
                  "creator": {
                    "type": "text"
                  },
                  "createdDate": {
                    "type": "date"
                  }
                }
              }
            }
            """;

데이터 검색 매서드

데이터가 많은만큼 검색 매서드가 조금 복잡하다.

 

검색 데이터

@Data
@NoArgsConstructor
@AllArgsConstructor
public class KnnSearchParameterVo {
    private String title;
    private String content;
    private String createdDate;
    private String creator;
}

요청 객체

@Data
public class KnnRequestDto {
    private List<KnnQuery> knn;
    private int size;

    @Data
    public static class KnnQuery {
        private String field;
        @JsonProperty("query_vector")
        private float[] queryVector;
        private int k;
        @JsonProperty("num_candidates")
        private int numCandidates;
        private double boost;
    }
}

검색하고자 하는 데이터들을 리스트로 만들어야한다.

 

다음은 검색 매서드이다.

public KnnSearchResultVo searchKnnSimilarityVersion3(KnnSearchParameterVo knnSearchParameterVo) {
    KnnRequestDto knnRequestDto = new KnnRequestDto();
    List<KnnRequestDto.KnnQuery> knnQueryList = new ArrayList<>();

    addKnnQueryIfPresent(knnQueryList, knnSearchParameterVo.getText(), EmbeddingVectors.TITLE_VECTOR.getVectorName());
    addKnnQueryIfPresent(knnQueryList, knnSearchParameterVo.getText(), EmbeddingVectors.CONTENT_VECTOR.getVectorName());
    addKnnQueryIfPresent(knnQueryList, knnSearchParameterVo.getCreator(), EmbeddingVectors.CREATOR_VECTOR.getVectorName());
    addKnnQueryIfPresent(knnQueryList, knnSearchParameterVo.getCreatedDate(), EmbeddingVectors.CREATED_VECTOR.getVectorName());

    knnRequestDto.setKnn(knnQueryList);
    knnRequestDto.setSize(5);

    return elasticSearchService.searchKnnSimilarity(knnRequestDto);
}

여기서 size는 검색하고자 하는 이웃 갯수를 의미한다.

 

요청 쿼리를 만드는 과정이 조금 손이 간다.

private void addKnnQueryIfPresent(List<KnnRequestDto.KnnQuery> knnQueryList, String text, String vectorType) {
    Optional<String> optionalText = Optional.ofNullable(text);
    optionalText.ifPresent(value -> knnQueryList.add(openAiService.makeKnnQuery(value, vectorType)));
}

null 체크를 하면서 진행

public KnnRequestDto.KnnQuery makeKnnQuery(String message, String field) {
    KnnRequestDto.KnnQuery knnQuery = new KnnRequestDto.KnnQuery();
    EmbeddingResponseDto titleResponseDto = createEmbedding(message);
    knnQuery.setField(field);
    knnQuery.setK(5);
    knnQuery.setBoost(0.5);
    knnQuery.setNumCandidates(10);
    knnQuery.setQueryVector(titleResponseDto.getData()
                                            .get(0)
                                            .getEmbedding());
    return knnQuery;
}

요청 쿼리의 세부 설정은 여기서 처리한다.

 

num_candidates를 검색 파라미터로 사용하면 Approximate kNN(대략적인 최근접 이웃)을 사용하게 된다.

 

num_candidates는 approximate하게 비교하기 위해 아래 그림과 같이 데이터를 특정 영역으로 나누게 되는데, 나눈 영역을 샤드라고 부른다.

 

 

샤드에서 num_candidates만큼의 후보를 뽑고, 샤드 별로 뽑힌 후보 벡터 중에서 조회하고자 하는 데이터에 가까운 상위 k개를 선택한다.

 

(샤드 수 x k개) 가 선정되면 그 중에서 다시 가장 점수가 높은 k개를 선정해 return한다.

 

boost는 가중치를 의미하는데 0.5는 모든 데이터를 같은 비중으로 두고 검색하겠다는 의미이다.

public KnnSearchResultVo searchKnnSimilarity(KnnRequestDto knnRequestDto) {
    String json = CommonUtils.objectToJsonString(knnRequestDto);

    String responseBody = requestElasticSearch("POST", "/knn-test/_search", json);

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

이렇게 만들고 요청을 하면된다.

 

마지막이 찝찝하게 됐는데, 이 글을 쓰기 전에 데이터를 정리하면서 knn-test 인덱스를 날려서 결과를 확인할 수 없게 됐다.

 

기회가 되면 추가해서 테스트 해보겠다.

 

마치며

다양한 정보를 활용를 활용하는 다른 검색 알고리즘이 있을 수는 있지만 내가 사용해본게 K-NN이었다.

 

생각보다 좋은 대략적인(Approximate) K-NN의 경우에도 정확도가 크게 떨어지지는 않았다.

(명확했던 데이터의 영향일지 모른다.)

 

일단 Elasticsearch에서 사용해본 것들은 다 정리해본 것 같다.

 

알고리즘적으로나 내부 시스템적으로나 아직도 모르는부분이 많은 것 같지만, 당장 공부하긴 힘든 부분일 것 같다.

공지사항
최근에 올라온 글
최근에 달린 댓글
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
글 보관함