LangChain 챗봇6장 · 문서 기반 챗봇11 / 21← 이전목차다음 →MetaContents

6장. 문서 기반 챗봇

RetrievalQA 와 LCEL 로 답하기

식당 세트 메뉴는 "밥, 국, 반찬"이 정해져 있어 주문이 간단하지만, 반찬을 바꾸려면 주방에 따로 말해야 합니다. 단품으로 하나씩 고르면 손이 더 가지만 원하는 대로 짤 수 있습니다. 찾아서 답하는 체인도 둘입니다. RetrievalQA 는 검색과 프롬프트와 모델을 한 번에 묶은 세트 메뉴이고, 3장에서 배운 LCEL 로 같은 일을 단품으로 짤 수 있습니다.

검색기와 모델을 한 번에 묶는 옛 방식 RetrievalQA 와 부품을 파이프로 잇는 지금 방식 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 에 가짜 모델을 끼워 두 방식의 입력과 출력 모양을 봅니다. 가짜 모델은 정해 둔 답을 돌려주므로 답의 글에는 뜻이 없고, 짜임만 봅니다. 첫 블록은 앞 절과 같은 준비입니다.

Python
# 준비: "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)
Python
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()(그대로 넘기기)로 질문 그대로 남습니다.
  • 진짜 모델의 기본 프롬프트가 영어라도 한국어 질문에는 대개 한국어로 답합니다. 다만 근거 조항 밝히기, 모르면 모른다고 하기 같은 규칙은 내 프롬프트에 한국어로 적는 편이 확실합니다.
← 이전 절6장 · 검색기와 k다음 절 →6장 · 근거 보여 주기와 모르면 모른다고 답하기