지난 글에서 텍스트에 레이블을 붙이는 분류를 살펴봤다. 분류는 미리 정해 둔 칸 중 하나를 고르는 일이라 정답이 유한했다. 이번에 다룰 텍스트 요약은 성격이 다르다 — 정답을 고르는 것이 아니라 짓는 일이고, 그래서 「맞았다·틀렸다」로 채점할 수가 없다.
뉴스 기사를 세 줄로, 보고서를 한 문단으로, 상담 이력을 결정 사항 목록으로 줄이는 이 기술은 쓰임이 넓은 만큼 실패하는 방식도 다양하다. 문장은 매끄러운데 원문에 없는 숫자가 들어가 있거나, 사실은 다 맞는데 읽으면 앞뒤가 안 이어지거나, ROUGE 점수는 높은데 사람이 보기엔 쓸모가 없다. 이 글은 그 각각이 어디서 오는지를 따라간다.
추출과 추상
두 방식이 가르는 것
추출적 요약은 원문에서 중요한 문장을 그대로 골라 이어 붙인다. 복사해 오는 방식이라 원문에 없는 말이 섞일 수가 없고, 모델도 필요 없어 수백 건을 초 단위로 처리한다. 대신 문제가 둘이다 — 고른 문장들 사이의 연결이 끊겨 읽기가 거칠고, 압축률에 한계가 있다. 한 문장이 최소 단위이므로 열 문장짜리 문서를 한 줄로 줄이는 일은 애초에 불가능하다.
추상적 요약은 원문을 읽은 뒤 새 문장을 생성한다. 여러 문장에 흩어진 내용을 한 문장으로 묶을 수 있고 문체도 다듬을 수 있어 사람이 쓴 요약에 가깝다. 대신 생성 모델이므로 원문에 없는 내용을 지어낼 수 있다.
이 차이를 요구사항 쪽에서 뒤집어 보면 고르는 기준이 나온다. 법률 검토나 의료 기록처럼 한 글자라도 지어내면 안 되는 문서는 추출 쪽이고, 읽는 사람이 원문을 다시 볼 일이 없는 브리핑·다이제스트는 추상 쪽이다.
두 단으로 잇기
긴 문서에서는 둘을 이어 붙이는 구성이 흔하다. 먼저 추출적 방법으로 문서를 3분의 1 정도로 줄이고, 그 결과를 추상적 모델에 넣어 최종 요약을 만든다.
이 구성이 값을 하는 이유는 비용과 품질 양쪽이다. 추상 모델의 입력 길이 한계를 추출 단계가 미리 흡수해 주고, 모델이 봐야 할 토큰이 줄어 추론이 싸진다. 그리고 관련 없는 문단이 먼저 걸러지므로 모델이 엉뚱한 대목에 주목할 여지도 준다. 대신 위험이 하나 붙는다 — 추출 단계에서 버린 문장에 결론이 들어 있었다면 그 뒤의 어떤 모델도 그것을 복구하지 못한다. 그래서 1단의 압축률은 보수적으로 잡는다. 절반까지만 줄이고 나머지를 2단에 맡기는 편이, 1단에서 10분의 1로 줄여 놓고 2단을 가볍게 돌리는 것보다 안전하다. 버릴지 말지를 판단하는 쪽은 문장을 읽고 뜻을 이해하는 모델이어야지 어휘 겹침을 세는 그래프 알고리즘이 아니기 때문이다.
TextRank
유사도 행렬
TextRank는 웹 페이지의 중요도를 매기던 PageRank를 문장에 그대로 옮긴 방법이다. 문장 하나가 노드이고, 두 문장 사이의 유사도가 그 둘을 잇는 간선의 가중치다.
발상은 「중요한 문장은 다른 중요한 문장들과 닮아 있다」는 것이다. 문서의 핵심 주제를 담은 문장은 여러 문장과 어휘를 공유하므로 간선이 굵게 많이 붙고, 곁가지 설명은 한두 문장과만 닿는다. 유사도는 보통 TF-IDF 벡터의 코사인 유사도로 잡고, 자기 자신과의 유사도는 1이라 반드시 0으로 지운다. 안 지우면 모든 문장이 자기 자신에게서 같은 크기의 점수를 받아 순위가 평평해진다.
감쇠 계수와 수렴
점수는 반복해서 갱신한다. 매 회 각 문장은 자기와 이어진 문장들의 점수를 간선 가중치에 비례해 나눠 받고, 그 값에 감쇠 계수 를 곱한 뒤 를 더한다.
는 PageRank에서 넘어온 값이다. 이 계수가 하는 일은 점수가 한 덩어리에 갇히는 것을 막는 것이다 — 서로만 닮은 문장 셋이 있으면 점수가 그 안에서 돌기만 하는데, 매 회 만큼을 모든 노드에 고루 뿌려 주면 그 고임이 풀린다. 를 1에 가깝게 올리면 고임이 심해지고, 0.5까지 내리면 모든 문장의 점수가 비슷해져 순위 자체가 사라진다.
반복은 점수 벡터의 변화량이 임계값 아래로 내려가면 멈춘다. 실무에서는 수십 회면 충분하고, 고정 횟수로 끊어도 결과가 거의 같다. 유사도 행렬을 행 합으로 나눠 정규화해야 값이 발산하지 않는다는 점만 주의한다.
한국어 문장 분리
TextRank의 정확도를 실제로 좌우하는 것은 알고리즘이 아니라 그 앞 단계, 곧 문장을 나누는 일이다.
마침표로 자르는 순진한 방법은 한국어에서 여러 군데서 깨진다. 「3.5%」·「2026. 9. 23.」의 마침표가 문장을 자르고, 「(주)한국」·「Dr. 김」 같은 약어도 마찬가지다. 반대로 구어체 문장은 마침표 없이 끝나기도 한다. 그래서 문장 분리는 정규식 대신 Kiwi의 split_into_sents 같은 형태소 기반 분리기에 맡기는 편이 안전하다. 분리가 어긋나면 한 노드가 두 문장을 담거나 한 문장이 둘로 쪼개지고, 그 노드의 유사도가 통째로 잘못 계산되어 결과 전체가 흔들린다.
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.feature_extraction.text import TfidfVectorizer
def textrank_summarize(sentences, top_n=3, d=0.85, iters=50):
tfidf = TfidfVectorizer().fit_transform(sentences)
sim = cosine_similarity(tfidf)
np.fill_diagonal(sim, 0)
# 행 합으로 정규화 — 안 하면 점수가 발산한다
row_sum = sim.sum(axis=1, keepdims=True)
row_sum[row_sum == 0] = 1
trans = sim / row_sum
scores = np.ones(len(sentences))
for _ in range(iters):
scores = (1 - d) + d * (trans.T @ scores)
top_idx = sorted(np.argsort(scores)[-top_n:].tolist())
return " ".join(sentences[i] for i in top_idx)
BART의 사전학습
노이즈 다섯 가지
BART는 인코더를 BERT처럼 양방향으로, 디코더를 GPT처럼 자동회귀로 둔 인코더-디코더 모델이다. 사전학습은 원문을 일부러 망가뜨린 뒤 원문을 복원하게 시키는 방식이고, 망가뜨리는 방법이 다섯 가지다.
| 노이즈 | 하는 일 |
|---|---|
| 토큰 마스킹 | 토큰 하나를 마스크로 바꾼다 |
| 토큰 삭제 | 토큰을 지운다. 어디가 지워졌는지도 모델이 알아내야 한다 |
| 텍스트 채우기 | 연속한 토큰 여러 개를 마스크 하나로 바꾼다 |
| 문장 뒤섞기 | 문서의 문장 순서를 섞는다 |
| 문서 회전 | 임의의 토큰을 문서 시작으로 삼아 돌린다 |
요약에 실제로 듣는 것
다섯 중 요약 성능을 실제로 올린 것은 텍스트 채우기와 문장 뒤섞기 조합이었다. 이유가 각각 뚜렷하다.
텍스트 채우기는 「몇 개가 지워졌는지 모르는 빈칸」을 메우게 한다. 마스크 하나가 토큰 세 개일 수도 영 개일 수도 있으므로, 모델은 빈칸을 메우면서 출력의 길이를 스스로 정하는 연습을 하게 된다. 이것이 요약의 핵심 능력과 정확히 겹친다 — 요약은 입력보다 짧은 출력을 만드는 일이고, 얼마나 짧을지는 내용이 정한다.
문장 뒤섞기는 문서 전체의 순서를 복원하게 한다. 개별 문장이 아니라 문서 수준의 구조를 인코더가 잡게 만드는 장치이고, 여러 문단에 흩어진 내용을 하나로 묶어야 하는 요약에서 그 능력이 그대로 쓰인다. 반면 토큰 마스킹 하나만 쓰면 BERT와 같은 목표가 되어 생성 쪽 이득이 거의 없다.
T5와의 차이
T5는 모든 태스크를 텍스트에서 텍스트로 가는 문제로 통일한다. 요약은 입력 앞에 summarize: 를 붙인 형태가 되고, 번역·분류도 각각의 접두사를 붙여 같은 모델에 넣는다.
구조는 BART와 거의 같지만 쓰는 방식이 갈린다. T5는 접두사만 바꿔 여러 태스크를 한 모델로 돌릴 수 있어 태스크가 여럿인 서비스에서 유리하고, BART는 요약 한 가지로 파인튜닝할 때 더 단순하다. 한국어에서는 SKT가 공개한 KoBART와 그것을 요약으로 파인튜닝한 공개 체크포인트들이 가장 손쉬운 출발점이다.
요약의 사실성
환각 세 유형
추상적 요약이 지어내는 내용은 세 가지로 나뉘고, 각각 탐지 난이도가 다르다.
개체 뒤바뀜은 원문에 있는 이름·기관·제품을 엉뚱한 자리에 놓는 것이다. 「A사가 B사를 인수했다」가 「B사가 A사를 인수했다」로 뒤집히는 식이고, 등장하는 단어는 전부 원문에 있으므로 겹침 기반 지표로는 전혀 안 잡힌다.
수치 오류는 숫자가 틀리는 것이다. 원문의 여러 숫자가 섞이거나(매출 79조와 영업이익 9조가 합쳐짐), 단위가 바뀌거나, 증감률이 절대값으로 바뀐다. 셋 중 탐지는 가장 쉽다.
없는 인과는 원문이 나란히 적기만 한 두 사실을 요약이 인과로 이어 버리는 것이다. 「HBM 공급이 늘었다. 영업이익이 증가했다」가 「HBM 공급 확대로 영업이익이 증가했다」가 된다. 대체로 그럴듯하고 실제로 맞는 경우도 많아 가장 잡기 어렵고, 틀렸을 때 가장 위험하다.
점검하는 순서
세 유형에 각각 다른 점검이 붙는다. 수치는 기계로 다 잡힌다 — 요약에서 숫자를 정규식으로 뽑아 원문에 같은 값이 있는지 대조하고, 없으면 그 건을 사람에게 넘긴다. 단위와 자릿수를 정규화해 두는 것이 전부다.
개체는 개체명 인식으로 요약과 원문에서 각각 목록을 뽑아 비교한다. 요약에만 있는 개체는 명백한 환각이고, 양쪽에 다 있는데 역할이 뒤바뀐 경우는 이것만으로 못 잡는다. 그때는 요약의 각 문장을 질문으로 바꿔 원문에 대해 질의응답 모델을 돌리고 답이 일치하는지 보는 방식을 쓴다. 인과는 「원문에 인과를 나타내는 표현이 있었는가」를 먼저 보고, 없이 요약에만 「~로 인해」·「덕분에」가 생겼으면 표시해 둔다. 완전 자동은 어렵고, 적어도 검수자의 눈이 그 자리로 가게 만드는 값은 한다.
점검을 붙이는 순서는 비용순이다. 숫자 대조는 정규식 몇 줄이라 거의 공짜이므로 언제나 켜 두고, 개체 비교는 개체명 인식 모델을 한 번 더 돌려야 하니 요약이 사람에게 그대로 나가는 경로에만 건다. 질의응답을 이용한 교차 확인은 요약 문장마다 추론을 한 번씩 더 하는 일이라 가장 비싸고, 사실이 틀렸을 때 손해가 큰 도메인에서만 값을 한다. 셋을 모두 통과한 요약도 완벽한 것은 아니라, 통과 여부를 화면에 표시해 검수자가 어디를 먼저 볼지 알게 하는 정도로 쓰는 것이 현실적이다.
요약 평가 지표
ROUGE
ROUGE는 생성 요약과 참조 요약 사이의 어휘 겹침을 센다. 세 가지를 함께 본다.
- ROUGE-1은 단어 단위 겹침이고, 내용어를 얼마나 담았는지를 본다.
- ROUGE-2는 이어진 두 단어의 겹침이고, 어순과 구문이 얼마나 비슷한지를 본다.
- ROUGE-L은 최장 공통 부분 수열 기반이라, 사이에 다른 말이 끼어도 순서만 같으면 점수를 준다.
ROUGE-2가 특히 낮으면 내용은 맞는데 문장을 다르게 구성한 것이고, ROUGE-1까지 낮으면 아예 다른 내용을 말하고 있는 것이다. 두 값을 함께 봐야 진단이 된다.
형태소 단위 계산
한국어에서는 어느 단위로 세느냐가 점수를 크게 바꾼다. 어절 단위로 세면 「매출이」와 「매출은」이 다른 토큰이라 겹치지 않는다. 뜻은 같은데 조사만 다른 자리가 전부 0점이 되는 것이다.
그래서 한국어 ROUGE는 형태소 단위로 센다. 절차는 원문과 참조 요약을 모두 형태소 분석기에 통과시켜 토큰 열로 바꾼 뒤 그 위에서 겹침을 세는 것이고, 조사·어미를 빼고 내용 형태소만 남기는 변형도 쓴다. 중요한 것은 같은 분석기와 같은 설정으로 양쪽을 처리하는 일이다. 참조는 어절, 생성은 형태소로 세면 숫자 자체가 뜻을 잃는다. 어절 기준으로 잰 점수와 형태소 기준으로 잰 점수는 같은 시스템에서도 크게 벌어지므로, 다른 곳의 수치와 비교할 때는 어느 단위로 쟀는지를 먼저 확인해야 한다.
겹침이 못 보는 것
ROUGE는 두 군데서 눈을 감는다. 같은 뜻을 다른 말로 적으면 점수가 떨어지고(「인수했다」와 「사들였다」), 반대로 원문의 문장을 그대로 베끼면 점수가 높게 나온다. 후자가 더 고약하다 — 추출적 요약이 추상적 요약보다 ROUGE에서 유리한 경향이 있고, 그래서 ROUGE만 올리다 보면 복사하는 모델이 이긴다.
BERTScore는 두 요약의 토큰을 임베딩으로 바꿔 코사인 유사도로 짝지어 맞춘다. 표현이 달라도 뜻이 가까우면 점수를 주므로 첫 번째 눈감음을 메운다. 다만 이것도 사실성은 안 본다 — 숫자가 틀린 요약도 의미 공간에서는 원문과 가깝다. 그래서 실무 평가는 세 층으로 쌓는다. 겹침(ROUGE)으로 내용 포함을 보고, 의미 유사도(BERTScore)로 표현 차이를 흡수하고, 앞 절의 사실성 점검으로 지어낸 것을 잡는다. 셋 중 하나라도 빠지면 그 방향의 실패가 지표에 안 나타난다.
긴 문서의 계층 요약
청크 크기와 겹침
BART·T5 계열의 입력 한계는 보통 1,024 토큰이다. 그보다 긴 문서는 잘라서 각각 요약하고 그 요약들을 다시 요약하는 계층 구성으로 처리한다.
자르는 단위는 토큰 수가 아니라 문서의 구조를 따른다. 문단이나 절 경계에서 끊고, 그 안에서 길이가 넘치면 문장 경계에서 다시 끊는다. 문장 중간에서 잘린 청크는 그 자체로 뜻이 안 통해 요약이 무너진다. 겹침은 한두 문장 정도 준다 — 앞 청크의 끝 문장을 다음 청크의 앞에 넣어 두면 경계에 걸친 내용이 양쪽에서 한 번씩 보인다.
정보가 새는 자리
계층 요약에서 내용이 사라지는 자리는 둘이다.
첫째는 중간 요약 단계다. 각 청크를 요약할 때 모델은 그 청크 안에서만 중요도를 판단하므로, 문서 전체에서는 핵심인데 그 청크 안에서는 곁가지인 문장이 버려진다. 결론이 마지막 문단에만 한 줄 적혀 있는 보고서에서 자주 생긴다. 완화하는 방법은 각 청크를 요약할 때 문서 제목이나 전체에 대한 한 줄 설명을 함께 넣어 주는 것이다.
둘째는 최종 병합 단계다. 중간 요약 열 개를 다시 요약하면 그 열 개에 고루 나오는 내용이 살아남고 한 곳에만 나온 내용이 떨어진다. 요약의 압축이 중복 제거로 작동하기 때문인데, 문서에 한 번만 나오는 중요한 사실이 정확히 그 성질에 걸린다. 그래서 병합 단계는 압축률을 낮게 잡고, 필요하면 병합을 두 번에 나눠 한 번에 줄이는 폭을 줄인다.
긴 컨텍스트를 그냥 쓰기
희소 어텐션을 쓰는 인코더-디코더(LED 계열)는 1만 토큰 이상을 한 번에 받는다. 계층 구성의 두 가지 누수를 구조적으로 피할 수 있다는 것이 장점이다.
긴 컨텍스트 LLM에 문서를 통째로 넣는 방법도 같은 이유로 품질이 좋다. 구현이 가장 간단하고, 요약의 형식·길이·관점을 프롬프트로 지시할 수 있다는 이점도 크다. 대신 토큰 단위 과금이라 문서가 길고 건수가 많으면 비용이 빠르게 올라가고, 지연 시간도 전용 모델보다 길다. 하루 수만 건을 처리하는 배치라면 파인튜닝한 작은 모델이, 하루 수십 건짜리 내부 도구라면 LLM이 대체로 맞는 선택이다. 판단이 애매하면 건수와 문서 평균 길이를 곱해 월 토큰을 먼저 어림해 보는 편이 빠르다. 그 값이 파인튜닝에 드는 한 번의 비용과 GPU 상주 비용을 넘어서는 지점이 곧 갈아탈 자리이고, 그 지점은 대개 생각보다 이르게 온다.
KoBART 파인튜닝
데이터 정제
요약 파인튜닝의 성패는 학습 데이터 쪽에서 거의 결정된다. 걸러야 할 짝이 셋이다.
원문보다 요약이 길거나 비슷한 길이인 짝은 버린다. 모델이 복사를 배우게 된다. 요약에 원문에 없는 개체나 숫자가 들어 있는 짝도 버린다 — 학습 데이터에 환각이 섞여 있으면 모델은 그것을 정답으로 배운다. 그리고 요약이 「본 기사는 ~에 대한 것이다」처럼 내용이 아니라 형식만 말하는 짝을 걸러낸다. 이런 짝은 뉴스 데이터셋에서 의외로 많이 나오고, 남겨 두면 모델이 그 틀을 그대로 익혀 어떤 입력에도 같은 문장으로 시작한다.
정제를 돌리고 나면 남은 짝의 압축률 분포를 한 번 본다. 원문 대비 요약 길이의 비율이 한 값 근처에 몰려 있어야 모델이 일관된 길이를 배우고, 0.1과 0.6이 섞여 있으면 같은 입력에 길이가 들쭉날쭉한 출력이 나온다. 분포가 넓으면 구간을 나눠 각각 따로 학습하거나, 목표 길이를 입력 앞에 표시로 붙여 모델이 그것을 보고 길이를 정하게 만든다.
생성 길이 잡기
파인튜닝이 끝난 뒤 출력 길이는 생성 설정이 정한다. 손잡이가 셋이고 하는 일이 다르다.
min_length는 하한을 강제한다. 이 값에 도달하기 전에는 종료 토큰이 나와도 무시하므로, 너무 크게 잡으면 할 말이 없는데도 문장을 이어 붙이다 환각이 늘어난다. max_length는 상한이고, 여기 걸려 잘리면 문장이 미완으로 끝나는 출력이 나온다. length_penalty는 빔 서치에서 후보 점수를 길이로 나눌 때의 지수다 — 1보다 크면 긴 후보에 유리해져 요약이 길어지고, 1보다 작으면 짧아진다.
순서는 max_length를 넉넉히 두고 length_penalty로 평균 길이를 맞춘 뒤, 너무 짧은 출력이 실제로 문제가 될 때만 min_length를 올리는 것이다. no_repeat_ngram_size=3 은 같은 세 단어 묶음이 두 번 나오지 않게 막는데, 이름이 반복될 법한 문서에서는 오히려 필요한 반복을 막으므로 값을 키우거나 꺼 본다.
from transformers import (
BartForConditionalGeneration,
PreTrainedTokenizerFast,
)
model_name = "digit82/kobart-summarization"
tokenizer = PreTrainedTokenizerFast.from_pretrained(model_name)
model = BartForConditionalGeneration.from_pretrained(model_name)
def summarize(text: str, max_len: int = 128) -> str:
inputs = tokenizer(
text, return_tensors="pt",
max_length=1024, truncation=True,
)
ids = model.generate(
inputs["input_ids"],
num_beams=4,
max_length=max_len,
min_length=30,
length_penalty=2.0,
early_stopping=True,
no_repeat_ngram_size=3,
)
return tokenizer.decode(ids[0], skip_special_tokens=True)
도메인을 옮길 때
뉴스로 학습한 요약 모델을 상담 로그나 회의록에 그대로 쓰면 눈에 띄게 나빠진다. 문서의 구조가 다르기 때문이다. 뉴스는 첫 문단에 핵심이 오는 역피라미드 구조라 모델이 앞쪽을 보는 습관을 익히는데, 회의록의 결론은 맨 뒤에 있고 상담 로그의 핵심은 중간의 한 발화에 있다.
옮길 때는 그 도메인 데이터 수백~수천 짝으로 추가 파인튜닝하는 것이 가장 확실하다. 라벨이 없다면 차선책으로 입력 쪽 구조를 바꾼다 — 회의록이라면 발화자와 시간 표시를 남겨 모델이 구조를 볼 수 있게 하고, 결론 부분을 앞으로 옮겨 넣는 전처리를 거는 식이다. 전문 용어가 문제라면 그 용어들이 서브워드로 잘게 쪼개지고 있지 않은지부터 확인한다. 한 용어가 토큰 여섯 개로 쪼개지면 모델이 그것을 하나의 개체로 다루기 어려워진다.
요약은 문서 전체를 줄여 내놓는 일이라 「무엇이 중요한가」를 모델이 정했다. 다음 글에서는 중요도를 묻는 쪽이 사람으로 바뀌는 자리, 곧 질문을 주고 그 답이 있는 대목만 찾아오는 질의응답을 다룬다.
읽어주셔서 감사합니다. 😊

