AWS 기술 블로그

Amazon OpenSearch Service Hybrid Query를 통한 검색 기능 강화

서론

최근 자체적인 생성형 AI를 만들기 위한 여러가지 노력들이 있습니다. 이때 검색 증강 생성(Retrieval Augmented Generation, RAG) 모델을 활용하여 외부 소스의 정보를 사전에 지식 데이터베이스로 사용하며 생성형 AI 모델의 정확성과 신뢰성을 향상시키기 위해 다양한 방법으로 실험이 진행 되고 있습니다.  Amazon OpenSearch Service는 Vector Database로 많은 사랑을 받고 있으며 2023년 11월 20일 기존 Lexical Search와 K-NN 기반 Vector검색 방식인 Semantic Search를 결합하여 검색 관련성을 개선하는 Hybrid Query 기능을 발표하였습니다.

생성형 AI를 자체적으로 구축하는 고객분들이 겪을 수 있는 흔한 문제는, Semantic Search를 통한 키워드 검색 시 neural을 이용해 검색 의도를 추론하기 때문에, 잘못된 추론으로 인해 전혀 다른 검색결과를 낼 수 있다는 점 입니다. 예를 들어 유저가 지구 최대의 정글인 ‘Amazon’에 대한 정보를 찾기 위해 ‘amazon’을 Semantic 검색할 경우, 잘못된 추론으로 오직 amazon.com의 정보를 제공할 수 있습니다. 이러한 잘못된 추론으로 인한 검색 품질 저하를 막기 위해, OpenSearch의 Hybrid Query 기능을 활용하여 Sementic Search와 Lexical Search를 함께 사용할 수 있습니다.

각 기법에는 장단점이 있고, 이를 서로 보완할 수 있도록 함께 사용하는 것이 권장됩니다. 이를 통해 각 기법의 가중치를 조절하여 정확도 높은 답변을 검색할 수 있고, 이는 생성형 AI의 성능 향상으로 이어집니다.

본 게시글은 Amazon OpenSearch의 검색 성능을 향상시키는 Hybrid Query 기능을 소개하며 테스트 데이터를 통해 Embedding, Indexing, Query 과정과 Semantic Search와 Lexical Search를 조합하는 방법 모두 실습해보고자 합니다.

사전 준비 사항

이 기능을 테스트 하기 위해선 데이터를 사전에 Embedding 하여 OpenSearch에 색인 해야 합니다. 본 게시글은 단순 쿼리를 비교하기 위함이니 별도의 데이터 색인 파이프라인은 구축하지 않습니다. Python3.11 기반의 Jupyter에서 본 실습을 진행합니다.

Amazon OpenSearch 생성

Hybrid Query는 Amazon OpenSearch Service 2.11 버전 부터 지원하는 기능입니다. 아래와 같이 Domains 생성시 Engine Version을 OpenSearch_2.11로 선택합니다.

테스트 데이터 준비

function_score를 활용한 검색을 위해선 데이터를 사전에 Embedding 하여 색인해야 합니다. 이는 OpenSearch의 Neural Semantic Search 기능을 활용하여 자동으로 Vector화 하도록 Index를 구성할 수도 있습니다. 하지만, 본 게시글에서는 모델별 비교를 위하여 별도로 Embedding하여 색인 하겠습니다. 본 게시글에서 별도 Embedding 하기 위해 ‘multilingual-e5-large‘을 사용하며 OpenSearch Neural Search를 위한 Embedding 모델로는 ‘msmarco-distilbert-base-tas-b‘ 를 사용하겠습니다.

OpenSearch의 Semantic Search 기능을 사용하기 위해선 Model을 OpenSearch내에 설치하고 ingest pipeline을 만들어 주어야 합니다. ingest pipeline은 Document가 indexing될때 변환 과정을 추가하여 다양한 프로세스를 주입할 수 있습니다. 각 파이프라인은 데이터 필터링 이나 변환, 강화등의 작업을 수행할 수 있습니다.

본 게시글에서 사용한 ingest pipeline 생성 관련 내용은 Semantic Search Documentation에 자세히 나와 있습니다.

Step 1 : 필수 Package 설치

색인을 하기위해 Local Jupyter Notebook 환경을 사용하였습니다. Pyhon3.11 환경에서 아래의 Step별 셸을 생성하여 실행해 줍니다.

!pip install --upgrade boto3 
!pip install -q beautifulsoup4 
!pip install -q langchain 
!pip install -q -U sentence-transformers 
!pip install -q -U fast-sentence-transformers 
!pip install -q -U opensearch-py

Step 2: 환경 변수 설정

OPENSEARCH_URL = 'https://opensearch-domain-endpoint' 
USERNAME = 'opensearch-id' 
PASSWORD = 'opensearch-pw' 

EMBEDDING_MODEL_NAME = 'intfloat/multilingual-e5-large' 
INDEX_NAME = 'test-index-v0.01' 

CHUNK_SIZE = 1024 
CHUNK_OVERLAB = 128

Step 3: 필요 함수 생성

from sentence_transformers import SentenceTransformer
import requests
import json
import datetime
import pdfplumber
from tqdm import tqdm

def extract_text_from_pdf_with_pdfplumber(pdf_path):
    text = []
    with pdfplumber.open(pdf_path) as pdf:
        text = [page.extract_text() for page in tqdm(pdf.pages, desc="Processing PDF")]
    return text
    
def create_document_from_pdf_text(pdf_text, additional_fields=None):
    headers = {
        'Content-Type': 'application/json'
    }
    documents = {
        "content": pdf_text,
    }
    if additional_fields:
        documents.update(additional_fields)
    return documents
    
## Index를 생성합니다.
def create_index(index_name, verbose=False):
    headers = {
        'Content-Type': 'application/json'
    }
    settings = {
        "settings": {
            "default_pipeline": "nlp-ingest-pipeline",
            "index": {
                "knn": True,
                "number_of_shards": 3,
                "number_of_replicas": 2
            }
        },
        "mappings": {
            "properties": {
                "title" :{
                    "type": "text"
                },
                "auditor" :{
                    "type": "keyword",
                    "null_value": "deactivate"
                },
                "body_chunk_embedding": {
                    "type": "knn_vector",
                    "dimension": 1024,
                },
                "body_chunk_default": {
                    "type": "text",
                    "analyzer": "nori",
                    "fields": {
                        "keyword": {
                            "type": "keyword"
                        }
                    }
                },
                "passage_embedding": {
                    "type": "knn_vector",
                    "dimension": 768,
                    "method": {
                    "engine": "lucene",
                    "space_type": "l2",
                    "name": "hnsw",
                    "parameters": {}
                    }
                }
            }
        },
    }

    response = requests.put(f'{OPENSEARCH_URL}/{index_name}', headers=headers, data=json.dumps(settings), auth=(USERNAME, PASSWORD))
    if verbose:
        print(response.json())
        
## 데이터를 색인합니다
def upsert_document(index_name, document, verbose=False):
    headers = {
        'Content-Type': 'application/json'
    }
    response = requests.post(f'{OPENSEARCH_URL}/{index_name}/_doc', headers=headers, data=json.dumps(document), auth=(USERNAME, PASSWORD))
    
    if verbose:
        print(response.json())

Step 4: Search Pipeline 생성

Hybrid Query는 normalization-processor과 함께 동작합니다. 따라서 Search Pipeline을 만들어 Query 단계와 Fetch 단계에서 쿼리의 가중치를 제공해 주어야 합니다. 아래와 같이 OpenSearch Dashboards의 Dev Tools을 사용하여 손쉽게 생성이 가능합니다.

PUT /_search/pipeline/nlp-search-pipeline
{
  "description": "Post processor for hybrid search",
  "phase_results_processors": [
    {
      "normalization-processor": {
        "normalization": {
          "technique": "min_max"
        },
        "combination": {
          "technique": "arithmetic_mean",
          "parameters": {
            "weights": [
              0.3,
              0.7
            ]
          }
        }
      }
    }
  ]
}

Step5: 데이터 색인

색인 데이터로는 Amazon OpenSearch Service의 Developer Guide를 사용할 예정입니다. 데이터는 이곳 에서 다운받을 수 있습니다.

from langchain.text_splitter import RecursiveCharacterTextSplitter
from fast_sentence_transformers import FastSentenceTransformer as SentenceTransformer

url = "[file_path]/opensearch-service-dg.pdf"
pdf_text = extract_text_from_pdf_with_pdfplumber(url)
document = create_document_from_pdf_text(pdf_text, {"title": "Amazon OpenSearch Service - Developer Guide", "author": "Amazon Web Services"})
embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
text_splitter = RecursiveCharacterTextSplitter(chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAB)

author = normalize_text(document['author'])
title = normalize_text(document['title'])
body_raw_data = ' '.join(filter(None, document['content']))
body_raw = normalize_text(body_raw_data)
body_chunk_list = text_splitter.split_text(body_raw)

for i_body, body_chunk in enumerate(body_chunk_list):
    body_chunk_embedding = embedding_model.encode(body_chunk, normalize_embeddings=True)
    new_document = {
        'url': url,
        'auditor': author,
        'title_raw': title,
        'body_chunk_default': body_chunk,
        'body_chunk_embedding': body_chunk_embedding.tolist(),
    }
    upsert_document(INDEX_NAME, new_document, True)

Hybrid Query 테스트

OpenSearch에서 지원하는 Compound queries는 총 6가지 입니다. OpenSearch 2.10 이전에도 6가지 방식 중 ‘bool query’를 사용하여 Sementic과 Lexical을 활용해 Reranking을 할 수 있었습니다. 하지만, Query 유형별로 Score 계산 방식과 그에 따른 Scale이 다르다는 문제가 있었습니다. 또한, OpenSearch에서는 점수 계산이 주로 Shard 단위로 이루어지기 때문에, 전체 Shard에 걸친 계산에서는 추가적인 정규화 과정이 필요했습니다. 이러한 복잡성으로 인해, 문서의 가중치를 적절하게 계산하고 적용하는 데 상당한 노력이 필요했습니다. bool query와 Hybrid Query의 차이점에 대해 더 자세히 알고 싶으시다면 Improve search relevance with hybrid search, generally available in OpenSearch 2.10 블로그를 확인하시기 바랍니다.

Hybrid Query는 BM25 기반으로 수행되는 Keyword Search와 K-NN 또는 neural기반의 Vector 검색을 결합합니다. BM25는 키워드가 포함된 Query에 대해 검색 결과를 정확하게 제공하고 Neural은 Query에 자연어 이해가 필요할 때 잘 작동합니다. 이 두 검색은 서로 다른 척도를 사용해 일치하는 문서의 점수를 계산합니다. 따라서 동일한 척도가 되도록 정규화 하는것이 매우 중요합니다. The ABCs of semantic search in OpenSearch 블로그에서 Sementic Search의 벤치마킹 점수를 확인할 수 있습니다.

Step 1: Query 생성

아래의 코드에서 Test 데이터 준비-Step4: Search Pipeline 생성 에서 만든 Search Pipeline을 사용합니다. 해당 pipeline에서 각 Query 항목별 weights를 계산하는것은 각 데이터 별로 상이합니다. 이 부분은 검색엔진을 운영 하며 색인 되는 데이터에 따라 지속적으로 변경해 주어야 하는 부분이며 최적의 값을 찾아가는 실험과 연구를 끊임없이 반복해야 합니다.

아래의 기능은 Jupyter에서 수행하기 때문에 다른 Query와의 결과 비교가 어렵습니다. 따라서 Body에서 나온 Vector 값을 그대로 활용하여 OpenSearch Dashboards의 Compare search results 기능을 활용해 두 Query의 결과를 비교해 보겠습니다.

query = "Tell me about the K-NN algorithm"
headers = {
    'Content-Type': 'application/json'
}

embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
query_vector = embedding_model.encode(query, normalize_embeddings=True)

body = {
    "size": 5,
    "min_score": 0.7,
    "query": {
        "hybrid": {
            "queries": [
                {
                    "multi_match": {
                        "query": query,
                        "fields": [
                            "body_chunk_default",
                            "body_chunk_tokenized",
                            "title_raw^2"
                        ]
                    }
                },
                {
                    "function_score": {
                        "query": {
                            "knn": {
                                "body_chunk_embedding": {
                                    "vector": query_vector.tolist(),
                                    "k": 5
                                }
                            }
                        }
                    }
                },
            ],
        }
    }
}
start_time = datetime.datetime.now()

response = requests.post(f'{OPENSEARCH_URL}/{INDEX_NAME}/_search?search_pipeline=nlp-search-pipeline', headers=headers, data=json.dumps(body), auth=(USERNAME, PASSWORD))
end_time = datetime.datetime.now()
elapsed = end_time - start_time
minutes, seconds = divmod(elapsed.total_seconds(), 60)

response_result = response.json()['hits']

result = []
for result_itr in response_result['hits']:
    retrival_document = {}
    retrival_document['_score'] = result_itr['_score']
    retrival_document['body_chunk_default'] = result_itr['_source']['body_chunk_default']
    retrival_document['body_chunk_embedding'] = result_itr['_source']['body_chunk_embedding']
    result.append(retrival_document)

    if True:
        print(f"✅ elapsed time ▶▶ {int(minutes)} Min, {int(seconds)} Sec")
        print(f"✅ score ▶▶ {result_itr['_score']}")
        print(f"✅ body_chunk_default ▶▶ {result_itr['_source']['body_chunk_default']}"
        print(f"✅ body_chunk_embedding ▶▶ {result_itr['_source']['body_chunk_embedding'][:4]}...")
        print("\n")

Step 2-1: Single Query 수행

우선은 Hybrid Query가 아닌 단순 Sementic Search만의 결과를 확인해 보겠습니다. 두 모델 모두 ‘OpenSearch Function’이라는 키워드로 검색하였습니다.

Query 1

{
  "size": 5,
  "min_score": 0.5,
  "_source": [
    "body_chunk_default"
  ],
  "query": {
    "neural": {
      "passage_embedding": {
        "query_text": "OpenSearch Function",
        "model_id": "f7jdAIwBiqqca_v0YfPz",
        "k": 5
      }
    }
  }
}

Query 2

{
  "size": 5,
  "min_score": 0.5,
  "_source": [
    "body_chunk_default"
  ],
  "query": {
    "function_score": {
      "query": {
        "knn": {
          "body_chunk_embedding": {
            "vector": [
              0.019265305250883102,
              -0.0016835234127938747,
              -0.041464488953351974,
              ...
            ],
            "k": 5
          }
        }
      }
    }
  }
}

검색 결과를 확인하면 ‘OpenSearch Function’ 에 관련 있는 검색 결과를 가져 오지만 문맥은 다양하게 나오는 것을 알 수 있습니다. 또한 min_score를 0.5로 조정하는 경우 Query 1의 결과는 NR(No Results)로  반환 됩니다. 이처럼 Sementic Search에 특정 Keyword로 검색을 하는 경우 의도와 다른 검색 결과가 나온 다는것을 확인 하였습니다.

min_score 미설정

min_score 0.5 설정

Step 2-2: Compound Query 수행

Compound search에서는 기존에 만들어져 있는 Search pipeline인 nlp-search-pipeline을 사용할 수 없습니다. 따라서 임시 Pipeline을 쿼리에 주입하여 수행하겠습니다. 또한 비교를 위하여 2.10 버전 이전에 사용되던 bool Query와 결과를 비교해 보겠습니다.

Query 1

{
  "size": 5,
  "min_score": 0.5,
  "search_pipeline" : {
    "phase_results_processors": [
      {
        "normalization-processor": {
          "normalization": {
            "technique": "min_max"
          },
          "combination": {
            "technique": "arithmetic_mean",
            "parameters": {
              "weights": [
                0.3,
                0.7
              ]
            }
          }
        }
      }
    ]
  },
  "_source": [
    "body_chunk_default"
  ],
  "query": {
    "hybrid": {
      "queries": [
        {
          "multi_match": {
            "query": "Tell me about the K-NN algorithm",
            "fields": [
              "body_chunk_default",
              "title_raw"
            ]
          }
        },
        {
          "neural": {
            "passage_embedding": {
              "query_text": "Tell me about the K-NN algorithm",
              "model_id": "f7jdAIwBiqqca_v0YfPz",
              "k": 5
            }
          }
        }
      ]
    }
  }
}

Query 2

{
  "size": 5,
  "min_score": 0.5,
  "search_pipeline" : {
    "phase_results_processors": [
      {
        "normalization-processor": {
          "normalization": {
            "technique": "min_max"
          },
          "combination": {
            "technique": "arithmetic_mean",
            "parameters": {
              "weights": [
                0.3,
                0.7
              ]
            }
          }
        }
      }
    ]
  },
  "_source": [
    "body_chunk_default"
  ],
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "Tell me about the K-NN algorithm",
            "fields": [
              "body_chunk_default",
              "title_raw"
            ]
          }
        },
        {
          "neural": {
            "passage_embedding": {
              "query_text": "Tell me about the K-NN algorithm",
              "model_id": "f7jdAIwBiqqca_v0YfPz",
              "k": 5
            }
          }
        }
      ]
    }
  }
}

아래의 Query결과를 비교해보면 확실히 결과의 품질이 다른것을 알 수 있습니다. neural과 Hybrid Query를 수행한 결과에서는 정확하게 질문의 의도인 K-NN 알고리즘에 대해 알려주는 페이지를 검색하며 순위 내에 관련 Context로 검색 되는것을 알 수 있습니다.

다음은 외부에서 Embedding하여 색인한 ‘multilingual-e5-large’ 모델과 ‘msmarco-distilbert-base-tas-b’ 모델을 테스트 해 보도록 하겠습니다. multilingual-e5-large 모델은 OpenSearch에서 제공하는 모델이 아니기 때문에 외부에서 색인전 Vector화 하는 작업이 추가적으로 필요합니다.

query = "Tell me about the K-NN algorithm"
embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
query_vector = embedding_model.encode(query, normalize_embeddings=True)

print(query_vector.tolist())

테스트 데이터 준비-Step5: 데이터 색인 단계에서 이미 Vector화 된 데이터를 body_chunk_embedding 컬럼에 색인 하였기에 해당 컬럼을  function_socre를 통해 검색하도록 하겠습니다.

Query 1

{
  "size": 5,
  "min_score": 0.5,
  "search_pipeline" : {
    "phase_results_processors": [
      {
        "normalization-processor": {
          "normalization": {
            "technique": "min_max"
          },
          "combination": {
            "technique": "arithmetic_mean",
            "parameters": {
              "weights": [
                0.3,
                0.7
              ]
            }
          }
        }
      }
    ]
  },
  "_source": [
    "body_chunk_default"
  ],
  "query": {
    "hybrid": {
      "queries": [
        {
          "multi_match": {
            "query": "Tell me about the K-NN algorithm",
            "fields": [
              "body_chunk_default",
              "title_raw"
            ]
          }
        },
        {
          "neural": {
            "passage_embedding": {
              "query_text": "Tell me about the K-NN algorithm",
              "model_id": "f7jdAIwBiqqca_v0YfPz",
              "k": 5
            }
          }
        }
      ]
    }
  }
}

Query 2

{
  "size": 5,
  "min_score": 0.5,
  "search_pipeline" : {
    "phase_results_processors": [
      {
        "normalization-processor": {
          "normalization": {
            "technique": "min_max"
          },
          "combination": {
            "technique": "arithmetic_mean",
            "parameters": {
              "weights": [
                0.3,
                0.7
              ]
            }
          }
        }
      }
    ]
  },
  "_source": [
    "body_chunk_default"
  ],
  "query": {
    "hybrid": {
      "queries": [
        {
          "multi_match": {
            "query": "Tell me about the K-NN algorithm",
            "fields": [
              "body_chunk_default",
              "title_raw"
            ]
          }
        },
        {
          "function_score": {
            "query": {
              "knn": {
                "body_chunk_embedding": {
                  "vector": [
                    0.0006457127165049314,
                    0.0009644012316130102,
                    -0.04208784177899361,
                    ...
                  ],
                  "k": 5
                }
              }
            }
          }
        }
      ]
    }
  }
}

검색 결과를 보면 상이한 결과가 나오긴 했으나 초기 질문 했던 “Tell me about the K-NN algorithm”이라는 질문에 대하여 관련 Context를 도출해 내는것을 볼 수 있습니다.

결론

RAG모델에서 OpenSearch를 Vector Database로 사용하는 경우 대부분은 Semantic Search를 사용합니다. 자연어 검색 사용시 문장의 개념적 의미를 바탕으로 구별하기 때문에 이는 자연어 처리에 최적화 되어 있습니다. 하지만 Semantic Search가 항상 올바른 정답을 주지 않습니다.

이때 Lexical Search의 형태소 분석을 활용하여 동의어를 추가해 준다면 보다 정확한 의미 파악이 가능해 집니다. Amazon OpenSearch Service의 Nori 형태소 분석기 플러그인에 대해 알고싶으신 분은 Amazon OpenSearch Service, 한국어 분석을 위한 ‘노리(Nori)’ 플러그인 활용 블로그를 봐주시기 바랍니다.

Hybrid Query를 활용하여 Semantic과 Lexical을 결합해 검색한다면 보다 정확한 답을 검색할 확률이 늘어나며 나아가 GenAI의 품질을 높힐 수 있습니다.

Sewoong Kim

Sewoong Kim

김세웅 Cloud Architect는 AWS Professional Services 팀의 일원으로서 컨테이너와 서버리스를 중심으로 AWS 기반 서비스를 구성하는 고객들에게 최적화된 아키텍처를 제공하고, GenAI Application Architect를 보다 고도화 하기 위한 여러 기술적인 도움을 드리고 있습니다.

Joonsun Baek

Joonsun Baek

Data Architect로서 AWS 클라우드 환경에서 고객들이 직면하는 데이터 관련 문제를 해결하는 데 도움을 주고 있습니다. 데이터의 이해, 처리 및 활용에 대한 전반적인 컨설팅 및 기술 지원을 제공하고 있습니다.

Seungje Mun

Seungje Mun

AWS Professional Service팀에서 Application Migration & Modernization 과정을 돕고 있습니다. Application Developer로서 다양한 고객과 클라우드로의 마이그레이션 여정에 함께하며, 그 과정에서 새로운 기술 도입에 대한 가능성을 제안합니다. 또한 고객의 생산성을 높일 수 있는 Agile Coach 역할을 함께 하고 있습니다.