문서에서 FAQ를 만들고 문서로 답하게 하기 — 근거 있는 자동 응답 설계

AI 기술

RAGFAQ 자동화근거 기반 응답환각 방지구조화 데이터

이 글은 누구를 위한 것인가

  • 제품 문서나 사용자 콘텐츠를 근거로 자동 응답을 만들려는 팀
  • "그럴듯한데 틀린 답"이 나오는 문제를 겪고 있는 개발자
  • FAQ 페이지를 수동으로 관리하다 한계에 온 경우

들어가며

FAQ는 두 방향으로 자동화할 수 있고, 둘은 완전히 다른 문제다.

[생성]  문서와 로그에서 "자주 묻는 질문"을 뽑아 FAQ 페이지를 만든다
        → 배치. 결과는 사람이 검수. 실패해도 즉시 피해 없음

[응답]  사용자 질문에 그 자리에서 답한다
        → 실시간. 틀리면 즉시 잘못된 정보를 전달

생성부터 시작하고, 응답은 나중에 붙이는 순서가 안전하다. 생성으로 얻은 검수 완료 FAQ가 응답의 근거 자료가 되기 때문이다.

이 글은 두 단계를 순서대로 다룬다.


1. 질문 후보를 어디서 뽑는가

가장 흔한 실수는 문서만 보고 LLM에게 "이 문서로 FAQ 만들어줘"라고 시키는 것이다. 그렇게 만든 FAQ는 아무도 안 묻는 질문으로 채워진다.

[질문 후보의 출처 — 우선순위 순]

  1. CS 문의 로그          실제로 물어본 것. 가장 신뢰도 높음
  2. 사이트 내 검색어      답을 못 찾아 검색한 것
  3. 검색 유입 쿼리 (GSC)  외부에서 우리를 찾은 말
  4. 커뮤니티·게시판 질문글
  5. 문서에서 추출         위 넷이 없을 때만

1~3번이 있으면 5번은 거의 필요 없다. 실제 질문에는 사용자가 쓰는 표현이 담겨 있고, 그게 검색 노출에도 유리하다.

# 질문 후보를 정규화하고 군집화
def build_question_candidates(logs: list[str]) -> list[dict]:
    normalized = [normalize_query(q) for q in logs]
    vecs = embed(normalized)                    # 임베딩
    clusters = cluster(vecs, min_size=5)        # 5회 이상 물은 것만

    out = []
    for c in clusters:
        out.append({
            "representative": pick_medoid(c),   # 군집 중심에 가장 가까운 실제 질문
            "count": len(c),
            "variants": c[:20],                 # 표현 변형 — 검색 매칭에 사용
        })
    return sorted(out, key=lambda x: -x["count"])

대표 질문을 LLM에게 새로 쓰게 하지 말고 실제 질문 중에서 고르는 것(medoid)이 낫다. 다듬은 문장은 사용자가 실제로 검색하는 말과 멀어진다.


2. 답변 생성 — 근거를 강제한다

질문이 정해지면 답을 만든다. 여기서 근거 문서 없이 답을 생성하면 안 된다.

❌ prompt: "다음 질문에 답하세요: {question}"
   → 모델의 사전 지식으로 답함. 우리 제품 사실과 다를 수 있음

✅ prompt: "다음 문서만 근거로 답하세요. 문서에 없으면 '문서에 없음'이라고 하세요.
            [문서] {retrieved_chunks}
            [질문] {question}"

검색·증강·생성의 기본 구조는 RAG 논문이 정식화했다. 실무에서 중요한 건 "문서에 없음"을 출력할 수 있게 만드는 것이다. 이 선택지가 없으면 모델은 반드시 뭔가를 지어낸다.

응답을 스키마로 강제하면 후처리가 안정된다.

{
  "type": "json_schema",
  "schema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "answerable": { "type": "boolean" },
      "answer": { "type": "string", "maxLength": 600 },
      "cited_chunk_ids": {
        "type": "array",
        "items": { "type": "string" },
        "minItems": 1
      },
      "confidence": { "type": "number", "minimum": 0, "maximum": 1 }
    },
    "required": ["answerable", "answer", "cited_chunk_ids", "confidence"]
  }
}

스키마 강제 방법은 구조화 출력 문서를 참고한다.

cited_chunk_ids를 받고 검증하는 것이 핵심 장치다.

def validate_answer(result: dict, retrieved: dict[str, str]) -> bool:
    if not result["answerable"]:
        return True                                  # 못 답한다고 한 건 정상

    # 1. 인용한 청크가 실제로 검색 결과에 있었는가
    if not set(result["cited_chunk_ids"]) <= set(retrieved):
        return False                                 # 없는 문서를 인용 = 환각

    # 2. 답변의 핵심 주장이 인용 문서에 실제로 있는가 (별도 검증)
    return verify_entailment(result["answer"], [retrieved[c] for c in result["cited_chunk_ids"]])

1번만으로도 상당수의 환각이 걸린다. 존재하지 않는 문서 ID를 인용하는 경우가 실제로 발생하기 때문이다.


3. "답할 수 없음"을 제대로 다루기

answerable: false가 나왔을 때의 처리가 시스템 신뢰도를 결정한다.

[나쁜 처리]
  답을 못 찾았습니다.
  → 사용자는 막다른 길

[좋은 처리]
  이 질문에 대한 정확한 답을 문서에서 찾지 못했습니다.

  관련이 있을 수 있는 문서:
    · 공개 페이지 설정 방법
    · 페이지 주소 변경

  [문의하기]  [이 질문 등록하기]

"이 질문 등록하기"가 데이터 수집 장치가 된다. 답을 못 한 질문이 곧 문서에 없는 내용이므로, 이것이 다음 문서 작성의 우선순위 목록이 된다.

CREATE TABLE unanswered_questions (
  id           BIGSERIAL PRIMARY KEY,
  question     TEXT NOT NULL,
  norm_hash    BYTEA NOT NULL,        -- 정규화 후 해시 — 중복 집계용
  asked_count  INT NOT NULL DEFAULT 1,
  first_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  resolved_doc TEXT                   -- 문서가 작성되면 연결
);
CREATE UNIQUE INDEX ON unanswered_questions (norm_hash);

asked_count 상위 목록이 문서 작성 백로그다. 무엇을 문서화할지 감으로 정하는 것보다 훨씬 정확하다.


4. 근거가 사용자 콘텐츠일 때

제품 문서가 아니라 사용자가 만든 콘텐츠를 근거로 답하는 경우는 문제가 하나 더 있다.

  근거의 정확성을 우리가 보장할 수 없다
  → "이 페이지에 따르면 ~라고 합니다" 형태여야 한다
  → "~입니다" 로 단정하면 우리가 보증한 것이 된다

캔버스형 큐레이션 보드를 만드는 Linkme의 도움말처럼 제품 FAQ와 사용자 페이지가 함께 있는 서비스에서는, 두 근거를 섞지 않는 것이 중요하다. 제품 동작에 대한 답은 공식 문서만, 페이지 내용에 대한 답은 그 페이지의 카드만 근거로 삼아야 한다. 섞이면 "제품이 이렇게 동작한다"는 답이 사용자가 쓴 문장에서 나오게 된다.

NAMESPACE_RULES = {
    "product":  {"sources": ["docs"],            "tone": "assert"},
    "user_page": {"sources": ["page_cards"],     "tone": "attribute"},
}

def answer(question: str, ns: str, page_id: str | None = None):
    rule = NAMESPACE_RULES[ns]
    chunks = retrieve(question, sources=rule["sources"], scope=page_id)
    # tone=attribute 이면 프롬프트에 "~에 따르면" 형식을 지시
    ...

검색 범위(namespace)를 분리하고 답변 어조까지 다르게 하는 것이 이 문제의 실질적 해법이다.


5. 캐시와 무효화 — 문서가 바뀌면

같은 질문에 매번 LLM을 호출하면 비용이 선형으로 는다. 캐시가 필요하지만, 문서가 바뀌면 답도 바뀌어야 한다.

CREATE TABLE faq_answers (
  id            BIGSERIAL PRIMARY KEY,
  question_hash BYTEA NOT NULL,
  answer        TEXT NOT NULL,
  cited_chunks  TEXT[] NOT NULL,       -- ★ 무효화의 열쇠
  doc_versions  JSONB NOT NULL,        -- {"chunk_id": "doc_rev"} 스냅샷
  model_version TEXT NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON faq_answers USING GIN (cited_chunks);

인용 청크를 기록해두면 정밀한 무효화가 가능하다.

-- 특정 문서가 수정되면, 그 문서를 인용한 답만 무효화
DELETE FROM faq_answers
WHERE cited_chunks && ARRAY['doc_pricing#3', 'doc_pricing#4'];

전체 캐시를 비우는 방식은 문서 하나만 고쳐도 전량 재생성이 일어나 비용이 튄다. GIN 인덱스와 배열 겹침 연산자(&&)로 영향받은 항목만 골라내면 재생성량이 크게 준다.

모델 버전도 함께 기록한다. 모델을 바꾸면 답변 품질과 어조가 달라지므로, 옛 모델로 만든 답이 남아 있으면 일관성이 깨진다.


6. FAQ 페이지를 검색에 노출하기

생성한 FAQ는 페이지로 만들 때 구조화 데이터를 붙인다.

{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [{
    "@type": "Question",
    "name": "공개 페이지 주소를 바꿀 수 있나요?",
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "설정에서 주소를 변경할 수 있습니다. 이전 주소는 30일간 새 주소로 자동 연결됩니다."
    }
  }]
}

스키마 정의는 FAQPage, 검색엔진의 실제 요건은 FAQ 구조화 데이터 가이드에 따로 있다. 둘이 다르므로 둘 다 확인해야 한다 — schema.org 정의만 보고 만들면 요건을 놓친다.

[주의]

  · 페이지에 실제로 보이는 내용만 마크업한다
    (숨겨진 텍스트를 구조화 데이터에만 넣으면 위반)
  · 사용자 생성 Q&A는 FAQPage가 아니라 QAPage
  · 광고성 문구를 답변에 넣지 않는다

질문 하나당 URL을 주는 것도 고려할 만하다. 사용자가 특정 질문을 검색으로 찾아오는 경로가 실제로 많고, 아코디언 안에만 있으면 그 유입을 못 받는다.


7. 품질 관리 — 사람이 검수하는 지점

전 과정을 자동화하면 반드시 틀린 답이 공개된다. 검수 지점을 어디에 둘 것인가가 설계 결정이다.

[생성 파이프라인의 검수 지점]

  질문 후보 → [검수 A] → 답변 생성 → [검수 B] → 공개

  검수 A: 이 질문을 FAQ에 넣을 것인가
          — 빠르다. 목록 보고 체크
  검수 B: 이 답이 맞는가
          — 느리다. 근거 문서와 대조 필요

B를 전수로 하면 자동화의 의미가 없다. 신뢰도로 나눈다.

  confidence >= 0.85 이고 인용 검증 통과  →  자동 공개 + 사후 샘플 검수
  그 외                                   →  검수 큐
  answerable = false                       →  문서 작성 백로그로

**사후 샘플 검수(주 20건 정도)**를 반드시 유지한다. 자동 공개분의 오류율을 모르면 임계값이 맞는지 판단할 수 없다.


8. 관측 지표

[생성]
  · 질문 후보 대비 공개 FAQ 수
  · answerable=false 비율        ← 문서 커버리지의 대리 지표
  · 검수 반려율                   ← 임계값이 낮으면 상승

[응답]
  · 답변 후 추가 질문 비율        ← 답이 불충분했다는 신호
  · "도움이 됐나요" 부정 비율
  · 답변 후 CS 문의 전환율        ← 가장 직접적인 실패 지표
  · 인용 검증 실패율              ← 0에 가까워야 정상

"답변 후 CS 문의 전환율"이 핵심이다. 자동 응답을 받고도 사람에게 문의한다면 그 답은 실패한 것이고, 어떤 질문에서 그런지 보면 개선 지점이 바로 나온다.


9. 정리

  1. 질문은 문서가 아니라 CS 로그·검색어에서 뽑는다
  2. 대표 질문은 생성이 아니라 실제 질문 중 선택 (medoid)
  3. 근거 없는 생성 금지 — "문서에 없음" 선택지를 반드시 준다
  4. cited_chunk_ids를 받고 실재 여부를 검증한다
  5. 답 못 한 질문을 적재 — 문서 작성 백로그가 된다
  6. 제품 문서와 사용자 콘텐츠의 검색 범위·어조를 분리
  7. 인용 청크 기록으로 정밀 캐시 무효화
  8. FAQPage 구조화 데이터는 schema.org와 검색엔진 요건을 둘 다 확인
  9. 신뢰도로 검수를 나누되 사후 샘플 검수는 유지

가장 효과가 큰 건 3번과 4번이다. "모른다고 말할 수 있게 하는 것"과 "인용을 검증하는 것" 두 가지만으로, 그럴듯하게 틀린 답의 대부분이 걸러진다.