AWS 기술 블로그

VARCO LLM과 Amazon OpenSearch를 이용하여 한국어 Chatbot 만들기

VARCO LLM은 엔씨소프트(NC SOFT)에서 제공하는 대용량 언어 모델(LLM)입니다. VARCO LLM KO-13B-IST는 VARCO LLM KO-13B-FM의 파인 튜닝(Fine Tuning) 모델로서 Question and Answering, Summarization등 다양한 태스크에 활용할 수 있으며, Amazon SageMaker를 이용하여 쉽게 배포하여 사용할 수 있습니다. 또한, 대규모 언어 모델(LLM)은 사전학습(Pre-train)을 통해 많은 경우에 좋은 답변을 할 수 있지만, 학습에 포함되지 않은 특정 영역(domain specific)에 대한 질문에 대해서는 때때로 정확히 답변할 수 없습니다. RAG (Retrieval Augmented Generation) 를 이용하면, 외부 문서 저장소에서 질문(Question)에 관련된 문서를 읽어와서 Prompt에 활용하는 방식으로 대용량 모델의 성능을 강화될 수 있습니다. 또한, Amazon OpenSearch는 오픈 소스 기반의 검색 및 분석 서비스로서 대규모 언어 모델에 RAG를 구현할 때 유용하게 활용될 수 있습니다. 여기서는 VARCO LLM와 Amazon OpenSearch를 이용하여 Question/Answering을 위한 한국어 Chatbot을 RAG를 이용하여 구현합니다.

본 게시글은 대규모 언어 모델을 위한 어플리케이션 개발 프레임워크인 LangChain을 활용하여 어플리케이션을 개발하며, Amazon의 대표적인 서버리스 서비스인 Amazon Lambda로 서빙(Serving)하는 인프라를 구축합니다. Amazon OpenSearch와Lambda를 비롯한 인프라를 배포하고 관리하기 위하여 Amazon CDK를 활용합니다.

Architecture 개요

전체적인 Architecture는 아래와 같습니다. 사용자의 질문은 Amazon CloudFront와 Amazon API Gateway를 거쳐서, Lambda에 전달됩니다. Lambda는 질문(Query)을 Vector화(Embedding)한 다음에, OpenSearch로 전달하여, 관련된 문서(relevant docuements)를 받은 후에 VARCO LLM에 전달하여 답변을 얻습니다. 이후 답변은 사용자에게 전달되어 채팅화면에 표시됩니다. 또한 채팅이력은 Amazon DynamoDB를 이용해 저장되고 활용됩니다.

  • 단계1: 사용자가 Question을 입력하면, Query가 CloudFront와 API Gateway를 거쳐서 Lambda (Chat)에 전달됩니다.
  • 단계2: Query를 Embedding에 전달하여 Vector로 변환합니다. 여기서는 Embedding을 위하여 “GPT-J Embedding”을 이용합니다.
  • 단계3: Vector화된 Query를 OpenSearch에 보내서 Query와 관련된 문서들을 가져옵니다.
  • 단계4: Query와 함께 관계된 문서를 VARCO LLM에 전달하여 응답(response)을 얻습니다.
  • 단계5: DynamoDB에 Call log를 저장합니다.
  • 단계6: 사용자에게 응답으로 결과를 전달합니다.

주요 시스템 구성

다음의 설명은 Github- VARCO LLM과 OpenSearch를 이용하여 한국어 Chatbot 만들기를 참조합니다. 이 Repository에서는VARCO LLM을 SageMaker JumpStart를 이용해 설치하고 RAG를 활용하는 방법에 대해 설명하고 있습니다.

Chat을 수행하는 Lambda에서 LangChain 이용하기

LangChain은 LLM application의 개발을 도와주는 Framework로 Question and Answering, Summarization등 다양한 task에 맞게 chain등을 활용하여 편리하게 개발할 수 있습니다. 따라서 아래와 같이 VARCO LLM 어플리케이션이 LangChain을 이용 할 수 있도록, ContentHandler을 이용해 입출력 형태를 맞추어 줍니다. SageMaker Endpoint로 제공되는 VARCO LLM의 입출력 파라미터와 Output은 JSON의 형태로 구성 됩니다.

  • VARCO LLM의 입출력 파라미터
    {
      "text": "input text here",
      "request_output_len": 512,
      "repetition_penalty": 1.1,
      "temperature": 0.9,
      "top_k": 50,
      "top_p": 0.9
    }

    – text: LLM에 전달하는 사용자의 입력
    – request_output_len: 생성되는 최대 token의 수. 기본값 1000
    – repetition_penalty: 반복을 제한하기 위한 파라미터. 기본값 1.3 ( 1.0이면 no panalty  )
    – temperature: 다음 token의 확률(probability). 기본값 0.5

  • VARCO LLM의 Output 포맷
    {
      "result": [
        "output text here"
      ]
    }
    

ContentHandler에 VARCO LLM의 입력과 출력의 포맷을 아래와 같이 정의합니다. 상세한 내용은 lambda-chat에서 확인할 수 있습니다.

class ContentHandler(LLMContentHandler):
    content_type = "application/json"
    accepts = "application/json"

    def transform_input(self, prompt: str, model_kwargs: dict) -> bytes:
        input_str = json.dumps({
            "text" : prompt, **model_kwargs
        })
        return input_str.encode('utf-8')
      
    def transform_output(self, output: bytes) -> str:
        response_json = json.loads(output.read().decode("utf-8"))
        return response_json["result"][0]

VARCO LLM은 SageMaker endpoint를 이용하여 생성하므로, LangChain의 SagemakerEndpoint와 ContentHandler를 이용하여 LangChain과 연결합니다.

from langchain.embeddings import SagemakerEndpointEmbeddings

content_handler = ContentHandler()
aws_region = boto3.Session().region_name
client = boto3.client("sagemaker-runtime")
parameters = {
    "request_output_len": 512,
    "repetition_penalty": 1.1,
    "temperature": 0.9,
    "top_k": 50,
    "top_p": 0.9
} 

llm = SagemakerEndpoint(
    endpoint_name = endpoint_name, 
    region_name = aws_region, 
    model_kwargs = parameters,
    endpoint_kwargs={"CustomAttributes": "accept_eula=true"},
    content_handler = content_handler
)

사용자 질문(Query)를 Embedding 하기

OpenSearch에 보내는 query를 vector로 변환하기 위해서는 embedding function이 필요합니다. 여기서는 GPT-J 6B Embedding FP16을 이용하여 embedding을 수행합니다. GPT-J embedding은 semantic search와 text generation에 유용하게 이용할 수 있습니다. GPT-J embedding은 SagMaker JumpStart를 이용해 배포할 수 있으므로 SageMaker Endpoint Embeddings을 이용하여 아래와 같이 정의합니다.

from langchain.embeddings.sagemaker_endpoint import EmbeddingsContentHandler
from typing import Dict, List
class ContentHandler2(EmbeddingsContentHandler):
    content_type = "application/json"
    accepts = "application/json"

    def transform_input(self, inputs: List[str], model_kwargs: Dict) -> bytes:
        input_str = json.dumps({ "text_inputs": inputs, ** model_kwargs})
        return input_str.encode("utf-8")

    def transform_output(self, output: bytes) -> List[List[float]]:
        response_json = json.loads(output.read().decode("utf-8"))
        return response_json["embedding"]

content_handler2 = ContentHandler2()
embeddings = SagemakerEndpointEmbeddings(
    endpoint_name = endpoint_embedding,
    region_name = embedding_region,
    content_handler = content_handler2,
)

OpenSearch를 이용하여 Vector Store 구성하기

LangChain의 OpenSearchVectorSearch을 이용하여 vectorstore를 정의합니다. 여기서는 개인화된 RAG를 적용하기 위하여 OpenSearch의 index에 UUID로 구성된 userId를 추가하였습니다. 또한 embedding_function로 GPT-J embedding을 지정하였습니다.

vectorstore = OpenSearchVectorSearch(
    index_name = 'rag-index-'+userId+'-*',
    is_aoss = False,
    #engine="faiss",  # default: nmslib
    embedding_function = embeddings,
    opensearch_url=opensearch_url,
    http_auth=(opensearch_account, opensearch_passwd), # http_auth=awsauth,
)

개인화된 RAG를 OpenSearch Index로 구현하기

OpenSearch에 문서를 넣을 때에, userId와 requestId를 이용해 index_name을 생성하였습니다. 아래처럼 userId를 index에 포함한후 RAG를 관련 index로 검색하면 개인화된 RAG를 구성할 수 있습니다. 만약, 그룹단위로 RAG를 구성한다면 userId 대신에 groupId를 생성하는 방법으로 응용할 수 있습니다.

new_vectorstore = OpenSearchVectorSearch(
    index_name = "rag-index-" + userId + '-' + requestId,
    is_aoss = False,
    #engine = "faiss",  # default: nmslib
    embedding_function = embeddings,
    opensearch_url = opensearch_url,
    http_auth = (opensearch_account, opensearch_passwd),
)
new_vectorstore.add_documents(docs)  

OpenSearch를 이용하여 Query하기

RAG를 이용해 LLM의 답변에 필요한 데이터를 Prompt로 제공할 수 있습니다. 아래와 같이 RAG를 수행하는 get_answer_using_template()은 사용자의 질문(Query)를 OpenSearch로 보내서 관련된 문서를 받은 후 VARCO LLM을 통해 답변을 얻습니다. 여기에서 RetrievalQA은 아래처럼 OpenSearch로 구성된 vectorstore를 retriever로 지정합니다. 이때 search_type을 similarity search로 지정하여 관련 문서를 3개까지 가져오도록 설정하였습니다. RAG의 문서와 함께 template를 사용하여 정확도를 높입니다.

from langchain.chains import RetrievalQA

def get_answer_using_template(query, vectorstore):  
    prompt_template = """다음은 User와 Assistant의 친근한 대화입니다. 
Assistant은 말이 많고 상황에 맞는 구체적인 세부 정보를 많이 제공합니다. 
Assistant는 모르는 질문을 받으면 솔직히 모른다고 말합니다.

    {context}

    Question: {question}
    Assistant:"""
    PROMPT = PromptTemplate(
        template=prompt_template, input_variables=["context", "question"]
    )

    qa = RetrievalQA.from_chain_type(
        llm=llm,
        chain_type="stuff",
        retriever=vectorstore.as_retriever(
            search_type="similarity", search_kwargs={"k": 3}
        ),
        return_source_documents=True,
        chain_type_kwargs={"prompt": PROMPT}
    )
    result = qa({"query": query})
    source_documents = result['source_documents']

    if len(source_documents)>=1 and enableReference == 'true':
        reference = get_reference(source_documents)
        return result['result']+reference
    else:
        return result['result']

RAG에서 Vector 검색에 사용하는 OpenSearch는 query size의 제한이 있습니다. 여기서는 1800자 이상의 query에 대해서만 RAG를 적용합니다.

if querySize<1800 and enableOpenSearch=='true': 
  answer = get_answer_using_template(text, vectorstore)
else:
  answer = llm(text) 

VARCO LLM의 응답에서 “### Assistant:” 이후를 응답으로 사용하기 위하여 아래와 같이 answer에서 msg를 추출합니다.

pos = answer.rfind('### Assistant:\n') + 15
msg = answer[pos:]

AWS CDK로 인프라 구현하기

OpenSearch를 위한 Role을 정의합니다.

const domainName = `${projectName}`
const accountId = process.env.CDK_DEFAULT_ACCOUNT;
const resourceArn = `arn:aws:es:${region}:${accountId}:domain/${domainName}/*`
const OpenSearchPolicy = new iam.PolicyStatement({
    resources: [resourceArn],
    actions: ['es:*'],
});
const OpenSearchAccessPolicy = new iam.PolicyStatement({
    resources: [resourceArn],
    actions: ['es:*'],
    effect: iam.Effect.ALLOW,
    principals: [new iam.AnyPrincipal()],
});  

아래와 같이 OpenSearch를 정의합니다.

let opensearch_url = "";
const domain = new opensearch.Domain(this, 'Domain', {
    version: opensearch.EngineVersion.OPENSEARCH_2_3,

    domainName: domainName,
    removalPolicy: cdk.RemovalPolicy.DESTROY,
    enforceHttps: true,
    fineGrainedAccessControl: {
        masterUserName: opensearch_account,
        masterUserPassword: cdk.SecretValue.unsafePlainText(opensearch_passwd)
    },
    capacity: {
        masterNodes: 3,
        masterNodeInstanceType: 'm6g.large.search',
        dataNodes: 3,
        dataNodeInstanceType: 'r6g.large.search',
    },
    accessPolicies: [OpenSearchAccessPolicy],
    ebs: {
        volumeSize: 100,
        volumeType: ec2.EbsDeviceVolumeType.GP3,
    },
    nodeToNodeEncryption: true,
    encryptionAtRest: {
        enabled: true,
    },
    zoneAwareness: {
        enabled: true,
        availabilityZoneCount: 3,
    }
});
opensearch_url = 'https://' + domain.domainEndpoint;

Chat에 사용할 Lambda의 Role을 정의합니다.

const roleLambda = new iam.Role(this, `role-lambda-chat-for-${projectName}`, {
    roleName: `role-lambda-chat-for-${projectName}`,
    assumedBy: new iam.CompositePrincipal(
        new iam.ServicePrincipal("lambda.amazonaws.com")
    )
});
roleLambda.addManagedPolicy({
    managedPolicyArn: 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
});
roleLambda.attachInlinePolicy( // add opensearch policy
    new iam.Policy(this, `opensearch-policy-for-${projectName}`, {
        statements: [OpenSearchPolicy],
    }),
);

lambda-chat을 아래와 같이 정의하고 필요한 권한을 부여합니다.

const lambdaChatApi = new lambda.DockerImageFunction(this, `lambda-chat-for-${projectName}`, {
    description: 'lambda for chat api',
    functionName: `lambda-chat-api-for-${projectName}`,
    code: lambda.DockerImageCode.fromImageAsset(path.join(__dirname, '../../lambda-chat')),
    timeout: cdk.Duration.seconds(60),
    memorySize: 4096,
    role: roleLambda,
    environment: {
        opensearch_url: opensearch_url,
        s3_bucket: s3Bucket.bucketName,
        s3_prefix: s3_prefix,
        callLogTableName: callLogTableName,
        varco_region: varco_region,
        endpoint_name: endpoint_name,
        opensearch_account: opensearch_account,
        opensearch_passwd: opensearch_passwd,
        embedding_region: embedding_region,
        endpoint_embedding: endpoint_embedding
    }
});
lambdaChatApi.grantInvoke(new iam.ServicePrincipal('apigateway.amazonaws.com'));
s3Bucket.grantRead(lambdaChatApi); // permission for s3
callLogDataTable.grantReadWriteData(lambdaChatApi); // permission for dynamo

VARCO LLM이 SageMaker Endpoint를 이용해 제공되므로, 아래와 같이 SageMaker 사용을 위한 권한도 부여합니다.

const SageMakerPolicy = new iam.PolicyStatement({  // policy statement for sagemaker
    actions: ['sagemaker:*'],
    resources: ['*'],
});
lambdaChatApi.role?.attachInlinePolicy( // add sagemaker policy
    new iam.Policy(this, `sagemaker-policy-for-${projectName}`, {
        statements: [SageMakerPolicy],
    }),
);

상세한 코드는cdk-varco-opensearch-stack.ts 을 참조합니다.

직접 실습 해보기

사전 준비 사항

1) 이 솔루션을 사용하기 위해서는 사전에 AWS Account가 준비되어 있어야 합니다.

2) VARCO LLM과 Embedding을 위하여, “ml.g5.12xlarge”와 “ml.g5.2xlarge”를 사용합니다. [Service Quotas – AWS services – Amazon SageMaker]에서 “ml.g5.12xlarge for endpoint usage”와 “ml.g5.2xlarge for endpoint usage”가 각각 최소 1개 이상이 되어야 합니다. 만약 Quota가 0인 경우에는 [Request quota increase]을 선택하여 요청합니다.

VARCO LLM 설치

여기서는 VARCO LLM을 설치하기 위하여 SageMaker JumbStart에서 Oregon(us-west-2) 리전을 이용합니다. AWS marketplace에 접속하여 “VARCO”로 검색합니다. 가장 최신 버전의 “VARCO LLM KO-13B-IST”을 선택합니다. 이후  [Continue to Subscribe]를 선택하고, 다시 [Continue to configuration]을 선택하여 Subscribe를 합니다.

이후 아래와 같이 [Available launch methods]로 [SageMaker console]을 선택합니다.

Software Version을 v1.0.1로 설정후에 [View in Amazon SageMaker]를 선택합니다.

Model 이름으로 “varco-llm-ko-13b-ist-1″을 입력하고 아래로 스크롤하여 [Next]을 선택합니다.

[Endpoint Name]과 [Enpoint configuration name]을 “endpoint-varco-llm-ko-13b-ist-1″로 입력합니다.

아래로 스크롤하여 [Variants] – [Production]에서 [Create production variant]을 선택합니다.

[Add model]에서 “varco-llm-ko-13b-ist-1″을 선택한 후에 [Save]를 선택합니다. 이후  [Edit]를 선택합니다.

아래로 스크롤하여 [Create endpoint configuration]을 선택합니다.

Endpoint configuration 생성이 성공하면, 아래로 스크롤하여 [Submit]을 선택합니다.

Embedding 설치

SageMaker Console에서 SageMaker Studio를 실행한 후에, SageMaker JumpStart에서 “GPT-J 6B Embedding FP16″를 고른 후에 Deploy를 선택합니다. 설치가 되면 “jumpstart-dft-hf-textembedding-gpt-j-6b-fp16″와 같이 Endpoint가 생성됩니다.

CDK를 이용한 인프라 설치

여기서는 Cloud9에서 AWS CDK를 이용하여 인프라를 설치합니다.

  1. Cloud9 Console에 접속하여 [Create environment]-[Name]에서 “chatbot”으로 이름을 입력하고, EC2 instance는 “m5.large”를 선택합니다. 나머지는 기본값을 유지하고, 하단으로 스크롤하여 [Create]를 선택합니다.
  2. Environment에서 “chatbot”를 [Open]한 후에 터미널을 실행합니다.
  3. EBS 크기 변경을 수행을 하기 위해서 스크립트를 다운로드 하여 수행합니다. 수행된 명령어는 EBS 용량을 80G로 변경합니다.
    curl https://raw.githubusercontent.com/aws-samples/generative-ai-demo-using-amazon-sagemaker-jumpstart-kr/main/resize.sh -o resize.sh
    chmod a+rx resize.sh && ./resize.sh 80
  4. 소스를 다운로드합니다.
    curl https://raw.githubusercontent.com/aws-samples/generative-ai-demo-using-amazon-sagemaker-jumpstart-kr/main/blogs/korean-chatbot-using-varco-llm-and-opensearch/korean-chatbot-using-varco-llm-and-opensearch.zip -o korean-chatbot-using-varco-llm-and-opensearch.zip && unzip korean-chatbot-using-varco-llm-and-opensearch.zip
  5. cdk 폴더로 이동하여 필요한 라이브러리를 설치합니다.
    cd korean-chatbot-using-varco-llm-and-opensearch/cdk-varco-opensearch/ && npm install
  6. Enpoint들의 주소를 수정합니다.
    LLM과 Embedding에 대한 Endpoint 생성시 얻은 주소로 아래와 같이 “cdk-varco-opensearch/lib/cdk-varco-opensearch-stack.ts”을 업데이트 합니다. Endpoint의 이름을 상기와 동일하게 설정하였다면, 수정없이 다음 단계로 이동합니다.
  7. CDK 사용을 위해 Bootstraping을 수행합니다.
    다음의 명령어로 Account ID를 확인합니다.

    aws sts get-caller-identity --query Account --output text

    아래와 같이 bootstrap을 수행합니다. 여기서 “account-id”는 상기 명령어로 확인한 12자리의 Account ID입니다. bootstrap 1회만 수행하면 되므로, 기존에 cdk를 사용하고 있었다면 bootstrap은 건너뛰어도 됩니다.

    cdk bootstrap aws://[account-id]/us-west-2
  8. 인프라를 설치합니다. 전체 설치를 위해 약 20분정도 소요됩니다.
    cdk deploy
  9. 설치가 완료되면 브라우저에서 아래와 같이 WebUrl를 확인하여 브라우저를 이용하여 접속합니다.

실행결과

채팅창에서 “Kendra에 대해 설명해줘.”로 입력합니다. VARCO LLM에서 학습되지 않은 경우에는 아래처럼 기대와 다른 정보를 전달합니다.

Amazon_Kendra.pdf을 다운받은 후에 아래 채팅장의 파일버튼을 선택하여 업로드합니다. 이후 Kendra에 등록이 되고 아래와 같이 요약(summarization) 결과를 보여줍니다. Amazon_Kendra.pdf는 Kendra 서비스에 대한 소개자료입니다.

이제 다시 “Kendra에 대해 설명해줘.”로 질문을 하면 업로드한 Amazon_Kendra.pdf을 참조하여 아래와 같이 Kendra에 대한 정확한 응답을 얻을 수 있습니다.

대용량 언어 모델(LLM)의 특성상 실습의  답변은 블로그의 화면과 조금 다를 수 있습니다.

리소스 정리하기

더이상 인프라를 사용하지 않는 경우에 아래처럼 모든 리소스를 삭제할 수 있습니다. Cloud9 console에 접속하여 배포된 자원을 삭제를 합니다.

cdk destroy

본 실습에서는 VARCO LLM의 endpoint와 embedding으로 “ml.g5.12xlarge”와 “ml.g5.2xlarge”를 사용하고 있으므로, 더이상 사용하지 않을 경우에 반드시 삭제하여야 합니다. 특히 cdk destroy 명령어로 Chatbot만 삭제할 경우에 SageMaker Endpoint가 유지되어 지속적으로 비용이 발생될 수 있습니다. 이를 위해 Endpoint Console에 접속해서 Endpoint를 삭제합니다. 마찬가지로 Models과 Endpoint configuration에서 설치한 VARCO LLM의 Model과 Configuration을 삭제합니다.

결론

엔씨소프트의 한국어 언어모델인 VARCO LLM과 Amazon OpenSearch를 활용하여 질문과 답변(Question/Answering) task를 수행하는 Chatbot 어플리케이션을 구현하였습니다. 대규모 언어 모델(LLM)을 활용하면 기존 Rule 기반의 Chatbot보다 훨씬 강화된 기능을 제공할 수 있습니다. 대규모 언어모델 확습에 포함되지 못한 특정 영역의 데이터는 Amazon OpenSearch를 통해 보완될 수 있으며, 이를 통해 질문과 답변을 chatbot로 제공하려는 기업들이 유용하게 사용될 수 있을 것으로 보여집니다. 또한 대규모 언어 모델을 개발하는 프레임워크인 LangChain을 VARCO LLM과 연동하는 방법과 Amazon OpenSearch와 관련된 서빙 인프라를 AWS CDK를 활용하여 쉽게 구현할 수 있었습니다. 한국어 대규모 언어 모델은 Chatbot뿐 아니라 향후 다양한 분야에서 유용하게 활용될 수 있을 것으로 기대됩니다.

실습 코드 및 도움이 되는 참조 블로그

아래의 링크에서 실습 소스 파일 및 기계 학습(ML)과 관련된 자료를 확인하실 수 있습니다.

Kyoung-Su Park

Kyoung-Su Park

박경수 솔루션즈 아키텍트는 다양한 워크로드에 대한 개발 경험을 바탕으로 고객이 최적의 솔루션을 선택하여 비즈니스 성과를 달성할 수 있도록 고객과 함께 효율적인 아키텍처를 구성하는 역할을 수행하고 있습니다. 현재 AWS의 Machine Learning, IoT, Analytics TFC에서 활동하고 있습니다.