[LLM] OpenSearch - VectorStore로 써보기

2024. 9. 4. 21:39DL

안녕하세요 오늘은 Vector Store로 많이 쓰이는 OpenSearch를 가져와 봤습니다. Elastic Search를 Fork해서 사용하는만큼 Elastic Search와 유사한점도 그리고 다른점도 있습니다. 하지만 오늘은 이 차이점을 설명하는 자리는 아니라 이만 줄이고, 이제 Vector Store로서 장/단점을 그리고 간단할 설치와 Langchain에서 사용까지 알아볼까 합니다.

 

1. OpenSearch의 장점과 단점

- 첫번째 장점 

그건 바로 최근 아주 관심을 많이 받고 있는 RAG중에서도 Full Text Search (Lexical Search)를 Simillerity Search(Semantic Search)와 같이 쓸 수 있단 점 입니다. 성능이 좋은 다른 DB도 많지만. 기본적으로 이 둘을 지원하면서 다른 여러가지 방법의 Sementic Search를 손쉽게 "무료로" 지원하는 DB는 많지 않습니다. 

- 두번째 장점

그건 바로 익숙함 인데요, 이 부분은 공감이 잘 안되실 수 있겠지만.. 데이터 엔지니어로 일하고 있는 제 입장에서는 ES는 아주 소중한 로그 저장소중 하나입니다. 그래서 자주 써보았기 때문에 아주 손쉽게 잘 사용할 수 있다. Index나 검색 방법 Client를 사용한 데이터 조회까지 불편함 없이 사용이 가능합니다.

 

- 첫번째 단점

운영이 쉽지 않다. 입니다. 왜냐하면 OpenSearch는 일단 성능을 유지하기 위한 비용이 많이 들어가고, 이 도구를 유지하기 위해서는 어딘가에 Build 하여 사용해야 하는데, 이런 버전관리며.. Storage 관리며.. 여러가지 관리가 쉽지 않습니다. 단.. 이건 AWS의 OpenSearch SaaS버전을 사용하게 되면 많이 줄어듭니다.

- 두번째 단점

관리와 별개로 Vector Search로만 쓰기는 너무 아깝다.. 성능을 더 높혀서 로그 저장과 같이 쓸거냐? 그러면... 로그가 쏟아져 들어오고 조회해가는 그 Resource사용과 Vector Search를 같이 사용하며 안정성에 문제가 될 수도 있고.. 여러가지 이슈로 병합하여 사용하기 쉽지 않은데... 그렇다고 단일로 쓰기는 좀 아까운?? (물론 RAG에 집어넣어야 할 문서가 엄청 많다면 얘기가 다릅니다.) 이건 어디까지나 개인적 단점입니다.

 

2. OpenSearch Test용 Install  

OpenSearch를 빌드하여 운영 용도로 사용하고 싶으신 분은 이 글을 참고만 해주세요, 이 글은 단순 Test용 OpenSearch 구현을 목적으로 합니다.

 

우선 저는 Docker-compose를 선택했습니다.

여기서 가장 중요하게 보셔야할 부분은 OPENSEARCH_INITIAL_ADMIN_PASSWORD입니다. 이 값에 여러분이 설정하고자 하는 초기 비밀번호를 넣어주시면 

ID : admin

PASSWD : 여러분이 넣은 passwd로 접속 할 수 있습니다.

version: '3'
services:
  opensearch-node1:
    image: opensearchproject/opensearch:latest
    container_name: opensearch-node1
    environment:
      - cluster.name=opensearch-cluster
      - node.name=opensearch-node1
      - discovery.seed_hosts=opensearch-node1,opensearch-node2
      - cluster.initial_master_nodes=opensearch-node1,opensearch-node2
      - bootstrap.memory_lock=true # along with the memlock settings below, disables swapping
      - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" # minimum and maximum Java heap size, recommend setting both to 50% of system RAM
      - "OPENSEARCH_INITIAL_ADMIN_PASSWORD=YourTestPasswd123!"
    ulimits:
      memlock:
        soft: -1
        hard: -1
      nofile:
        soft: 65536 # maximum number of open files for the OpenSearch user, set to at least 65536 on modern systems
        hard: 65536
    volumes:
      - opensearch-data1:/usr/share/opensearch/data
    ports:
      - 9200:9200
      - 9600:9600 # required for Performance Analyzer
    networks:
      - opensearch-net
  opensearch-node2:
    image: opensearchproject/opensearch:latest
    container_name: opensearch-node2
    environment:
      - cluster.name=opensearch-cluster
      - node.name=opensearch-node2
      - discovery.seed_hosts=opensearch-node1,opensearch-node2
      - cluster.initial_master_nodes=opensearch-node1,opensearch-node2
      - bootstrap.memory_lock=true
      - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
      - "OPENSEARCH_INITIAL_ADMIN_PASSWORD=YourTestPasswd123!"
    ulimits:
      memlock:
        soft: -1
        hard: -1
      nofile:
        soft: 65536
        hard: 65536
    volumes:
      - opensearch-data2:/usr/share/opensearch/data
    networks:
      - opensearch-net
  opensearch-dashboards:
    image: opensearchproject/opensearch-dashboards:latest
    container_name: opensearch-dashboards
    ports:
      - 5601:5601
    expose:
      - "5601"
    environment:
      OPENSEARCH_HOSTS: '["https://opensearch-node1:9200","https://opensearch-node2:9200"]'
    networks:
      - opensearch-net

volumes:
  opensearch-data1:
  opensearch-data2:

networks:
  opensearch-net:

 

3. Dashboard 접속

실행된 Docker의 DashBoard의 주소 (아마도 localhost:5601)로 접속해보시면 다음과 같은 화면이 나오고 접속이 가능합니다.

auth page
index management

왼쪽 매뉴에 들어가서 index Management를 찾아주시고 들어갑니다. 그리고 다시 왼쪽의 매뉴를 보면 Index가 있습니다.

index menu

그럼 다음과 같은 index목록이 나타나고, 우측 상단에 create index를 눌러서 인덱스를 생성해 줍니다. 저는 이미 생성 했고, 제가 개발하고 있는 evpedia의 이름을 가진 index가 보이네요 ㅎㅎ

index list

 

4. Langchain으로 Document 전송하기

우선 Langchain을 사용할것이고 opensearch-py 라이브러리가 필요합니다. (Document Split 과정은 생략하겠습니다.)

 

주의해야할 점이 있는데.. 이건 Langchain의 OpenSearchVectorSearch 클래스의 큰 문제? 라고 생각하긴 하는데, 사실 기본적인 Vector저장을 위한 index를 만들기가 쉽지 않습니다. 그래서 아까 만들어둔 index를 그냥 우선 class에 기입하시구요..

 

해당 객체의 create_index를 통해서 index를 만들고 만들어진 index이름으로 다시 객채를 생성하거나 값을 변경하는것을 추천드립니다.

그러면 index가 vector search를 위한 값들로 생성이 됩니다.

from langchain.vectorstores import OpenSearchVectorSearch
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from opensearchpy import OpenSearch, RequestsHttpConnection
from langchain_community.utilities.redis import get_client
from opensearchpy import OpenSearch
import pandas as pd
import time
from datetime import datetime

# embedding Function 생성
embeddings = OpenAIEmbeddings(
    api_key=OPENAI_API_KEY, 
    model="text-embedding-3-large",
    dimensions=1536,
    chunk_size=5000
)

# OpenSearch 연결 설정
host = 'localhost'  # OpenSearch 엔드포인트
port = 9200
auth = ('admin', 'YourOpensearchPasswd12!')  # 기본 인증 정보

# BM25 사용을 위한 Client 생성
client = OpenSearch(
    hosts = [{'host': host, 'port': port}],
    http_auth = auth,
    use_ssl = True,
    verify_certs = False,
    ssl_show_warn = False,
    connection_class = RequestsHttpConnection
)

# OpenSearchVectorSearch 초기화
vector_store = OpenSearchVectorSearch(
    index_name="test_evpedia",
    embedding_function=embeddings,
    opensearch_url="https://localhost:9200",
    http_auth=auth,
    use_ssl=True,
    verify_certs=False,
    ssl_show_warn=False
)

 

다음은 저는 기존에 사용하던 PgVector로 부터 데이터를 parquet으로 내려받아 놓은게 있는데요, 이 부분은 각자의 데이터를 가져와주시면 됩니다.

def parse_datetime(value):
    metadata = value.copy()
    metadata["create_date"] = metadata["create_date"][:10]

    return metadata

migration_df = pd.read_parquet("./migration_data.parquet", engine='pyarrow')
migration_df["cmetadata"] = migration_df["cmetadata"].apply(lambda x: parse_datetime(x))
print(f"Total load Data set: {len(migration_df)}")

docs = []
for _, data in migration_df.iterrows():
    docs.append(Document(page_content=data["document"], metadata=data["cmetadata"]))

print(docs[:5])
print(len(docs))

 

코드를 보셔서 아시겠지만. add_document 함수를 사용하기 위해서, docs 리스트 안에 Document 객체로 말아 넣고 있습니다.

 

그 다음 데이터를 업로드 해볼게요!

for i in range(200):
    input_docs = docs[i*100:(i+1)*100]
    print(f"문서 개수 {len(input_docs)}")
    if len(input_docs) == 0:
        break

    # 데이터 임베딩 및 인덱싱
    vector_store.add_documents(input_docs)
    
    print(f"Data embedded and indexed successfully Count: {i+1*100}.")

    time.sleep(10)

 

이렇게 분할하여 전송하는 이유는 제 GPT 레벨이 낮아서.. 한번에 많은 양의 데이터가 인코딩이 안됩니다. 그래서 100개씩 잘라서 넣어주고 있습니다.

 

이렇게 하면 데이터가 정상적으로 index안에 들어가있는 것은 dashboard 또는 조회를 통해서 알 수 있습니다.

 

5. Sementic Search

아주 간단합니다. 만들어둔 OpenSearchVectorSearch 객체에 as_retriever 매서드를 사용해 줍시다.

retriever = vector_store.as_retriever()
retriever.invoke("아이오닉 5를 구매할 때 알아야할 중요한 5가지 사항을 알려줘")

 

어? 그럼 BM25는 어떻게 쓰냐구요? 아쉽게도 OpenSearch는 BM25 Retriever를 직접 제공하지 않습니다. 여러분이 만들어 쓰셔야합니다.  거기에 사용되는 아주 중요한 부분은 바로 요고 입니다.

 

query_dict = {"query": {"match": {"text": query}}, "size": search_size}
res = client.search(index=self.index_name, body=query_dict)

 

아까 만들어둔 Client에 직접 조회해서 가져와야합니다.. 

 

 

우선 이정도로 마치고 다음번에는 Retriever를 직접 구현하는 세션을 가져볼까 합니다. 이런식으로 BM25를 따로 쓸 수 없잖아요?

 

간단하게 스포하면, 

 

저는 지금 Multi Query Retriever와 ParentDocumentRetriever를 쓰고있는데요, Langchain이 v0.2에 들어오면서 LLM기반의 ReRaker를 지원하기 시작했고, 이것을 모두 하나의 Retriever의 get_relevant_documents에 섞어서 쓰고 있습니다. 조회 한번에 

2개의 Retrieve와 ReRank까지 가능하죠 

 

다음시간에 만나요우!!