Elasticsearch Vector-search 예제

JunMyung Lee·2022년 12월 5일
1

Elasticsearch

목록 보기
6/42

Git : https://github.com/mertyn88/vector-search
Elasticsearch 8.5.2의 버전으로 벡터검색에 대한 예제이다.

버전차이의 문제와, 구글링을 통한 예제는 tensorflow를 사용하여 찾는 부분이 많았으며, 본 예제는 Nori Analyzer와
doc2vec을 이용하여 유사문서를 찾는 예제이다.

Similar vector search in Elasticsearch

Elasticsearch를 통해 유사한 문서를 검색하는 예제

Docker

docker-compose.yml

Important note: Localhost ES, Kibana 8.5.2를 사용

  • Elasticsearch Node 2
  • Kibana

run docker

docker-compose -f ./docker-compose.yml up -d

Jupyter

해당 예제는 jupyter-notebook을 통해서만 테스트가 진행

Code

# Set Import
from elasticsearch import Elasticsearch
import pandas
from nltk.tokenize import RegexpTokenizer
from gensim.models.doc2vec import Doc2Vec, TaggedDocument
from pynori.korean_analyzer import KoreanAnalyzer
# Set Nori Analyzer
nori = KoreanAnalyzer(
           decompound_mode='DISCARD', # DISCARD or MIXED or NONE
           infl_decompound_mode='DISCARD', # DISCARD or MIXED or NONE
           discard_punctuation=True,
           output_unknown_unigrams=False,
           synonym_filter=False, mode_synonym='NORM', # NORM or EXTENSION
)

# NNG, NNP (명사, 대명사) filter
def _filter(term):
    result = []
    for _idx, _tag in enumerate(term['posTagAtt']):
        if _tag in ['NNG', 'NNP']:
            result.append(term['termAtt'][_idx])
    return result

# Analyzer
def _do_analysis(text):
    return _filter(nori.do_analysis(text))

# 띄어쓰기 Tokenizer
def _nltk_tokenizer(_wd):
  return RegexpTokenizer(r'\w+').tokenize(_wd.lower())

print(_do_analysis("아빠가 방에 들어가신다."))
['아빠', '방']
# Set frame
frame = pandas.read_csv('../data/travel.csv', encoding='utf-8').fillna('')
frame = frame.query("description != ''")
frame = frame.reset_index(drop=False)
frame['token_description'] = frame['description'].apply(_do_analysis)
# Token length > 0 filter
frame = frame.query("token_description.str.len() > 0")
data = {
    'seq': frame['index'],
    'title': frame['title'].tolist(),
    'description': frame['description'].tolist(),
    'token_description': frame['token_description'].tolist()
}
frame = pandas.DataFrame(data)
frame.head(3)
seq title description token_description
0 0 어답산관광지 병지방계곡,캠핑장 [병지방, 계곡, 캠, 핑장]
1 1 유현문화관광지 풍수원성당, 유물전시관, 산책로 [풍, 수원, 성당, 유물, 전시, 관, 산책, 로]
2 2 웰리힐리파크 관광단지 스키장, 골프장, 곤돌라, 콘도 [스키, 장, 골프, 장, 곤돌라, 콘]
# Set TaggedDocument, [seq, token]
frame_doc = frame[['seq','token_description']].values.tolist()
tagged_data = [TaggedDocument(words=_d, tags=[uid]) for uid, _d in frame_doc]
tagged_data[:3]
[TaggedDocument(words=['병지방', '계곡', '캠', '핑장'], tags=[0]),
 TaggedDocument(words=['풍', '수원', '성당', '유물', '전시', '관', '산책', '로'], tags=[1]),
 TaggedDocument(words=['스키', '장', '골프', '장', '곤돌라', '콘'], tags=[2])]
# Train
model = Doc2Vec(
    window=3,          # window: 모델 학습할때 앞뒤로 보는 단어의 수
    vector_size=100,    # size: 벡터 차원의 크기
    alpha=0.025,        # alpha: learning rate
    min_alpha=0.025,
    min_count=2,        # min_count: 학습에 사용할 최소 단어 빈도 수
    dm = 0,              # dm: 학습방법 1 = PV-DM, 0 = PV-DBOW
    negative = 5,       # negative: Complexity Reduction 방법, negative sampling
    seed = 9999
)

"""
epoch
한 번의 epoch는 인공 신경망에서 전체 데이터 셋에 대해 forward pass/backward pass 과정을 거친 것을 말함. 즉, 전체 데이터 셋에 대해 한 번 학습을 완료한 상태

batch size
batch size는 한 번의 batch마다 주는 데이터 샘플의 size. 여기서 batch(보통 mini-batch라고 표현)는 나눠진 데이터 셋을 뜻하며 iteration는 epoch를 나누어서 실행하는 횟수
메모리의 한계와 속도 저하 때문에 대부분의 경우에는 한 번의 epoch에서 모든 데이터를 한꺼번에 집어넣을 수는 없습니다. 그래서 데이터를 나누어서 주게 되는데 이때 몇 번 나누어서 주는가를 iteration, 각 iteration마다 주는 데이터 사이즈를 batch size
"""

max_epochs = 10
model.build_vocab(tagged_data)
for epoch in range(max_epochs):
    #print('iteration {0}'.format(epoch))
    model.train(tagged_data,
                total_examples=model.corpus_count,
                epochs=model.epochs)
    # decrease the learning rate
    model.alpha -= 0.002
    # fix the learning rate, no decay
    model.min_alpha = model.alpha

# store the model to mmap-able files
#model.save('./model.doc2vec')
# load the model back
#model_loaded = Doc2Vec.load('./model.doc2vec')

model.wv.vectors[:3]
array([[-8.2193622e-03,  6.7749298e-03, -6.2365090e-03,  5.2402792e-03,
        -4.6296441e-03, -4.7527459e-03,  2.4112677e-03,  5.7777343e-03,
        -1.2703657e-03, -5.1266574e-03, -1.7415201e-03,  1.5200340e-03,
         7.4816141e-03,  1.1664641e-03, -5.5718194e-03,  7.0228814e-03,
        -1.7327452e-03, -8.0134859e-03,  2.1830511e-03,  6.5284539e-03,
         2.0196950e-03,  1.3344670e-03, -8.6302888e-03, -1.4463830e-03,
        -3.6384177e-03, -1.4376461e-03,  4.2982958e-03, -5.0189043e-03,
        -3.2979273e-03,  6.0618282e-03,  2.3644101e-03, -6.6062331e-04,
        -8.4960023e-03,  8.7066712e-03,  4.5560561e-03, -4.6418346e-03,
         7.5713051e-03, -7.0132697e-03, -6.5153148e-03,  3.6592449e-03,
         7.5526941e-03, -9.8076165e-03,  5.4272129e-03, -8.2187047e-03,
         7.0318556e-03,  5.1973341e-03,  1.5876698e-03, -5.4299831e-03,
         7.5712288e-03, -9.9267438e-03,  8.5650627e-03,  1.1790371e-03,
        -3.8849604e-03,  6.8626092e-03,  1.1697662e-03,  8.6435378e-03,
        -1.1592471e-03, -4.3920744e-03,  2.1206522e-03,  4.7955285e-03,
        -4.0982963e-05,  7.9155937e-03, -9.3902657e-03, -1.3651741e-03,
        -3.9263366e-04, -5.0132619e-03, -4.3503047e-04, -2.4629128e-03,
         9.6908044e-03,  6.9340039e-03, -2.8083325e-05,  4.9437879e-04,
         8.5202614e-03, -9.0881996e-03,  1.9592082e-03,  9.3845185e-03,
         7.0294854e-04, -9.8714354e-03,  2.4092877e-03, -8.9288400e-03,
        -5.4936018e-03, -7.2597242e-03,  6.5645743e-03, -5.0571309e-03,
         5.6476868e-03,  9.0139750e-03, -1.3698959e-03, -3.0222666e-03,
         1.9279898e-03,  3.3574998e-03,  6.1958479e-03,  9.8291012e-03,
         1.3717902e-03,  9.9756559e-03,  8.9205634e-03, -4.3565761e-03,
        -4.9003270e-03, -2.8724326e-03,  1.0620725e-03, -3.0619490e-03]
       ],
      dtype=float32)
# Index docs
docs = [
    {
        '_index': 'vector_sample',
        '_source': {
            'title': _row['title'],
            'description': _row['description'],
            'description_vector': model.dv.vectors[_idx,:].tolist()
        }
    }
    for _idx, _row in frame.iterrows()
]
docs[0]
{
    '_index': 'vector_sample',
    '_source': {
    'title': '어답산관광지',
    'description': '병지방계곡,캠핑장',
    'description_vector': 
        [
        -0.0748947337269783,
        -0.22988615930080414,
        -0.08189909905195236,
        0.17290998995304108,
        0.1827821135520935,
        0.12810979783535004,
        -0.07078631967306137,
        ...
        -0.19546788930892944,
        -0.007501175627112389,
        0.12139209359884262,
        0.10605379194021225,
        -0.20055268704891205,
        -0.05768788978457451,
        0.34618064761161804,
        -0.22516191005706787,
        0.0590231791138649,
        0.1486528366804123,
        0.14388953149318695,
        0.26719143986701965,
        0.17399340867996216,
        0.16179141402244568,
        0.35135209560394287,
        -0.23531629145145416
        ]
    }
}
# Set Elasticsearch client ( == 8.5.2 )
client = Elasticsearch(hosts='http://127.0.0.1:9200')
# Create Sample Index
client.indices.create(index='vector_sample', body={
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0
  },
   "mappings": {
    "properties": {
      "title": {
        "type": "keyword"
      },
      "description": {
        "type": "keyword"
      },
      "description_vector": {
        "type": "dense_vector",
        "dims": 100
      }
    }
  }
}
)
ObjectApiResponse({'acknowledged': True, 'shards_acknowledged': True, 'index': 'vector_sample'})
# Index bulk
from elasticsearch import helpers
res = helpers.bulk(client, docs)
res
(721, [])
# Search Sample
test = model.infer_vector(_do_analysis('옛날 스무나무 아래 약수가 있어 이를 마시고 위장병과 피부병에 효험이 있어 많은 사람이 이 약수를 마시고 덕을 보았다 하여 다덕약수라고 불리움'))
test.tolist()[:3]
[-0.14725027978420258, -0.20942197740077972, -0.06804680079221725]
# Query
script_query = {
        "script_score": {
            "query": {"match_all": {}},
            "script": {
                "source": "cosineSimilarity(params.query_vector, 'description_vector') + 1.0",
                "params": {"query_vector": test.tolist()}
            }
        }
    }
response = client.search(
        index="vector_sample",
        query=script_query,
        size=10,
        source_includes=["title", "description"]
    )
response['hits']['total']
{'value': 721, 'relation': 'eq'}
# Result
for idx, hit in enumerate(response["hits"]["hits"]):
    print('[' + str(idx) + ':' + str(hit["_score"]) + '] '  + hit["_source"]['description'])
[0:1.9649781] 옛날 스무나무 아래 약수가 있어 이를 마시고 위장병과 피부병에 효험이 있어 많은 사람이 이 약수를 마시고 덕을 보았다 하여 다덕약수라고 불리움
[1:1.9122046] 심산계곡에 자리잡은 약수탕은 선달산, 옥석산 아래 깊은 계곡에 위치하고 있고, 약수는 예부터 위장병과 피부병에 효험이 있다.
[2:1.786823] 무등산을 느낄 수 있음
[3:1.7127932] 데미샘은 3개도 10개 시군에 걸쳐 218.6㎞를 흐르는 우리나라에서 4번째로 긴강인 섬진강의 발원지이다
[4:1.7120755] 중탄산 온천수 및 알칼리성 온천수 등 신진대사를 촉진하는 2가지 온천수가 있음
[5:1.7029405] 숲과 계곡이 아름다운 청정도량
[6:1.7003778] 옛날 석기 시대의 사람들이 이곳에서 살았으리라 짐작되는 혈거동굴로서 연구 가치가 매우 높다. 허준은 허가바위에서 『동의보감』을 완성했다고 한다.
[7:1.6945602] 등명해변관광지
[8:1.6934646] 온천수  - 수질 : 26.5℃ / PH 9.7(국내 최고의 강 알칼리성 수질)      ▶ 류머티즘, 알레르기성 피부염 등에 탁월한 효과
[9:1.6844268] 온천과 약찜의 효능을 한꺼번에 즐길수 있음
profile
11년차 검색개발자 입니다. 여러 지식과 함께 실제 서비스를 운영 하면서 발생한 이슈에 대해서 정리하고 공유하고자 합니다.

0개의 댓글