본문 바로가기

ML/LLM

2. vector embedding 구현하기 (with elastic search)

1. vector embedding 소개 글에서 이어집니다. [https://uiandwe.tistory.com/1397]

 

앞썬 포스팅에서는 vector embedding에 대한 기술적인 설명과 함께 함께 사용되는 벡터 디비에 대해서 설명하였습니다.

이번 포스팅에서는 LLM모델을 사용해서 embedding을 구현하고 간단한 Faq가 할수 있도록 초안 코드를 작성해보도록 하겠습니다.

해당 포스팅의 전체 코드는 다음과 같습니다. (벡터디비가 필요하므로, colab에서 하실경우 따로 saas형태의 디비를 구축하셔야 합니다.)

4-2.multilingual-e5-large-elasticsearch.ipynb
0.29MB

 

 

 

 

다음의 라이브러리를 설치해야 합니다.

!pip install transformers
!pip install elasticsearch

 

 

1. 모델 선언 및 임베딩 함수 작성

해당 코드는 multilingual-e5-large 모델을 사용하여 입력된 텍스트를 임베딩으로 변환합니다. 함수 text_embedding은 입력된 텍스트를 모델에 전달하고, 모델의 출력에서 평균 풀링을 수행하여 벡터를 생성합니다. 

# 필요한 라이브러리 import
import torch
from transformers import AutoModel, AutoTokenizer
from sentence_transformers import SentenceTransformer, util
from transformers import AutoConfig, PretrainedConfig, PreTrainedModel
from torch import Tensor
import torch.nn as nn
import torch.nn.functional as F

# 'multilingual-e5-large' 모델과 토크나이저 로드
tokenizer = AutoTokenizer.from_pretrained('intfloat/multilingual-e5-large')
model = AutoModel.from_pretrained('intfloat/multilingual-e5-large')
model = model.to("cpu")  # 모델을 CPU로 설정합니다.


# 평균 풀링(average pooling)을 이용하여 문장의 벡터 임베딩을 생성하는 함수
def average_pool(last_hidden_states: Tensor,
                 attention_mask: Tensor) -> Tensor:
    # attention mask를 이용해 패딩된 토큰에 대한 값들을 0으로 채워줍니다.
    last_hidden = last_hidden_states.masked_fill(~attention_mask[..., None].bool(), 0.0)
    # 패딩된 토큰들을 제외하고 나머지 값들의 평균을 구합니다.
    return last_hidden.sum(dim=1) / attention_mask.sum(dim=1)[..., None]

# 입력된 텍스트를 임베딩으로 변환하는 함수
def text_embedding(text):
    batch_dict = tokenizer(text, max_length=512, padding=True, truncation=True, return_tensors='pt')
    outputs = model(**batch_dict)
    embeddings = average_pool(outputs.last_hidden_state, batch_dict['attention_mask'])
    
    return embeddings[0].tolist()

 

 

결과물은 다음과 같습니다.

len(text_embedding("안녕하세요.")) # 1024 


text_embedding("안녕하세요.")
[1.446635365486145,
 -0.20181967318058014,
 -0.6811693906784058,
 -1.1217223405838013,
 1.512732982635498,
 -1.0387330055236816,
 ....

 

 

 

2. 벡터 디비 연동

다음은 벡터 디비로 사용할 엘라스틱서치의 연동입니다.

엘라스틱 서치는 로컬 docker로 실행한 상태입니다.

docker run  -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --name elasticsearch8 docker.elastic.co/elasticsearch/elasticsearch:8.7.0

도커는 로컬에서 테스트로 진행하기 때문에 single-node로 실행했습니다. 참고로 8.x 미만 Elasticsearch 버전에서는 Dense vector를 지원하지 않습니다. 최신버전으로 설치하는걸 추천드립니다.

 

 

다음은 사용할 도큐먼트를 생성하는 코드 입니다.

#서버 엘라스틱 서치 도큐먼트 생성
from elasticsearch import Elasticsearch
es = Elasticsearch('http://127.0.0.1:9200')


index_name = "faq_hierarchy_index"

# index 생성
index_template = {
  "index_patterns": [
    "faq_hierarchy_index*"
  ],
  "template": {
    "settings": { 
      "refresh_interval": -1, # 성능을 향상시키는 방법을 찾아보면 refresh inverval 설정을 -1
      "number_of_shards": 10,
      "number_of_replicas": 1,
      "search": {
        "slowlog": { # Query, Fetch, Indexing 활동에 대해 Slow Log를 남길 수 있다.
          "threshold": {
            "fetch": {
              "trace": "200ms",
              "debug": "500ms",
              "info": "800ms",
              "warn": "1s"
            },
            "query": {
              "trace": "500ms",
              "debug": "1s",
              "info": "2s",
              "warn": "3s"
            }
          }
        }
      }
    },
    "mappings": { # 칼럼 설정
      "properties": {
        "id": {
          "type": "integer"
        },
        "answer": {
          "type": "text"
        },
        "category": {
          "type": "text"
        },          
        "question_embedding": {
          "type": "dense_vector",
          "dims": dims
        }
      }
    }
  }
}

es.indices.create(index=index_name, body=index_template['template'])

# ObjectApiResponse({'acknowledged': True, 'shards_acknowledged': True, 'index': 'faq_hierarchy_index'})

사용할 도큐먼트는 id / answer / category / question_embedding  칼럼 가지는 인덱스를 생성하며

answer에 들어갈 텍스트를 벡터로 변환하여 question_embedding 칼럼에 저장할 예정입니다.

question_embedding 칼럼 타입은 Dense Vector으로 지정했습니다. (Dims는 지정된 차원입니다. 해당 차원만으로만 저장이 가능합니다.)

 

Elasticsearch의 Dense Vector는 벡터 데이터를 저장하고 쿼리하는 데 사용되는 특별한 유형의 필드 타입입니다. 이 필드 타입을 사용하면 Elasticsearch에서 벡터 데이터를 효과적으로 저장하고 벡터 간의 유사도를 계산할 수 있습니다. 해당 칼럼에는 코사인 유사도(Cosine Similarity)나 내적(Dot Product)과 같은 연산을 통해 벡터 간 유사도를 계산합니다.
주어진 쿼리 벡터와 인덱스에 저장된 벡터 간의 유사도를 계산하여 검색 결과를 랭킹으로 반환할 수 있습니다.

자세한 사항은 공식 문서를 보시면 됩니다. (https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html)

 

 

 

 

다음은 엘라스틱 서치 검색 예제 입니다.

data = es.search(index=index_name, body={"query":{"match_all":{}}})
data

# ObjectApiResponse({'took': 2, 'timed_out': False, '_shards': {'total': 10, 'successful': 10, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'max_score': None, 'hits': []}})

 

 

 

다음은 엘라스틱 서치에 insert 입니다.

"안녕하세요" 문장을 임베딩 후 엘라스틱서치에 저장합니다.

doc = {"id": "0",
       "answer": "안녕하세요",
       "category": "인사말",
       "question_embedding": text_embedding("안녕하세요")
      }

res = es.index(index=index_name, body=doc)
res

# ObjectApiResponse({'_index': 'faq_hierarchy_index', '_id': 'zj8upIwBdTokNiS0h939', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 0, '_primary_term': 1})

 

 

초기 성능 향상을 위해 refresh를 -1로 설정했기 때문에 insert한 데이터가 바로 보이지 않습니다. (DBMS로 따지면 커밋전 상태)

refresh를 통해서 저장내용을 적용합니다.

es.indices.refresh(index=index_name)
# ObjectApiResponse({'_shards': {'total': 20, 'successful': 10, 'failed': 0}})

 

 

 

 

3. 벡터 검색 구현

공식 가이드 라인 (https://www.elastic.co/kr/blog/text-similarity-search-with-vectors-in-elasticsearch)

벡터 검색 구현을 위해 Elasticsearch의 Function Score Query에서 cosineSimilarity(params.query_vector, '칼럼이름') + 1.0"를 사용합니다.

# 벡터 select 
script_query = {
        "script_score": {
            "query": {"match_all": {}},
            "script": {
            "source": "cosineSimilarity(params.query_vector, 'question_embedding') + 1.0",
            "params": {"query_vector": text_embedding("안녕")}
            }
        }
}

response = es.search(
        index=index_name,
        query=script_query,
        size=3,
    )

res['hits']['hits'][0]['_source']


# {'id': '0',
 'answer': '안녕하세요',
 'category': '인사말',
 'question_embedding': [0.7088878750801086,
  0.019538506865501404,
  0.30907055735588074,
  -1.2022367715835571,
  0.8806310296058655,
  ....

cosineSimilarity() 함수 : 지정된 쿼리 벡터와 문서 벡터 간의 코사인 유사성 측정값을 계산합니다.
params.query_vector: 검색하고자 하는 벡터 필트가 들어갑니다. 여기서 칼럼명은 'question_embedding' 입니다.
+ 1.0 : 코사인 유사도는 -1부터 1까지의 값을 가지며, 유사도 값이 높을수록 두 벡터 간의 유사도가 더 높다는 것을 의미합니다.
Elasticsearch에서는 0부터 1 사이의 값을 반환하므로, 이를 보정하기 위해 1을 더해줍니다.

공식문서(https://www.elastic.co/guide/en/elasticsearch/reference/7.3/query-dsl-script-score-query.html#vector-functions)

 

 

 

 

사용하기 펀하게 위의 코드를 함수로 만들었습니다.

이제 사용할 데이터를 입력하고 검색하여 원하는 Faq의 초기 모델이 완성되었습니다.

def es_query(text) :
    text = re.sub(r"[^\uAC00-\uD7A30-9a-zA-Z\s]", "", text)
    faq_query = text_embedding(text)
    
    
    # 벡터 select 
    script_query = {
            "script_score": {
                "query": {"match_all": {}},
                "script": {
                "source": "cosineSimilarity(params.query_vector, 'question_embedding') + 1.0",
                "params": {"query_vector": faq_query}
                }
            }
    }

    response = es.search(
            index=index_name,
            query=script_query,
            size=2,
        )

    return response




response = es_query("주차비는 얼마인가요?")
for idx, res in enumerate(response['hits']['hits']):
    print(idx, res['_source']['category'], res['_source']['answer'])
    print()
    
    
##################################################################
0 주차정보안내 아래 버튼을 누르시고 하단에 주차정보.폐이지를 참고하시기 바랍니다

1 주차민원사고접수 주차와 관련된 각종 민원사고 신고 접수는 채팅상담을 통해 가능하십니다.

 

 

 

vector embedding을 활용한 간단한 faq 로직을 실습해봤습니다. 해당 코드를 수정하거나 / 데이터를 추가하면 자신만의 간단한 Faq 서비스를 만들 수 있습니다.

다음 포스팅에서는 전체적인 MLops을 위해 airflow + jupyter + NES를 구현해 보도록 하겠습니다.