6장. 문서 기반 챗봇
실습 4 — RetrievalQA 와 LCEL 로 근거 붙인 답의 짜임 보기
실습 3에서 저장한 색인(out_ch06_faiss)을 불러와 검색기를 만들고, 가짜 모델(FakeListChatModel)을 끼워 옛 방식(RetrievalQA)과 지금 방식(LCEL)의 체인을 짭니다. 가짜 모델은 정해 둔 답을 돌려주므로, 이 실습에서 볼 것은 답의 내용이 아니라 "모델이 받는 프롬프트"와 "답과 근거가 돌아오는 모양"입니다. 실습 3을 먼저 해야 합니다. API 키가 없어도 됩니다.
1단계. 저장한 색인으로 검색기 만들기
import warnings
import zlib
warnings.filterwarnings("ignore", category=DeprecationWarning)
from langchain_community.vectorstores import FAISS
from langchain_core.embeddings import Embeddings
class BigramEmbeddings(Embeddings):
"""글자 두 개 묶음(바이그램)을 세어 숫자 목록을 만드는 연습용 임베딩"""
def __init__(self, size=512):
self.size = size
def _vector(self, text):
text = text.replace(" ", "").replace("\n", "")
vec = [0.0] * self.size
for i in range(len(text) - 1):
# 글자 두 개를 crc32 로 0~511 사이의 칸 번호로 바꿔 그 칸을 1 올린다
vec[zlib.crc32(text[i:i + 2].encode("utf-8")) % self.size] += 1.0
norm = sum(v * v for v in vec) ** 0.5 or 1.0
return [v / norm for v in vec]
def embed_documents(self, texts):
return [self._vector(t) for t in texts]
def embed_query(self, text):
return self._vector(text)
# 실습 3이 저장한 조항 단위 색인을 같은 임베딩으로 불러온다(내가 만든 색인이라 pickle 을 허락한다)
db = FAISS.load_local("out_ch06_faiss", BigramEmbeddings(), allow_dangerous_deserialization=True)
# as_retriever: 벡터DB 를 검색기로 바꾼다. search_kwargs 의 k 는 돌려줄 조각 수
retriever = db.as_retriever(search_kwargs={"k": 2})
question = "출발 5일 전에 취소하면 수수료가 얼마예요?"
docs = retriever.invoke(question)
print(type(retriever).__name__, "/ 찾은 조각", len(docs), "개")
for d in docs:
print(f" {d.metadata['article']}({d.metadata['page']}쪽)", d.page_content[:45], "…")VectorStoreRetriever / 찾은 조각 2 개
제5조(2쪽) 제5조 손님이 취소할 때 출발 10일 전까지 취소하면 낸 돈을 전액 돌려준다. 출 …
제7조(2쪽) 제7조 날씨와 천재지변 태풍, 폭설 같은 날씨나 천재지변으로 항공편이나 배가 뜨지 …코드가 의미하는 것 FAISS.load_local 로 실습 3의 색인을 불러오는 것은, 실제 챗봇이 켜질 때 하는 일과 같습니다. PDF 를 다시 읽거나 임베딩을 다시 만들지 않습니다. as_retriever(search_kwargs={"k": 2}) 로 바꾼 검색기는 질문만 받으면 근거 조각 두 개를 돌려주는 체인 부품이 되고, 조각의 metadata 에 든 article 과 page 가 그대로 따라오므로 출처를 붙일 준비가 끝납니다.
2단계. RetrievalQA 로 묶어 보기
from langchain_classic.chains import RetrievalQA
from langchain_core.language_models.fake_chat_models import FakeListChatModel
# 가짜 모델: 무엇을 받든 정해 둔 답을 차례로 돌려준다
fake = FakeListChatModel(responses=["출발 3~6일 전 취소는 상품 금액의 30%를 수수료로 뗍니다."])
qa = RetrievalQA.from_chain_type(
llm=fake, # 답을 쓸 모델
retriever=retriever, # 근거를 찾을 검색기
chain_type="stuff", # 찾은 조각을 모두 이어 붙여(stuff) 프롬프트 하나에 넣는다
return_source_documents=True, # 답과 함께 근거 조각도 돌려준다
)
out = qa.invoke({"query": question})
print("열쇠:", list(out))
print("답:", out["result"])
print("근거:", [d.metadata["article"] for d in out["source_documents"]])
# RetrievalQA 안에 든 기본 프롬프트(영어)의 시스템 지시 첫 두 줄
system_template = qa.combine_documents_chain.llm_chain.prompt.messages[0].prompt.template
print(system_template.split("\n")[:2])열쇠: ['query', 'result', 'source_documents']
답: 출발 3~6일 전 취소는 상품 금액의 30%를 수수료로 뗍니다.
근거: ['제5조', '제7조']
["Use the following pieces of context to answer the user's question.", "If you don't know the answer, just say that you don't know, don't try to make up an answer."]코드가 의미하는 것 RetrievalQA.from_chain_type 은 검색기와 모델을 받아 "찾고, 이어 붙이고(chain_type="stuff"), 묻는" 세 일을 한 객체에 묶습니다. return_source_documents=True 를 켜야 답과 함께 근거 조각이 돌아오므로, 출처를 보여 주는 챗봇에는 이 인자가 빠지면 안 됩니다. qa.combine_documents_chain.llm_chain.prompt 를 따라 들어가 기본 프롬프트를 꺼내 본 것은, 세트 메뉴 안에 어떤 지시가 숨어 있는지 확인하려는 것입니다.
3단계. LCEL 로 같은 체인 짜고 프롬프트 들여다보기
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
def format_docs(docs):
"""조각 목록을 '[제5조 · 2쪽] 내용' 줄로 이어 붙인다"""
return "\n".join(f"[{d.metadata['article']} · {d.metadata['page']}쪽] {d.page_content}" for d in docs)
prompt = ChatPromptTemplate.from_messages([
("system",
"너는 구름다리여행의 약관 안내 직원이다. 아래 [근거]에 적힌 내용으로만 두 문장 이내로 답한다.\n"
"근거에 없는 내용이면 '약관에서 찾지 못했습니다.'라고만 답한다.\n\n[근거]\n{context}"),
("human", "{question}"),
])
def peek(prompt_value):
"""모델이 받을 시스템 메시지를 찍어 보고 그대로 넘긴다"""
print("--- 모델이 받는 시스템 메시지 ---")
print(prompt_value.to_messages()[0].content)
print("---")
return prompt_value
fake = FakeListChatModel(responses=["출발 3~6일 전 취소는 상품 금액의 30%를 수수료로 뗍니다."])
# 맨 앞 사전: 같은 질문이 context 칸(검색기 → 이어 붙이기)과 question 칸(그대로)으로 동시에 들어간다
chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| RunnableLambda(peek)
| fake
| StrOutputParser()
)
print("답:", chain.invoke(question))--- 모델이 받는 시스템 메시지 ---
너는 구름다리여행의 약관 안내 직원이다. 아래 [근거]에 적힌 내용으로만 두 문장 이내로 답한다.
근거에 없는 내용이면 '약관에서 찾지 못했습니다.'라고만 답한다.
[근거]
[제5조 · 2쪽] 제5조 손님이 취소할 때 출발 10일 전까지 취소하면 낸 돈을 전액 돌려준다. 출발 7일 전부터 9일 전까지는 상품 금액의 10%, 3일 전부터 6일 전까지는 30%, 1일 전과 2일 전은 50%를 취소 수수료로 뗀다. 출발 당일에 취소하거나 연락 없이 나오지 않으면 돌려주지 않는다.
[제7조 · 2쪽] 제7조 날씨와 천재지변 태풍, 폭설 같은 날씨나 천재지변으로 항공편이나 배가 뜨지 못하면 수수료 없이 취소하거나 날짜를 바꿀 수 있다. 이미 쓴 숙박이나 교통의 비용은 돌려주지 않는다.
---
답: 출발 3~6일 전 취소는 상품 금액의 30%를 수수료로 뗍니다.코드가 의미하는 것 LCEL 체인의 맨 앞 사전은 같은 질문을 context 칸(검색기 → format_docs)과 question 칸(RunnablePassthrough())으로 나눠 보내, 프롬프트의 두 빈칸을 한 번에 채웁니다. format_docs 가 조각마다 "[제5조 · 2쪽]" 머리표를 붙이고, 시스템 지시에 "근거에 없으면 '약관에서 찾지 못했습니다.'"를 한국어로 적은 것이 세트 메뉴로는 바꾸기 어려웠던 부분입니다. RunnableLambda(peek) 은 체인 가운데에 끼워 모델이 받을 글을 엿보는 창이며, RAG 를 점검할 때 가장 먼저 보는 곳입니다.
4단계. 답과 근거를 함께 돌려받기
import json
from langchain_core.runnables import RunnableParallel
fake = FakeListChatModel(responses=[
"출발 3~6일 전 취소는 상품 금액의 30%를 수수료로 뗍니다.",
"국내선은 15kg 짐 한 개가 포함되고, 기내에는 7kg 이하 가방 하나를 들고 탈 수 있습니다.",
])
answer_chain = prompt | fake | StrOutputParser()
# 1) 검색 결과(docs)와 질문을 먼저 사전으로 받고 2) assign 으로 그 사전에 answer 칸을 더한다
rag = RunnableParallel(docs=retriever, question=RunnablePassthrough()).assign(
answer=lambda x: answer_chain.invoke({"context": format_docs(x["docs"]), "question": x["question"]})
)
log = []
for q in ["출발 5일 전에 취소하면 수수료가 얼마예요?", "짐은 몇 kg까지 실을 수 있나요?"]:
out = rag.invoke(q)
# 출처는 모델이 아니라 우리 코드가 metadata 에서 붙인다
sources = [f"{d.metadata['article']}({d.metadata['page']}쪽)" for d in out["docs"]]
print("질문:", q)
print(" 답:", out["answer"])
print(" 근거:", ", ".join(sources))
log.append({"질문": q, "답": out["answer"], "근거": sources})
with open("out_ch06_qa_log.json", "w", encoding="utf-8") as f:
json.dump(log, f, ensure_ascii=False, indent=2)
print("저장: out_ch06_qa_log.json")질문: 출발 5일 전에 취소하면 수수료가 얼마예요?
답: 출발 3~6일 전 취소는 상품 금액의 30%를 수수료로 뗍니다.
근거: 제5조(2쪽), 제7조(2쪽)
질문: 짐은 몇 kg까지 실을 수 있나요?
답: 국내선은 15kg 짐 한 개가 포함되고, 기내에는 7kg 이하 가방 하나를 들고 탈 수 있습니다.
근거: 제10조(3쪽), 제12조(3쪽)
저장: out_ch06_qa_log.json코드가 의미하는 것 RunnableParallel(docs=retriever, question=RunnablePassthrough()).assign(answer=…) 는 검색 결과를 프롬프트에 넣고 끝내지 않고 사전의 docs 칸에 남겨 둔 채 답을 더하는 짜임입니다. 덕분에 답 아래의 출처(sources)를 모델이 아니라 우리 코드가 metadata 에서 붙일 수 있고, 모델이 없는 조항 번호를 지어낼 틈이 사라집니다. out_ch06_qa_log.json 에 질문·답·근거를 함께 남기는 것은 나중에 "왜 이렇게 답했는가"를 근거와 대조해 볼 상담 기록을 쌓는 일입니다.
이 실습이 뜻하는 것
이 실습은 오픈북 시험에서 학생이 책의 어느 쪽을 펴 놓고 답을 쓰는지 어깨 너머로 지켜보는 일과 같습니다. 쪽을 찾고(1단계), 세트 메뉴(RetrievalQA)로 한 번에 풀게 하고(2단계), 단품(LCEL)으로 짜서 학생 앞에 펴 놓인 쪽을 들여다보고(3단계), 답 옆에 펴 본 쪽을 적게 합니다(4단계). 여기서 펴 놓은 쪽이 프롬프트의 [근거], 답 옆의 쪽 표시가 출처입니다. 비유와 다른 점은, 이 학생(가짜 모델)은 펴 놓은 쪽을 읽지 않고 미리 외운 답을 말한다는 것입니다. 그래서 짜임만 믿고, 답의 내용은 믿지 않습니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 검색기 | 제5조(2쪽), 제7조(2쪽) | 저장한 색인을 불러와 k=2 로 찾았습니다. 정답 제5조가 1위입니다 |
| 2. RetrievalQA | query, result, source_documents | 답과 근거 조각이 한 사전으로 돌아옵니다. 기본 프롬프트는 "모르면 모른다고 하라"는 영어 지시입니다 |
| 3. LCEL | 시스템 메시지 안의 [근거] 두 조항 | 우리가 쓴 한국어 규칙 아래에 제5조와 제7조가 쪽 번호와 함께 그대로 끼워졌습니다 |
| 4. 답과 근거 | 제5조(2쪽), 제10조(3쪽) 등 | assign 으로 검색 결과를 남겨 두어, 답 아래에 우리 코드가 출처를 붙였습니다 |
한 줄로 줄이면, RAG 체인이 "검색한 조각을 프롬프트에 끼워 넣고, 답과 근거를 함께 돌려주는" 짜임이라는 것을 모델 없이 확인한 실습입니다. 3단계에서 본 시스템 메시지가 진짜 모델이 받을 글 그대로입니다. 제7조는 질문과 상관없는 근거인데 k=2 라서 따라 들어왔습니다. 진짜 모델은 프롬프트의 "근거에 적힌 내용으로만" 규칙에 따라 제5조를 골라 쓸 것입니다. 모델 자리에 ChatOpenAI 를 끼우는 일은 프로젝트에서 합니다.
코드 설명
db.as_retriever(search_kwargs={"k": 2})는 벡터DB 를 검색기로 바꿉니다. 검색기는invoke(질문)으로 부르고,|로 다른 부품에 잇습니다.RetrievalQA.from_chain_type(llm=…, retriever=…, chain_type="stuff", return_source_documents=True)는 옛 방식의 묶음 체인입니다. 입력은{"query": …}, 출력은result와source_documents입니다.{"context": retriever | format_docs, "question": RunnablePassthrough()}는 같은 질문을 두 칸에 동시에 넣습니다.RunnablePassthrough()는 받은 것을 그대로 넘기는 부품입니다.RunnableParallel(docs=retriever, question=RunnablePassthrough()).assign(answer=…)는 검색한 조각을 버리지 않고 사전에 남긴 채 답을 더합니다. assign 은 "칸을 더 배정하다"라는 뜻입니다. 그래서 답과 근거를 함께 돌려받습니다.out_ch06_qa_log.json에는 질문, 답, 근거 조항이 남습니다. 가짜 모델의 답이라 내용은 미리 적어 둔 것입니다.