이번 포스팅에서는 실제로 RAG를 구현하여 소득세법 전문을 학습한 챗봇을 만들어보겠습니다. 이론으로만 배웠던 RAG를 실제로 구현해보면서 각 단계를 자세히 살펴보겠습니다!
먼저 국가법령정보센터에서 소득세법을 Word 파일(.docx)로 저장합니다.
⚠️ 주의: 한글 파일(.hwp)은 Python으로 읽기 어렵고, 줄바꿈에 있는 단어가 하나의 단어일 경우 제대로 인식하지 못하는 문제가 있습니다. 반면 Word 파일은 이러한 문제가 없어 더 안정적입니다.
다운로드한 Word 파일을 프로젝트 폴더에 저장한 후, 다음과 같은 단계로 챗봇을 구현할 예정입니다:
구현 단계
문서를 읽을 때는 LangChain의 Document Loader를 사용합니다.
%pip install --upgrade --quiet langchain_community docx2txtfrom langchain_community.document_loaders import Docx2txtLoader
loader = Docx2txtLoader("./tax.docx")
documents = loader.load()
print(documents)실행 결과
[Document(metadata={'source': './tax.docx'}, page_content='소득세법\n\n소득세법\n\n[시행 2025. 10. 1.] [법률 제21065호, 2025. 10. 1., 타법개정]\n\n기획재정부(재산세제과(양도소득세)) 044-215-4312\n\n기획재정부(소득세제과(근로소득)) 044-215-4216\n\n...')]출력 결과를 보면 문서의 내용이 한 줄로 모두 출력되는 것을 확인할 수 있습니다. 이를 적절한 크기로 구분해야 합니다!
문서를 구분하는 방법은 LangChain의 Text Splitter를 활용합니다. 이 중 RecursiveCharacterTextSplitter는 여러 구분자를 기준으로 문서를 재귀적으로 구분할 수 있습니다.
%pip install -qu langchain-text-splittersfrom langchain_community.document_loaders import Docx2txtLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1500,
chunk_overlap=200
)
loader = Docx2txtLoader("./tax.docx")
document_list = loader.load_and_split(text_splitter)
document_listRecursiveCharacterTextSplitter는 두 가지 중요한 설정값을 가집니다:
1. chunk_size 📏
2. chunk_overlap 🔗
실행 결과는 다음과 같은 리스트 형태로 출력됩니다
[Document(metadata={'source': './tax.docx'}, page_content='소득세법\n\n소득세법\n\n[시행 2025. 10. 1.]...'),
Document(metadata={'source': './tax.docx'}, page_content='1. 구성원 간 이익의 분배비율이...'),
Document(metadata={'source': './tax.docx'}, page_content='⑤ 공동으로 소유한 자산에...'),
...]각 Document는 적절한 크기로 분할되어 메타데이터(출처)와 함께 저장됩니다! 🎉
OpenAI의 Embedding 모델을 활용합니다. API Key가 필요하므로 환경변수를 먼저 로드합니다.
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings
# 환경변수를 불러옴
load_dotenv()
# OpenAI에서 제공하는 Embedding Model을 활용해서 chunk를 vector화
embedding = OpenAIEmbeddings(model='text-embedding-3-large')이번 실습에서는 Chroma라는 벡터 데이터베이스를 사용합니다. Chroma는 인메모리 데이터베이스로 실습하기에 가장 간단하기 때문입니다.
%pip install langchain-chromaChroma에는 여러 메서드가 있는데, 지금은 Document가 있는 상황이므로 from_documents를 사용하여 데이터베이스를 생성합니다.
from langchain_chroma import Chroma
# 데이터를 처음 저장할 때
database = Chroma.from_documents(
documents=document_list,
embedding=embedding,
collection_name='chroma-tax',
persist_directory="./chroma"
)
# 이미 저장된 데이터를 사용할 때 database = Chroma(collection_name='chroma-tax', persist_directory="./chroma", embedding_function=embedding )💡 참고: persist_directory 옵션을 사용하면 임베딩 결과가 디스크에 저장되어 노트북을 닫아도 데이터가 유지됩니다!
잘 저장되었는지 간단한 쿼리로 유사도 검색을 테스트해봅니다.
query = "연봉이 5천만원인 직장인의 소득세는 얼마인가요?"
retrieved_docs = database.similarity_search(query, k=3)
retrieved_docssimilarity_search 메서드는 유사도 검색을 수행하여 가장 유사한 문서를 반환합니다. 기본값은 4개이며, k=3과 같이 파라미터를 수정하여 개수를 조절할 수 있습니다.
실행 결과
[Document(id='8246c897-9984-43a0-aaa4-b82518550059', metadata={'source': './tax.docx'}, page_content='③ 다음 각 호에 따른 소득의 금액은 종합소득과세표준을 계산할 때 합산하지 아니한다...'),
Document(id='d9ec7197-6529-40c2-9a2f-4cc4e90e71aa', metadata={'source': './tax.docx'}, page_content='[전문개정 2009. 12. 31.]\n\n\n\n제10조(납세지의 변경신고)...'),
Document(id='b637278a-f967-47bb-9023-780019b64791', metadata={'source': './tax.docx'}, page_content='2. 법인의 주주총회...')]질문과 가장 관련 있는 문서 3개가 성공적으로 검색되었습니다! 🎯
먼저 가장 기본적인 방법으로 프롬프트를 직접 작성하여 LLM에 전달해봅니다.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o")prompt = f"""[Identity]
- 당신은 소득세 전문가입니다.
- [Context]를 참고해서 사용자의 질문에 답변해주세요.
[Context]
{retrieved_docs}
Question: {query}
"""
ai_message = llm.invoke(prompt)
print(ai_message.content)실행 결과:
연봉 5천만 원인 직장인의 소득세를 계산하기 위해서는 연봉에서 소득 공제를 적용한 후 과세표준에 따라 세율을 적용해야 합니다. 일반적인 소득세 계산 과정을 설명하겠습니다...이 방법도 작동하지만, 매번 프롬프트를 직접 작성하는 것은 번거롭습니다. LangChain에서는 더 효율적인 방법을 제공합니다!
Retrieval된 데이터를 효과적으로 활용하기 위해 LangChain Hub에서 제공하는 프롬프트("rlm/rag-prompt")를 사용할 수 있습니다.
from langchain import hub
prompt = hub.pull("rlm/rag-prompt")RetrievalQA를 통해 검색과 답변 생성을 하나의 체인으로 연결합니다.
from langchain.chains import RetrievalQA
qa_chain = RetrievalQA.from_chain_type(
llm,
retriever=database.as_retriever(),
chain_type_kwargs={"prompt": prompt}
)
# 최신 LangChain에서는 .invoke() 사용을 권장
ai_message = qa_chain.invoke({"query": query})
ai_message⚠️ 중요: LangChain의 RetrievalQA는 버전 0.1.17부터 deprecated되었으며, langchain 1.0에서 완전히 제거될 예정입니다. 실제 프로덕션 환경에서는 create_retrieval_chain을 사용하는 것을 권장합니다.
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate
system_prompt = (
"Use the given context to answer the question. "
"If you don't know the answer, say you don't know. "
"Context: {context}"
)
prompt = ChatPromptTemplate.from_messages([
("system", system_prompt),
("human", "{input}"),
])
question_answer_chain = create_stuff_documents_chain(llm, prompt)
rag_chain = create_retrieval_chain(database.as_retriever(), question_answer_chain)
result = rag_chain.invoke({"input": query})
print(result["answer"])Chroma는 기본적으로 인메모리 데이터베이스이기 때문에 노트북을 닫으면 임베딩 결과물이 사라집니다.
데이터를 영구적으로 저장하려면 from_documents 메서드에 다음 파라미터를 추가합니다:
database = Chroma.from_documents(
documents=document_list,
embedding=embedding,
collection_name='chroma-tax',
# 컬렉션 이름 지정
persist_directory="./chroma"
# 저장 경로 지정
)이렇게 하면 지정한 이름의 폴더가 생성되며, 다음에 노트북을 다시 열었을 때 저장된 임베딩을 불러와 사용할 수 있습니다! 💪
저장된 데이터를 불러올 때는
database = Chroma(
collection_name='chroma-tax',
persist_directory="./chroma",
embedding_function=embedding
)완성된 RAG 시스템의 전체 동작 흐름을 정리하면 다음과 같습니다:
1. Retrieval (검색) 🔍
2. Augmented (증강) 📚
3. Generation (생성) ✨