블로그 목록
NLPRoBERTa한국어Extractive SummarizationMulti-task금융

LQ-FSE-Base 개발기: 한국어 증권 리포트에서 핵심 문장을 뽑아내는 모델 만들기

klue/roberta-base에 Inter-sentence Transformer를 얹고, GPT 라벨링한 증권 리포트·금융 뉴스로 학습해 대표문장 추출과 역할 분류(outlook·event·financial·risk)를 한 번에 처리하는 LQ-FSE-Base를 만든 과정을 정리합니다.

LangQuant2026년 5월 23일22 분

왜 요약이 아니라 "추출"인가

증권사 리포트 한 편은 보통 A4 5–15장입니다. 분석가의 톤·서론·차트 설명·디스클레이머가 다 섞여 있고, 정작 시장이 알아야 할 한 줄은 본문 중간 어딘가에 박혀 있습니다. 랭퀀트가 다루는 리포트와 금융 뉴스 코퍼스는 사람이 다 읽기에는 양이 많고, 그렇다고 LLM에 통째로 던지는 방식은 비용이 빠르게 누적됩니다.

가장 단순한 길은 LLM(GPT-4.1)에게 리포트 전체를 던지고 abstractive 요약을 받는 것입니다. 품질은 좋지만, 운영 단위로 매번 호출할 때는 세 가지 한계가 명확합니다:

  1. 할루시네이션 — 리포트에 없는 수치를 만들어내거나, "전망"을 "결정"으로 둔갑시킴. 금융 문맥에서는 한 글자 틀리면 신호가 뒤집힙니다.
  2. 정보 손실 — 추상화 과정에서 분석가의 원문 뉘앙스가 깎여 나갑니다. "확대될 전망"이 "확대"로 단축되면 전망이라는 메타정보가 사라집니다.
  3. 요약을 위해 리포트 전체를 읽어야 함 → LLM 토큰 비용 — abstractive 모델은 문서 전체를 입력으로 받아야 결론을 쓸 수 있습니다. 하루 수만 건의 리포트를 다 통과시키면 토큰 비용이 빠르게 부담이 됩니다.

그래서 방향을 바꿨습니다. 추론 단계에서는 LLM을 호출하지 않고, 작은 BERT가 원문 문장 중 핵심만 골라내도록 학습합니다. 대신 GPT-4.1은 학습 데이터를 만드는 티처 로 한 번만 쓰고, 그 판단을 BERT에 distill 합니다. 추가로 각 핵심 문장의 역할 (outlook / event / financial / risk)만 메타데이터로 따로 뽑아 후속 파이프라인이 필요한 종류의 문장만 골라 쓸 수 있게 만들었습니다.

이렇게 하면:

  • 비용 절감 — 추론 시 작은 BERT 한 번에 끝나니 LLM 토큰 비용 자체가 사라집니다. GPT-4.1 비용은 라벨링 시점 1회로 끝.
  • 할루시네이션 0 — 원문에 있는 문장만 그대로 뽑아내므로 새 텍스트가 생성될 여지가 없습니다.
  • 정보 추적성 — 모든 핵심 문장이 원문 위치로 곧장 역추적됩니다.

모델 이름은 LQ-FSE-Base (LangQuant Financial Sentence Extractor).

목표: 핵심 문장 + 그 문장의 역할

단순히 "이 문장이 중요한가?" 만으로는 부족합니다. 같은 리포트 안에서도 전망(outlook) 한 줄과 재무 실적(financial) 한 줄과 리스크(risk) 한 줄은 다 핵심이지만 의미가 완전히 다릅니다. 그래서 한 문장에 대해 두 가지를 동시에 예측하기로 했습니다.

{
  "is_representative": true,                        // 대표문장 여부
  "role": "outlook | event | financial | risk"     // 문장의 역할
}
  • outlook — 전망/예측 ("HBM3E 양산이 본격화되면서 AI 시장 점유율이 확대될 전망")
  • event — 이벤트/사건 ("삼성전자가 신규 반도체 공장 착공을 발표")
  • financial — 재무 실적/수치 ("영업이익이 전분기 대비 30% 증가한 12조원")
  • risk — 리스크 요인 ("중국 시장 불확실성이 여전히 리스크 요인으로 작용")

이 둘이 묶여 나와야 "이번 리포트의 핵심 outlook 3줄 + 핵심 risk 1줄" 같은 구조화된 요약을 자동으로 만들 수 있습니다.

데이터: 증권 리포트 + 금융 뉴스 마크다운 코퍼스

원본은 크롤링된 한국 증권사 리포트 PDF와 금융 매체의 본문 텍스트입니다. PDF는 IBM의 Docling 으로 마크다운 변환했습니다 — 표·각주·페이지 머리말이 흩어진 증권 리포트 PDF에서 본문 단락의 순서와 구조를 비교적 안정적으로 살려주기 때문에, 문장 분리 단계에서 잡음이 크게 줄었습니다.

전처리는 4단계로 흘러갑니다.

1. 원본 PDF 리포트
        │  Docling  (PDF → Markdown, 표·페이지 메타 보존)

2. 마크다운 리포트 / 뉴스 본문 (JSON)
        │  preprocess  (문장 분리 + 잡문 제거)

3. preprocessed_data.json
        │  gpt_labeling_batch  (GPT-4.1 Batch API)

4. labeled_data.json  (대표문장 + role 라벨)
        │  make_training_data  (train/val split)

5. training_data_{train, val}.json

라벨링은 GPT-4.1 Batch API를 썼습니다. 단건 호출 대비 절반 가격이고 throughput이 훨씬 좋아 수십만 건을 며칠 안에 다 돌릴 수 있었습니다. 배치는 1,000건씩 8개 묶음으로 나눠 제출했고, 결과를 모아 학습 데이터로 변환했습니다.

GPT 라벨링을 그대로 정답으로 쓰면 위험합니다. 그래서 샘플 1,000건은 사람이 직접 검수해서 GPT 라벨이 적어도 85% 이상 사람 판단과 일치하는지 확인한 뒤에 본격 라벨링에 들어갔습니다. 가장 자주 틀리는 케이스는 outlook과 event의 경계 — "공장 착공 발표"가 event인지, "AI 시장 점유율 확대 전망"이 outlook인지의 판단 기준을 프롬프트에 명시적으로 박아 넣어야 일치율이 올라갔습니다.

최종 validation 셋은 4,609개 문서, 81,984개 문장. 문서당 평균 17–18문장, 그중 약 21%가 대표문장으로 라벨링됐습니다.

모델: klue/roberta-base + Inter-sentence Transformer

문제 자체가 "문장 하나만 보고 중요도를 판단하기 어려운" 태스크입니다. 같은 문장이라도 주변 문장과의 관계 안에서만 핵심인지 판가름 나기 때문입니다. 예: "영업이익이 12조원이다" 한 줄은 그 위의 "전망이 어둡다"가 있느냐 없느냐에 따라 핵심 여부가 갈립니다.

그래서 두 단계 구조로 갑니다:

[문서: max_sentences=30, max_length=128]


[각 문장] → klue/roberta-base → [CLS] 벡터  (문장별 인코딩)


Inter-sentence TransformerEncoder (2 layers, 8 heads)  ← 문장 간 관계

    ├── Linear → Sigmoid → 대표문장 점수 (0~1)

    └── Linear → 역할 분류 (4-class: outlook / event / financial / risk)

핵심은 Inter-sentence Transformer입니다. RoBERTa로 각 문장을 [CLS]로 압축한 다음, 그 [CLS] 벡터들을 다시 한 번 Transformer에 통과시켜 "문서 내 문장 간 어텐션"을 학습합니다. 이게 있느냐 없느냐가 baseline 대비 가장 큰 차이를 만들었습니다.

손실은 두 헤드의 가중합:

loss = BCE(scores, is_representative)              # 대표문장 이진 분류
     + role_weight * CE(role_logits, role_label,   # 역할 다중 분류
                       ignore_index=-1)            # (대표문장 아니면 -1)

role_weight = 0.5 — 역할 분류는 대표문장으로 라벨링된 곳에서만 학습됩니다 (ignore_index=-1). 이렇게 두면 모델이 먼저 "이게 핵심 문장인가"를 학습하면서, 핵심인 문장에 한해서만 "그게 어떤 종류의 핵심인가"를 학습하게 됩니다.

학습 과정에서 만난 함정

1. 빈 문장 패딩이 NaN을 만들었다

max_sentences=30으로 고정하다 보니 짧은 문서는 빈 문장으로 패딩됩니다. 처음에는 빈 문장도 그냥 BERT에 통과시켰는데, attention_mask가 전부 0인 입력이 들어가면 softmax 분모가 0이 되어 NaN이 backward로 흘러나오는 버그가 생겼습니다.

해결: baseline 모델에서는 attention_mask.sum() > 0 인 위치만 BERT에 실제로 통과시키고, 나머지는 zero 임베딩으로 채우는 방식으로 우회. DocumentEncoder에서는 inter-sentence Transformer의 src_key_padding_mask로 빈 문장을 가려줍니다.

2. 라벨 매칭에 substring 허용이 필요했다

라벨링된 대표문장과 원문 문장이 항상 정확히 일치하지 않습니다. GPT가 라벨링하면서 마지막 문장부호를 떼거나, 공백을 살짝 정리하거나, 인용부호를 빼는 경우가 흔합니다. exact match만 쓰면 recall이 절반으로 떨어졌습니다.

해결: sentence in rep or rep in sentence 의 양방향 substring 매칭을 허용. False positive가 약간 늘지만 매칭률은 90%대로 올라옵니다.

3. Best 모델 기준을 accuracy로 두면 안 된다

대표문장은 전체 문장의 약 21% — 클래스 불균형이 큽니다. accuracy 기준으로 best 모델을 고르면 "모든 문장을 non-rep로 예측" 하는 trivial 솔루션도 0.79를 받습니다.

해결: validation F1 기준으로 best 체크포인트를 저장하도록 학습 루프를 구성했습니다.

학습 설정

파라미터
Base modelklue/roberta-base
Inter-sentence Transformer2 layers, 8 heads, dim_ff=2048
OptimizerAdamW (lr=2e-5, weight_decay=0.01)
SchedulerLinear warmup (10% of steps)
Batch size4 (문서 단위)
Max sentences / doc30
Max tokens / sentence128
Role weight0.5
Gradient clippingmax_norm=1.0
Best 기준Validation F1

배치 사이즈가 4밖에 안 되는 이유는 단위가 문서기 때문입니다. 4문서 × 30문장 × 128토큰 = 한 배치에 BERT를 480번 돌리는 셈이라 GPU 메모리가 빠르게 찹니다.

성능

Validation 셋(4,609문서 / 81,984문장, threshold=0.5) 기준:

카테고리지표점수
대표문장 추출Accuracy0.874
Precision0.717
Recall0.693
F10.705
Macro Doc F10.699
역할 분류Overall Accuracy0.851
outlook F10.871
event F10.841
financial F10.867
risk F10.766
랭킹NDCG0.747
MAP0.803

Baseline과의 비교

같은 validation 셋에서 고전적인 extractive 베이스라인들과 비교했습니다.

모델PrecisionRecallF1
Lead-3 (앞 3문장)0.3130.2440.274
Lead-5 (앞 5문장)0.2870.3730.324
TextRank0.3340.3340.334
Random-K0.2770.2770.277
LQ-FSE-Base0.7170.6930.705

증권 리포트는 핵심이 첫 단락이 아니라 본문 전체에 분포해 있어 Lead-K가 잘 안 먹습니다. TextRank처럼 어휘 기반 그래프 방법도 도메인 용어(전망·예상·추정)의 가중치를 못 잡습니다. 학습된 LQ-FSE-Base가 F1 기준 2배 이상 차이를 만듭니다.

Threshold sweep

운영에 쓸 cutoff를 정하기 위해 threshold를 0.1–0.9로 sweep했습니다.

ThresholdPrecisionRecallF1
0.30.6460.7780.706
0.40.6820.7350.708
0.5 (default)0.7170.6930.705
0.60.7460.6470.693
0.70.7800.5890.671

0.4가 최적입니다 (F1 0.708). 운영에서는 "핵심 문장을 가능한 한 다 잡고 싶다"는 정책이라 recall을 약간 더 가져가는 0.3–0.4 구간을 사용합니다.

문서 길이별 성능

문장 수F1문서 수
1–50.91793
6–100.828938
11–150.7401,047
16–200.690859
21–300.6091,672

문서가 길수록 성능이 떨어집니다 — 후보 수가 많아질수록 false positive가 늘기 때문입니다. 30문장이 넘는 긴 리포트는 현재 앞 30문장만 잘라서 처리하지만, 다음 버전에서는 chunked inference + re-ranking 으로 풀어볼 계획입니다.

역할 분류 Confusion Matrix

True \ Predoutlookeventfinancialrisk
outlook6,567341309269
event3373,13013484
financial3461813,78287
risk3381071041,620

가장 헷갈리는 쌍은 outlook ↔ event입니다 ("발표했다"의 시점성 vs 미래지향성). risk는 표본이 가장 적어 F1이 0.77로 가장 낮습니다.

사용법

import re
import torch
from transformers import AutoConfig, AutoModel, AutoTokenizer
 
repo_id = "LangQuant/LQ-FSE-base"
 
config    = AutoConfig.from_pretrained(repo_id, trust_remote_code=True)
model     = AutoModel.from_pretrained(repo_id, trust_remote_code=True)
tokenizer = AutoTokenizer.from_pretrained(repo_id)
model.eval()
 
text = (
    "삼성전자의 2024년 4분기 실적이 시장 예상을 상회했다. "
    "메모리 반도체 가격 상승으로 영업이익이 전분기 대비 30% 증가했다. "
    "HBM3E 양산이 본격화되면서 AI 반도체 시장 점유율이 확대될 전망이다. "
    "다만 중국 시장의 불확실성은 여전히 리스크 요인으로 남아 있다."
)
 
# 문장 분리 + 패딩
sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', text.strip()) if s.strip()]
max_len, max_sent = config.max_length, config.max_sentences
 
padded = sentences[:max_sent]
num_real = len(padded)
while len(padded) < max_sent:
    padded.append("")
 
ids_list, mask_list = [], []
for s in padded:
    if s:
        enc = tokenizer(s, max_length=max_len, padding="max_length",
                        truncation=True, return_tensors="pt")
    else:
        enc = {"input_ids":      torch.zeros(1, max_len, dtype=torch.long),
               "attention_mask": torch.zeros(1, max_len, dtype=torch.long)}
    ids_list.append(enc["input_ids"])
    mask_list.append(enc["attention_mask"])
 
input_ids      = torch.cat(ids_list).unsqueeze(0)
attention_mask = torch.cat(mask_list).unsqueeze(0)
doc_mask       = torch.zeros(1, max_sent)
doc_mask[0, :num_real] = 1
 
with torch.no_grad():
    scores, role_logits = model(input_ids, attention_mask, doc_mask)
 
role_labels = config.role_labels   # ["outlook","event","financial","risk"]
for i, sent in enumerate(sentences):
    score = scores[0, i].item()
    role  = role_labels[role_logits[0, i].argmax().item()]
    mark  = "*" if score >= 0.4 else " "
    print(f"  {mark} [{score:.4f}] [{role:10s}] {sent}")

출력 예시

  * [0.8732] [financial ] 삼성전자의 2024년 4분기 실적이 시장 예상을 상회했다.
  * [0.7145] [financial ] 메모리 반도체 가격 상승으로 영업이익이 전분기 대비 30% 증가했다.
  * [0.9021] [outlook   ] HBM3E 양산이 본격화되면서 AI 반도체 시장 점유율이 확대될 전망이다.
  * [0.6483] [risk      ] 다만 중국 시장의 불확실성은 여전히 리스크 요인으로 남아 있다.

score는 대표문장 확률 (0~1), role은 4-class 분류 결과입니다. 한 리포트의 핵심 outlook·event·financial·risk 문장을 한 번의 forward pass로 다 뽑아낼 수 있습니다.

랭퀀트에서는 어떻게 쓰이나

LQ-FSE-Base는 [[lq-kbert-base]]와 함께 랭퀀트 NLP 파이프라인의 두 번째 레이어입니다.

  1. 크롤러가 증권 리포트·뉴스 본문을 수집
  2. LQ-FSE-Base가 본문에서 대표문장 + role을 뽑아 구조화된 요약을 생성
  3. 그 요약을 LQ-KBERT-Base에 통과시켜 sentiment·action·certainty 6차원으로 분류
  4. 종목 단위로 집계 → 운영 시그널·리서치 알람으로 전달

기존에 GPT로 abstractive 요약을 돌리던 부분을 LQ-FSE-Base로 대체하면서 요약 처리 비용이 1/60 수준으로 떨어졌고, 무엇보다 할루시네이션이 0 (원문 문장만 뽑기 때문)이 되었습니다. 분석가가 "이 한 줄 어디서 나왔어?" 라고 물으면 원문 위치로 곧장 역추적이 됩니다.

아직 풀지 못한 것

  • 긴 문서: 30문장이 넘는 리포트는 잘려나갑니다. Chunk → re-rank 파이프라인이 필요합니다.
  • risk 클래스 데이터 부족: 4개 role 중 risk가 표본이 가장 적어 F1이 0.77에 머뭅니다. 리스크 섹션이 짧고 정형화되어 있어서 라벨링 단계에서 잘 안 잡힙니다.
  • 표 / 수치 박스 인식: 마크다운으로 변환되는 시점에서 표 안의 핵심 수치가 일반 본문 문장처럼 보입니다. 모델이 "표 잔재"를 잘못된 financial 핵심으로 잡는 경우가 있습니다.

다음 단계

  • GPT-5 재라벨링: 현재 라벨은 GPT-4o-mini 기반. 1년 동안 운영하면서 라벨링 가이드라인이 정교해진 만큼, 같은 코퍼스를 GPT-5로 다시 라벨링해 risk·event 경계의 노이즈를 줄여보려 합니다.
  • DocumentEncoder의 sentence-level positional encoding: 현재는 inter-sentence Transformer에 별도 position encoding이 없습니다. "리포트의 결론은 마지막에 온다" 같은 위치 패턴을 명시적으로 줄 여지가 있습니다.
  • Cross-document re-ranking: 같은 종목에 대한 여러 리포트의 핵심 문장끼리 비교해 컨센서스이견 을 자동으로 가르는 방향. 단일 문서 단위 추출의 다음 단계입니다.

긴 글 읽어주셔서 감사합니다. 모델은 Hugging Face에 공개되어 있습니다 — huggingface.co/LangQuant/LQ-FSE-base. 사용하시면서 발견하신 이슈나 개선 아이디어는 언제든 환영합니다.

랭퀀트의 시그널·리서치 공식 채널은 t.me/langquant_ai 입니다 — 신호 알림과 운영 업데이트를 받아보실 수 있습니다.

Citation

@misc{langquant2026lqfse,
  title  = {LQ-FSE-Base: Korean Financial Sentence Extractor with Role Classification},
  author = {LangQuant},
  year   = {2026},
  url    = {https://huggingface.co/LangQuant/LQ-FSE-base}
}

이 모델은 학술 연구 및 실험용으로만 제공됩니다.
본 모델의 출력은 금융/투자 자문으로 간주될 수 없으며, 발생하는 모든 결과에 대해 LangQuant는 책임을 지지 않습니다.