14장. 근거 기반 답변
따라하기 — 출처를 밝히며 답하기
출력 안내: 실행 코드는 답변·도구 결과·청크 본문을 글자 수로 자르지 않고 출력합니다. 아래의 기존 실행 예시는 일부 축약된 기록이며, 실제 실행 화면에서 전체 내용을 확인하세요.
목표
13장에서 만든 chroma_db/를 불러와 검색 → 컨텍스트 주입 → 생성을 한 함수로 잇습니다. 정책 질문 네 개에 답하게 하고, 출처가 붙는지, 문서에 없는 질문에는 기권하는지 확인합니다.
0. 실습 준비
VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.
$ conda activate myenv
이번 장은 새로 설치할 것이 없습니다. 대신 chroma_db/ 폴더가 있어야 합니다. 탐색기에서 폴더가 보이는지 확인하세요.
13장을 먼저 실행해야 합니다. chroma_db/는 lesson13_rag_embedding.py를 실행해야 만들어집니다. 폴더가 없으면 아래 명령으로 먼저 만듭니다.
$ python lesson13_rag_embedding.py
1. 파일 만들기
haru-market 폴더 맨 위에 새 파일을 만듭니다.
lesson14_rag_retriever.py
아래 코드 전체를 복사해 붙여 넣고 저장합니다.
# -*- coding: utf-8 -*-
"""[14장] RAG 3 — Retriever 연결과 출처 기반 답변
검색(13장)까지 됐다. 이제 검색 결과를 '근거'로 답변을 만든다.
원칙: 정책 문의는 반드시
1) 검색된 조항을 근거로만 답한다
2) 어느 조항에서 왔는지 출처를 밝힌다
3) 문서에 없으면 "확인되지 않는다"고 말한다 (환각 억제)
실행: python lesson14_rag_retriever.py
(13장을 먼저 실행해 chroma_db/ 가 만들어져 있어야 한다)
"""
import os
from google.genai import types
from langchain_chroma import Chroma
from langchain_google_genai import GoogleGenerativeAIEmbeddings
from pydantic import BaseModel, Field, ValidationError
from config import EMBEDDING_MODEL, MODEL, ROOT, get_client
client = get_client()
# ── 1. 저장된 벡터 DB 로드 (재인덱싱 없음) ────────────────────────────
# 13장에서 만든 벡터 저장소가 있는지 먼저 확인한다. 폴더가 없는데 Chroma(...) 를
# 부르면 빈 폴더를 새로 만들어 버려서, 오류 없이 검색 결과만 0건이 된다.
CHROMA_DIR = ROOT / "chroma_db"
NEED_13 = "13장 lesson13_rag_embedding.py 를 먼저 실행하세요."
if not CHROMA_DIR.exists():
raise SystemExit(f"[준비 필요] chroma_db/ 폴더가 없습니다. {NEED_13}")
embeddings = GoogleGenerativeAIEmbeddings(
model=EMBEDDING_MODEL, google_api_key=os.getenv("GOOGLE_API_KEY"))
vectorstore = Chroma(
collection_name="haru_policy",
embedding_function=embeddings,
persist_directory=str(CHROMA_DIR),
)
if not vectorstore.get(limit=1)["ids"]: # 폴더는 있는데 저장된 청크가 0개
raise SystemExit(f"[준비 필요] chroma_db/ 에 정책 문서가 없습니다. {NEED_13}")
# Retriever: "질문 → 관련 문서 k개" 를 표준 인터페이스로 감싼 것
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
ABSTAIN = "해당 내용은 정책 문서에서 확인되지 않아 상담원 확인이 필요합니다."
class PolicyAnswer(BaseModel):
answer: str = Field(description="출처 표기를 제외한 고객 안내문")
evidence_ids: list[int] = Field(description="답변의 근거로 사용한 발췌 번호 목록. 근거가 없으면 빈 목록")
RAG_SYSTEM = """당신은 하루마켓 고객지원 상담원 '하루'입니다.
아래 [정책 발췌]에 있는 내용으로만 답합니다.
규칙:
1. 발췌에 근거가 있으면: answer에 답변을 쓰고, evidence_ids에 실제로 사용한
발췌 번호를 넣습니다. 답변의 모든 정책 설명을 뒷받침하는 발췌를 선택합니다.
출처는 코드가 붙이므로 answer에 문서명·조항명·발췌 번호 등 출처 표기를 쓰지 않습니다.
2. 발췌에 근거가 없으면: evidence_ids는 빈 목록으로, answer는 아래 문장으로 씁니다.
해당 내용은 정책 문서에서 확인되지 않아 상담원 확인이 필요합니다.
절대 추측해서 답하지 않습니다.
3. 금액·기간 등 숫자와 영업일 같은 조건은 발췌 그대로 씁니다. 존댓말, 3~5문장."""
def format_policy_answer(result: PolicyAnswer, docs) -> str:
"""에이전트가 고른 발췌를 검증하고 원본 메타데이터로 출처를 붙인다."""
if not result.evidence_ids:
return ABSTAIN
if not result.answer.strip() or result.answer.strip() == ABSTAIN:
return ABSTAIN
labels = []
for number in dict.fromkeys(result.evidence_ids):
if number < 1 or number > len(docs):
return ABSTAIN
metadata = docs[number - 1].metadata
title, article = metadata.get("doc_title"), metadata.get("article")
if not title or not article:
return ABSTAIN
label = f"{title} · {article}"
if label not in labels:
labels.append(label)
return result.answer.strip() + "\n\n" + "\n".join(
f"(근거: {label})" for label in labels)
def answer_policy_question(question: str) -> str:
# ① 검색 (Retrieval)
docs = retriever.invoke(question)
print(f" [검색] {len(docs)}개 청크:")
for d in docs:
print(f" - [{d.metadata.get('article', '?')}] "
f"{d.page_content.replace(chr(10), ' ')}")
# ② 컨텍스트 주입 (Augmented) — 출처 라벨을 붙여서 넣는다
context = "\n\n".join(
f"[발췌 {i+1}] ({d.metadata.get('doc_title', '정책')} · "
f"{d.metadata.get('article') or '조항 미상'})\n{d.page_content}"
for i, d in enumerate(docs)
)
prompt = f"[정책 발췌]\n{context}\n\n[고객 문의]\n{question}"
# ③ 생성 (Generation)
response = client.models.generate_content(
model=MODEL, contents=prompt,
config=types.GenerateContentConfig(
system_instruction=RAG_SYSTEM, temperature=1.0,
response_mime_type="application/json", response_schema=PolicyAnswer),
)
try:
result = PolicyAnswer.model_validate_json(response.text or "", strict=True)
except ValidationError:
return ABSTAIN
return format_policy_answer(result, docs)
if __name__ == "__main__":
questions = [
"단순 변심인데 반품 배송비 얼마예요?", # 표1에 있음
"불량품이 왔는데 배송비를 제가 내야 하나요?", # 표1에 있음
"환불 신청하면 카드 취소는 며칠 걸려요?", # 표2에 있음
"해외 배송도 반품 되나요?", # 문서에 없음 → 모른다고 해야
]
for q in questions:
print(f"\n고객: {q}")
print(f"하루: {answer_policy_question(q)}")
print("""
──────────────────────────────────────────────────────────
확인할 것
- 근거가 있는 답변에 (근거: doc_title · article) 출처가 붙는다
- 에이전트가 발췌를 선택하고 코드는 메타데이터의 이름을 그대로 붙인다
- '해외 배송 반품'은 문서에 없다 → "확인되지 않는다" 한 문장만 나와야 정상
(근거 표기가 붙지 않는다 — RAG_SYSTEM 규칙 2)
남은 문제: 고객은 '반품'이라 쓰고 문서는 '청약철회'라 쓴다 → 15장 검색 품질
──────────────────────────────────────────────────────────""")
2. 코드에서 볼 곳
이번 파일은 lesson12_rag_chunking을 가져오지 않습니다. 청크는 이미 chroma_db/에 들어 있기 때문입니다. 파일 앞부분에서 저장소가 있는지 확인하고, 없으면 13장을 먼저 실행하라는 안내를 찍고 멈춥니다.
핵심은 answer_policy_question과 format_policy_answer입니다. 검색·컨텍스트 주입·생성 뒤에, 코드가 선택된 발췌를 검증하고 출처를 붙입니다.
def answer_policy_question(question: str) -> str:
# ① 검색 (Retrieval)
docs = retriever.invoke(question)
print(f" [검색] {len(docs)}개 청크:")
for d in docs:
print(f" - [{d.metadata.get('article', '?')}] "
f"{d.page_content.replace(chr(10), ' ')}")
# ② 컨텍스트 주입 (Augmented) — 출처 라벨을 붙여서 넣는다
context = "\n\n".join(
f"[발췌 {i+1}] ({d.metadata.get('doc_title', '정책')} · "
f"{d.metadata.get('article') or '조항 미상'})\n{d.page_content}"
for i, d in enumerate(docs)
)
prompt = f"[정책 발췌]\n{context}\n\n[고객 문의]\n{question}"
# ③ 생성 (Generation)
response = client.models.generate_content(
model=MODEL, contents=prompt,
config=types.GenerateContentConfig(
system_instruction=RAG_SYSTEM, temperature=1.0,
response_mime_type="application/json", response_schema=PolicyAnswer),
)
try:
result = PolicyAnswer.model_validate_json(response.text or "", strict=True)
except ValidationError:
return ABSTAIN
return format_policy_answer(result, docs)
PolicyAnswer의 answer에는 안내문, evidence_ids에는 사용한 발췌 번호가 들어갑니다. format_policy_answer는 해당 청크의 doc_title과 article을 그대로 조합합니다. 중복 출처는 한 번만 표시하며, 근거가 없거나 응답 구조·번호·메타데이터가 유효하지 않으면 출처 없이 확인 필요 문구를 반환합니다.
| 단계 | 누가 하나 | 호출 |
|---|---|---|
| ① 검색 | 임베딩 모델 + Chroma | 임베딩 API 1번 |
| ② 컨텍스트 주입 | 우리 코드 (문자열을 이어 붙일 뿐) | 없음 |
| ③ 답변 생성·근거 선택 | LLM | LLM 1번 |
| 출처 검증·표기 | 우리 코드 | 없음 |
질문 하나에 임베딩 1번, LLM 1번입니다.
실행부의 질문 네 개 가운데 마지막 하나가 이 장의 시험 문제입니다.
| 질문 | 정책 문서에 |
|---|---|
| 단순 변심인데 반품 배송비 얼마예요? | 있음 (제2조 표 1, 제6조, FAQ) |
| 불량품이 왔는데 배송비를 제가 내야 하나요? | 있음 (제2조 표 1, FAQ) — 표준 질문 Q3과 같은 내용 |
| 환불 신청하면 카드 취소는 며칠 걸려요? | 있음 (제5조 표 2) |
| 해외 배송도 반품 되나요? | 없음 |
3. 실행하기
실행하기 전에 짐작해 보세요.
- "해외 배송도 반품 되나요?"에도 검색 결과가 나올까요? 나온다면 몇 개일까요?
- 그 질문에 모델은 뭐라고 답할까요?
$ python lesson14_rag_retriever.py
LLM을 네 번 호출합니다. 실행 시간은 모델과 API 응답 상황에 따라 달라집니다.
실행 결과 (답변 문장과 선택하는 발췌 번호는 실행마다 달라질 수 있습니다.)
고객: 단순 변심인데 반품 배송비 얼마예요?
[검색] 4개 청크:
- [제5조 (환불 처리 기간), 제6조 (환불 금액 산정), 제7조 (반품·교환 불가 사유)] 계좌이체·무통장 계좌 환급 검수 후 3일 이내 하루포인트·쿠폰 즉시 복원 검수 후 1일 이내 간편결...
- [제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ)] 서 제외된다. 제9조 (분쟁 처리) 반품·교환·환불에 관하여 회사와 고객 간 분쟁이 발생한 경우, ...
- [제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간)] 행된다. 제4조 (교환) ① 동일 상품의 다른 색상·사이즈 교환은 수령일로부터 7일 이내 신청 가능...
- [제1조 (용어의 정의), 제2조 (반품 사유별 기준), 제3조 (반품 신청 절차), 제4조 (교환)] "환불 신청"은 법률상 청약철회에 해당한다. 제2조 (반품 사유별 기준) [표 1] 사유별 신청 기...
하루: 단순 변심으로 인한 반품 시 편도 배송비 3,000원을 고객이 부담합니다. 해당 배송비는 환불 금액에서 차감됩니다.
(근거: 반품교환환불정책 · 제5조 (환불 처리 기간), 제6조 (환불 금액 산정), 제7조 (반품·교환 불가 사유))
(근거: 반품교환환불정책 · 제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ))
(근거: 반품교환환불정책 · 제1조 (용어의 정의), 제2조 (반품 사유별 기준), 제3조 (반품 신청 절차), 제4조 (교환))
고객: 불량품이 왔는데 배송비를 제가 내야 하나요?
[검색] 4개 청크:
- [제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ)] 서 제외된다. 제9조 (분쟁 처리) 반품·교환·환불에 관하여 회사와 고객 간 분쟁이 발생한 경우, ...
- [제7조 (반품·교환 불가 사유), 제8조 (배송 지연 보상), 제9조 (분쟁 처리)] 제7조 (반품·교환 불가 사유) 다음 각 호에 해당하면 반품·교환이 제한된다. ① 고객의 사용·착용...
- [제5조 (환불 처리 기간), 제6조 (환불 금액 산정), 제7조 (반품·교환 불가 사유)] 계좌이체·무통장 계좌 환급 검수 후 3일 이내 하루포인트·쿠폰 즉시 복원 검수 후 1일 이내 간편결...
- [제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간)] 행된다. 제4조 (교환) ① 동일 상품의 다른 색상·사이즈 교환은 수령일로부터 7일 이내 신청 가능...
하루: 하자·오배송으로 인한 불량품의 경우, 회사가 배송비를 전액 부담하므로 고객님께서 배송비를 내지 않으셔도 됩니다. 걱정하지 마시고 고객센터로 문의해 주시기 바랍니다.
(근거: 반품교환환불정책 · 제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ))
고객: 환불 신청하면 카드 취소는 며칠 걸려요?
[검색] 4개 청크:
- [제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간)] 행된다. 제4조 (교환) ① 동일 상품의 다른 색상·사이즈 교환은 수령일로부터 7일 이내 신청 가능...
- [제5조 (환불 처리 기간), 제6조 (환불 금액 산정), 제7조 (반품·교환 불가 사유)] 계좌이체·무통장 계좌 환급 검수 후 3일 이내 하루포인트·쿠폰 즉시 복원 검수 후 1일 이내 간편결...
- [제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ)] 서 제외된다. 제9조 (분쟁 처리) 반품·교환·환불에 관하여 회사와 고객 간 분쟁이 발생한 경우, ...
- [제1조 (용어의 정의), 제2조 (반품 사유별 기준), 제3조 (반품 신청 절차), 제4조 (교환)] "환불 신청"은 법률상 청약철회에 해당한다. 제2조 (반품 사유별 기준) [표 1] 사유별 신청 기...
하루: 신용·체크카드의 경우, 환불은 반품 상품의 회수·검수가 완료된 후 검수 후 3일과 카드사 3~5일 소요 기간 내에 승인 취소로 진행됩니다. 다만 카드 취소 반영 시점은 카드사 사정에 따라 달라질 수 있습니다.
(근거: 반품교환환불정책 · 제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간))
고객: 해외 배송도 반품 되나요?
[검색] 4개 청크:
- [제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ)] 서 제외된다. 제9조 (분쟁 처리) 반품·교환·환불에 관하여 회사와 고객 간 분쟁이 발생한 경우, ...
- [제7조 (반품·교환 불가 사유), 제8조 (배송 지연 보상), 제9조 (분쟁 처리)] 제7조 (반품·교환 불가 사유) 다음 각 호에 해당하면 반품·교환이 제한된다. ① 고객의 사용·착용...
- [제1조 (용어의 정의), 제2조 (반품 사유별 기준), 제3조 (반품 신청 절차), 제4조 (교환)] "환불 신청"은 법률상 청약철회에 해당한다. 제2조 (반품 사유별 기준) [표 1] 사유별 신청 기...
- [제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간)] 행된다. 제4조 (교환) ① 동일 상품의 다른 색상·사이즈 교환은 수령일로부터 7일 이내 신청 가능...
하루: 해당 내용은 정책 문서에서 확인되지 않아 상담원 확인이 필요합니다.
(이하 생략)
| 이렇게 나오면 | 원인과 조치 |
|---|---|
[준비 필요] chroma_db/ 폴더가 없습니다. |
13장을 실행하지 않았습니다. python lesson13_rag_embedding.py를 실행한 뒤 다시 돌립니다 |
429 RESOURCE_EXHAUSTED |
짧은 시간에 요청이 몰렸습니다. 잠시 기다렸다가 다시 실행합니다 |
4. 무엇을 관찰했나
답변의 금액과 기간을 정책과 대조한다
| 질문 | 답의 핵심 | 정책 문서의 원문 |
|---|---|---|
| 단순 변심 반품 배송비 | 편도 3,000원, 환불 금액에서 차감 | 제6조 "편도 배송비 3,000원을 환불 금액에서 차감한다" |
| 불량품 배송비 | 회사가 전액 부담 | FAQ "하자·오배송은 회사가 배송비를 전액 부담합니다" |
| 카드 취소 기간 | 검수 후 3일 + 카드사 3~5일 | 제5조 표 2 "검수 후 3일 + 카드사 3~5일" |
3장에서는 같은 모델이 환불 규정을 짐작으로 채워 금액이 달라졌습니다. 이번에는 발췌의 내용을 바탕으로 답했습니다. 규정을 알려 준 것은 모델의 기억이 아니라 우리 코드가 넣어 준 발췌입니다.
출처가 붙었다 — 선택한 청크의 메타데이터를 그대로 썼다
세 답 모두 (근거: ...)가 붙었습니다. 불량품 질문에서 모델은 첫 번째 발췌를 골랐고, 코드는 그 청크의 메타데이터를 다음과 같이 표시했습니다.
(근거: 반품교환환불정책 · 제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ))
FAQ만 따로 골라 이름을 쓰는 것이 아니라, 선택한 청크의 article 전체를 표시합니다. 출처 범위가 실제 답변에 사용한 조항보다 넓을 수 있지만, 이름은 저장된 값 그대로입니다. 여러 발췌를 선택하면 출처도 여러 줄이 됩니다.
출처 이름이 정확하다고 답변 내용까지 검증된 것은 아닙니다. 선택한 청크의 본문이 답변을 뒷받침하는지 정책 원문과 대조합니다.
문서에 없는 질문 — 검색은 4개를 돌려줬고, 모델은 기권했다
"해외 배송도 반품 되나요?"에도 [검색] 4개 청크가 나왔습니다. 정책 문서에 해외 배송 이야기는 없습니다. 검색은 가장 가까운 것을 골랐을 뿐입니다.
모델은 그 네 개를 읽고 근거 번호를 빈 목록으로 반환했고, 코드는 다음 문구를 출력했습니다.
하루: 해당 내용은 정책 문서에서 확인되지 않아 상담원 확인이 필요합니다.
해외 배송 반품 규정을 지어내지 않았습니다. 인사말도 근거 표기도 없이 정해 준 한 문장입니다. 근거가 없을 때 출처를 붙이지 않는 처리도 확인했습니다.
누가 무엇을 했는가
| 한 일 | 누가 |
|---|---|
| 질문마다 가까운 청크 4개를 골랐다 | 임베딩 모델 + Chroma |
| 청크에 꼬리표를 붙이고, 응답을 검증한 뒤 메타데이터로 출처를 표시했다 | 우리 코드 |
| 발췌를 읽고 답변과 근거 번호를 선택했다. 근거가 없으면 빈 목록을 반환했다 | LLM |
| k=4, 꼬리표의 모양, 세 규칙, 기권 문구를 정했다 | 우리(개발자) |
5. 지금 폴더의 모습
haru-market/
├── config.py
├── data/
├── chroma_db/ ← 13장에서 만든 것. 이번 장이 불러 씁니다
├── (중략)
├── lesson13_rag_embedding.py
└── lesson14_rag_retriever.py ← 이번 장
핵심 정리
answer_policy_question한 함수에 검색 → 컨텍스트 주입 → 생성이 들어 있습니다. 질문 하나에 임베딩 1번, LLM 1번입니다.- 답변의 금액과 기간은 검색된 정책 원문과 대조해 확인합니다.
- 모델은 근거 발췌를 선택하고, 코드는
doc_title · article로 출처를 붙입니다. - 문서에 없는 질문에 검색은 4개를 돌려줬고, 모델은 정해 준 한 문장으로 기권했습니다. 근거 표기는 붙지 않았습니다.