Skip to main content

질의응답 (Question Answering)

질의응답(QA)은 주어진 **질문(Question)**에 대해 **문맥(Context)**에서 답변을 찾아내는 태스크입니다. 검색 엔진, 고객 서비스, 사내 문서 질의 등 실무에서 가장 직접적으로 활용되는 NLP 태스크이며, RAG(Retrieval-Augmented Generation) 파이프라인의 핵심 컴포넌트이기도 합니다.

학습 목표

이 문서를 완료하면 다음을 수행할 수 있습니다.
  • Extractive QA와 Generative QA의 차이를 설명할 수 있습니다
  • SQuAD/KorQuAD 형식의 데이터를 이해하고 전처리할 수 있습니다
  • BERT 기반 Extractive QA 모델을 Fine-tuning할 수 있습니다
  • T5 기반 Generative QA를 구현할 수 있습니다
  • EM(Exact Match)과 F1-Score로 QA 모델을 평가할 수 있습니다

QA 유형 비교

유형입력출력대표 모델특징
Extractive QA질문 + 문맥문맥 내 spanBERT, RoBERTa문맥에 답이 반드시 존재
Generative QA질문 + 문맥자유 형식 답변T5, GPT문맥을 재구성하여 답변
Open-domain QA질문만답변Retriever + Reader검색 단계 포함

SQuAD / KorQuAD 데이터 형식

Extractive QA의 표준 데이터 형식은 SQuAD(Stanford Question Answering Dataset)입니다. KorQuAD는 이를 한국어로 구축한 데이터셋입니다.
{
  "context": "서울은 대한민국의 수도이다. 인구는 약 970만 명으로 ...",
  "question": "대한민국의 수도는 어디인가?",
  "answers": {
    "text": ["서울"],
    "answer_start": [0]
  }
}
핵심은 answer_start입니다. 답변 텍스트가 문맥 내에서 시작하는 문자 위치를 나타내며, 모델은 이 시작 위치와 끝 위치를 예측하도록 학습됩니다.

실습 1: BERT Extractive QA

1

데이터셋 로딩

KorQuAD 1.0 데이터셋을 로딩합니다.
from datasets import load_dataset

# KorQuAD 1.0 데이터셋
dataset = load_dataset("squad_kor_v1")
print(dataset)
# DatasetDict({
#     train:      Dataset({num_rows: 60407})
#     validation: Dataset({num_rows: 5774})
# })

# 데이터 구조 확인
sample = dataset["train"][0]
print(f"질문: {sample['question']}")
print(f"문맥: {sample['context'][:100]}...")
print(f"답변: {sample['answers']['text']}")
print(f"시작 위치: {sample['answers']['answer_start']}")
2

전처리: 문자 위치를 토큰 위치로 변환

BERT 모델은 토큰 단위로 동작하므로, 문자 단위 answer_start토큰 단위 시작/끝 위치로 변환해야 합니다.
from transformers import AutoTokenizer

model_name = "klue/bert-base"
tokenizer = AutoTokenizer.from_pretrained(model_name)

def preprocess_qa(examples):
    """QA 데이터 전처리: 문자 위치 → 토큰 위치 변환"""
    questions = [q.strip() for q in examples["question"]]

    # 질문 + 문맥 토큰화
    tokenized = tokenizer(
        questions,
        examples["context"],
        max_length=384,
        truncation="only_second",   # 문맥만 잘라냄
        stride=128,                  # 슬라이딩 윈도우 오버랩
        return_overflowing_tokens=True,
        return_offsets_mapping=True,
        padding="max_length",
    )

    # 오버플로우로 생성된 샘플과 원본 매핑
    sample_map = tokenized.pop("overflow_to_sample_mapping")
    offset_mapping = tokenized.pop("offset_mapping")

    start_positions = []
    end_positions = []

    for i, offsets in enumerate(offset_mapping):
        sample_idx = sample_map[i]
        answers = examples["answers"][sample_idx]

        if len(answers["answer_start"]) == 0:
            start_positions.append(0)
            end_positions.append(0)
            continue

        start_char = answers["answer_start"][0]
        end_char = start_char + len(answers["text"][0])

        # 문맥 영역의 시작/끝 토큰 인덱스 찾기
        sequence_ids = tokenized.sequence_ids(i)

        # 문맥 영역 범위 확인
        ctx_start = 0
        while sequence_ids[ctx_start] != 1:
            ctx_start += 1
        ctx_end = len(sequence_ids) - 1
        while sequence_ids[ctx_end] != 1:
            ctx_end -= 1

        # 답변이 현재 청크에 포함되지 않으면 (0, 0)
        if (offsets[ctx_start][0] > end_char or
            offsets[ctx_end][1] < start_char):
            start_positions.append(0)
            end_positions.append(0)
        else:
            # 시작 토큰 찾기
            idx = ctx_start
            while idx <= ctx_end and offsets[idx][0] <= start_char:
                idx += 1
            start_positions.append(idx - 1)

            # 끝 토큰 찾기
            idx = ctx_end
            while idx >= ctx_start and offsets[idx][1] >= end_char:
                idx -= 1
            end_positions.append(idx + 1)

    tokenized["start_positions"] = start_positions
    tokenized["end_positions"] = end_positions
    return tokenized

# 전처리 적용
tokenized_dataset = dataset.map(
    preprocess_qa,
    batched=True,
    remove_columns=dataset["train"].column_names,
)
긴 문맥 처리: 문맥이 max_length를 초과하면 슬라이딩 윈도우(stride=128)로 여러 청크로 분할됩니다. return_overflowing_tokens=True로 모든 청크를 유지하고, overflow_to_sample_mapping으로 원본 샘플과의 매핑을 추적합니다.
3

모델 학습

Question Answering 전용 모델을 로딩하고 학습합니다.
from transformers import (
    AutoModelForQuestionAnswering,
    TrainingArguments,
    Trainer,
)

# QA 모델: BERT + 시작/끝 위치 예측 헤드
model = AutoModelForQuestionAnswering.from_pretrained(model_name)

training_args = TrainingArguments(
    output_dir="./qa-results",
    eval_strategy="epoch",
    save_strategy="epoch",
    learning_rate=3e-5,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    num_train_epochs=3,
    weight_decay=0.01,
    fp16=True,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset["train"],
    eval_dataset=tokenized_dataset["validation"],
)

trainer.train()
4

추론 및 평가

HuggingFace pipeline으로 간편하게 추론합니다.
from transformers import pipeline

qa_pipeline = pipeline(
    "question-answering",
    model=trainer.model,
    tokenizer=tokenizer,
    device=0,
)

# 추론 예시
result = qa_pipeline(
    question="대한민국의 수도는 어디인가?",
    context="서울은 대한민국의 수도이다. 인구는 약 970만 명으로 "
            "대한민국에서 가장 큰 도시이다.",
)

print(f"답변: {result['answer']}")
print(f"신뢰도: {result['score']:.4f}")
print(f"위치: {result['start']} ~ {result['end']}")
# 답변: 서울
# 신뢰도: 0.9823
# 위치: 0 ~ 2
**EM(Exact Match)**과 F1-Score로 평가합니다.
def normalize_answer(s):
    """답변 정규화: 공백, 조사, 구두점 제거"""
    import re
    s = re.sub(r"[.!?,;:'\"\-()]", "", s)
    s = " ".join(s.split())
    return s.strip()

def compute_em(prediction, ground_truth):
    """Exact Match: 정답과 완전히 일치하면 1, 아니면 0"""
    return int(normalize_answer(prediction) ==
               normalize_answer(ground_truth))

def compute_f1(prediction, ground_truth):
    """Token-level F1: 예측과 정답의 토큰 겹침 비율"""
    pred_tokens = normalize_answer(prediction).split()
    gold_tokens = normalize_answer(ground_truth).split()

    common = set(pred_tokens) & set(gold_tokens)
    if len(common) == 0:
        return 0.0

    precision = len(common) / len(pred_tokens)
    recall = len(common) / len(gold_tokens)
    return 2 * precision * recall / (precision + recall)

실습 2: T5 Generative QA

Generative QA는 답변을 직접 생성하므로, 문맥에 없는 표현으로도 답변할 수 있습니다.
from transformers import T5ForConditionalGeneration, T5Tokenizer

model_name = "paust/pko-t5-base"
tokenizer = T5Tokenizer.from_pretrained(model_name)
model = T5ForConditionalGeneration.from_pretrained(model_name)

def generative_qa(question, context, max_length=64):
    """T5 기반 Generative QA"""
    # 입력 형식: "question: {질문} context: {문맥}"
    input_text = f"question: {question} context: {context}"

    inputs = tokenizer(
        input_text,
        return_tensors="pt",
        max_length=512,
        truncation=True,
    )

    outputs = model.generate(
        **inputs,
        max_length=max_length,
        num_beams=4,           # 빔 서치로 품질 향상
        early_stopping=True,
    )

    answer = tokenizer.decode(outputs[0], skip_special_tokens=True)
    return answer

# 사용 예시
context = """서울은 대한민국의 수도이다. 한강이 도시 중앙을 관통하며,
인구는 약 970만 명이다. 1394년 조선 태조가 한양으로 수도를 옮겼다."""

print(generative_qa("서울의 인구는 얼마인가?", context))
# 약 970만 명

Extractive vs Generative QA 비교

항목Extractive QAGenerative QA
답변 소스문맥 내 span 추출자유 형식 생성
대표 모델BERT, RoBERTaT5, GPT, LLaMA
장점답변 근거 명확, 환각 적음유연한 답변, 요약 가능
단점문맥에 답이 없으면 실패환각(Hallucination) 위험
평가 지표EM, F1EM, F1, ROUGE
RAG 연결Reader 컴포넌트Generator 컴포넌트
RAG와의 연결: RAG 파이프라인에서 Extractive QA는 Reader 역할을, Generative QA는 Generator 역할을 합니다. 실무에서는 Retriever(검색) + Generator(생성) 조합이 일반적이며, 이는 RAG 탭에서 자세히 다룹니다.
start_positionsend_positions의 전처리가 올바른지 확인하세요. 특히 offset_mapping에서 문맥 영역(sequence_id == 1)만 사용해야 합니다. 질문 영역(sequence_id == 0)의 토큰이 답변으로 선택되지 않도록 주의하세요.
BERT의 최대 토큰 길이(512)를 넘는 문맥은 슬라이딩 윈도우로 분할됩니다. stride 값을 적절히 설정하고(64~128), 여러 청크의 예측 중 가장 높은 신뢰도의 답변을 선택하세요.
Generative QA는 문맥에 없는 정보를 만들어낼 수 있습니다. num_beams를 높이고 no_repeat_ngram_size를 설정하면 품질이 개선됩니다. 프로덕션 환경에서는 Extractive QA 결과와 교차 검증하거나, RAG에서 출처 표시(Citation)를 추가하는 것이 좋습니다.

다음 문서