6장. 문서 기반 챗봇
RetrievalQA 와 LCEL 로 답하기
식당 세트 메뉴는 "밥, 국, 반찬"이 정해져 있어 주문이 간단하지만, 반찬을 바꾸려면 주방에 따로 말해야 합니다. 단품으로 하나씩 고르면 손이 더 가지만 원하는 대로 짤 수 있습니다. 찾아서 답하는 체인도 둘입니다. RetrievalQA 는 검색과 프롬프트와 모델을 한 번에 묶은 세트 메뉴이고, 3장에서 배운 LCEL 로 같은 일을 단품으로 짤 수 있습니다.
왼쪽 파랑 상자가 RetrievalQA, 오른쪽 보라 상자가 LCEL 체인입니다. 둘 다 질문을 받아 검색기로 조각을 찾고, 조각을 이어 붙여(stuff, 채워 넣기) 프롬프트에 넣고, 모델의 답을 돌려줍니다. 여기서 세트 메뉴가 RetrievalQA.from_chain_type(...), 단품 주문이 {"context": ..., "question": ...} | prompt | model | parser 입니다. 비유와 다른 점은, 세트 메뉴의 기본 프롬프트가 영어로 적혀 있어서 한국어 규칙("근거의 조항을 밝혀라")을 넣으려면 결국 프롬프트를 따로 넘겨야 한다는 것입니다.
두 방식 견주기
| 견줄 것 | RetrievalQA(옛 방식) | LCEL 체인(지금 방식) |
|---|---|---|
| 있는 곳 | langchain_classic.chains(따로 된 패키지) | langchain_core 의 부품 |
| 만드는 법 | RetrievalQA.from_chain_type(llm=, retriever=) | {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | model | StrOutputParser() |
| 입력과 출력 | {"query": 질문} → {"result": 답, "source_documents": [...]} | 질문 글 → 답 글(근거는 따로 챙긴다) |
| 프롬프트 | 영어 기본 프롬프트. 바꾸려면 chain_type_kwargs | 내가 쓴 프롬프트 그대로 |
| 중간 들여다보기 | 어렵다 | RunnableLambda 로 끼워 본다 |
RetrievalQA 같은 옛 체인 클래스는 langchain 1.x 본체에는 없고, 4장의 옛 메모리 클래스처럼 langchain-classic 패키지로 옮겨졌습니다. 인터넷의 글과 책에 자주 나와서 이 장은 그 생김새를 보여 주고, 실제로 짜는 챗봇은 LCEL 로 만듭니다.
짧은 예시
조항 네 개짜리 벡터DB 에 가짜 모델을 끼워 두 방식의 입력과 출력 모양을 봅니다. 가짜 모델은 정해 둔 답을 돌려주므로 답의 글에는 뜻이 없고, 짜임만 봅니다. 첫 블록은 앞 절과 같은 준비입니다.
# 준비: "FAISS 벡터DB 에 저장하고 검색하기" 절과 같은 연습용 임베딩과 조항 네 개짜리 벡터DB
import zlib
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
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)
emb = BigramEmbeddings()
docs = [
Document("제5조 출발 10일 전까지 취소하면 전액 돌려준다.", metadata={"article": "제5조"}),
Document("제10조 국내선 상품은 15kg 짐 한 개가 포함된다.", metadata={"article": "제10조"}),
Document("제12조 반려동물은 7kg 이하의 개와 고양이만 된다.", metadata={"article": "제12조"}),
Document("제14조 여권의 남은 기간이 6개월 이상이어야 한다.", metadata={"article": "제14조"}),
]
db = FAISS.from_documents(docs, emb)from langchain_classic.chains import RetrievalQA
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
retriever = db.as_retriever(search_kwargs={"k": 2})
fake = FakeListChatModel(responses=["(가짜 답) 10일 전까지는 전액 돌려줍니다."] * 2)
# 옛 방식: from_chain_type 이 검색 → 이어 붙이기(stuff) → 모델을 한 번에 묶는다
qa = RetrievalQA.from_chain_type(llm=fake, retriever=retriever, return_source_documents=True)
out = qa.invoke({"query": "취소하면 돈을 돌려주나요?"})
print("RetrievalQA 의 열쇠:", list(out))
print(" 근거:", [d.metadata["article"] for d in out["source_documents"]])
def format_docs(docs):
"""조각 목록을 '[조항] 내용' 줄로 이어 붙인다"""
return "\n".join(f"[{d.metadata['article']}] {d.page_content}" for d in docs)
# 지금 방식: 사전의 두 칸을 동시에 채운 뒤 프롬프트 → 모델 → 글
prompt = ChatPromptTemplate.from_messages([
("system", "아래 약관으로만 답하라.\n{context}"),
("human", "{question}"),
])
chain = {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | fake | StrOutputParser()
print("LCEL 의 답:", chain.invoke("취소하면 돈을 돌려주나요?"))RetrievalQA 의 열쇠: ['query', 'result', 'source_documents']
근거: ['제5조', '제12조']
LCEL 의 답: (가짜 답) 10일 전까지는 전액 돌려줍니다.RetrievalQA 는 입력 열쇠 query 와 함께 답(result)과 근거 조각(source_documents)을 한 사전으로 돌려줍니다. return_source_documents=True 덕분에 근거로 쓴 제5조와 제12조를 바로 확인할 수 있습니다. LCEL 체인은 답의 글만 돌려줍니다. 두 방식 모두 가짜 모델의 정해 둔 답이 나왔으니, 모델 자리에 ChatOpenAI 를 끼우면 그대로 진짜 RAG 가 됩니다. LCEL 쪽에서 근거까지 함께 돌려받는 법은 실습 4에서 봅니다.
코드가 의미하는 것
이 코드는 같은 식당에서 세트 메뉴와 단품 주문을 한 번씩 해 보는 일입니다. 첫 블록은 앞 절과 같은 벡터DB 준비이고, 둘째 블록이 같은 검색기와 가짜 모델로 RetrievalQA(세트)와 LCEL 체인(단품)을 차례로 짜서 부릅니다.
| 코드 | 하는 일 | 챗봇에서 뜻하는 것 |
|---|---|---|
FakeListChatModel(responses=[...] * 2) | 정해 둔 답을 두 번 돌려주는 가짜 모델을 만든다 | 키 없이 체인의 짜임만 시험하는 대역입니다. 두 체인이 한 번씩 부르므로 답을 두 개 둡니다 |
RetrievalQA.from_chain_type(llm=, retriever=, return_source_documents=True) | 검색 → 이어 붙이기 → 모델을 한 객체로 묶는다 | 세트 메뉴입니다. 근거 조각까지 함께 돌려받도록 마지막 인자를 켰습니다 |
qa.invoke({"query": ...}) | 사전으로 질문을 넣고 사전으로 답을 받는다 | 옛 체인은 정해진 열쇠 이름을 씁니다. 열쇠를 틀리면 질문이 전달되지 않습니다 |
{"context": retriever | format_docs, "question": RunnablePassthrough()} | 같은 질문을 두 칸에 동시에 넣어, 한 칸은 근거 글로 바꾸고 한 칸은 그대로 둔다 | 단품 주문의 핵심입니다. 근거를 어떤 꼴로 이어 붙일지([조항] 내용)를 우리가 정합니다 |
| prompt | fake | StrOutputParser() | 프롬프트를 채워 모델에 보내고 답을 글로 꺼낸다 | 3장에서 짠 체인과 같은 꼴입니다. 앞에 검색 칸이 붙었을 뿐입니다 |
결과에서 읽을 것은 두 가지입니다.
- 두 방식이 돌려주는 꼴이 다릅니다.
RetrievalQA는query,result,source_documents세 열쇠의 사전을, LCEL 체인은 답의 글 한 줄을 돌려줍니다. 근거를 화면에 보이려면 LCEL 쪽은 따로 챙겨야 합니다. - 답의 글은 뜻이 없습니다. "(가짜 답) 10일 전까지는 전액 돌려줍니다."는 가짜 모델에 미리 적어 둔 글입니다. 이 예시에서 믿을 것은 근거 목록
['제5조', '제12조']처럼 검색이 정한 부분뿐입니다.
주의할 점
RetrievalQA의 입력 열쇠는"query", 답의 열쇠는"result"입니다."question","answer"로 쓰면 오류가 나거나 빈 값이 나옵니다.- LCEL 체인의 맨 앞 사전
{"context": ..., "question": ...}은 3장의RunnableParallel과 같습니다. 같은 질문이 두 칸에 동시에 들어가, 한쪽은 검색기를 거쳐 근거 글이 되고 한쪽은RunnablePassthrough()(그대로 넘기기)로 질문 그대로 남습니다. - 진짜 모델의 기본 프롬프트가 영어라도 한국어 질문에는 대개 한국어로 답합니다. 다만 근거 조항 밝히기, 모르면 모른다고 하기 같은 규칙은 내 프롬프트에 한국어로 적는 편이 확실합니다.