실무 Multi-Agent 오케스트레이션 14장 · 근거 기반 답변 5 / 7 ← 이전목차다음 → TechLead Cro

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개를 돌려줬고, 모델은 정해 준 한 문장으로 기권했습니다. 근거 표기는 붙지 않았습니다.
← 이전 절세 가지 규칙과 기권 — 없으면 없다고 말하게 한다다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com