본문 바로가기
개발/개발이야기

ChatGPT과 Langchain를 이용한 나만의 지식검색 챗봇 만들기

by zian지안 2023. 6. 3.

지난 포스팅에서 Langchain을 이용한 비즈니스 애플리케이션을 만들기 위해 고민해야 할 점들에 대해 이야기했었습니다. 

이번 포스팅에서는 실제로 Langchain을 이용한 비즈니스 애플리케이션이라는 콘셉트로 만든 '나만의 지식검색 챗봇'에 대한 간단한 소개와 데모를 보여드리겠습니다.

기획 의도

'나만의 지식검색 챗봇'은 사실 개인적으로 활용하기 위한 용도로 기획하였습니다. 거의 매일 여러 IT뉴스나 블로그 포스팅을 읽고 있는데 그중에 보관하고 나중에도 읽어볼 만한 링크는 텔레그램에 저장하여 나중에 다시 검색해서 읽곤 하였습니다. (에버노트나 포켓도 사용해 봤지만 PC/모바일 환경에서 가장 간단하게 사용하기에는 개인적으로 텔레그램이 가장 좋았습니다)  하지만 텔레그램의 검색기능이 한글 검색에서는 한계가 있고, 링크로 저장한 본문 내용 자체를 검색하진 못한다는 한계가 있었죠

개인 기록 용도로 사용하는 텔레그램

때문에 뉴스나 블로그 링크의 내용 자체를 저장하고 나중에 본문을 검색하여 글을 다시 읽거나 요약하고 싶다는 생각으로 '나만의 지식검색 챗봇'을 기획하게 되었습니다.

프로그램 설계 및 구조

나만의 텔레그램 챗봇 구성도

챗봇을 위한 메신저는 가장 간단하게 연동할 수 있는 텔레그램을 활용하기로 하였습니다. 처음에는 저장/답변을 한 번에 하는 봇 하나로 활용하려고 생각했는데, '저장' 커맨드와 '질문'커맨드를 각각 입력해 가면서 봇을 사용하기가 불편해서 결국 '저장봇'과 '답변봇'으로 분리하였습니다. 

저장봇으로 데이터를 저장, 답변봇으로 질문에 대한 답을 얻는 텔레그램 봇

 저장봇은 입력받은 텍스트가 URL이면 해당 웹 페이지로 접속하여 웹페이지의 내용을 크롤링합니다. 입력받은 텍스트가 일반 텍스트이면 입력받은 문장 그대로 사용합니다.

원문 텍스트는 간단한 전처리 과정을 거칩니다. 텍스트를 적절한 문장 단위로 분리합니다. 후술 하겠지만, 원본 텍스트로 부터 얼마나 잘 문장단위를 분리하느냐가 나중에 검색 품질에 영향을 주게 됩니다.

분리된 문장단위의 텍스트는 임베딩 과정을 거쳐 벡터 스토어에 저장됩니다. 

답변봇은 사용자로부터 받은 질문을 벡터 스토어에 질의합니다. 이 과정에서 similarity search를 통해 입력받은 질문과 가장 유사한 답변 목록을 추출합니다. 

이렇게 추출된 답변은 API를 통해 ChatGPT로 질의하는데, 이때 Prompt를 통해 ChatGPT가 적절한 답변을 할 수 있도록 사전에 프롬프트 엔지니어링 과정을 거치게 되고, ChatGPT는 주어진 답변 목록과 프롬프트의 지시에 따라 적합한 답변을 출력합니다.

전체 코드를 공개하기는 부끄러워서 주요 로직 부분만 소개합니다..

봇 서비스

사실 순수하게 봇 서비스만 구현해도 되지만, API를 통해 기능을 사용하기 위해 FastAPI를 사용하여 봇 서비스를 구현하였습니다. 개인 용도로 간단하게 작성했기 때문에 좋은 코드는 아닙니다(...)

#main.py

archivebot_token = os.environ.get('ARCHIVE_BOT_TOKEN')
answerbot_token = os.environ.get('ANSWER_BOT_TOKEN')

app = FastAPI()

archiveBot = telebot.TeleBot(token=archivebot_token)
answerBot = telebot.TeleBot(token=answerbot_token)

service = ArchiveService()
pp = Preprocess()


# 문서를 추가한다
@app.post("/add-doc")
def add_doc(docs:List[MyDoc]):
  service.add_doc(docs)
  return "추가되었습니다"


@app.post("/init")
def initialize():
  service.init_archive()
  return "초기화 되었습니다"

@app.get("/query")
def query(q:str):
  query_result = service.query(q)
  return query_result

@answerBot.message_handler(func=lambda message: True)
def answer_all_message(message):
  try:
    result = service.query(message.text)
    answer = f"{result['answer']}"
  except: 
    answer = "오류가 발생하였습니다"
  
  answerBot.send_message(chat_id=message.chat.id, text=answer)

@archiveBot.message_handler(func=lambda message: True)
def archive_all_message(message):
  url = ""

  if (message.text=="/init"):
    result = service.init_archive()
    archiveBot.send_message(chat_id=message.chat.id, text="초기화 되었습니다")
    return
  if pp.is_url(message.text):
    url = message.text
    text = pp.get_content(url)
  else :
    text = message.text

  if len(text) > 0:
    docs = []
    docs.append(pp.set_mydoc( text, url ))

    service.add_doc(docs)
    archiveBot.send_message(chat_id=message.chat.id, text="등록하였습니다")
  else: 
    archiveBot.send_message(chat_id=message.chat.id, text="등록에 실패하였습니다")

Thread(target=lambda: answerBot.polling(), daemon=True).start()
Thread(target=lambda: archiveBot.polling(), daemon=True).start()

봇 서비스는 저장봇과 답변봇 두 가지이며, API는 데이터를 초기화하기 위한  /init, 데이터 추가를 위한 /add-doc  질의를 테스트하기 위한 /query로 구분됩니다. 이 포스팅에서는 API관련 내용은 생략하고 봇 관련 내용만 설명하겠습니다.

전처리

텍스트가 URL인지 판별하기 위해서 validators를, 웹페이지를 크롤링하기 위해서는 BeatifulSoup을 사용하였습니다. set_mydoc함수는 Text 이외에 원문 URL, 입력일시등 metadata를 입력하는 역할을 합니다.


  # url인지 판별하는 함수
  def is_url(self, url_string: str) -> bool:
    result = validators.url(url_string)
    if isinstance(result, ValidationFailure):
        return False
    return result

  # url link인 경우 url의 내용을 가져온다
  def get_content(self, url:str) -> str:
    html = requests.get(url)
    soup = BeautifulSoup(html.text, features="lxml")
    script_tag = soup.find_all(['script', 'style', 'header', 'footer', 'form'])

    for script in script_tag:
      script.extract()

    # 페이지의 전체 내용
    content = soup.get_text('\n', strip=True)
    return content

  # 일반 메시지인 경우
  def set_mydoc(self, text, url) -> MyDoc:

    doc = MyDoc(
      content=text,
      meta= {
        'url': url,
        'create_date': datetime.now().strftime('%Y.%m.%d %H:%M:%S')
      }
    )
    return doc

 

텍스트 전처리 및 저장

임베딩(Embedding)

임베딩은 SentenceTransformersEmbedding을 사용하였습니다. 문서를 확인하면, 여러 가지 모델이 있는데 그중에 Multi-lingual을 지원하는 모델 중에 distiluse-base-multilingual-cased-v1 모델이 성능이 좋았습니다. 하지만 여러 가지 모델을 테스트해 본 결과, 한글 임베딩으로 사용하기에는 jhgan/ko-sroberta-multitask 모델이 더 성능이 좋았기 때문에 최종으로 적용하였습니다. OpenAI임베딩은 API비용이 소모되고, 개인적인 용도로는 비용 소모를 최소로 하고 싶었기 때문에 사용하지 않았습니다. 

def __init__(self):
    os.environ["OPENAI_API_KEY"] = os.environ.get('OPENAI_API_KEY')

    # self.embeddings = SentenceTransformerEmbeddings(model_name="all-MiniLM-L6-v2")
    # self.embeddings = SentenceTransformerEmbeddings(model_name="distiluse-base-multilingual-cased-v1")
    self.embeddings = SentenceTransformerEmbeddings(model_name="jhgan/ko-sroberta-multitask")
    # self.embeddings = OpenAIEmbeddings()
    load_dotenv()

텍스트 전처리 및 벡터 스토어 저장

Langchain과 LLM을 이용하는 애플리케이션에서는 원본 텍스트를 어떤 단위로 구분할 것인가가 굉장히 중요한 문제입니다. 우선 LLM은 입력받을 수 있는 토큰이 제한되어 있습니다(ChatGPT의 경우 4096 token).  LLM에 프롬프트와 검색 결과를 보낸 뒤 답변을 받는 구조의 이런 애플리케이션은 한 번에 너무 많은 텍스트를 LLM에 보낼 수가 없습니다.

이런 구조 때문에 적당한 단위의 문장으로 쪼개서 프롬프트와 함께 LLM에 보내야 하는데, 그렇다고 너무 짧은 단위로 문장을 분리하면 검색된 결과가 너무 단순하여 제대로 된 답변을 할 수가 없습니다.

이와 같은 검색결과를 LLM에 프롬프트와 함께 보내 답을 받습니다

따라서 적절하게 맥락에 맞는 단위로 문단을 분리하여 저장해야 하는데, 가장 적절한 문장 단위 파악은 계속 테스트해 보면서 찾아내는 방법밖에는 없습니다. 또는 '맥락에 맞는 문단 단위'를 찾아내는 별도의 기술을 활용할 수도 있겠습니다. 여기서는 Langchain에서 제공하는 CharactorTextSplitter를 이용해 개행문자 기준으로 500자 단위로 단순하게 분리하였습니다.

벡터 스토어는 Langchain에서 기본적으로 제공하는 FAISS를 사용하였습니다. 지속적으로 새로 추가되는 텍스트가 기존 벡터 스토어에도 추가되어야 하므로, 벡터를 파일로 저장하여 텍스트를 추가할 때마다 파일이 업데이트되도록 하였습니다.

  #문서 추가
  def add_doc(self, docs: List[MyDoc]):
    vector_store = FAISS.load_local("./store", embeddings=self.embeddings)

    # texts에 대한 전처리 필요
    texts = []
    metadatas = []
    for doc in docs:
      content = doc.content
      metadata = doc.meta

      ## 문장단위로 분리(splitter 사용)
      text_splitter = CharacterTextSplitter(
        separator= "\n",
        chunk_size = 500,
        chunk_overlap = 0,
        length_function = len
      )
      sentences = text_splitter.split_text(content)

      for sentence in sentences:
        texts.append(sentence)
        metadatas.append(metadata)
        
      vector_store.add_texts(texts=texts, metadatas=metadatas)
      
    FAISS.save_local(vector_store, "./store")

원문 텍스트가 이와 같은 과정을 통해 벡터 스토어에 저장되면 저장봇의 역할은 완료됩니다.

질문을 벡터 스토어에서 검색

답변봇을 통해 사용자가 질문을 하면, 질의 문장을 벡터 스토어에 저장된 데이터와 비교하여 검색합니다. 가장 간단하게 FAISS의 simulaity_search를 이용하여 입력받은 문장과 가장 유사도가 높은 문단을 검색합니다. 

벡터 검색이 일반 키워드 검색과 다른 점은 질의문과 가장 유사한 문장을 찾는다는데 있습니다. 때문에 질의문이 문장이 아닌 단어 또는 키워드일 경우 오히려 정확한 답변을 하지 못하는 경우가 있습니다. 또한 앞에서도 이야기했듯, 저장된 텍스트가 너무 긴 문단 또는 너무 짧은 문단으로 저장된 경우에도 검색 품질이 떨어질 수 있습니다.

vector_store = FAISS.load_local("./store", embeddings=self.embeddings)
       
query_result = vector_store.similarity_search(query, k=3)

similarity_search의 기본 k값(결과 개수)은 4이지만, 간혹 token제한으로 오류가 발생하는 경우가 있어 안전하게 k=3으로 설정했습니다.

LLM을 통해 답변 생성

벡터 스토어에서 검색한 결괏값과 사용자의 질의문을 지시 프롬프트와 함께 LLM에 전달하여 LLM으로부터 답변을 받아옵니다. 가급적 비용이 발생하는 서비스를 제외하고 구현하고 싶었지만, 현재는 Langchain을 이용하여 한국어 답변을 얻어낼 수 있는 LLM이 OpenAI 외에는 대안이 별로 없어서 어쩔 수 없이 OpenAI를 이용하였습니다. 

    llm = OpenAI()

    chain = load_qa_chain(llm, chain_type="stuff")
    
    answer = chain.run(input_documents=query_result, question=query)

프롬프트도 변경하지 않았고, LLM의 기본 chain을 이용하여 처리하였습니다. 향후 프롬프트에 대한 연구를 좀 더 해 본다면 직접 프롬프트를 수정하여 더 좋은 답변을 받을 수 있도록 수정도 가능할 것입니다.

참고로 Langchain은 LLama-cpp 기반 언어모델을 local로 동작시킬 수 있는 라이브러리(LLama-cpp-python)도 제공하기 때문에 LLama기반 언어모델도 적용하여 답변을 얻어낼 수 있습니다. 제대로 된 한국어 답변을 내놓은 것도 아니고, GPU 없이 CPU만으로는 답변 시간도 오래 걸리는 문제가 있습니다. 

    callback_manager = CallbackManager([StreamingStdOutCallbackHandler()])
    llm = LlamaCpp(
      model_path="./models/WizardLM-7B-uncensored.ggmlv3.q4_0.bin",
      max_tokens=1024, 
      n_batch=512, 
      temperature = 0.1, 
      verbose=True, 
      n_ctx = 2048,
      n_threads = 12,
      callback_manager=callback_manager
    )

    chain = load_qa_chain(llm, chain_type="stuff")

GPU없이 랩탑CPU에서 LLM 실행은 무리가 있긴 합니다.

약간 신기한 건, LLaMA기반 모델이 한국어도 답변을 내놓지는 못하지만, 한국어 질의에 대해서는 어느 정도 맥락을 이해하고 답변했다는 것입니다. 오픈소스 언어모델이 좀 더 빨리 발전하기를 기대합니다.

향후 개선하고 싶은 점

'나만의 지식검색 챗봇'이라고 거창한 제목을 달았지만,  아직은 간단하게 대충 만든 프로그램이고, 몇 가지 개선하고 싶은 점이 있습니다.

1. 네이버 블로그와 같은 몇몇 사이트들은 일반적인 방식으로 크롤링되지 못하게 되어있어 데이터 수집이 어려운데, 다양한 웹사이트에서 데이터를 수집할 수 있도록 개선, 이와 관련하여 텍스트 데이터 전처리 방식 개선

2. 저장봇에 첨부문서를 업로드했을 때 첨부 문서의 내용을 저장하고 검색

3. 답변봇이 답변한 내용이 어떤 url로부터 찾아낸 답변인지 확인할 수 있는 방법

4. 현재는 벡터 스토어에 데이터 추가하거나 초기화하는 2가지 기능밖에 없는데, 특정 데이터를 업데이트하는 기능(벡터 DB 등을 사용하면 가능)

그밖에 여러 편의성을 위해 개선하고 싶은 점들이 있는데, 과연 언제 가능할지는 모르겠습니다...

정리:LangChain을 이용하여 비즈니스 애플리케이션을 만들 때 고려사항

Langchain을 이용한 어플리케이션 개발시 연구가 필요한 부분

본문 내용에서도 각각 강조했지만, Langchain을 이용하여 애플리케이션을 만들 때 몇 가지 연구가 필요한 부분이 있습니다. 

1. 데이터 수집과 전처리: 원문 데이터를 나중에 잘 검색될 수 있는 형태로 어떻게 수집하고 전처리해야 하는가?

2. 데이터 보관과 검색: 어떻게 데이터를 저장하고, 저장된 데이터 중 사용자의 질의에 가장 적합한 데이터를 검색할 것인가?

3. 프롬프트 엔지니어링: 검색한 데이터에서 LLM이 가장 적절한 답변을 만들 수 있는 프롬프트는 무엇인가, 또는 애플리케이션의 의도하는 답변을 생성할 수 있는 프롬프트는 무엇인가?

소감

간단한 프로그램이었지만, Langchain이라는 프레임워크와, LLM을 활용하기 위한 다양한 기술을에 대해 이해하고 고민할 수 있었습니다. 프레임워크를 활용하는 스킬도 중요하지만, 결과물이 어떤 모습이어야 하며 원하는 결과물을 위해 어떤 점들을 고민하고 연구해야 할지 깨닫게 되었습니다. 

나중에 또 다른 아이디어가 생기면, 다른 방식으로도 활용하고 공부하면 좋겠습니다.