BERT
BERT(Bidirectional Encoder Representations from Transformers)는 Google이 2018년에 발표한 사전학습 언어 모델입니다.Devlin et al. (2019) GPT가 왼쪽에서 오른쪽으로 다음 단어를 예측하는 디코더 라면, BERT는 문장 전체를 양쪽에서 동시에 읽는 양방향 인코더 입니다.
1GPT와 무엇이 다른가¶
| GPT (디코더) | BERT (인코더) | |
|---|---|---|
| 방향 | 단방향(인과 마스크) | 양방향(마스크 없음) |
| 과제 | 다음 토큰 예측 | 빈칸 채우기(MLM) |
| 정규화 위치 | 부품 앞(pre-LN) | 부품 뒤(post-LN) |
| Q·K·V | 한 번에(c_attn) | 따로(query·key·value) |
| 임베딩 | 토큰 + 위치 | 토큰 + 위치 + 문장 타입 |
| 출력 헤드 | 어휘 로짓 | 변환 + MLM 헤드 |
이번에 쓸 다국어 BERT(base)의 설정은 다음과 같습니다 — 층 12, 임베딩 768, 헤드 12, 잠재 차원 3,072, 어휘 119,547.
정규화 epsilon은 BERT 규약인 10-12, 활성화는 오차함수 기반 GELU(approximate=False)입니다.
import numpy as np
import keras
from keras import ops, layers
print('Keras:', keras.__version__)Keras: 3.10.02인코더 만들기¶
GPT 장과 같은 방식 — 수식을 드러내는 부품만 커스텀 레이어로, 나머지는 함수형으로 조립합니다. 다른 점은 어텐션이 양방향(인과 마스크 없음)이고, 정규화를 부품 뒤에 두며(사후 정규화), 출력 헤드가 빈칸 예측용 MLM 헤드 라는 것입니다.
먼저 양방향 자기어텐션 입니다.
어텐션 장의 스케일드 닷-프로덕트 그대로이되 인과 마스크가 없고, 질의·색인·값을 각각 별도의 Dense 로 만듭니다.
@keras.saving.register_keras_serializable(package="bert")
class SelfAttention(layers.Layer):
"""양방향 멀티헤드 자기어텐션 (인과 마스크 없음)."""
def __init__(self, 임베딩차원, 헤드수, **kw):
super().__init__(**kw)
self.임베딩차원, self.헤드수 = 임베딩차원, 헤드수
self.헤드차원 = 임베딩차원 // 헤드수
self.query = layers.Dense(임베딩차원, name="query")
self.key = layers.Dense(임베딩차원, name="key")
self.value = layers.Dense(임베딩차원, name="value")
def get_config(self):
return {**super().get_config(), "임베딩차원": self.임베딩차원, "헤드수": self.헤드수}
def call(self, x):
B, T, C = ops.shape(x)[0], ops.shape(x)[1], ops.shape(x)[2]
def 헤드분리(텐서):
텐서 = ops.reshape(텐서, (B, T, self.헤드수, self.헤드차원))
return ops.transpose(텐서, (0, 2, 1, 3))
q, k, v = 헤드분리(self.query(x)), 헤드분리(self.key(x)), 헤드분리(self.value(x))
점수 = ops.matmul(q, ops.transpose(k, (0, 1, 3, 2))) / ops.sqrt(float(self.헤드차원))
가중치 = ops.softmax(점수, axis=-1) # 인과 마스크 없음(양방향)
y = ops.matmul(가중치, v)
return ops.reshape(ops.transpose(y, (0, 2, 1, 3)), (B, T, C))인코더 블록 은 어텐션과 피드포워드 각각 뒤에 잔차 연결 + 계층 정규화를 둡니다(사후 정규화).
피드포워드는 블록 안에서 Dense 두 개와 ops.gelu 로 바로 조립합니다(BERT는 오차함수 기반 GELU).
def 인코더_블록(임베딩차원, 헤드수, 잠재차원, name):
입력 = keras.Input(shape=(None, 임베딩차원))
어텐션 = SelfAttention(임베딩차원, 헤드수, name="self")(입력)
어텐션 = layers.Dense(임베딩차원, name="attn_out")(어텐션)
x = layers.LayerNormalization(epsilon=1e-12, name="layer_norm1")(입력 + 어텐션) # 사후 정규화
은닉 = layers.Dense(잠재차원, name="intermediate")(x) # 확장
은닉 = ops.gelu(은닉, approximate=False) # GELU(오차함수)
은닉 = layers.Dense(임베딩차원, name="out")(은닉) # 축소
x = layers.LayerNormalization(epsilon=1e-12, name="layer_norm2")(x + 은닉) # 사후 정규화
return keras.Model(입력, x, name=name)임베딩 은 단어·위치·문장 타입 세 가지를 더해 정규화한 모듈입니다.
위치·타입은 입력 길이에 따라 정해지므로 커스텀 레이어(PositionTypeEmbedding)가 처리합니다.
@keras.saving.register_keras_serializable(package="bert")
class PositionTypeEmbedding(layers.Layer):
"""위치 임베딩 + 문장 타입(0) 임베딩을 더한다."""
def __init__(self, 최대길이, 임베딩차원, **kw):
super().__init__(**kw)
self.최대길이, self.임베딩차원 = 최대길이, 임베딩차원
self.위치 = layers.Embedding(최대길이, 임베딩차원, name="pos")
self.타입 = layers.Embedding(2, 임베딩차원, name="type")
def get_config(self):
return {**super().get_config(), "최대길이": self.최대길이, "임베딩차원": self.임베딩차원}
def call(self, 단어임베딩):
T = ops.shape(단어임베딩)[1]
return 단어임베딩 + self.위치(ops.arange(0, T)) + self.타입(ops.zeros((1,), "int32"))
def 임베딩_모듈(어휘수, 최대길이, 임베딩차원, name="embedding"):
토큰 = keras.Input(shape=(None,), dtype="int32")
x = layers.Embedding(어휘수, 임베딩차원, name="word")(토큰)
x = PositionTypeEmbedding(최대길이, 임베딩차원, name="pos_type")(x)
return keras.Model(토큰, layers.LayerNormalization(epsilon=1e-12, name="layer_norm")(x), name=name)MLM 헤드 는 히든을 한 번 변환(Dense GELU 정규화)한 뒤, 단어 임베딩과 가중치를 공유해 어휘 로짓으로 투영하고, 어휘별 편향 을 더합니다.
함수형 그래프에서 편향은 작은 커스텀 레이어(BiasAdd)가 들고 있습니다.
@keras.saving.register_keras_serializable(package="bert")
class BiasAdd(layers.Layer):
"""어휘별 편향을 더한다 (MLM 출력 bias)."""
def build(self, 입력형태):
self.bias = self.add_weight(shape=(입력형태[-1],), name="bias", initializer="zeros")
def call(self, x):
return x + self.bias
def MLM_헤드(임베딩, 임베딩차원, name="head"):
입력 = keras.Input(shape=(None, 임베딩차원))
x = layers.Dense(임베딩차원, name="transform")(입력)
x = ops.gelu(x, approximate=False)
x = layers.LayerNormalization(epsilon=1e-12, name="layer_norm")(x)
공유가중치 = ops.transpose(임베딩.get_layer("word").embeddings) # weight tying
로짓 = ops.matmul(x, 공유가중치)
return keras.Model(입력, BiasAdd(name="bias")(로짓), name=name)마지막으로 임베딩·인코더·헤드를 함수형으로 이어 붙여 keras.Model 로 구성합니다(서브클래싱이 필요 없습니다).
def BERT(어휘수, 최대길이, 임베딩차원, 층수, 헤드수, 잠재차원):
토큰 = keras.Input(shape=(None,), dtype="int32")
임베딩 = 임베딩_모듈(어휘수, 최대길이, 임베딩차원)
x = 임베딩(토큰)
인코더 = keras.Sequential(
[인코더_블록(임베딩차원, 헤드수, 잠재차원, name=f"block_{i}") for i in range(층수)],
name=f"encoder_x{층수}")
x = 인코더(x)
헤드 = MLM_헤드(임베딩, 임베딩차원)
로짓 = 헤드(x)
return keras.Model(토큰, 로짓, name="bert")설정 = dict(어휘수=119547, 최대길이=512, 임베딩차원=768, 층수=12, 헤드수=12, 잠재차원=3072)
model = BERT(**설정)
print("파라미터 수:", model.count_params())파라미터 수: 1779745232.1전체 구조 한눈에 보기¶
함수형 모델이라 keras.utils.plot_model 이 구조를 그대로 그려 줍니다.
keras.utils.plot_model(model, show_shapes=True)
BERT의 전체 구조. 입력 토큰이 임베딩을 거쳐 인코더 블록 12개(encoder_x12)를 통과한 뒤, MLM 헤드(head)에서 어휘 크기(119,547)의 로짓이 된다.
3가중치 이식 — Linear는 전치, LayerNorm은 gamma/beta¶
다국어 BERT 가중치를 공개 파일에서 받습니다. GPT 장과 두 가지가 다릅니다.
선형층은 전치(transpose) 합니다. GPT-2는
Conv1D규약이라 가중치가(입력, 출력)이고, BERT는 PyTorchnn.Linear라(출력, 입력)입니다. 그래서 KerasDense(=(입력, 출력))에 넣으려면.T로 뒤집어야 합니다.LayerNorm 파라미터 이름이
gamma·beta입니다(GPT 체크포인트의weight·bias에 해당).
import torch
URL = "https://huggingface.co/google-bert/bert-base-multilingual-cased/resolve/main/pytorch_model.bin"
경로 = keras.utils.get_file("mbert-pytorch_model.bin", URL)
상태사전 = {이름: 텐서.numpy() for 이름, 텐서 in
torch.load(경로, map_location="cpu", weights_only=True).items()}
def 전치(가중치): return 가중치.T # nn.Linear (출력,입력) -> Dense (입력,출력)
def 이식(model, 상태사전):
e = "bert.embeddings."
임베딩 = model.get_layer("embedding")
임베딩.get_layer("word").set_weights([상태사전[e + "word_embeddings.weight"]])
위치타입 = 임베딩.get_layer("pos_type")
위치타입.위치.set_weights([상태사전[e + "position_embeddings.weight"]])
위치타입.타입.set_weights([상태사전[e + "token_type_embeddings.weight"]])
임베딩.get_layer("layer_norm").set_weights([상태사전[e + "LayerNorm.gamma"], 상태사전[e + "LayerNorm.beta"]])
for i, 블록 in enumerate(model.get_layer("encoder_x12").layers):
p = f"bert.encoder.layer.{i}."
블록.get_layer("self").query.set_weights([전치(상태사전[p+"attention.self.query.weight"]), 상태사전[p+"attention.self.query.bias"]])
블록.get_layer("self").key.set_weights([전치(상태사전[p+"attention.self.key.weight"]), 상태사전[p+"attention.self.key.bias"]])
블록.get_layer("self").value.set_weights([전치(상태사전[p+"attention.self.value.weight"]), 상태사전[p+"attention.self.value.bias"]])
블록.get_layer("attn_out").set_weights([전치(상태사전[p+"attention.output.dense.weight"]), 상태사전[p+"attention.output.dense.bias"]])
블록.get_layer("layer_norm1").set_weights([상태사전[p+"attention.output.LayerNorm.gamma"], 상태사전[p+"attention.output.LayerNorm.beta"]])
블록.get_layer("intermediate").set_weights([전치(상태사전[p+"intermediate.dense.weight"]), 상태사전[p+"intermediate.dense.bias"]])
블록.get_layer("out").set_weights([전치(상태사전[p+"output.dense.weight"]), 상태사전[p+"output.dense.bias"]])
블록.get_layer("layer_norm2").set_weights([상태사전[p+"output.LayerNorm.gamma"], 상태사전[p+"output.LayerNorm.beta"]])
헤드 = model.get_layer("head")
c = "cls.predictions."
헤드.get_layer("transform").set_weights([전치(상태사전[c+"transform.dense.weight"]), 상태사전[c+"transform.dense.bias"]])
헤드.get_layer("layer_norm").set_weights([상태사전[c+"transform.LayerNorm.gamma"], 상태사전[c+"transform.LayerNorm.beta"]])
헤드.get_layer("bias").set_weights([상태사전[c+"bias"]]) # 디코더는 단어 임베딩과 공유
이식(model, 상태사전)
print("이식 완료 — 이제 진짜 BERT입니다.")이식 완료 — 이제 진짜 BERT입니다.4토크나이저 — WordPiece¶
토크나이저도 GPT 계열과 다릅니다.
바이트 페어 인코딩 장의 GPT-2는 바이트 단위 BPE, GPT 장의 KoGPT2(한국어 절)는 메타스페이스 BPE입니다.
BERT는 WordPiece 입니다: 어휘에 있는 가장 긴 조각부터 그리디로 잘라내고, 단어 중간에서 이어지는 조각에는 ## 접두사를 붙입니다.
어휘는 vocab.txt(한 줄에 한 토큰)에 들어 있습니다.
import urllib.request, unicodedata, re
VOCAB_URL = "https://huggingface.co/google-bert/bert-base-multilingual-cased/resolve/main/vocab.txt"
줄들 = urllib.request.urlopen(VOCAB_URL).read().decode("utf-8").split("\n")
어휘 = {토큰: 토큰id for 토큰id, 토큰 in enumerate(줄들) if 토큰}
역어휘 = {토큰id: 토큰 for 토큰, 토큰id in 어휘.items()}
특수토큰 = {"[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"}
print("어휘 크기:", len(어휘))어휘 크기: 119547먼저 텍스트를 기본 분절 합니다 — 공백으로 나누고, 한자(CJK)는 한 글자씩, 구두점은 따로 떼어냅니다.
def 구두점인가(문자):
cp = ord(문자)
if 33 <= cp <= 47 or 58 <= cp <= 64 or 91 <= cp <= 96 or 123 <= cp <= 126:
return True
return unicodedata.category(문자).startswith("P")
def 한자인가(문자):
cp = ord(문자)
return any(시작 <= cp <= 끝 for 시작, 끝 in
[(0x4E00, 0x9FFF), (0x3400, 0x4DBF), (0xF900, 0xFAFF), (0x20000, 0x2A6DF)])
def 기본분절(text):
조각들 = []
for 어절 in text.split():
버퍼 = ""
for 문자 in 어절:
if 한자인가(문자) or 구두점인가(문자): # 한자·구두점은 한 글자씩 분리
if 버퍼: 조각들.append(버퍼); 버퍼 = ""
조각들.append(문자)
else:
버퍼 += 문자
if 버퍼: 조각들.append(버퍼)
return 조각들그 다음 각 조각을 WordPiece 로 잘라냅니다.
def wordpiece(단어):
if 단어 in 어휘:
return [단어]
조각들, 시작 = [], 0
while 시작 < len(단어):
끝 = len(단어); 후보토큰 = None
while 끝 > 시작:
부분 = 단어[시작:끝]
토큰 = ("##" + 부분) if 시작 > 0 else 부분 # 단어 중간이면 ## 접두사
if 토큰 in 어휘:
후보토큰 = 토큰; break
끝 -= 1
if 후보토큰 is None: # 어디서도 못 자르면 미등록
return ["[UNK]"]
조각들.append(후보토큰); 시작 = 끝
return 조각들
def encode(text):
text = unicodedata.normalize("NFC", " ".join(text.split()))
부분들 = re.split(r"(\[MASK\]|\[CLS\]|\[SEP\]|\[UNK\]|\[PAD\])", text) # 특수 토큰 보존
ids = [어휘["[CLS]"]]
for 부분 in 부분들:
if 부분 in 특수토큰:
ids.append(어휘[부분])
else:
for 단어 in 기본분절(부분):
ids.extend(어휘.get(t, 어휘["[UNK]"]) for t in wordpiece(단어))
ids.append(어휘["[SEP]"])
return ids
def decode(ids):
토큰들 = [역어휘[i] for i in ids if 역어휘[i] not in ("[CLS]", "[SEP]", "[PAD]")]
return "".join(t[2:] if t.startswith("##") else " " + t for t in 토큰들).strip()토큰화 예를 봅니다.
예문 = "딥러닝 언어 모델"
ids = encode(예문)
print("토큰:", [역어휘[i] for i in ids])
print("ID :", ids)토큰: ['[CLS]', '딥', '##러', '##닝', '언', '##어', '모', '##델', '[SEP]']
ID : [101, 9126, 30873, 106065, 9548, 12965, 9283, 118791, 102][CLS] 와 [SEP] 가 문장 앞뒤에 붙고, "딥러닝"은 딥 ##러 ##닝 으로 잘립니다.
## 는 "앞 토큰에 바로 이어지는 조각"이라는 표시입니다.
5빈칸 채우기 (Masked Language Modeling)¶
BERT의 대표 과제입니다.
문장에 [MASK] 를 두면, BERT는 앞뒤 문맥을 모두 보고 그 자리에 올 단어를 예측합니다.
모델의 출력이 곧 각 위치의 어휘 로짓이므로, [MASK] 위치의 로짓에서 가장 확률이 높은 토큰을 고르면 됩니다.
def 빈칸채우기(text, k=5):
ids = encode(text)
로짓 = model.predict(np.array([ids], "int64"), verbose=0)[0]
마스크위치 = ids.index(어휘["[MASK]"])
상위 = 로짓[마스크위치].argsort()[-k:][::-1]
return [역어휘[t] for t in 상위]
print(빈칸채우기("대한민국의 수도는 [MASK]."))['서울', '[UNK]', '도쿄', '서울특별시', '수도'][MASK] 의 왼쪽(“대한민국의 수도는”)만이 아니라 오른쪽(“.”)까지 함께 본다는 점이 GPT의 단방향 예측과 결정적으로 다릅니다.
6검증 — HF 없이¶
진짜 BERT에서 미리 기록해 둔 기준값과 맞춰 봅니다. 토크나이저와 모델이 모두 정확해야 같은 예측이 나옵니다.
예측 = 빈칸채우기("대한민국의 수도는 [MASK].")
print("상위 5개:", 예측)
assert 예측 == ['서울', '[UNK]', '도쿄', '서울특별시', '수도']
print("\n검증 통과 — Keras BERT가 진짜 BERT와 동일하게 빈칸을 채웁니다.")상위 5개: ['서울', '[UNK]', '도쿄', '서울특별시', '수도']
검증 통과 — Keras BERT가 진짜 BERT와 동일하게 빈칸을 채웁니다.7저장과 로드¶
가중치 이식은 한 번만 하면 됩니다.
이식한 모델을 .keras 한 파일로 저장해 두면, 다음부터는 다운로드·이식 없이 바로 불러올 수 있습니다.
GPT 장과 마찬가지로, 커스텀 레이어·모델에 @keras.saving.register_keras_serializable 과 get_config 가 있어야 구조까지 함께 저장돼 load_model 로 복원됩니다 — 위 클래스들에 넣어 두었습니다.
model.save("bert.keras") # 구조 + 가중치를 한 파일로
model2 = keras.models.load_model("bert.keras")
# 로드한 모델이 원본과 같은 출력을 내는지 확인
x = np.array([encode("대한민국의 수도는 [MASK].")], dtype="int64")
assert np.allclose(model.predict(x, verbose=0),
model2.predict(x, verbose=0), atol=1e-5)
print("로드한 모델 == 원본 모델")로드한 모델 == 원본 모델8문서 임베딩 — Sentence-BERT¶
같은 인코더를 또 다르게 쓸 수 있습니다. MLM 헤드 대신 인코더가 내놓은 토큰 표현을 평균 내면, 문장 하나가 벡터 하나가 됩니다 — 문서 임베딩 입니다. 의미가 가까운 문장은 벡터도 가깝고(코사인 유사도가 큼), 이것이 의미 검색·RAG의 토대입니다. 출력의 축이 하나 더 늘어난 셈입니다 — GPT는 다음 토큰, BERT-MLM은 빈칸 토큰, 여기서는 문장 전체를 벡터로.
8.1인코더만, 머리는 평균 풀링¶
앞 ‘가중치 이식’ 절의 인코더(임베딩_모듈·인코더_블록)를 그대로 쓰되, MLM 헤드 없이 토큰 표현을 그대로 내보냅니다.
def 문장인코더(어휘수, 최대길이, 임베딩차원, 층수, 헤드수, 잠재차원):
토큰 = keras.Input(shape=(None,), dtype="int32")
임베딩 = 임베딩_모듈(어휘수, 최대길이, 임베딩차원)
x = 임베딩(토큰)
인코더 = keras.Sequential(
[인코더_블록(임베딩차원, 헤드수, 잠재차원, name=f"block_{i}") for i in range(층수)],
name=f"encoder_x{층수}")
return keras.Model(토큰, 인코더(x), name="encoder") # 토큰 표현 (B, T, 768)
def 문장벡터(model, text):
토큰표현 = model.predict(np.array([encode(text)], "int64"), verbose=0)[0] # (T, 768)
return 토큰표현.mean(axis=0) # 평균 풀링 -> (768,)
def 코사인(a, b):
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))이식도 앞 절과 같은 구조입니다 — LayerNorm 키 이름이 weight·bias 이고(다국어 BERT는 gamma·beta였습니다), MLM 헤드가 없으며, 체크포인트마다 접두사만 다릅니다.
def 인코더_이식(model, 상태사전, 접두=""):
e = 접두 + "embeddings."
임베딩 = model.get_layer("embedding")
임베딩.get_layer("word").set_weights([상태사전[e + "word_embeddings.weight"]])
위치타입 = 임베딩.get_layer("pos_type")
위치타입.위치.set_weights([상태사전[e + "position_embeddings.weight"]])
위치타입.타입.set_weights([상태사전[e + "token_type_embeddings.weight"]])
임베딩.get_layer("layer_norm").set_weights([상태사전[e + "LayerNorm.weight"], 상태사전[e + "LayerNorm.bias"]])
for i, 블록 in enumerate(model.get_layer("encoder_x12").layers):
p = f"{접두}encoder.layer.{i}."
블록.get_layer("self").query.set_weights([전치(상태사전[p+"attention.self.query.weight"]), 상태사전[p+"attention.self.query.bias"]])
블록.get_layer("self").key.set_weights([전치(상태사전[p+"attention.self.key.weight"]), 상태사전[p+"attention.self.key.bias"]])
블록.get_layer("self").value.set_weights([전치(상태사전[p+"attention.self.value.weight"]), 상태사전[p+"attention.self.value.bias"]])
블록.get_layer("attn_out").set_weights([전치(상태사전[p+"attention.output.dense.weight"]), 상태사전[p+"attention.output.dense.bias"]])
블록.get_layer("layer_norm1").set_weights([상태사전[p+"attention.output.LayerNorm.weight"], 상태사전[p+"attention.output.LayerNorm.bias"]])
블록.get_layer("intermediate").set_weights([전치(상태사전[p+"intermediate.dense.weight"]), 상태사전[p+"intermediate.dense.bias"]])
블록.get_layer("out").set_weights([전치(상태사전[p+"output.dense.weight"]), 상태사전[p+"output.dense.bias"]])
블록.get_layer("layer_norm2").set_weights([상태사전[p+"output.LayerNorm.weight"], 상태사전[p+"output.LayerNorm.bias"]])여기서는 한국어 BERT인 klue/bert-base 를 씁니다(어휘 32,000개).
토크나이저는 위 WordPiece 코드를 그대로 쓰고, 어휘만 klue 것으로 바꿉니다.
문장모델 = 문장인코더(어휘수=32000, 최대길이=512, 임베딩차원=768, 층수=12, 헤드수=12, 잠재차원=3072)
# klue 어휘로 교체 (위 encode 를 그대로 재사용)
줄들 = urllib.request.urlopen("https://huggingface.co/klue/bert-base/resolve/main/vocab.txt").read().decode("utf-8").split("\n")
어휘 = {토큰: i for i, 토큰 in enumerate(줄들) if 토큰}
역어휘 = {i: 토큰 for 토큰, i in 어휘.items()}
print("toy:", [역어휘[i] for i in encode("딥러닝 언어 모델")])toy: ['[CLS]', '딥', '##러', '##닝', '언어', '모델', '[SEP]']8.2BERT를 그대로 쓰면 왜 부족한가¶
BERT 자체는 강력한 인코더지만, 그 토큰 표현을 그대로 평균 내 문서 임베딩으로 쓰는 건 별개의 문제입니다.
먼저 한국어 BERT 가중치(klue/bert-base)를 이식해 시험해 봅니다.
klue_경로 = keras.utils.get_file("klue-bert.bin", "https://huggingface.co/klue/bert-base/resolve/main/pytorch_model.bin")
klue_상태사전 = {이름: 텐서.numpy() for 이름, 텐서 in
torch.load(klue_경로, map_location="cpu", weights_only=True).items()}
인코더_이식(문장모델, klue_상태사전, 접두="bert.") # transformers 본체라 bert. 접두사
문장 = ["한 남자가 음식을 먹는다.", # 0
"한 남자가 무언가를 먹고 있다.", # 1 (0과 같은 뜻)
"한 여자가 바이올린을 연주한다."] # 2 (전혀 다른 뜻)
벡 = [문장벡터(문장모델, s) for s in 문장]
print("BERT 유사:", round(코사인(벡[0], 벡[1]), 3), "| 무관:", round(코사인(벡[0], 벡[2]), 3))BERT 유사: 0.876 | 무관: 0.81뜻이 같은 0·1은 0.876, 전혀 다른 0·2도 0.810 — 거의 구분하지 못합니다. 사전훈련된 BERT의 토큰 표현을 그대로 평균 내면 모든 문장 벡터가 좁은 원뿔에 뭉쳐(이방성, anisotropy) 유사도가 다 높게 나오기 때문입니다. 그래서 BERT 표현을 그대로 평균 낸 임베딩은 의미 비교에 바로 쓰기 어렵습니다.
8.3Sentence-BERT — 미세조정으로 의미 공간을 정렬¶
Sentence-BERT(SBERT) 는 바로 이 문제를 풉니다.
같은 BERT를 샴(Siamese) 구조 로 문장 쌍(자연어 추론 NLI·문장 유사도 STS) 데이터에 미세조정해, “뜻이 가까우면 벡터도 가깝게” 임베딩 공간을 정렬합니다.
이 학습 은 미세조정(사후 훈련)이라 여기서 구현하지는 않고, klue/bert-base에서 이미 미세조정된 jhgan/ko-sbert-sts 가중치를 같은 인코더에 그대로 이식 합니다 — 구조는 그대로, 가중치만 교체.
sbert_경로 = keras.utils.get_file("ko-sbert.bin", "https://huggingface.co/jhgan/ko-sbert-sts/resolve/main/pytorch_model.bin")
sbert_상태사전 = {이름: 텐서.numpy() for 이름, 텐서 in
torch.load(sbert_경로, map_location="cpu", weights_only=True).items()}
인코더_이식(문장모델, sbert_상태사전, 접두="") # SBert 본체라 접두사 없음
벡 = [문장벡터(문장모델, s) for s in 문장]
유사, 무관 = 코사인(벡[0], 벡[1]), 코사인(벡[0], 벡[2])
print("SBERT 유사:", round(유사, 3), "| 무관:", round(무관, 3))
assert abs(유사 - 0.863) < 0.01 and abs(무관 - 0.023) < 0.01 # 진짜 SBERT와 일치
print("검증 통과 — 진짜 SBERT와 같은 임베딩")SBERT 유사: 0.863 | 무관: 0.023
검증 통과 — 진짜 SBERT와 같은 임베딩아키텍처도 토크나이저도 그대로인데, 가중치만 BERT에서 SBERT로 바꾸자 같은 뜻(0·1)은 0.863으로 높고 다른 뜻(0·2)은 0.023으로 뚝 떨어집니다. SBERT의 가치는 새 구조가 아니라 미세조정 에 있다는 것을 보여줍니다.
8.4의미 검색¶
문서들을 미리 임베딩해 두고, 질의 임베딩과 코사인 유사도가 가장 큰 문서 를 고르면 의미 기반 검색입니다.
문서 = ["딥러닝은 인공신경망으로 학습한다.",
"고양이는 포유류 동물이다.",
"어제 주식 시장이 크게 올랐다."]
질의 = "신경망 기반 기계학습"
질의벡 = 문장벡터(문장모델, 질의)
for 문서벡, d in sorted(((문장벡터(문장모델, d), d) for d in 문서),
key=lambda 쌍: -코사인(질의벡, 쌍[0])):
print(f" {코사인(질의벡, 문서벡):.3f} {d}") 0.884 딥러닝은 인공신경망으로 학습한다.
0.078 고양이는 포유류 동물이다.
0.046 어제 주식 시장이 크게 올랐다.질의에 단어가 하나도 겹치지 않아도(질의의 "신경망"과 문서의 “인공신경망”) 의미로 가장 가까운 문서를 찾아냅니다. 이 문서 임베딩이 검색·RAG의 출발점입니다.
9정리¶
BERT는 GPT와 같은 트랜스포머이지만 양방향 인코더 이고, 과제가 빈칸 채우기(MLM) 입니다. 그래서 코드도 여러 군데 달라집니다 — 인과 마스크 제거, 사후 정규화(post-LN), Q·K·V 분리, 문장 타입 임베딩, MLM 헤드.
가중치 이식의 주의점은 둘 — 선형층은
nn.Linear라 전치 하고, LayerNorm은gamma·beta이름으로 읽습니다.토크나이저는 세 번째 계열인 WordPiece — 어휘에서 가장 긴 조각부터 그리디로 자르고 이어지는 조각에
##를 붙입니다.모델도 토크나이저도 처음부터 만들고 실제 가중치를 이식해,
transformers없이 BERT의 MLM을 그대로 재현했습니다.같은 인코더의 토큰 표현을 평균 내면 문서 임베딩(SBERT) 이 됩니다 — BERT 표현을 그대로 평균 내면 의미 구분이 약하지만, 문서 임베딩에 맞춰 미세조정한 SBERT 가중치로 바꾸면 의미 공간이 정렬돼 의미 검색이 됩니다. 출력이 생성·빈칸만이 아니라 밀집 표현 일 수도 있음을 보여줍니다.
- Devlin, J., Chang, M.-W., Lee, K., & Toutanova, K. (2019). BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. https://arxiv.org/abs/1810.04805