2026년 4월 15일 수업은 두 파트로 나뉘었다. 앞부분은 day 6 코사인 유사도 챗봇 구조에 질문-답변 딕셔너리를 결합한 호텔 FAQ 챗봇이었다. Doc2Vec 방식과 비교해 데이터 규모가 성능에 어떤 영향을 주는지 확인했다. 뒷부분은 Word2Vec를 TensorFlow로 직접 구현해서 임베딩이 실제로 어떻게 학습되는지 확인했다.

1. 호텔 FAQ 챗봇: 질문-답변 딕셔너리 구조

day 6 코사인 유사도 챗봇과 달라진 점은 데이터 구조다.
day 6: 단일 문서에서 가장 유사한 문장을 찾아 그대로 반환
day 7: 질문 파일과 답변 파일을 따로 불러와 딕셔너리로 연결 → 질문과 유사한 FAQ를 찾으면 대응하는 답변을 반환

import nltk

# 질문 파일과 답변 파일을 각각 로드
sent_tokens     = nltk.sent_tokenize(raw_data)      # 질문 리스트
sent_tokens_ans = nltk.sent_tokenize(raw_data_ans)  # 답변 리스트

# 질문을 key, 답변을 value로 연결
res = dict(zip(sent_tokens, sent_tokens_ans))
# {'How much is the price?': 'Our standard room starts at $120 per night.', ...}

TF-IDF 코사인 유사도 응답 함수

def response(user_response):
    # 사용자 입력을 질문 목록 끝에 임시 추가
    sent_tokens.append(user_response)
    
    # 전체 질문(입력 포함)을 TF-IDF 벡터로 변환
    TfidfVec = TfidfVectorizer(tokenizer=LemNormalize, stop_words='english')
    tfidf = TfidfVec.fit_transform(sent_tokens)
    
    # 입력 벡터(마지막)와 나머지 질문들의 코사인 유사도
    vals = cosine_similarity(tfidf[-1], tfidf)
    idx  = vals.argsort()[0][-2]   # 가장 유사한 FAQ 질문 인덱스
    
    flat = vals.flatten()
    flat.sort()
    req_tfidf = flat[-2]  # 두 번째로 높은 유사도 값 (자기 자신 제외)
    
    sent_tokens.remove(user_response)
    
    if req_tfidf == 0:
        return "I am sorry! I don't understand you", req_tfidf
    else:
        # 유사한 FAQ 질문에 대응하는 답변을 딕셔너리에서 조회
        bot_response = res[sent_tokens[idx]]
        return bot_response, req_tfidf

day 6과의 차이: sent_tokens[idx]를 그대로 반환하는 게 아니라, 이를 키로 res 딕셔너리를 조회해 대응하는 답변을 가져온다.

LemNormalize는 소문자 변환, 구두점 제거, 단어 토큰화, 품사 기반 표제어 추출을 처리한다.

def get_wordnet_pos(tag):
    """NLTK POS 태그를 WordNet 형식으로 변환 — lemmatize 정확도를 높이기 위함"""
    if tag.startswith('J'): return 'a'   # 형용사
    elif tag.startswith('V'): return 'v' # 동사
    elif tag.startswith('R'): return 'r' # 부사
    else: return 'n'                     # 명사(기본값)

def LemTokens(tokens):
    pos_tags = pos_tag(tokens)
    return [lemmer.lemmatize(word, get_wordnet_pos(tag)) for word, tag in pos_tags]

2. Doc2Vec 기반 챗봇

이게 뭔지: Word2Vec을 단어 단위에서 문서(문장) 단위로 확장한 모델. 각 문서 전체를 하나의 벡터로 표현한다.
왜 시도하는가: TF-IDF는 단어 빈도 기반이라 의미적 유사성을 잡기 어렵다. Doc2Vec은 문맥을 학습하므로 더 정확할 것이라는 기대였다.

from gensim.models.doc2vec import Doc2Vec, TaggedDocument
from nltk.tokenize import word_tokenize

# 각 문장에 고유 태그 부여 — Doc2Vec 학습의 핵심
# TaggedDocument(words=토큰 리스트, tags=[식별자])
tagged_data = [
    TaggedDocument(words=word_tokenize(_d.lower()), tags=[str(i)])
    for i, _d in enumerate(sent_tokens)
]

# 학습
model = Doc2Vec(vector_size=20, alpha=0.025, min_alpha=0.00025, min_count=1, dm=1)
# dm=1: CBOW 방식 — 주변 단어로 중심 단어(문서) 예측
model.build_vocab(tagged_data)

for epoch in range(100):
    model.train(tagged_data, total_examples=model.corpus_count, epochs=100)
    model.alpha    -= 0.0002  # 에폭마다 학습률 감소
    model.min_alpha = model.alpha

Doc2Vec은 이론적으로는 TF-IDF보다 더 의미 기반으로 문장을 비교할 수 있어 보인다. 하지만 실제 성능은 데이터 양에 크게 좌우된다. FAQ 문장이 수십 개 수준이면, 문서 벡터를 안정적으로 학습하기에는 정보가 부족할 수 있다.

응답 함수:

def doc2vec_response(user_response):
    # 입력 문장을 벡터로 추론
    user_tokens = word_tokenize(user_response.lower())
    v1 = model.infer_vector(user_tokens)
    
    # 가장 유사한 문서 태그 검색
    similar_doc = model.dv.most_similar(positive=[v1], topn=1)
    
    # 태그(인덱스)를 이용해 답변 반환
    return sent_tokens_ans[int(similar_doc[0][0])], similar_doc[0][1]

Doc2Vec의 한계

실제 테스트에서 Doc2Vec는 TF-IDF보다 부정확했다. “How much is the price?”를 입력했을 때 인덱스 31 문장(why do the costs vary from day to day?)을 찾아 관련 답변을 반환했지만, TF-IDF는 더 직접적인 질문을 찾아냈다.

이유: Doc2Vec는 대규모 코퍼스에서 의미 있는 벡터를 학습한다. 47개 문장짜리 FAQ 데이터는 너무 작다. TF-IDF는 단어 빈도 기반이라 소규모 데이터에서도 잘 작동한다.

즉, “더 최신 기법”이 항상 더 좋은 것은 아니다. 데이터가 적을 때는 학습형 모델보다 단순한 통계 기반 방법이 오히려 더 안정적일 수 있다는 점을 확인한 셈이다.

TF-IDF

필요한 데이터: 적어도 가능

소규모 FAQ 정확도: 높음

의미 유사성 파악: 제한적

Doc2Vec

필요한 데이터: 대규모 코퍼스 필요

소규모 FAQ 정확도: 낮음

의미 유사성 파악: 학습 시 가능

추가로, gensim 4.3.2와 최신 scipy 사이에 scipy.linalgtriu 함수 경로가 변경되어 ImportError가 발생했다. 라이브러리 버전 호환성 문제는 실습 중 흔히 만나는 현실이다.

3. Word2Vec 직접 구현

gensim 한 줄로 끝나는 작업을 TensorFlow로 직접 구현했다. 목적은 “임베딩이 학습된다”는 말의 실제 의미를 코드로 확인하는 것이었다.

데이터 준비

왕, 왕비, 왕자 등 성별·역할이 연관된 10개 문장을 코퍼스로 사용했다.

corpus = [
    'king is a strong man',
    'queen is a wise woman',
    'boy is a young man',
    'girl is a young woman',
    'prince is a young king',
    'princess is a young queen',
    'man is strong',
    'woman is pretty',
    'prince is a boy will be king',
    'princess is a girl will be queen'
]

is, a, will, be 같은 불용어 제거 후 남은 단어로 어휘 집합을 만들었다.

word2int = {word: i for i, word in enumerate(words)}
# {'king': 0, 'strong': 1, 'man': 2, 'queen': 3, ...}

Skip-Gram 학습 데이터 생성

Skip-Gram의 원리: 중심 단어로 주변 단어들을 예측한다. window size 2면 앞뒤 2개 단어씩이 타겟이 된다.

WINDOW_SIZE = 2
data = []
for sentence in sentences:
    for idx, word in enumerate(sentence):
        for neighbor in sentence[max(idx - WINDOW_SIZE, 0) : min(idx + WINDOW_SIZE, len(sentence)) + 1]:
            if neighbor != word:
                data.append([word, neighbor])
# 결과: [['king', 'strong'], ['king', 'man'], ['strong', 'king'], ...]

각 단어는 원핫 벡터로 변환해서 모델 입력으로 사용한다.

신경망 구조

임베딩 차원을 2로 설정한 이유: 2차원이면 학습 결과를 그래프로 바로 시각화할 수 있다.

입력: 원핫 벡터 (15차원, 어휘 크기)
   ↓ W1 (15×2) — 이 가중치 행렬이 곧 임베딩
은닉층: 2차원 벡터 (저차원 임베딩)
   ↓ W2 (2×15) + softmax
출력: 어휘 크기만큼의 확률 분포 (타겟 단어 예측)
import tensorflow as tf

W1 = tf.Variable(tf.random.normal([ONE_HOT_DIM, EMBEDDING_DIM]))  # 15×2
b1 = tf.Variable(tf.random.normal([1]))
W2 = tf.Variable(tf.random.normal([EMBEDDING_DIM, ONE_HOT_DIM]))  # 2×15
b2 = tf.Variable(tf.random.normal([1]))

optimizer = tf.optimizers.SGD(learning_rate=0.05)

for i in range(20000):
    with tf.GradientTape() as tape:
        hidden_layer = tf.add(tf.matmul(X_train, W1), b1)
        prediction   = tf.nn.softmax(tf.add(tf.matmul(hidden_layer, W2), b2))
        # 크로스 엔트로피: 예측 확률 분포와 실제 단어(원핫) 사이의 차이
        loss_value   = tf.reduce_mean(-tf.reduce_sum(Y_train * tf.math.log(prediction), axis=[1]))
    
    grads = tape.gradient(loss_value, [W1, b1, W2, b2])
    optimizer.apply_gradients(zip(grads, [W1, b1, W2, b2]))

손실: iteration 0에서 약 7.16 → 18000에서 약 2.08

이 과정을 직접 구현해 보면서 확인한 핵심은 W1이 그냥 중간 가중치가 아니라, 학습이 끝난 뒤 각 단어의 임베딩 벡터 자체가 된다는 점이다. 보통 라이브러리 한 줄로 쓰면 보이지 않는 부분이지만, 직접 행렬 곱과 softmax, 손실, 역전파를 거치고 나면 임베딩이 어떻게 만들어지는지 구조가 명확해진다.

임베딩 결과

학습된 W1을 각 단어의 2D 좌표로 사용해 시각화했다.

boy / girl
(0.04, -1.85) / (0.04, -1.85)
man / woman
(2.29, -2.54) / (3.50, -3.06)
king / queen
(1.33, -2.04) / (1.74, -2.05)
prince / princess
(-1.90, -3.52) / (-1.83, -3.46)

boy와 girl이 거의 같은 위치에 놓이고, king/queen이 별도 군집을 형성한다. 10개 문장, 15개 단어짜리 소규모 코퍼스임에도 의미 유사성이 공간 거리에 반영됐다.

물론 이 결과는 작은 예제이기 때문에 완전한 의미 표현이라고 보기는 어렵다. 그래도 “자주 비슷한 자리에서 나오는 단어들이 가까워진다”는 Word2Vec의 핵심 아이디어를 직관적으로 확인하기에는 충분했다.

Day 7에서 실제로 연결된 세 가지 포인트

  1. TF-IDF 기반 FAQ 챗봇은 적은 데이터에서 강했다.
  2. Doc2Vec은 의미 기반 접근이지만 데이터가 적으면 불안정했다.
  3. Word2Vec 직접 구현을 통해 임베딩이 어떻게 학습되는지 내부 구조를 확인했다.

즉, 이번 수업은 “어떤 챗봇이 더 좋다”를 결론내리는 날이라기보다, 검색 기반 방식과 학습 기반 의미 표현의 차이를 몸으로 확인하는 날에 가까웠다.


핵심 정리

TF-IDF + 코사인 유사도

방식: 단어 빈도 기반 유사 질문 검색

적합한 규모: 소규모 (수십~수백 문장)

Doc2Vec

방식: 문서 벡터 의미 유사도 검색

적합한 규모: 대규모 코퍼스 필요

Word2Vec 직접 구현에서 확인한 것: gensim Word2Vec 한 줄이 내부적으로는 원핫 인코딩 → 행렬 곱 → 소프트맥스 → 역전파 전체를 처리한다. W1 행렬이 바로 임베딩 벡터들이다.

Community

Comments

0 comments

Comments appear immediately. Use report if something needs review.

No comments yet.