이 글은 누구를 위한 것인가
- 제품 문서나 사용자 콘텐츠를 근거로 자동 응답을 만들려는 팀
- "그럴듯한데 틀린 답"이 나오는 문제를 겪고 있는 개발자
- 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번이다. "모른다고 말할 수 있게 하는 것"과 "인용을 검증하는 것" 두 가지만으로, 그럴듯하게 틀린 답의 대부분이 걸러진다.