6장 — Chroma를 활용한 벡터 저장소 구성

Chroma는 오픈소스 벡터 저장소로, 임베딩 벡터를 효율적으로 저장하고 검색하는 기능을 제공합니다. 특히 로컬에 디렉터리를 생성해 벡터 저장소를 구성할 수 있어 데이터를 외부에 저장할 수 없는 환경에서 유용합니다. 도커로 배포할 수도 있어 여러 서버에서 접근이 필요한 상황에도 쓸 수 있고, LangChain에서는 langchain-chroma 패키지로 손쉽게 RAG 파이프라인을 구성할 수 있습니다.

이 장은 종합부동산세법을 지식 베이스로 삼아 RAG 파이프라인을 처음부터 끝까지 만들어 봅니다.

!uv add -q python-dotenv langchain langchain-openai langchain-community \
  langchain-text-splitters docx2txt langchain-chroma

문서 준비: 왜 PDF가 아니라 워드인가

국가법령정보센터에서 ‘종합부동산세법’을 내려받습니다. LangChain의 Document Loaders를 쓰려면 한글(HWP) 파일은 쓸 수 없으니 PDF나 DOC 중에 골라야 합니다. 법령을 내려받으면 기본 확장자가 서식 있는 텍스트 포맷(RTF) 인데 Document Loaders가 이 확장자를 읽지 못하므로, 파일을 열어 [다른 이름으로 저장]으로 확장자를 .docx로 변경합니다.

여기서 이 장의 가장 실용적인 조언이 나옵니다.

우리말로 된 문서는 가능하면 DOC 파일을 사용하는 것이 좋습니다. PDF는 영문 문서에 최적화되어 있어 한 단어가 줄바꿈이 되어 다음 줄에 나타난 경우, 같은 단어라는 것을 인지하지 못합니다.

예시가 구체적입니다. 종합부동산세법 제1조(목적)에 “목적으로 한다”라는 구절이 있는데, “목”이라는 글자 다음 줄에 “적”이 들어가 있으면 PDF를 텍스트로 파싱할 때 “목적”을 한 단어로 인지하지 못하고 “목”과 “적으로”로 나누어 파싱하게 됩니다. 이를 방지하려면 되도록 우리말 문서는 마이크로소프트 워드를 쓰는 편이 좋습니다.

문서 분할: 어떤 분할기를 쓸까

from langchain_community.document_loaders import Docx2txtLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1500,     # 각 청크의 최대 문자 수(너무 크면 정보가 희석, 너무 작으면 맥락 손실)
    chunk_overlap=200,   # 인접한 청크 간 중복되는 문자 수(맥락 연결성 유지)
    separators=[
        "\n\n",      # 빈 줄(문단 구분 우선)
        "\n",        # 줄바꿈(문장 구분)
        " ",         # 공백(단어 구분)
        ".",         # 마침표(문장 종료)
        ",",         # 쉼표(절 구분)
        "​",    # 폭 없는 공백(웹에서 흔히 발견)
        ",",    # 전각 쉼표(한글 텍스트용)
        "、",    # 한중일 쉼표(동아시아 언어용)
        ".",    # 전각 마침표(한글 텍스트용)
        "。",    # 한중일 마침표(동아시아 언어용)
        "",          # 마지막 구분자(강제 분할)
    ],
)

loader = Docx2txtLoader('../documents/law.docx')
document_list = loader.load_and_split(text_splitter=text_splitter)

RecursiveCharacterTextSplitter 는 저자가 여러 분할기 중 가장 괜찮은 결과를 얻은 방식입니다. 문서를 문자 단위로 분할하되 가능한 한 문장이나 단락의 의미를 보존하려고 시도합니다. separators로 지정한 문장 부호로 나눠진 텍스트가 같은 문맥을 가질 가능성이 높기 때문이고, chunk_size를 초과하면 재귀적으로 더 작은 단위로 분할해 최대한 문맥을 보존합니다.

다른 분할기들과의 비교가 선택 기준을 보여 줍니다.

분할기 특징 한계
RecursiveCharacterTextSplitter 구분자 우선순위에 따라 재귀 분할, 의미 보존 시도 — (책의 추천)
CharacterTextSplitter 단순히 문자 수로만 분할 문맥을 보존하기 어렵다
CharacterTextSplitter.from_tiktoken_encoder 토큰 단위 분할로 정확한 토큰 수 제어 가능 문맥을 보존할 수 없다
SemanticChunker 의미 단위 분할을 표방 임베딩 모델 비용이 발생하고 구동 시간이 오래 걸리지만, 저자 경험상 만족할 만한 결과를 얻을 수 없었다

chunk_overlap=200이전 청크의 마지막 200자가 다음 청크의 시작 부분에 포함된다는 뜻으로, 이를 통해 문맥의 연속성을 유지합니다.

임베딩 모델 불러오기

2장에서 확인한 대로 업스테이지 임베딩이 우리말에 우수한 성능을 보이므로 embedding-passage 모델을 씁니다. 다만 주의할 점이 있습니다. langchain-upstage 패키지가 LangChain 1.x와 호환되지 않아, langchain-openaiOpenAIEmbeddingsbase_url을 바꿔 업스테이지 모델을 사용합니다.

import os
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings

load_dotenv()

embedding = OpenAIEmbeddings(
    base_url='https://api.upstage.ai/v1/solar',
    api_key=os.getenv("UPSTAGE_API_KEY"),
    model='embedding-passage',
    check_embedding_ctx_length=False  # 알 수 없는 모델에 대한 토큰 길이 확인 비활성화(필수)
)

check_embedding_ctx_length=False필수라는 주석이 중요합니다. OpenAI가 모르는 모델이므로 토큰 길이 검사를 꺼야 합니다.

벡터 저장소 만들기

from langchain_chroma import Chroma

# 데이터를 처음 저장할 때
vector_store = Chroma.from_documents(
    documents=document_list,
    embedding=embedding,
    collection_name='tax-original',
    persist_directory="./tax-original"
)
  • collection_name: 관계형 데이터베이스(RDB) 개념으로 보자면 테이블의 이름
  • persist_directory: Chroma 벡터 저장소가 생성되는 경로의 이름

persist_directory에 대한 경고가 실질적입니다.

persist_directory를 지정하지 않으면 메모리상에만 벡터 저장소가 구성되고, 노트북을 종료하면 생성된 벡터 저장소가 사라집니다. 임베딩 모델을 구동한 비용을 지불했는데 벡터 저장소가 사라지면 아쉬우니 반드시 지정해 두세요.

검색 테스트

question = '10억짜리 집을 2채 가지고 있을 때 세금을 얼마나 내나요?'

# k 값을 조절해서 얼마나 많은 데이터를 불러올지 결정
retriever = vector_store.as_retriever(search_kwargs={"k": 3})
retriever.invoke(question)

search_kwargs에서 선언한 대로 검색 결과가 높은 총 세 개의 문서를 가져옵니다. 결과에는 metadata에 출처 파일 경로가, page_content에 실제 법 조문이 담겨 옵니다.

RAG 체인 구성

LangChain에서 RAG 파이프라인을 만드는 가장 간단한 방법은 create_retrieval_chain 입니다. 이 함수는 retrievercombine_docs_chain을 인수로 받습니다.

  • retriever: 벡터 저장소에서 검색하는 역할 — 이미 Chroma로 선언했습니다.
  • combine_docs_chain: 검색된 문서를 하나로 묶는 역할 — create_stuff_documents_chain을 씁니다.

stuff라는 이름의 유래가 재미있습니다.

영어 단어 stuff는 동사로 쓸 때 ‘물건으로 가득 채우다’ 라는 뜻입니다. 이와 유사하게 create_stuff_documents_chain검색된 청크들을 하나의 문자열로 결합해 프롬프트에 통합하는 체인을 생성합니다. 모든 문서를 하나로 합쳐 단일 프롬프트로 만드는 가장 단순한 방법이며, 문서가 적거나 크기가 작을 때 특히 효과적입니다.

프롬프트는 LangChain 허브에서

create_stuff_documents_chainllmprompt를 인수로 받습니다. 프롬프트를 직접 작성할 수도 있지만, LangChain 허브 에서 전 세계 엔지니어가 작성한 프롬프트를 가져다 쓸 수 있습니다. 어떤 피드백을 받았는지도 확인할 수 있습니다.

프롬프트를 처음부터 작성하는 것은 어려울 수 있으니, 허브에서 필요한 프롬프트를 가져와 운영하고자 하는 프로젝트에 맞게 커스텀하는 것을 추천합니다.

책은 langchain-ai/retrieval-qa-chat 프롬프트를 씁니다.

from langchain_openai import ChatOpenAI
from langsmith import Client
from langchain_classic.chains.combine_documents import create_stuff_documents_chain
from langchain_classic.chains import create_retrieval_chain

llm = ChatOpenAI(model='gpt-4o-mini')

# LangChain 허브에서 검증된 RAG용 프롬프트 템플릿을 가져온다.
client = Client()
retrieval_qa_chat_prompt = client.pull_prompt(
    "langchain-ai/retrieval-qa-chat", include_model=True
)

# 검색된 문서들을 하나의 콘텍스트로 결합하는 체인을 만든다.
combine_docs_chain = create_stuff_documents_chain(llm, retrieval_qa_chat_prompt)

# retriever와 문서 결합 체인을 연결하여 최종 RAG 체인을 구성한다.
retrieval_chain = create_retrieval_chain(retriever, combine_docs_chain)

답변 생성

ai_message = retrieval_chain.invoke({"input": question})
ai_message['answer']

결과가 제대로 나옵니다.

‘10억짜리 집을 2채 소유하고 있는 경우, 주택분 종합부동산세액은 1천분의 27의 세율이 적용됩니다. 따라서 두 집의 합계 가액은 20억이 되고, 세금은 다음과 같이 계산됩니다. 과세표준 = 20억 = 20,000,000,000원, 세액 = 과세표준 × 세율 = 20,000,000,000 × (27/1,000) = 540,000,000원. 즉, 두 집에 대한 종합부동산세는 5억 4천만 원이 됩니다.’

Runnable — LangChain을 이해하는 열쇠

이 장의 개념적 핵심입니다.

Runnable은 LangChain의 모든 구성 요소가 따르는 표준 인터페이스입니다. 규격이 같아야 서로 조립이 가능한 레고 블럭과 유사하게, LangChain의 컴포넌트는 모두 Runnable이라는 공통 규격을 따릅니다. LangChain의 컴포넌트는 모두 클래스로 이루어져 있는데, 모든 구성 요소가 Runnable이라는 클래스를 상속받는 방식으로 구현되어 있습니다.

ChatOpenAI, ChatOllama, ChatHuggingFace도, retrieval_chain도 모두 Runnable입니다. 그래서 전부 invoke()로 실행됩니다.

Chain은 이러한 Runnable들을 목적에 맞게 순서대로 연결한 파이프라인입니다. 앞선 retrieval_chain은 이런 과정을 하나의 사슬처럼 묶어둔 것입니다.

질문 → 문서 검색(retriever) → 검색 결과와 질문을 프롬프트에 채움(combine) → LLM → 답

개발자는 복잡한 내부 과정을 신경 쓸 필요 없이 체인에 질문만 던지면(invoke) 최종 결과를 얻습니다. 즉 LangChain을 활용한 개발은 다양한 기능을 가진 Runnable 블록들을 조립하여 데이터가 흐르는 길(Chain)을 만드는 과정입니다.

모듈화가 주는 것

이 구조의 값어치는 교체 가능성에 있습니다.

  • retriever 모듈을 다른 벡터 저장소로 교체하거나 LLM을 다른 모델로 변경하더라도 전체 시스템의 구조는 그대로 유지된다.
  • 각 모듈이 잘 정의된 인터페이스를 갖추고 있어 테스트와 디버깅이 용이하며 재사용성도 뛰어나다.
  • 대규모 프로덕션 환경에서 특히 값지다. 시스템 일부를 수정하거나 성능을 개선할 때 전체를 재설계하지 않고 해당 모듈만 업데이트하면 되기 때문이다.

정리

  • 우리말 문서는 PDF보다 워드(.docx) 다. PDF는 줄바꿈된 단어를 두 단어로 파싱한다.
  • 분할기는 RecursiveCharacterTextSplitter 가 기본값이다. SemanticChunker는 비용과 시간을 쓰고도 기대만큼의 결과를 못 줬다.
  • persist_directory를 반드시 지정한다. 안 그러면 돈 주고 만든 벡터 저장소가 노트북을 닫는 순간 사라진다.
  • 프롬프트는 처음부터 쓰지 말고 LangChain 허브에서 가져와 커스텀한다.
  • create_retrieval_chain(retriever, combine_docs_chain) 한 줄이 RAG 파이프라인이다. stuff는 문서를 프롬프트에 가득 채운다는 뜻이다.
  • 모든 것이 Runnable이다. 레고 블럭처럼 규격이 같아서 조립되고, 그래서 벡터 저장소나 LLM을 갈아 끼워도 구조가 그대로다.