Tech for good

[처음 배우는 딥러닝 챗봇] Ch8. 챗봇 엔진 만들기 본문

IT/Data Science

[처음 배우는 딥러닝 챗봇] Ch8. 챗봇 엔진 만들기

Diana Kang 2021. 7. 15. 21:48

출처: 조경래 , 『처음 배우는 딥러닝 챗봇, 한빛미디어(2020), p,235-272.


목차

  • 8. 챗봇 엔진 만들기
    • 8.1. 챗봇 엔진 소개
    • 8.2. 챗봇 엔진 구조 
    • 8.3. 전처리 과정
    • 8.4. 단어 사전 구축 및 시퀀스 생성
    • 8.5. 의도 분류 모델
      • 8.5.1. 의도 분류 모델 학습
      • 8.5.2. 의도 분류 모듈 생성
    • 8.6. 개체명 인식 모델 학습
      • 8.6.1. 개체명 인식 모델 학습
      • 8.6.2. 개체명 인식 모듈 생성
      • 8.6. 개체명 인식 모델
    • 8.7. 답변 검색
      • 8.7.1. 데이터베이스 제어 모듈 생성
      • 8.7.2. 답변 검색 모듈 생성

8. 챗봇 엔진 만들기

8.1. 챗봇 엔진 소개

'챗봇 엔진'이란 챗봇에서 핵심 기능을 하는 모듈이며, 화자의 질문을 이해하고 알맞은 답변을 출력하는 역할을 한다. 챗봇을 구현하는 방법은 다양하며, 최근에는 음성 챗봇을 활용해 전화 상담까지 가능한 기술 수준에 도달해있다.
현재 국내외 업체들이 개발한 챗봇 엔진을 활용하는 봇 빌더가 많이 출시되어 있다. 그 중에서도 이 책에서는 카카오톡 및 네이버톡톡에서 제공하는 봇 빌더 툴을 활용하여 챗봇을 쉽게 제작해보자.

8.2. 챗봇 엔진 구조

챗봇 엔진을 설계하기 전에 우리가 만들려는 챗봇의 목적과 어떤 도메인 지식을 가지는 챗봇을 만들 것이지 결정해야 한다. 그에 따라 챗봇 엔진 개발 방법론과 학습에 필요한 데이터셋이 달라지기 때문이다. 이번 장에서는 간단하게 음식 예약 및 주문을 도와주는 음식점 예약 주문에 특화된 챗봇 엔진을 만들어보자.

핵심 기능 설명
질문 의도 분류 화자의 질문 의도를 파악한다. 이는 해당 질문을 의도 분류 모델을 이용해 의도 클래스를 예측하는 문제이다.
개체명 인식 화자의 질문에서 단어 토큰별 개체명을 인식한다. 이는 단어 토큰에 맞는 개체명을 예측하는 문제이다.
핵심 키워드 추출 화자의 질문 의미에서 핵심이 될 만한 단어 토큰을 추출한다. 형태소 분석기를 이용해 핵심 키워드가 되는 명사나 동사를 추출한다.
답변 검색 해당 질문의 의도, 개체명, 핵심 키워드 등을 기반으로 답변을 학습 DB에서 검색한다.
소켓 서버 다양한 종류(카카오톡, 네이버톡톡)의 챗봇 클라이언트에서 요청하는 질문을 처리하기 위해 소켓 서버 프로그램 역할을 한다. 이 책에서는 챗봇 엔진 서버 프로그램이라고 부른다.


다음은 우리가 목표하고 있는 챗봇 엔진의 처리 과정을 나타낸 그림이다.

화자의 질의 문장이 입력되면 챗봇 엔진은 제일 먼저 전처리를 진행한다. 형태소 분석기를 이용해 단어 토큰(키워드)을 추출한 뒤 명사나 동사 등 필요한 품사만 남기고 불용어는 제거한다. 그 다음 의도 분석과 개체명 인식을 완료한 후 결과값을 이용해 적절한 답변을 학습 DB에서 검색화자에게 답변을 출력한다.
해당 챗봇 엔진에는 자연어 처리를 위해 2가지 딥러닝 모델(의도 분석, 개체명 인식)을 사용하고 있다. 해당 도메인 지식에 맞는 딥러닝 모델 학습 데이터셋을 많이 보유하고 있다면 성능이 우수한 챗봇 엔진 개발에 도움이 된다.
하지만 품질 좋은 데이터셋을 구하는건 쉽지 않다. 따라서 부족한 모델 성능을 보완하기 위해 룰 베이스 + 딥러닝 모델을 같이 사용하여야 한다. 모든 상황에 대해 규칙을 설정하기란 불가능에 가깝지만 자주 반복되고 빈도가 높은 문제에 대해서는 작은 학습 데이터셋만 가지고도 딥러닝 모델보다 품질이 우수한 결과를 보여준다.

8.3. 전처리 과정

우리가 만드는 챗봇 엔진의 전처리 과정은 형태소 분석기로 토크나이징 작업을 한 후, 문장 해석에 의미 있는 정보만 남긴 다음 나머지 불용어들은 제거한다.

8.4. 단어 사전 구축 및 시퀀스 생성

챗봇 엔진에서 의도 분류 및 개체명 인식 모델의 학습을 하려면 먼저 단어 사전을 구축해야 한다. 그 다음, 입력한 문장을 단어 인덱스 사전을 이용해 단어 시퀀스 벡터로 변환하는 과정을 거친다.

8.5. 의도 분류 모델

챗봇 엔진에 화자의 질의가 입력되었을 때 전처리 과정을 거친 후 해당 문장의 의도를 분류한다. 문장을 의도 클래스별로 분류하기 위해서는 CNN 모델을 사용한다. 여기서는 다양한 의도를 분류하기엔 학습 데이터 수가 한정적이기 때문에 인사, 욕설, 주문, 예약, 기타 5가지 의도로만 분류할 것이다.

의도명 분류 클래스 설명
인사 0 텍스트가 인사말인 경우
욕설 1 텍스트가 욕설인 경우
주문 2 텍스트가 주문 관련 내용인 경우
예약 3 텍스트가 예약 관련 내용인 경우
기타 4 어떤 의도에도 포함되지 않는 경우

 

8.5.1. 의도 분류 모델 학습

챗봇 엔진의 의도 분류 모듈을 만들기 전에 해당 모델의 설계 및 학습을 진행한다.

1. 데이터 읽어오기
2. 단어 시퀀스 생성
3. 단어 인덱스 시퀀스 벡터 생성

  • 앞에서 생성한 단어 시퀀스 벡터의 크기를 동일하게 맞추기 위해 MAX_SEQ_LEN 크기만큼 시퀀스 벡터를 패딩 처리

4. 학습용, 검증용, 테스트용 데이터셋 생성(7:2:1)
5. CNN 모델 정의 (e.g. 하이퍼파라미터 설정, 모델 정의, 3,4,5-gram 이후 합치기)
6. 케라스 모델 생성
7. 모델 학습
8. 모델 평가(테스트 데이터셋 이용)
9. 모델 저장

8.5.2. 의도 분류 모듈 생성

앞서 학습한 의도 분류 모델 파일을 활용하여 텍스트의 의도 클래스를 예측하는 의도 분류 모듈을 생성해보고 테스트해보자.
1. 챗봇 엔진의 의도 분류 모듈을 생성한다.

#  챗봇 엔진 의도 분류 모듈 생성
import tensorflow as tf
from tensorflow.keras.models import Model, load_model
from tensorflow.keras import preprocessing



## 의도 분류 모듈 생성
class IntentModel:
    def __init__(self, model_name, proprocess):
        ## 의도 클래스별 레이블
        self.labels = {0:"인사", 1:"욕설", 2:"주문", 3:"예약", 4:"기타"}
        ## 의도 분류 모델 불러오기
        self.model = load_model(model_name)
        ## 챗봇 Proprocess 객체
        self.p = proprocess



    # 의도 클래스 예측
    def predict_class(self,query):
        ## 형태소 분석
        pos = self.p.pos(query)
        
        # 문장 내 키워드 추출 및 불용어 제거 후 인덱스로 전환
        keywords= self.p.get_keywords(pos, without_tag=True)
        sequences = [self.p.get_wordix_sequence(keywords)]
        
        # 단어 시퀀스 벡터 크기
        from config.GlobalParams import MAX_SEQ_LEN
        
        # 패딩 처리
        padded_seqs = preprocessing.sequence.pad_sequences(sequences, maxlen=MAX_SEQ_LEN, padding='post')
        
        # 모델을 활용한 예측, 예측된 값 중 가장 큰 값의 인덱스 반환
        predict = self.model.predict(padded_seqs)
        predict_class = tf.math.argmax(predict, axis=1)

        return predict_class.numpy()[0]

2. IntentModel 클래스를 테스트한다.

# IntentModel 클래스 테스트
from utils.Preprocess import Preprocess
from models.intent.IntentModel import IntentModel

p = Preprocess(word2index_dic='/Users/dianakang/NLP/train_tools/dict/chatbot_dict.bin',
               userdic='/Users/dianakang/NLP/utils/user_dic.tsv')

intent = IntentModel(model_name='/Users/dianakang/NLP/models/intent/intent_model.h5', proprocess=p)
query = "오늘 탕수육 주문 가능한가요?"
predict = intent.predict_class(query)
predict_label = intent.labels[predict]

print(query)
print("의도 예측 클래스 : ", predict)
print("의도 예측 레이블 : ", predict_label)

 

8.6. 개체명 인식 모델 학습

챗봇 엔진에 입력된 문장의 의도가 분류된 후 문장 내 개체명 인식(Named Entity Recognition)을 진행한다. 책에서는 개체명 인식을 위해 양방향 LSTM 모델을 사용하였다. 이번 모델에서 인식 가능한 주요 개체명은 다음과 같다.

개체명 설명
B_FOOD 음식
B_DT, B_TI 날짜, 시간(학습 데이터의 영향으로 날짜와 시간을 혼용해서 사용한다.)
B_PS 사람
B_OG 조직, 회사
B_LC 지역

 

8.6.1. 개체명 인식 모델 학습

1. 개체 인식 모델 학습을 위해 말뭉치 데이터를 불러온다.
2. 필요한 단어와 *BIO태그만 불러와 학습용 데이터셋을 생성한다.

(*BIO태그: https://wikidocs.net/24682)

3. 토크나이저 객체를 생성한다.
4. 단어와 태그 사전의 크기를 정의한다.
5. 학습용 단어 시퀀스를 생성한다.
6. 모델의 입출력 벡터 크기를 동일하게 맞추기 위해 시퀀스 패딩 작업을 한다.
7. 학습데이터와 테스트데이터를 분리한다.
8. 학습과 테스트용 출력 데이터를 태그 사전 크기에 맞게 원-핫 인코딩한다.
9. 모델을 정의, 평가, 저장한다.
10. 테스트 데이터셋으로 모델을 예측 및 평가한다.

8.6.2. 개체명 인식 모듈 생성

이제 챗봇 엔진의 개체명 인식 모듈을 만들 차례이다. 이 모듈은 앞서 학습한 개체명 인식 모델 파일을 활용해 입력한 문장 내부의 개체명을 인식하는 기능을 가지고 있다.

 

#  개체명 인식 모듈 생성
import tensorflow as tf
import numpy as np
from tensorflow.keras.models import Model, load_model
from tensorflow.keras import preprocessing

# 개체명 인식 모델 모듈
class NerModel:
    def __init__(self, model_name, proprocess):

        # BIO 태그 클래스별 레이블
        self.index_to_ner = {1: '0', 2:'B_DT', 3:'B_FOOD', 4: 'I', 5:'B_OG', 6:'B_PS',
                             7: 'B_LC', 8:'NNP', 9:'B_TI', 0:'PAD'}

        # 의도 분류 모델 불러오기
        self.model = load_model(model_name)

        # 챗봇 Preprocess 객체
        self.p = proprocess

    # 개체명 클래스 예측 - 질문(query)를 전달받는다.
    def predict(self, query):
        ## 형태소 분석 - 문장의 단어를 나눠 태깅 처리한다.
        pos = self.p.pos(query)

        # 각 단어마다의 품사 정보 가져오기 (불용어 제거)
        keywords = self.p.get_keywords(pos, without_tag=True)
        ## 가져온 단어에 대하여 인덱싱
        sequences = [self.p.get_wordidx_sequence(keywords)]

        # 패딩 처리 - 기존 단어 외의 나머지 40이하의 범위는 전부 0으로 처리
        max_len = 40
        padded_seqs = preprocessing.sequence.pad_sequences(sequences,
                                                           padding='post', value=0, maxlen=max_len)

        # 키워드별 개체명 예측
        predict = self.model.predict(np.array([padded_seqs[0]]))
        predict_class = tf.math.argmax(predict, axis=-1)   # argmax()를 통해 가장 높은 값의 인덱스를 반환

        # 예측된 인덱스를 index_to_ner을 통해 BIO형식으로 바꿔준다.
        tags = [self.index_to_ner[i] for i in predict_class.numpy()[0]]
        # 키워드와 예측된 태그를 압축하여 리스트화 시킨 후 반환
        return list(zip(keywords, tags))

    def predict_tags(self, query):
        # 형태소 분석
        pos = self.p.pos(query)

        # 문장 내 키워드 추출(불용어 제거)
        keywords = self.p.get_keywords(pos, without_tag=True)
        sequences = [self.p.get_wordidx_sequence(keywords)]

        # 패딩 처리
        max_len = 40
        padded_seqs = preprocessing.sequence.pad_sequences(sequences,
                                                           padding='post', value=0, maxlen=max_len)

        predict = self.model.predict(np.array([padded_seqs[0]]))
        predict_class = tf.math.argmax(predict, axis=-1)

        tags = []
        for tag_idx in predict_class.numpy()[0]:
            if tag_idx == 1: continue       # 만약 인덱스 태그가 1('O')이라면 건너뛰고,
            tags.append(self.index_to_ner[tag_idx]) ## 예측 결과 태그를 tags에 따로 저장

        if len(tags) == 0:  ## 태그의 길이가 0이라면 None을 반환  (*여기 줄맞춤 불확실)
            return None

        return tags
# NerModel 클래스 테스트

from utils.Preprocess import Preprocess
from models.ner.NerModel import NerModel

p = Preprocess(word2index_dic="/Users/dianakang/NLP/train_tools/dict/chatbot_dict.bin",
               userdic="/Users/dianakang/NLP/utils/user_dic.tsv")

ner = NerModel(model_name="/Users/dianakang/NLP/models/ner/ner_model.h5", proprocess=p)

query = "오늘 오전 13시 3분에 탕수육 주문하고 싶어요"

predicts = ner.predict(query)

print(predicts)

 

 

8.7. 답변 검색

지금까지 화자로부터 입력된 문장을 전처리, 의도분류, 개체명 인식하는 과정을 알아보았다. 이제 이 과정들을 거쳐 해석된 데이터를 기반으로 적절한 답변을 학습 DB로부터 검색하는 방법을 알아볼 것이다. 이 책에서는 단순한 수준의 SQL 구문을 이용해 룰베이스 기반으로 답변을 검색하는 방법을 소개한다.

이 책에서 다루는 챗봇 엔진은 입력되는 문장을 해석하기 위해 딥러닝 모델을 사용하고 있으며,
해석 결과를 기반으로 답변을 출력하는 것은 룰 베이스 기반으로 처리한다.

 

8.7.1. 데이터베이스 제어 모듈 생성

답변 검색 모듈을 만들기 전에 데이터베이스 제어를 쉽게 할 수 있는 모듈을 먼저 만들어보자.

import pymysql
import pymysql.cursors
import logging

class Database:
    '''
    데이터베이스 제어
    '''

    def __init__(self, host, user, password, db_name, charset='utf8'):
        self.host = host
        self.user = user
        self.password = password
        self.charset = charset
        self.db_name = db_name
        self.conn = None

    # DB 연결
    def connect(self):
        if self.conn != None:
            return

        self.conn = pymysql.connect(
            host=self.host,
            user=self.user,
            password=self.password,
            db=self.db_name,
            charset=self.charset
        )

    # DB 연결 닫기
    def close(self):
        if self.conn is None:
            return

        if not self.conn.open:
            self.conn = None
            return
        self.conn.close()
        self.conn = None

    # SQL 구문 실행
    def execute(self,sql):
        last_row_id = -1
        try:
            with self.conn.cursor() as cursor:
                cursor.execute(sql)
            self.conn.commit()
            last_row_id = cursor.lastrowid
            # logging.debug("execute last_row_id : %d", last_row_id)
        except Exception as ex:
            logging.error(ex)

        finally:
            return last_row_id

    # SELECT 구문 실행 후 단 1개의 데이터 ROW만 불러옴
    def select_one(self, sql):
        result = None

        try:
            with self.conn.cursor(pymysql.cursor.DictCursor) as cursor:
                cursor.execute(sql)
                result = cursor.fetchone()
        except Exception as ex:
            logging.error(ex)

        finally:
            return result

    # SELECT 구문 실행 후 전체 데이터 ROW 불러옴
    def select_all(self, sql):
        result = None

        try:
            with self.conn.cursor(pymysql.cursors.DictCursor) as cursor:
                cursor.execute(sql)
                result = cursor.fetchall()
        except Exception as ex:
            logging.error(ex)

        finally:
            return result

 

8.7.2. 답변 검색 모듈 생성

챗봇 엔진은 입력되는 문장은 전처리, 의도 분류, 개체명 인식 과정을 거쳐서 나온 자연어 해석 결과를 이용해 학습 DB에서 적절한 답변을 검색한다. 해석된 결과 항목에 따라 학습 DB에서 어떤 방식으로 답변을 검색할지 결정하는 일은 챗봇 엔진 설계자의 몫이다.
이 책에서는 단순하게 의도명과 개체명 2가지 항목만 가지고 답변을 검색해 화자에게 제공하도록 하겠다. 다음은 챗봇 엔진 답변 검색 과정을 나타낸 그림이다.

챗봇 엔진 답변 검색 과정

이제 답변 검색 모듈을 만들어볼 차례이다. 이 모듈은 답변 검색에 필요한 기능을 제공하는 클래스이다. 

# 챗봇 답변 검색 모듈
class FindAnswer:
    def __init__(self, db):
        self.db = db

    # 검색 쿼리 생성
    def _make_query(self, intent_name, ner_tags):
        sql = "select * from chatbot_train_data"
        if intent_name != None and ner_tags == None:
            sql = sql + "where intent= '{}'".format(intent_name)

        elif intent_name != None and ner_tags != None:
            where = 'where intent= "%s" ' % intent_name
            if (len(ner_tags) > 0):
                where += 'and ('
                for ne in ner_tags:
                    where += " ner like '%{}%' or ".format(ne)
                where = where[:-3] + ')'
            sql = sql + where

        # 동일한 답변이 2개 이상인 경우 랜덤으로 선택
        sql = sql + "order by rand() limit 1"
        return sql


    # 답변 검색
    def search(self, intent_name, ner_tags):
        # 의도명과 개체명으로 답변 검색
        sql = self._make_query(intent_name, ner_tags)
        answer = self.db.select_one(sql)

        # 검색되는 답변이 없으면 의도명만 검색
        if answer is None:
            sql = self._make_query(intent_name, None)
            answer = self.db.select_one(sql)

        return (answer['answer'], answer['answer_image'])

    # NER 태그를 실제 입력된 단어로 변환
    def tag_to_word(self, ner_predicts, answer):
        for word, tag in ner_predicts:

            # 변환해야 하는 태그가 있는 경우 추가
            if tag == 'B_FOOD':
                answer = answer.replace(tag, word)

        answer = answer.replace('{', '')
        answer = answer.replace('}', '')
        return answer

 

다음은 FindAnswer 클래스를 테스트하는 코드이다. 이 테스트 코드는 챗봇 엔진의 전체 동작 과정을 한 번에 보여주고 있다. 

즉, 챗봇 엔진의 동작 코드이다. 이 예제가 서버 환경에서 작동될 수 있도록 수정하면 챗봇 엔진 서버 프로그램이 된다.

# 챗봇 엔진 동작
from config.DatabaseConfig import *
from utils.Database import Database
from utils.Preprocess import Preprocess

# 전처리 객체 생성
p = Preprocess(word2index_dic = '/Users/dianakang/NLP/train_tools/dict/chatbot_dict.bin',
               userdic='/Users/dianakang/NLP/utils/user_dic.tsv')

# 질문/답변 학습 디비 연결 객체 생성
db = Database(
    host=DB_HOST, user=DB_USER, password=DB_PASSWORD, db_name=DB_NAME
)
db.connect()    # 디비 연결

# 원문
query = "오전에 탕수육 10개 주문합니다"

# 의도 파악
from model.intent.IntentModel import IntentModel
intent = IntentModel(model_name = '/Users/dianakang/NLP/models/intent/intent_model.h5', proprocess=p)
predict = intent.predict_class(query)
intent_name = intent.labels[predict]

# 개체명 인식
from models.ner.NerModel import NerModel
ner = NerModel(model_name='/Users/dianakang/NLP/models/ner/ner_model.h5', proprocess=p)
predicts = ner.predict(query)
ner_tags = ner.predict_tags(query)

print("질문 : ", query)
print("=" * 40)
print("의도 파악 : ", intent_name)
print("답변 검색에 필요한 NER 태그 : ", ner_tags)
print("=" * 40)

# 답변 검색
from utils.FindAnswer import FindAnswer

try:
    f = FindAnswer(db)
    answer_text, answer_image = f.search(intent_name, ner_tags)
    answer = f.tag_to_word(predicts, answer_text)
except:
    answer = "죄송해요, 무슨 말인지 모르겠어요."

print("답변 : ", answer)

db.close()  #  디비 연결 끊음