Split View: NLP & 텍스트 처리 완전 정복: BERT fine-tuning부터 RAG 시스템, 다국어 처리까지
NLP & 텍스트 처리 완전 정복: BERT fine-tuning부터 RAG 시스템, 다국어 처리까지
NLP & 텍스트 처리 완전 정복: BERT fine-tuning부터 RAG 시스템, 다국어 처리까지
자연어 처리(NLP)는 인간 언어를 컴퓨터가 이해하고 생성하도록 하는 핵심 AI 기술입니다. 이 가이드에서는 텍스트 전처리의 기초부터 BERT fine-tuning, RAG 파이프라인 구축, 다국어 처리까지 실전 코드와 함께 체계적으로 다룹니다.
목차
- 텍스트 전처리와 토큰화
- 서브워드 토큰화: BPE, WordPiece, SentencePiece
- 단어 임베딩: Word2Vec, FastText, GloVe
- 시퀀스 모델의 발전: RNN에서 Transformer까지
- BERT 계열 모델과 Fine-tuning
- 텍스트 태스크 실전
- RAG 시스템 구축
- 다국어 NLP와 한국어 특수성
- 퀴즈
1. 텍스트 전처리와 토큰화
1.1 한국어 형태소 분석 (KoNLPy, MeCab)
한국어는 교착어(agglutinative language)로, 어간에 다양한 접사가 결합하여 의미를 표현합니다. 따라서 영어의 단순 공백 기반 토큰화는 한국어에 효과적이지 않습니다. KoNLPy는 한국어 형태소 분석을 위한 대표적인 파이썬 라이브러리입니다.
from konlpy.tag import Okt, Mecab, Komoran
okt = Okt()
text = "자연어 처리는 인공지능의 핵심 분야입니다."
# 형태소 분석
morphs = okt.morphs(text)
print("형태소:", morphs)
# ['자연어', '처리', '는', '인공지능', '의', '핵심', '분야', '입니다', '.']
# 품사 태깅
pos_tags = okt.pos(text)
print("품사 태깅:", pos_tags)
# [('자연어', 'Noun'), ('처리', 'Noun'), ('는', 'Josa'), ...]
# 명사 추출
nouns = okt.nouns(text)
print("명사:", nouns)
# ['자연어', '처리', '인공지능', '핵심', '분야']
# MeCab 사용 (더 빠르고 정확한 분석)
# mecab = Mecab()
# mecab_morphs = mecab.morphs(text)
MeCab은 일본어 형태소 분석기를 한국어에 맞게 포팅한 것으로, 속도와 정확도 면에서 뛰어납니다. 대규모 데이터 처리 시에는 MeCab을 권장합니다.
# KoNLPy를 활용한 한국어 텍스트 전처리 파이프라인
import re
from konlpy.tag import Okt
def preprocess_korean(text: str) -> list[str]:
"""한국어 텍스트 전처리 파이프라인"""
# 특수문자 제거 (한글, 영문, 숫자만 유지)
text = re.sub(r'[^가-힣a-zA-Z0-9\s]', '', text)
# 불필요한 공백 제거
text = re.sub(r'\s+', ' ', text).strip()
okt = Okt()
# 명사와 동사 형용사만 추출
tokens = [
word for word, pos in okt.pos(text)
if pos in ['Noun', 'Verb', 'Adjective'] and len(word) > 1
]
return tokens
sample = "자연어 처리 기술이 빠르게 발전하고 있습니다!"
print(preprocess_korean(sample))
1.2 불용어 처리와 정규화
# 한국어 불용어 처리
KOREAN_STOPWORDS = [
'이', '가', '을', '를', '은', '는', '의', '에', '에서',
'로', '으로', '와', '과', '도', '만', '까지', '부터'
]
def remove_stopwords(tokens: list[str], stopwords: list[str]) -> list[str]:
return [token for token in tokens if token not in stopwords]
# 텍스트 정규화
def normalize_text(text: str) -> str:
# 반복 문자 제거: 'ㅋㅋㅋㅋ' -> 'ㅋㅋ'
text = re.sub(r'(.)\1{2,}', r'\1\1', text)
# 영어 소문자 변환
text = text.lower()
return text
2. 서브워드 토큰화: BPE, WordPiece, SentencePiece
2.1 BPE (Byte Pair Encoding)
BPE는 자주 등장하는 문자 쌍을 반복적으로 병합하여 어휘를 구성합니다. OOV(Out-of-Vocabulary) 문제를 효과적으로 해결하며 GPT 계열 모델에서 사용됩니다.
from tokenizers import Tokenizer, trainers, pre_tokenizers, models, decoders
# BPE 토크나이저 학습
tokenizer = Tokenizer(models.BPE())
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=True)
tokenizer.decoder = decoders.ByteLevel()
trainer = trainers.BpeTrainer(
vocab_size=30000,
min_frequency=2,
special_tokens=["[UNK]", "[PAD]", "[BOS]", "[EOS]"]
)
# 학습 데이터 파일 경로 목록
files = ["train_corpus.txt"]
tokenizer.train(files, trainer)
# 토큰화 예시
encoding = tokenizer.encode("자연어 처리는 fascinating합니다!")
print("토큰:", encoding.tokens)
print("ID:", encoding.ids)
# 저장 및 로드
tokenizer.save("bpe_tokenizer.json")
loaded_tokenizer = Tokenizer.from_file("bpe_tokenizer.json")
2.2 WordPiece vs SentencePiece
WordPiece는 BERT에서 사용하는 서브워드 토큰화 방식으로, 서브워드에 ## 접두사를 붙여 연속성을 표현합니다. SentencePiece는 언어에 독립적인 방식으로 언어 경계 없이 원시 텍스트를 처리합니다.
import sentencepiece as spm
# SentencePiece 모델 학습
spm.SentencePieceTrainer.train(
input='corpus.txt',
model_prefix='sp_model',
vocab_size=32000,
character_coverage=0.9995, # 한국어/일본어는 높게 설정
model_type='bpe', # 또는 'unigram'
pad_id=0,
unk_id=1,
bos_id=2,
eos_id=3
)
sp = spm.SentencePieceProcessor()
sp.load('sp_model.model')
text = "자연어 처리는 NLP의 핵심입니다."
tokens = sp.encode_as_pieces(text)
ids = sp.encode_as_ids(text)
print("SentencePiece 토큰:", tokens)
# ['▁자연어', '▁처리', '는', '▁NLP', '의', '▁핵심', '입니다', '.']
| 방식 | 사용 모델 | 특징 |
|---|---|---|
| BPE | GPT-2, RoBERTa | 빈도 기반 병합, 결정적 |
| WordPiece | BERT, DistilBERT | 가능도 최대화, ## 접두사 |
| SentencePiece | T5, mBART, XLM-R | 언어 독립적, 공백 포함 처리 |
| Unigram LM | Albert, mBERT | 확률 모델 기반, 유연한 세분화 |
3. 단어 임베딩: Word2Vec, FastText, GloVe
3.1 Word2Vec
Word2Vec은 신경망 기반의 단어 임베딩 방법으로, CBOW(Continuous Bag of Words)와 Skip-gram 두 가지 아키텍처가 있습니다.
from gensim.models import Word2Vec
from konlpy.tag import Okt
# 한국어 코퍼스 준비
okt = Okt()
corpus = [
"자연어 처리는 인공지능의 핵심 분야입니다",
"BERT는 양방향 트랜스포머 모델입니다",
"텍스트 분류와 감성 분석에 BERT를 활용합니다",
]
# 형태소 분석 후 토큰화
tokenized_corpus = [okt.morphs(sentence) for sentence in corpus]
# Word2Vec 학습 (Skip-gram)
model = Word2Vec(
sentences=tokenized_corpus,
vector_size=100, # 임베딩 차원
window=5, # 컨텍스트 윈도우 크기
min_count=1,
sg=1, # 1: Skip-gram, 0: CBOW
workers=4,
epochs=100
)
# 유사 단어 검색
similar_words = model.wv.most_similar('자연어', topn=5)
print("유사 단어:", similar_words)
# 단어 벡터 추출
vector = model.wv['자연어']
print("벡터 shape:", vector.shape) # (100,)
# 단어 유추 (king - man + woman ≈ queen)
result = model.wv.most_similar(
positive=['왕', '여자'],
negative=['남자'],
topn=3
)
3.2 FastText와 GloVe
FastText는 단어를 문자 n-gram의 합으로 표현하여 OOV 단어도 처리합니다. GloVe는 전역 동시 출현 행렬을 활용한 임베딩입니다.
from gensim.models import FastText
import numpy as np
# FastText 학습
ft_model = FastText(
sentences=tokenized_corpus,
vector_size=100,
window=5,
min_count=1,
sg=1,
min_n=2, # 최소 n-gram 크기
max_n=6, # 최대 n-gram 크기
epochs=100
)
# OOV 단어도 처리 가능
# 학습 데이터에 없는 단어도 n-gram으로 벡터 생성
oov_vector = ft_model.wv['자연언어처리'] # 학습에 없어도 처리 가능
# 사전학습된 한국어 FastText 임베딩 로드
from gensim.models import KeyedVectors
# cc.ko.300.vec: Common Crawl 기반 한국어 FastText (fasttext.cc 제공)
# ko_fasttext = KeyedVectors.load_word2vec_format('cc.ko.300.vec')
3.3 Sentence Transformers를 활용한 문장 임베딩
단어 수준 임베딩의 한계를 극복한 문장 수준 임베딩입니다.
from sentence_transformers import SentenceTransformer
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
# 다국어 지원 모델 로드
model = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')
# 한국어 특화 모델
ko_model = SentenceTransformer('snunlp/KR-ELECTRA-discriminator')
sentences = [
"자연어 처리는 인공지능의 한 분야입니다.",
"NLP는 AI의 중요한 영역입니다.",
"오늘 날씨가 맑고 좋네요.",
]
# 문장 임베딩 생성
embeddings = model.encode(sentences)
print("임베딩 shape:", embeddings.shape) # (3, 768)
# 코사인 유사도 계산
sim_matrix = cosine_similarity(embeddings)
print("유사도 행렬:")
print(sim_matrix)
# 시맨틱 서치
query = "언어 모델이란 무엇인가?"
query_embedding = model.encode([query])
similarities = cosine_similarity(query_embedding, embeddings)[0]
for sent, score in zip(sentences, similarities):
print(f"유사도 {score:.4f}: {sent}")
4. 시퀀스 모델의 발전: RNN에서 Transformer까지
4.1 RNN에서 LSTM, GRU로의 발전
import torch
import torch.nn as nn
# LSTM 기반 텍스트 분류기
class LSTMClassifier(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(
embed_dim, hidden_dim,
num_layers=2,
batch_first=True,
dropout=0.3,
bidirectional=True
)
self.classifier = nn.Sequential(
nn.Linear(hidden_dim * 2, hidden_dim),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(hidden_dim, num_classes)
)
def forward(self, x):
embedded = self.embedding(x) # (batch, seq_len, embed_dim)
output, (hidden, cell) = self.lstm(embedded)
# 양방향 마지막 hidden state 연결
final_hidden = torch.cat([hidden[-2], hidden[-1]], dim=1)
return self.classifier(final_hidden)
# GRU는 LSTM보다 파라미터가 적고 학습이 빠름
class GRUClassifier(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.gru = nn.GRU(
embed_dim, hidden_dim,
num_layers=2,
batch_first=True,
bidirectional=True
)
self.fc = nn.Linear(hidden_dim * 2, num_classes)
def forward(self, x):
embedded = self.embedding(x)
_, hidden = self.gru(embedded)
out = torch.cat([hidden[-2], hidden[-1]], dim=1)
return self.fc(out)
4.2 Attention 메커니즘
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads):
super().__init__()
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
self.W_o = nn.Linear(d_model, d_model)
def scaled_dot_product_attention(self, Q, K, V, mask=None):
scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
attn_weights = torch.softmax(scores, dim=-1)
return torch.matmul(attn_weights, V), attn_weights
def forward(self, query, key, value, mask=None):
batch_size = query.size(0)
Q = self.W_q(query).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
K = self.W_k(key).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
V = self.W_v(value).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
attn_output, _ = self.scaled_dot_product_attention(Q, K, V, mask)
attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)
return self.W_o(attn_output)
5. BERT 계열 모델과 Fine-tuning
5.1 BERT 사전 학습 태스크
BERT(Bidirectional Encoder Representations from Transformers)는 두 가지 사전 학습 태스크를 사용합니다.
- MLM (Masked Language Model): 입력 토큰의 15%를
[MASK]로 치환하고 원래 토큰을 예측 - NSP (Next Sentence Prediction): 두 문장이 실제로 연속인지 여부를 이진 분류
5.2 KLUE-BERT를 활용한 한국어 텍스트 분류
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from transformers import TrainingArguments, Trainer
from datasets import Dataset
import torch
import numpy as np
from sklearn.metrics import accuracy_score, f1_score
# KLUE-BERT 로드
model_name = "klue/bert-base"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(
model_name,
num_labels=5 # 감성 분류: 매우부정/부정/중립/긍정/매우긍정
)
# 데이터 준비
train_data = {
"text": [
"이 영화 정말 재미있어요! 강력 추천합니다.",
"서비스가 너무 불친절하고 품질도 별로예요.",
"평범한 수준이네요. 특별한 점은 없었어요.",
],
"label": [4, 0, 2]
}
def tokenize_function(examples):
return tokenizer(
examples["text"],
max_length=128,
padding="max_length",
truncation=True
)
dataset = Dataset.from_dict(train_data)
tokenized_dataset = dataset.map(tokenize_function, batched=True)
# 학습 설정
training_args = TrainingArguments(
output_dir="./klue-bert-sentiment",
num_train_epochs=3,
per_device_train_batch_size=16,
per_device_eval_batch_size=32,
warmup_ratio=0.1,
weight_decay=0.01,
learning_rate=2e-5,
logging_dir="./logs",
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="f1",
fp16=True, # GPU 메모리 절약
)
def compute_metrics(eval_pred):
logits, labels = eval_pred
predictions = np.argmax(logits, axis=-1)
return {
"accuracy": accuracy_score(labels, predictions),
"f1": f1_score(labels, predictions, average="weighted")
}
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset,
eval_dataset=tokenized_dataset,
compute_metrics=compute_metrics,
)
trainer.train()
5.3 KoELECTRA NER Fine-tuning
from transformers import AutoTokenizer, AutoModelForTokenClassification
from transformers import pipeline
# KoELECTRA 기반 NER
model_name = "monologg/koelectra-base-finetuned-ner"
tokenizer = AutoTokenizer.from_pretrained(model_name)
ner_model = AutoModelForTokenClassification.from_pretrained(model_name)
# NER 파이프라인
ner_pipeline = pipeline(
"ner",
model=ner_model,
tokenizer=tokenizer,
aggregation_strategy="simple"
)
text = "삼성전자 이재용 회장이 서울 강남구에서 기자회견을 열었습니다."
entities = ner_pipeline(text)
for entity in entities:
print(f"엔티티: {entity['word']}, 유형: {entity['entity_group']}, "
f"점수: {entity['score']:.4f}")
# 엔티티: 삼성전자, 유형: ORG, 점수: 0.9987
# 엔티티: 이재용, 유형: PER, 점수: 0.9923
# 엔티티: 서울 강남구, 유형: LOC, 점수: 0.9856
5.4 RoBERTa, DeBERTa 비교
| 모델 | 주요 개선점 | 특징 |
|---|---|---|
| BERT | 기준 모델 | MLM + NSP |
| RoBERTa | NSP 제거, 더 많은 데이터 | 동적 마스킹, 큰 배치 |
| DeBERTa | 분리된 어텐션 | 위치와 콘텐츠 별도 처리 |
| KLUE-BERT | 한국어 특화 | 한국어 위키/뉴스로 학습 |
| KoELECTRA | ELECTRA 방식 | Generator-Discriminator 구조 |
6. 텍스트 태스크 실전
6.1 텍스트 분류와 감성 분석
# 다양한 NLP 태스크를 위한 HuggingFace 파이프라인
from transformers import pipeline
# 감성 분석
sentiment = pipeline(
"sentiment-analysis",
model="snunlp/KR-FinBert-SC"
)
result = sentiment("이 제품은 가격 대비 품질이 매우 훌륭합니다.")
print(result) # [{'label': 'positive', 'score': 0.9876}]
# 텍스트 요약
summarizer = pipeline(
"summarization",
model="gogamza/kobart-summarization"
)
long_text = """
자연어 처리(NLP)는 컴퓨터가 인간의 언어를 이해하고 처리할 수 있도록 하는
인공지능의 한 분야입니다. 최근 트랜스포머 기반 모델의 등장으로 NLP 분야는
급격한 발전을 이루었습니다. BERT, GPT, T5 등의 모델은 다양한 NLP 태스크에서
인간 수준에 근접한 성능을 보여주고 있습니다.
"""
summary = summarizer(long_text, max_length=50, min_length=20)
print(summary[0]['summary_text'])
# 질의응답 (QA)
qa_pipeline = pipeline(
"question-answering",
model="monologg/koelectra-base-v3-finetuned-korquad"
)
context = "서울은 대한민국의 수도이며 인구 약 950만 명이 거주하는 대도시입니다."
question = "서울의 인구는 얼마입니까?"
answer = qa_pipeline(question=question, context=context)
print(f"답변: {answer['answer']}, 점수: {answer['score']:.4f}")
6.2 기계번역 (Neural Machine Translation)
from transformers import MarianMTModel, MarianTokenizer
# Helsinki-NLP 기반 번역 모델
model_name = "Helsinki-NLP/opus-mt-ko-en"
tokenizer = MarianTokenizer.from_pretrained(model_name)
model = MarianMTModel.from_pretrained(model_name)
def translate(text: str, src_lang: str = "ko", tgt_lang: str = "en") -> str:
inputs = tokenizer(text, return_tensors="pt", padding=True)
translated = model.generate(**inputs, num_beams=5)
return tokenizer.decode(translated[0], skip_special_tokens=True)
korean_text = "인공지능 기술이 우리의 삶을 크게 변화시키고 있습니다."
english_translation = translate(korean_text)
print(f"번역: {english_translation}")
7. RAG 시스템 구축
7.1 RAG 파이프라인 개요
RAG(Retrieval-Augmented Generation)는 검색 기반으로 외부 지식을 활용하여 LLM의 답변 품질을 높이는 기법입니다. 크게 문서 청킹 → 임베딩 → 벡터 저장 → 검색 → 재순위화 → 생성 단계로 구성됩니다.
7.2 LangChain을 활용한 RAG 파이프라인
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import PGVector
from langchain_community.chat_models import ChatOllama
from langchain.chains import RetrievalQA
from langchain.schema import Document
# 1단계: 문서 청킹
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=["\n\n", "\n", "。", ".", " ", ""],
length_function=len,
)
documents = [
Document(
page_content="BERT는 2018년 Google이 발표한 양방향 트랜스포머 모델입니다...",
metadata={"source": "nlp_guide.txt", "page": 1}
)
]
chunks = text_splitter.split_documents(documents)
print(f"청크 수: {len(chunks)}")
# 2단계: 임베딩 모델 설정 (한국어 지원)
embeddings = HuggingFaceEmbeddings(
model_name="jhgan/ko-sroberta-multitask",
model_kwargs={"device": "cuda"},
encode_kwargs={"normalize_embeddings": True}
)
# 3단계: pgvector에 저장
CONNECTION_STRING = "postgresql+psycopg2://user:password@localhost:5432/ragdb"
COLLECTION_NAME = "nlp_documents"
vectorstore = PGVector.from_documents(
documents=chunks,
embedding=embeddings,
collection_name=COLLECTION_NAME,
connection_string=CONNECTION_STRING,
)
# 4단계: 검색기 설정
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5}
)
# 5단계: RAG 체인 구성
llm = ChatOllama(model="llama3.1:8b-instruct-q4_K_M")
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
return_source_documents=True,
verbose=True,
)
query = "BERT의 사전 학습 방법은 무엇인가요?"
result = qa_chain.invoke({"query": query})
print(result["result"])
7.3 Two-Stage Retrieval: Bi-encoder + Cross-encoder
from sentence_transformers import SentenceTransformer, CrossEncoder
import numpy as np
# Stage 1: Bi-encoder로 후보 문서 검색 (빠름)
bi_encoder = SentenceTransformer('jhgan/ko-sroberta-multitask')
# Stage 2: Cross-encoder로 재순위화 (정확함)
cross_encoder = CrossEncoder('cross-encoder/mmarco-mMiniLMv2-L12-H384-v1')
def two_stage_retrieval(query: str, corpus: list[str], top_k: int = 5) -> list[str]:
"""Bi-encoder + Cross-encoder 두 단계 검색"""
# Stage 1: 빠른 초기 검색 (top 20)
query_emb = bi_encoder.encode(query, convert_to_tensor=True)
corpus_emb = bi_encoder.encode(corpus, convert_to_tensor=True)
from sentence_transformers.util import cos_sim
scores = cos_sim(query_emb, corpus_emb)[0]
top_indices = scores.argsort(descending=True)[:20].tolist()
candidates = [corpus[i] for i in top_indices]
# Stage 2: Cross-encoder로 정밀 재순위화
pairs = [[query, doc] for doc in candidates]
rerank_scores = cross_encoder.predict(pairs)
# 최종 top_k 반환
ranked = sorted(
zip(candidates, rerank_scores),
key=lambda x: x[1],
reverse=True
)
return [doc for doc, _ in ranked[:top_k]]
# 사용 예시
corpus = [
"BERT는 마스크 언어 모델로 사전 학습됩니다.",
"GPT는 자동회귀 방식으로 텍스트를 생성합니다.",
"오늘 날씨는 맑고 기온은 20도입니다.",
]
results = two_stage_retrieval("BERT 학습 방법", corpus)
for doc in results:
print(f"- {doc}")
8. 다국어 NLP와 한국어 특수성
8.1 mBERT와 XLM-R
from transformers import AutoTokenizer, AutoModel
import torch
# XLM-RoBERTa: 100개 언어 지원
xlmr_tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
xlmr_model = AutoModel.from_pretrained("xlm-roberta-base")
# 다국어 문장 처리
sentences = {
"ko": "자연어 처리는 인공지능의 핵심입니다.",
"en": "NLP is the core of artificial intelligence.",
"ja": "自然言語処理はAIの核心です。",
}
for lang, sentence in sentences.items():
inputs = xlmr_tokenizer(
sentence,
return_tensors="pt",
max_length=128,
padding=True,
truncation=True
)
with torch.no_grad():
outputs = xlmr_model(**inputs)
# [CLS] 토큰의 임베딩을 문장 표현으로 사용
sentence_embedding = outputs.last_hidden_state[:, 0, :]
print(f"{lang}: embedding shape = {sentence_embedding.shape}")
8.2 CJK(한중일) 토큰화 특수성
CJK(Chinese, Japanese, Korean) 언어는 공백 기반 분리가 효과적이지 않아 특수한 처리가 필요합니다.
from transformers import BertTokenizer
# BERT 기본 토크나이저의 CJK 처리
tokenizer = BertTokenizer.from_pretrained("bert-base-multilingual-cased")
texts = {
"한국어": "자연어처리", # 교착어: 조사/어미가 어간에 붙음
"중국어": "自然语言处理", # 고립어: 각 글자가 의미 단위
"일본어": "自然言語処理", # 교착어 + 한자/히라가나/가타카나 혼용
"영어": "NaturalLanguageProcessing",
}
for lang, text in texts.items():
tokens = tokenizer.tokenize(text)
print(f"{lang}: {tokens}")
8.3 한국어 특화 전처리
# 한국어 교착어 특성을 고려한 전처리
# 어절 = 어간 + 접사 (무한 변형 가능)
# 예: '먹다', '먹어', '먹었다', '먹을', '먹히다', '먹이다'
from konlpy.tag import Mecab
def extract_korean_features(text: str) -> dict:
"""한국어 텍스트에서 언어적 특성 추출"""
mecab = Mecab()
pos_tags = mecab.pos(text)
features = {
"nouns": [], # 명사 (주요 정보 단위)
"verbs": [], # 동사 원형
"adjectives": [], # 형용사 원형
"entities": [] # 고유명사
}
for word, tag in pos_tags:
if tag.startswith('NN'): # 명사류
features["nouns"].append(word)
elif tag.startswith('VV'): # 동사
features["verbs"].append(word)
elif tag.startswith('VA'): # 형용사
features["adjectives"].append(word)
elif tag == 'NNP': # 고유명사
features["entities"].append(word)
return features
text = "삼성전자가 새로운 인공지능 반도체를 개발했다고 발표했습니다."
features = extract_korean_features(text)
print(features)
퀴즈
Q1. BPE 토큰화가 OOV 문제를 해결하는 방식은?
정답: BPE는 자주 등장하는 문자 쌍을 반복적으로 병합하여 서브워드 어휘를 구성합니다. 알 수 없는 단어도 학습된 서브워드 단위로 분해할 수 있어 OOV를 최소화합니다.
설명: 전통적인 단어 기반 어휘는 학습 시 보지 못한 단어를 [UNK]로 처리하는 OOV 문제가 있습니다. BPE는 문자 단위부터 시작하여 자주 등장하는 쌍을 병합하므로, 신조어나 복합어도 기존 서브워드의 조합으로 표현할 수 있습니다. 예를 들어 "한국어NLP"는 학습 어휘에 없어도 "한국어", "NLP" 또는 더 작은 단위로 분해됩니다.
Q2. BERT의 MLM과 NSP 사전 훈련 태스크는?
정답: MLM은 입력 토큰의 15%를 무작위로 마스킹하고 원래 토큰을 예측하는 태스크이며, NSP는 두 문장이 실제로 연속적인 문장인지 이진 분류하는 태스크입니다.
설명: MLM은 양방향 컨텍스트를 학습하게 하여 BERT가 문맥을 깊이 이해하도록 합니다. 마스킹된 15% 중 80%는 [MASK]로, 10%는 랜덤 토큰으로, 10%는 원래 토큰으로 유지됩니다. NSP는 문장 간 관계를 학습하게 하지만 이후 RoBERTa 등에서는 NSP가 성능 개선에 기여하지 않아 제거되었습니다.
Q3. RAG에서 bi-encoder와 cross-encoder를 함께 사용하는 two-stage retrieval의 이점은?
정답: Bi-encoder는 쿼리와 문서를 독립적으로 인코딩하여 빠른 ANN 검색을 수행하고, Cross-encoder는 쿼리-문서 쌍을 함께 처리하여 정밀한 관련성 점수를 계산합니다. 두 단계를 결합하면 속도와 정확도를 모두 확보할 수 있습니다.
설명: Bi-encoder만 사용하면 빠르지만 쿼리-문서 상호작용을 포착하지 못해 정확도가 낮습니다. Cross-encoder는 정확하지만 모든 문서 쌍을 계산해야 하므로 느립니다. Two-stage 방식은 bi-encoder로 후보군(20-100개)을 추린 후 cross-encoder로 재순위화하여 정확도와 효율성을 균형 있게 달성합니다.
Q4. SBERT가 BERT보다 문장 유사도 계산에 효율적인 이유는?
정답: SBERT는 Siamese/Triplet 네트워크로 문장을 고정 크기 벡터로 인코딩하므로, 유사도 계산 시 미리 계산된 벡터 간의 코사인 유사도만 계산하면 됩니다. BERT는 두 문장을 연결하여 매번 새로운 포워드 패스가 필요합니다.
설명: n개 문장의 유사도를 모두 계산할 때 BERT는 n(n-1)/2번의 포워드 패스가 필요하지만(약 65시간), SBERT는 n번의 인코딩 후 벡터 연산만 하면 되어(약 5초) 수천 배 빠릅니다. 특히 의미 검색, 클러스터링 등 대규모 비교가 필요한 태스크에서 실용적입니다.
Q5. 한국어 NLP에서 교착어 특성이 영어와 다른 처리를 요구하는 이유는?
정답: 한국어는 어간에 조사, 어미, 접사가 결합하는 교착어로 단어 변형이 매우 다양합니다. '먹다', '먹어요', '먹었습니다'는 동일한 어간에서 파생되므로, 형태소 분석을 통해 어간을 추출해야 동일한 개념으로 처리할 수 있습니다.
설명: 영어는 'eat', 'eats', 'eating'처럼 변형이 단순하여 간단한 어간 추출로 처리 가능합니다. 반면 한국어는 하나의 어간에 수백 개의 접사 조합이 가능하여 형태소 분석기(MeCab, Okt 등)가 필수입니다. 또한 공백 기반 분리 시 조사가 붙어 있어 '인공지능의', '인공지능이', '인공지능을'이 모두 다른 단어로 취급되는 문제가 발생합니다.
마무리
이 가이드에서는 텍스트 전처리부터 BPE 토큰화, Word2Vec/FastText 임베딩, BERT fine-tuning, RAG 파이프라인, 다국어 처리까지 NLP의 핵심 주제를 실전 코드와 함께 다루었습니다. 한국어 NLP는 교착어 특성을 고려한 형태소 분석이 핵심이며, RAG 시스템에서는 두 단계 검색(bi-encoder + cross-encoder)을 활용하면 속도와 정확도를 모두 확보할 수 있습니다.
NLP & Text Processing Complete Guide: BERT Fine-tuning, RAG Systems, and Multilingual Processing
NLP & Text Processing Complete Guide: BERT Fine-tuning, RAG Systems, and Multilingual Processing
Natural Language Processing (NLP) is a core AI technology that enables computers to understand and generate human language. This guide covers everything from text preprocessing basics through BERT fine-tuning, RAG pipeline construction, and multilingual processing — with hands-on code throughout.
Table of Contents
- Text Preprocessing and Tokenization
- Subword Tokenization: BPE, WordPiece, SentencePiece
- Word Embeddings: Word2Vec, FastText, GloVe
- Sequence Model Evolution: RNN to Transformer
- BERT Family Models and Fine-tuning
- Text Task Practical Guide
- Building RAG Systems
- Multilingual NLP and Korean Language Specifics
- Quiz
1. Text Preprocessing and Tokenization
1.1 Korean Morpheme Analysis (KoNLPy, MeCab)
Korean is an agglutinative language where various affixes attach to stems to express meaning. Simple whitespace-based tokenization (effective in English) is therefore insufficient for Korean. KoNLPy is the leading Python library for Korean morpheme analysis.
from konlpy.tag import Okt, Mecab, Komoran
okt = Okt()
text = "자연어 처리는 인공지능의 핵심 분야입니다."
# Morpheme analysis
morphs = okt.morphs(text)
print("Morphemes:", morphs)
# ['자연어', '처리', '는', '인공지능', '의', '핵심', '분야', '입니다', '.']
# Part-of-speech tagging
pos_tags = okt.pos(text)
print("POS tags:", pos_tags)
# [('자연어', 'Noun'), ('처리', 'Noun'), ('는', 'Josa'), ...]
# Noun extraction
nouns = okt.nouns(text)
print("Nouns:", nouns)
# ['자연어', '처리', '인공지능', '핵심', '분야']
# MeCab provides faster and more accurate analysis for production use
# mecab = Mecab()
# mecab_morphs = mecab.morphs(text)
MeCab is a Japanese morphological analyzer ported to Korean; it outperforms Okt in speed and accuracy for large-scale data processing.
# Korean text preprocessing pipeline using KoNLPy
import re
from konlpy.tag import Okt
def preprocess_korean(text: str) -> list[str]:
"""Korean text preprocessing pipeline"""
# Remove special characters (keep Hangul, Latin, digits)
text = re.sub(r'[^가-힣a-zA-Z0-9\s]', '', text)
text = re.sub(r'\s+', ' ', text).strip()
okt = Okt()
# Extract only nouns, verbs, and adjectives (length > 1)
tokens = [
word for word, pos in okt.pos(text)
if pos in ['Noun', 'Verb', 'Adjective'] and len(word) > 1
]
return tokens
sample = "자연어 처리 기술이 빠르게 발전하고 있습니다!"
print(preprocess_korean(sample))
1.2 Stopword Removal and Normalization
KOREAN_STOPWORDS = [
'이', '가', '을', '를', '은', '는', '의', '에', '에서',
'로', '으로', '와', '과', '도', '만', '까지', '부터'
]
def remove_stopwords(tokens: list[str], stopwords: list[str]) -> list[str]:
return [token for token in tokens if token not in stopwords]
def normalize_text(text: str) -> str:
# Collapse repeated characters: 'ㅋㅋㅋㅋ' -> 'ㅋㅋ'
text = re.sub(r'(.)\1{2,}', r'\1\1', text)
text = text.lower()
return text
2. Subword Tokenization: BPE, WordPiece, SentencePiece
2.1 BPE (Byte Pair Encoding)
BPE iteratively merges the most frequent character pair to build a vocabulary. It effectively solves the OOV (Out-of-Vocabulary) problem and is used in GPT-family models.
from tokenizers import Tokenizer, trainers, pre_tokenizers, models, decoders
# Train a BPE tokenizer
tokenizer = Tokenizer(models.BPE())
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=True)
tokenizer.decoder = decoders.ByteLevel()
trainer = trainers.BpeTrainer(
vocab_size=30000,
min_frequency=2,
special_tokens=["[UNK]", "[PAD]", "[BOS]", "[EOS]"]
)
files = ["train_corpus.txt"]
tokenizer.train(files, trainer)
# Tokenize example
encoding = tokenizer.encode("Natural Language Processing is fascinating!")
print("Tokens:", encoding.tokens)
print("IDs:", encoding.ids)
# Save and reload
tokenizer.save("bpe_tokenizer.json")
loaded_tokenizer = Tokenizer.from_file("bpe_tokenizer.json")
2.2 WordPiece vs SentencePiece
WordPiece (used in BERT) marks continuation subwords with the ## prefix. SentencePiece is language-agnostic and processes raw text without language-specific rules.
import sentencepiece as spm
# Train SentencePiece model
spm.SentencePieceTrainer.train(
input='corpus.txt',
model_prefix='sp_model',
vocab_size=32000,
character_coverage=0.9995, # Set high for CJK languages
model_type='bpe', # or 'unigram'
pad_id=0,
unk_id=1,
bos_id=2,
eos_id=3
)
sp = spm.SentencePieceProcessor()
sp.load('sp_model.model')
text = "Natural Language Processing is the core of NLP."
tokens = sp.encode_as_pieces(text)
ids = sp.encode_as_ids(text)
print("SentencePiece tokens:", tokens)
| Method | Models | Key Feature |
|---|---|---|
| BPE | GPT-2, RoBERTa | Frequency-based merging, deterministic |
| WordPiece | BERT, DistilBERT | Likelihood maximization, ## prefix |
| SentencePiece | T5, mBART, XLM-R | Language-agnostic, handles whitespace |
| Unigram LM | ALBERT, mBERT | Probabilistic model, flexible segmentation |
3. Word Embeddings: Word2Vec, FastText, GloVe
3.1 Word2Vec
Word2Vec produces dense word representations using a shallow neural network. It offers two architectures: CBOW (Continuous Bag of Words) and Skip-gram.
from gensim.models import Word2Vec
from konlpy.tag import Okt
okt = Okt()
corpus = [
"Natural language processing is a core field of AI",
"BERT is a bidirectional transformer model",
"Text classification uses BERT for sentiment analysis",
]
# Tokenize corpus
tokenized_corpus = [sentence.lower().split() for sentence in corpus]
# Train Word2Vec (Skip-gram)
model = Word2Vec(
sentences=tokenized_corpus,
vector_size=100,
window=5,
min_count=1,
sg=1, # 1: Skip-gram, 0: CBOW
workers=4,
epochs=100
)
# Find similar words
similar = model.wv.most_similar('bert', topn=5)
print("Similar words:", similar)
# Word vector
vector = model.wv['bert']
print("Vector shape:", vector.shape) # (100,)
3.2 FastText and GloVe
FastText represents words as the sum of their character n-grams, enabling embedding of OOV words. GloVe uses global co-occurrence statistics.
from gensim.models import FastText
ft_model = FastText(
sentences=tokenized_corpus,
vector_size=100,
window=5,
min_count=1,
sg=1,
min_n=2,
max_n=6,
epochs=100
)
# OOV words are handled via n-gram composition
oov_vector = ft_model.wv['multilingualnlp'] # Works even if unseen
print("OOV vector shape:", oov_vector.shape)
3.3 Sentence Embeddings with Sentence Transformers
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
# Multilingual model
model = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')
sentences = [
"Natural language processing is a branch of artificial intelligence.",
"NLP is an important area of AI research.",
"The weather is sunny and pleasant today.",
]
embeddings = model.encode(sentences)
print("Embedding shape:", embeddings.shape) # (3, 768)
sim_matrix = cosine_similarity(embeddings)
print("Similarity matrix:")
print(sim_matrix.round(4))
# Semantic search
query = "What is a language model?"
query_embedding = model.encode([query])
similarities = cosine_similarity(query_embedding, embeddings)[0]
for sent, score in zip(sentences, similarities):
print(f"Score {score:.4f}: {sent}")
4. Sequence Model Evolution: RNN to Transformer
4.1 From RNN to LSTM and GRU
import torch
import torch.nn as nn
class LSTMClassifier(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(
embed_dim, hidden_dim,
num_layers=2,
batch_first=True,
dropout=0.3,
bidirectional=True
)
self.classifier = nn.Sequential(
nn.Linear(hidden_dim * 2, hidden_dim),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(hidden_dim, num_classes)
)
def forward(self, x):
embedded = self.embedding(x)
output, (hidden, cell) = self.lstm(embedded)
# Concatenate last hidden states from both directions
final_hidden = torch.cat([hidden[-2], hidden[-1]], dim=1)
return self.classifier(final_hidden)
class GRUClassifier(nn.Module):
"""GRU has fewer parameters than LSTM and trains faster"""
def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.gru = nn.GRU(
embed_dim, hidden_dim,
num_layers=2,
batch_first=True,
bidirectional=True
)
self.fc = nn.Linear(hidden_dim * 2, num_classes)
def forward(self, x):
embedded = self.embedding(x)
_, hidden = self.gru(embedded)
out = torch.cat([hidden[-2], hidden[-1]], dim=1)
return self.fc(out)
4.2 Multi-Head Attention
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads):
super().__init__()
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
self.W_o = nn.Linear(d_model, d_model)
def scaled_dot_product_attention(self, Q, K, V, mask=None):
scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
attn_weights = torch.softmax(scores, dim=-1)
return torch.matmul(attn_weights, V), attn_weights
def forward(self, query, key, value, mask=None):
batch_size = query.size(0)
Q = self.W_q(query).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
K = self.W_k(key).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
V = self.W_v(value).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
attn_output, _ = self.scaled_dot_product_attention(Q, K, V, mask)
attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)
return self.W_o(attn_output)
5. BERT Family Models and Fine-tuning
5.1 BERT Pre-training Tasks
BERT (Bidirectional Encoder Representations from Transformers) uses two pre-training objectives:
- MLM (Masked Language Model): Replace 15% of input tokens with
[MASK]and predict the original tokens. - NSP (Next Sentence Prediction): Predict whether two sentences are consecutive in the original document.
5.2 KLUE-BERT for Korean Text Classification
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from transformers import TrainingArguments, Trainer
from datasets import Dataset
import numpy as np
from sklearn.metrics import accuracy_score, f1_score
model_name = "klue/bert-base"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(
model_name,
num_labels=5 # very negative / negative / neutral / positive / very positive
)
train_data = {
"text": [
"This movie is really entertaining! Highly recommended.",
"The service was rude and the quality was poor.",
"It was average. Nothing special.",
],
"label": [4, 0, 2]
}
def tokenize_function(examples):
return tokenizer(
examples["text"],
max_length=128,
padding="max_length",
truncation=True
)
dataset = Dataset.from_dict(train_data)
tokenized_dataset = dataset.map(tokenize_function, batched=True)
training_args = TrainingArguments(
output_dir="./klue-bert-sentiment",
num_train_epochs=3,
per_device_train_batch_size=16,
per_device_eval_batch_size=32,
warmup_ratio=0.1,
weight_decay=0.01,
learning_rate=2e-5,
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="f1",
fp16=True,
)
def compute_metrics(eval_pred):
logits, labels = eval_pred
predictions = np.argmax(logits, axis=-1)
return {
"accuracy": accuracy_score(labels, predictions),
"f1": f1_score(labels, predictions, average="weighted")
}
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset,
eval_dataset=tokenized_dataset,
compute_metrics=compute_metrics,
)
trainer.train()
5.3 KoELECTRA NER Fine-tuning
from transformers import AutoTokenizer, AutoModelForTokenClassification
from transformers import pipeline
model_name = "monologg/koelectra-base-finetuned-ner"
tokenizer = AutoTokenizer.from_pretrained(model_name)
ner_model = AutoModelForTokenClassification.from_pretrained(model_name)
ner_pipeline = pipeline(
"ner",
model=ner_model,
tokenizer=tokenizer,
aggregation_strategy="simple"
)
text = "Samsung Electronics CEO Lee Jae-yong held a press conference in Gangnam, Seoul."
entities = ner_pipeline(text)
for entity in entities:
print(f"Entity: {entity['word']}, Type: {entity['entity_group']}, "
f"Score: {entity['score']:.4f}")
5.4 Comparing BERT Family Models
| Model | Key Improvement | Notes |
|---|---|---|
| BERT | Baseline | MLM + NSP |
| RoBERTa | Removes NSP, more data | Dynamic masking, larger batches |
| DeBERTa | Disentangled attention | Separate position and content attention |
| KLUE-BERT | Korean-specific | Trained on Korean Wikipedia and news |
| KoELECTRA | ELECTRA architecture | Generator-discriminator structure |
6. Text Task Practical Guide
6.1 Text Classification and Sentiment Analysis
from transformers import pipeline
# Sentiment analysis
sentiment = pipeline(
"sentiment-analysis",
model="distilbert-base-uncased-finetuned-sst-2-english"
)
result = sentiment("This product offers excellent value for the price.")
print(result) # [{'label': 'POSITIVE', 'score': 0.9987}]
# Summarization
summarizer = pipeline("summarization", model="facebook/bart-large-cnn")
long_text = """
Natural language processing (NLP) is a subfield of linguistics, computer
science, and artificial intelligence. It focuses on the interactions between
computers and human language, particularly how to program computers to
process and analyze large amounts of natural language data.
"""
summary = summarizer(long_text, max_length=50, min_length=20)
print(summary[0]['summary_text'])
# Question answering
qa = pipeline(
"question-answering",
model="deepset/roberta-base-squad2"
)
context = "Seoul is the capital of South Korea with a population of approximately 9.5 million."
question = "What is the population of Seoul?"
answer = qa(question=question, context=context)
print(f"Answer: {answer['answer']}, Score: {answer['score']:.4f}")
6.2 Neural Machine Translation
from transformers import MarianMTModel, MarianTokenizer
model_name = "Helsinki-NLP/opus-mt-ko-en"
tokenizer = MarianTokenizer.from_pretrained(model_name)
model = MarianMTModel.from_pretrained(model_name)
def translate(text: str) -> str:
inputs = tokenizer(text, return_tensors="pt", padding=True)
translated = model.generate(**inputs, num_beams=5)
return tokenizer.decode(translated[0], skip_special_tokens=True)
korean_text = "인공지능 기술이 우리의 삶을 크게 변화시키고 있습니다."
print(f"Translation: {translate(korean_text)}")
7. Building RAG Systems
7.1 RAG Pipeline Overview
Retrieval-Augmented Generation (RAG) enhances LLM answer quality by retrieving relevant external knowledge. The pipeline consists of: document chunking → embedding → vector storage → retrieval → re-ranking → generation.
7.2 LangChain RAG Pipeline with pgvector
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import PGVector
from langchain_community.chat_models import ChatOllama
from langchain.chains import RetrievalQA
from langchain.schema import Document
# Step 1: Document chunking
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=["\n\n", "\n", ".", " ", ""],
length_function=len,
)
documents = [
Document(
page_content="BERT is a bidirectional transformer model published by Google in 2018...",
metadata={"source": "nlp_guide.txt", "page": 1}
)
]
chunks = text_splitter.split_documents(documents)
print(f"Number of chunks: {len(chunks)}")
# Step 2: Embedding model (multilingual support)
embeddings = HuggingFaceEmbeddings(
model_name="sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
model_kwargs={"device": "cuda"},
encode_kwargs={"normalize_embeddings": True}
)
# Step 3: Store in pgvector
CONNECTION_STRING = "postgresql+psycopg2://user:password@localhost:5432/ragdb"
COLLECTION_NAME = "nlp_documents"
vectorstore = PGVector.from_documents(
documents=chunks,
embedding=embeddings,
collection_name=COLLECTION_NAME,
connection_string=CONNECTION_STRING,
)
# Step 4: Configure retriever
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5}
)
# Step 5: Build RAG chain
llm = ChatOllama(model="llama3.1:8b-instruct-q4_K_M")
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
return_source_documents=True,
)
query = "What are BERT's pre-training methods?"
result = qa_chain.invoke({"query": query})
print(result["result"])
7.3 Two-Stage Retrieval: Bi-encoder + Cross-encoder
from sentence_transformers import SentenceTransformer, CrossEncoder
from sentence_transformers.util import cos_sim
# Stage 1: Bi-encoder for fast candidate retrieval
bi_encoder = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')
# Stage 2: Cross-encoder for precise re-ranking
cross_encoder = CrossEncoder('cross-encoder/mmarco-mMiniLMv2-L12-H384-v1')
def two_stage_retrieval(query: str, corpus: list[str], top_k: int = 5) -> list[str]:
"""Two-stage retrieval: bi-encoder + cross-encoder"""
# Stage 1: Fast initial retrieval (top 20 candidates)
query_emb = bi_encoder.encode(query, convert_to_tensor=True)
corpus_emb = bi_encoder.encode(corpus, convert_to_tensor=True)
scores = cos_sim(query_emb, corpus_emb)[0]
top_indices = scores.argsort(descending=True)[:20].tolist()
candidates = [corpus[i] for i in top_indices]
# Stage 2: Cross-encoder re-ranking for precision
pairs = [[query, doc] for doc in candidates]
rerank_scores = cross_encoder.predict(pairs)
ranked = sorted(
zip(candidates, rerank_scores),
key=lambda x: x[1],
reverse=True
)
return [doc for doc, _ in ranked[:top_k]]
corpus = [
"BERT is pre-trained using masked language modeling.",
"GPT generates text in an autoregressive manner.",
"Today the weather is clear with a temperature of 20 degrees.",
]
results = two_stage_retrieval("How does BERT learn?", corpus)
for doc in results:
print(f"- {doc}")
8. Multilingual NLP and Korean Language Specifics
8.1 mBERT and XLM-R
from transformers import AutoTokenizer, AutoModel
import torch
# XLM-RoBERTa: supports 100 languages
xlmr_tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
xlmr_model = AutoModel.from_pretrained("xlm-roberta-base")
sentences = {
"Korean": "자연어 처리는 인공지능의 핵심입니다.",
"English": "NLP is the core of artificial intelligence.",
"Japanese": "自然言語処理はAIの核心です。",
}
for lang, sentence in sentences.items():
inputs = xlmr_tokenizer(
sentence,
return_tensors="pt",
max_length=128,
padding=True,
truncation=True
)
with torch.no_grad():
outputs = xlmr_model(**inputs)
# Use [CLS] token embedding as sentence representation
sentence_embedding = outputs.last_hidden_state[:, 0, :]
print(f"{lang}: embedding shape = {sentence_embedding.shape}")
8.2 CJK Tokenization Specifics
CJK (Chinese, Japanese, Korean) languages require specialized handling since whitespace-based tokenization is ineffective.
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained("bert-base-multilingual-cased")
texts = {
"Korean": "자연어처리", # Agglutinative: postpositions attach to stems
"Chinese": "自然语言处理", # Isolating: each character is a meaning unit
"Japanese": "自然言語処理", # Agglutinative + kanji/hiragana/katakana mix
"English": "NaturalLanguageProcessing",
}
for lang, text in texts.items():
tokens = tokenizer.tokenize(text)
print(f"{lang}: {tokens}")
8.3 Korean Language Challenges
# Korean agglutinative characteristics:
# One verb stem can produce hundreds of surface forms:
# 먹다, 먹어, 먹었다, 먹을, 먹히다, 먹이다, 먹어서, 먹더라도...
from konlpy.tag import Mecab
def extract_korean_features(text: str) -> dict:
"""Extract linguistic features from Korean text"""
mecab = Mecab()
pos_tags = mecab.pos(text)
features = {
"nouns": [],
"verbs": [],
"adjectives": [],
"proper_nouns": []
}
for word, tag in pos_tags:
if tag.startswith('NN'):
features["nouns"].append(word)
elif tag.startswith('VV'):
features["verbs"].append(word)
elif tag.startswith('VA'):
features["adjectives"].append(word)
elif tag == 'NNP':
features["proper_nouns"].append(word)
return features
text = "Samsung announced development of a new AI semiconductor."
features = extract_korean_features(text)
print(features)
Quiz
Q1. How does BPE tokenization solve the OOV problem?
Answer: BPE iteratively merges the most frequently occurring character pairs to build a subword vocabulary. Any unseen word can be decomposed into learned subword units, minimizing OOV occurrences.
Explanation: Traditional word-based vocabularies handle unseen words as [UNK], losing all information. BPE starts from characters and merges frequent pairs, so neologisms and compound words can always be represented as a combination of existing subwords. For example, "multilingualnlp" might be split into "multi", "lingual", "nlp" or even character pairs, but never falls back to [UNK].
Q2. What are BERT's MLM and NSP pre-training tasks?
Answer: MLM randomly masks 15% of input tokens and trains the model to predict the original tokens. NSP trains the model to classify whether two sentences are consecutive in the original document.
Explanation: MLM enables bidirectional context understanding — BERT can use both left and right context to predict a masked word, which GPT (unidirectional) cannot do. Of the masked 15%, 80% are replaced with [MASK], 10% with a random token, and 10% are left unchanged. NSP was later shown by RoBERTa to not meaningfully improve performance and was dropped in subsequent models.
Q3. What is the advantage of two-stage retrieval combining bi-encoder and cross-encoder in RAG?
Answer: Bi-encoders independently encode queries and documents enabling fast ANN search, while cross-encoders process query-document pairs jointly for precise relevance scoring. Combining both achieves good speed and accuracy simultaneously.
Explanation: A bi-encoder alone is fast but cannot capture fine-grained query-document interaction, limiting accuracy. A cross-encoder is highly accurate but requires evaluating all documents at query time, making it too slow for large corpora. The two-stage approach uses the bi-encoder to produce a small candidate set (20-100 documents), then applies the cross-encoder only to those candidates — getting the best of both worlds.
Q4. Why is SBERT more efficient than BERT for sentence similarity computation?
Answer: SBERT uses a Siamese/Triplet network to encode sentences into fixed-size vectors. Similarity computation only requires comparing pre-computed vectors with cosine similarity. BERT requires a new forward pass for every sentence pair.
Explanation: Computing pairwise similarities for n sentences with BERT requires n(n-1)/2 forward passes (roughly 65 hours for 10,000 sentences), while SBERT requires n encoding steps followed by fast vector operations (approximately 5 seconds). This makes SBERT orders of magnitude faster for semantic search, clustering, and other large-scale comparison tasks.
Q5. Why does Korean agglutinative morphology require different processing from English?
Answer: Korean attaches postpositions, verb endings, and affixes to stems, producing enormous surface form variety. Morpheme analysis is required to normalize words to their stems; without it, '인공지능의', '인공지능이', '인공지능을' are treated as three different words rather than the same concept.
Explanation: English morphology is relatively simple — 'eat', 'eats', 'eating' — and basic stemming suffices. Korean stems can combine with hundreds of postposition and ending combinations, so a specialized morphological analyzer (MeCab, Okt, Komoran) is essential. This also affects vocabulary size: Korean BERT models trained with raw whitespace tokenization show dramatically worse performance than those with morpheme-aware preprocessing.
Summary
This guide covered the full NLP text processing stack: Korean morpheme analysis, BPE/WordPiece/SentencePiece tokenization, Word2Vec/FastText/SBERT embeddings, the RNN-to-Transformer evolution, BERT family fine-tuning (KLUE-BERT, KoELECTRA), practical NLP tasks (NER, sentiment, QA, summarization, translation), RAG pipeline construction with pgvector and LangChain, two-stage retrieval, and multilingual processing with XLM-R. The key insight for Korean NLP is that agglutinative morphology demands morpheme-aware tokenization, and for RAG systems, combining bi-encoder speed with cross-encoder precision delivers the best retrieval quality.