질의응답: 문서에서 답을 찾는 기술
추출형·생성형·오픈도메인 QA의 원리부터 BERT 스팬 예측, KorQuAD 파인튜닝, RAG 기반 오픈도메인 QA까지 질의응답 시스템의 전체 기술 스택을 한국어 코드와 함께 완전히 해설한다.
지난 글에서 긴 문서를 압축하는 요약 기술을 살펴봤다. 이번에는 문서에서 특정 질문의 답을 정확히 찾아내는 **질의응답(Question Answering, QA)**을 다룬다. 단순히 검색 결과를 반환하는 것이 아니라 “BTS는 언제 데뷔했나요?”라는 질문에 대해 “2013년 6월 13일”이라는 정확한 답을 추출하거나 생성하는 기술이다. 챗봇, 문서 검색, 고객 지원 자동화, 시험 자동 채점 등 다양한 분야에서 핵심 역할을 한다.
QA의 세 가지 유형
추출형 QA(Extractive QA): 주어진 문맥 텍스트에서 정답에 해당하는 스팬(span, 텍스트 구간)을 직접 추출한다. “답이 문서 안에 있다”는 가정 하에 작동하며, BERT 계열 모델이 강점을 보인다.
추상형 QA(Abstractive QA): 문서의 내용을 바탕으로 새로운 문장을 생성해 답한다. 문맥을 종합·재구성하는 능력이 필요하며, T5, BART, GPT 계열이 사용된다. “위 자료를 바탕으로 주요 시사점을 서술하라” 같은 복합 질문에 적합하다.
오픈도메인 QA(Open-domain QA): 특정 문서가 아닌 대규모 지식베이스(위키피디아, 내부 문서 DB 등) 전체를 대상으로 질문에 답한다. “검색 + 읽기” 2단계 구조로 작동하며, RAG(Retrieval-Augmented Generation)가 대표적인 구현 방식이다.
추출형 QA의 핵심: 스팬 예측
추출형 QA의 핵심은 “정답이 시작하는 토큰”과 “정답이 끝나는 토큰”을 예측하는 것이다.
BERT는 질문과 문맥을 [CLS] 질문 [SEP] 문맥 [SEP] 형태로 이어 붙여 입력받는다. 각 토큰의 숨겨진 상태(768차원 벡터)에 두 개의 독립적인 선형 레이어를 적용해 시작 위치와 끝 위치에 대한 확률 분포를 계산한다. 두 확률의 합이 가장 높은 (start, end) 쌍을 정답 스팬으로 선택한다.
# 내부 동작 원리 (간략)
import torch
import torch.nn.functional as F
def predict_span(model, input_ids, attention_mask):
outputs = model(
input_ids=input_ids,
attention_mask=attention_mask,
)
# outputs.start_logits: (batch, seq_len)
# outputs.end_logits: (batch, seq_len)
start_probs = F.softmax(outputs.start_logits, dim=-1)
end_probs = F.softmax(outputs.end_logits, dim=-1)
# 유효한 (start, end) 쌍에서 최고 확률 조합 선택
# (start <= end, end - start <= max_answer_len)
best_score = -1
best_start, best_end = 0, 0
for s in range(len(start_probs[0])):
for e in range(s, min(s + 30, len(end_probs[0]))):
score = start_probs[0][s] * end_probs[0][e]
if score > best_score:
best_score = score
best_start, best_end = s, e
return best_start, best_end
KorQuAD: 한국어 QA 벤치마크
**KorQuAD(Korean Question Answering Dataset)**는 위키피디아 기반의 한국어 추출형 QA 데이터셋이다. 영어의 SQuAD에 해당한다. KorQuAD 1.0은 70K+ 질문-답 쌍, KorQuAD 2.0은 HTML 구조 포함 복잡한 문서까지 다룬다.
from datasets import load_dataset
from transformers import (
AutoTokenizer,
AutoModelForQuestionAnswering,
Trainer,
TrainingArguments,
DefaultDataCollator,
)
MODEL_NAME = "klue/roberta-base"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForQuestionAnswering.from_pretrained(MODEL_NAME)
# KorQuAD 로드
dataset = load_dataset("squad_kor_v1") # KorQuAD 1.0
def preprocess(examples):
questions = [q.strip() for q in examples["question"]]
inputs = tokenizer(
questions,
examples["context"],
max_length=384,
truncation="only_second",
stride=128,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding="max_length",
)
# start/end 포지션 레이블 계산 (오프셋 매핑 활용)
# ... (생략: 실제 구현은 HuggingFace 예제 참조)
return inputs
tokenized = dataset.map(preprocess, batched=True, remove_columns=dataset["train"].column_names)
args = TrainingArguments(
output_dir="./qa-model",
per_device_train_batch_size=16,
num_train_epochs=2,
learning_rate=3e-5,
warmup_ratio=0.1,
)
trainer = Trainer(
model=model,
args=args,
train_dataset=tokenized["train"],
eval_dataset=tokenized["validation"],
data_collator=DefaultDataCollator(),
tokenizer=tokenizer,
)
trainer.train()
# KorQuAD 1.0 기준 EM ~84, F1 ~91 달성 가능
오픈도메인 QA: RAG 접근법
추출형 QA는 정답이 포함된 문서가 이미 주어져 있어야 한다. 하지만 실제 서비스에서는 수백만 개의 문서 중 어디에 답이 있는지 모른다. 이를 해결하는 것이 오픈도메인 QA다.
from transformers import RagTokenizer, RagRetriever, RagTokenForGeneration
# RAG = Dense Retrieval + Generator
tokenizer = RagTokenizer.from_pretrained("facebook/rag-token-nq")
retriever = RagRetriever.from_pretrained(
"facebook/rag-token-nq",
index_name="legacy",
)
model = RagTokenForGeneration.from_pretrained(
"facebook/rag-token-nq",
retriever=retriever,
)
inputs = tokenizer("What is the capital of South Korea?", return_tensors="pt")
generated = model.generate(**inputs, num_beams=2)
answer = tokenizer.batch_decode(generated, skip_special_tokens=True)
# → ["Seoul"]
한국어 오픈도메인 QA의 경우 HuggingFace RAG 대신 커스텀 파이프라인을 구성하는 경우가 많다: BM25 또는 Dense Retriever로 관련 청크 검색 → KLUE-RoBERTa로 스팬 추출 구조가 일반적이다.
평가 지표: EM과 F1
def compute_em_f1(prediction: str, gold: str) -> dict:
"""Exact Match and token-level F1"""
pred_tokens = prediction.split()
gold_tokens = gold.split()
# Exact Match
em = int(prediction == gold)
# Token-level F1
common = set(pred_tokens) & set(gold_tokens)
if not common:
return {"em": em, "f1": 0.0}
precision = len(common) / len(pred_tokens)
recall = len(common) / len(gold_tokens)
f1 = 2 * precision * recall / (precision + recall)
return {"em": em, "f1": f1}
# 예시
print(compute_em_f1("2013년 6월", "2013년 6월 13일"))
# {'em': 0, 'f1': 0.667}
EM(Exact Match): 예측값과 정답이 완전히 일치하는 비율. 엄격한 지표.
F1: 예측 토큰과 정답 토큰의 겹침 비율. EM보다 관대하고 부분 정답을 인정한다. 실제 QA 평가에서는 여러 정답 중 최고 F1을 취하는 방식을 사용한다.
유형별 선택 가이드
| 상황 | 권장 접근 |
|---|---|
| 고정 문서, 빠른 답변 | KLUE-RoBERTa 추출형 QA |
| 문서 없이 지식 활용 | GPT-4o/Claude 직접 질의 |
| 대규모 내부 문서 검색 | RAG (Dense Retrieval + LLM) |
| 복합·추론 질문 | Chain-of-Thought + LLM |
QA 기술은 다음에 다룰 기계 번역, 텍스트 생성과 마찬가지로 현대 LLM의 핵심 능력 중 하나다. RAG 기반 오픈도메인 QA는 LLM 시대에 더욱 중요해졌으며, 이후 RAG 전용 포스트에서 상세히 다룰 예정이다.
지난 글: 텍스트 요약: 길고 복잡한 문서를 한 문단으로
다음 글: 기계 번역: 언어의 장벽을 넘는 기술
읽어주셔서 감사합니다. 😊