튼튼발자 개발 성장기🏋️

LangChain이란 본문

AI/LLM 서비스 개발

LangChain이란

시뻘건 튼튼발자 2026. 6. 9. 11:13
반응형

RAG (Retrieval Augmented Generation)

LLM의 한계

  • 환각
  • 지식 컷오프
  • 보안 및 개인화 불가

Retrieval (검색)

  • 대규모 언어 모델(LLM)이 학습하지 않은 정보를 가져오는 것
  • 질문과 가장 연관성이 높은 문서 조각들을 벡터 DB에서 찾음

Augmented (증강)

  • 검색된 문서 조각을 사용자의 원래 질문과 합쳐서 "이 문서를 참고해서 질문에 답해줘"라는 식의 확장된 프롬프트 생성

Generation (생성)

  • 제공된 확실한 증거 데이터를 바탕으로 왜곡 없는 정확한 답변을 생성
Caution
RAG는 환각을 줄이는 방법이지 완전히 없애는 방법은 아니다. 검색된 문서가 부정확하거나 질문과 관련없는 문서가 들어가면 답변도 틀릴 수 있다.

Vector DB

Embedding

  • 텍스트를 Vector로 변환하는 방법
  • 문장이나 문서의 의미를 숫자 배열로 표현한다.
Caution
질문과 문서는 같은 임베딩 모델로 벡터화해야 서로의 유사도를 비교할 수 있다.

Vector

  • 텍스트를 수학적으로 표현한 숫자 배열
  • 비슷한 의미의 문장은 Vector 공간에서 가깝게 위치한다.

유사도 검색

  • 질문 Vector와 문서 Vector를 비교해 가까운 문서를 찾는다.
  • 두 개의 Vector가 얼마나 "가까운지"를 계산하는 방법
  • 유사도 계산에는 보통 다음 개념을 사용

좌표 공간에서 두 점 사이의 직선 거리를 계산한다. 거리가 짧을수록 두 Vector가 더 가깝다고 본다.

두 Vector가 바라보는 방향이 얼마나 비슷한지 비교한다. 크기보다 방향이 중요할 때 자주 사용한다.

두 Vector가 얼마나 떨어져 있는지 나타내는 거리 개념이다. 유사도 검색에서는 이 거리를 기준으로 가까운 문서를 찾는다.

Vector DB 저장소

  • Embedding을 통해 생성된 Vector를 저장하는 데이터베이스

(Python notebook) 환경 설정

환경변수 불러오기

  • .env 파일에 API Key 등록
# .env
OPENAI_API_KEY=sk-...
uv add python-dotenv
from dotenv import load_dotenv

load_dotenv()

첫 LLM

uv add langchain-openai
from langchain_openai import ChatOpenAI

llm = ChatOpenAI()
llm.invoke("알서포트 회사에 대해 알려줘")
uv add langchain-upstage
from langchain_upstage import ChatUpstage

llm = ChatUpstage()
llm.invoke("알서포트 회사에 대해 알려줘")
AIMessage(
    content="알서포트(Alseport)는 한국의 글로벌 IT 기업으로, 솔루션 제공과 프로덕션 서비스를 중심으로 다양한 분야에서 활동하고 있습니다.",
    additional_kwargs={"refusal": None},
    response_metadata={
        "token_usage": {
            "completion_tokens": 246,
            "prompt_tokens": 25,
            "total_tokens": 271,
        },
        "model_name": "gpt-3.5-turbo-0125",
        "finish_reason": "stop",
    },
    id="lc_run--019e...",
    usage_metadata={
        "input_tokens": 25,
        "output_tokens": 246,
        "total_tokens": 271,
    },
)

LangChain을 활용한 RAG

LangChain으로 RAG를 구성할 때는 문서를 읽는 단계와 질문에 답변하는 단계를 분리해서 생각하면 이해하기 쉽다. 먼저 문서를 작은 단위로 나누고 벡터로 저장한다. 이후 사용자의 질문이 들어오면 질문과 가까운 문서 조각을 검색하고, 검색된 내용을 LLM의 Context로 전달해 답변을 생성한다.

기본 RAG 구성

가장 단순한 RAG는 크게 데이터 전처리 단계와 런타임 단계로 나눌 수 있다.

데이터 전처리 단계

데이터 전처리 단계는 원본 문서를 검색 가능한 상태로 만드는 과정이다. 애플리케이션 실행 전에 미리 수행해둘 수 있다.

1
Load원본 문서를 LangChain의 Document 형태로 읽어온다.
2
Split긴 문서를 검색하기 좋은 크기의 문서 조각으로 나눈다.
3
Embed문서 조각을 Embedding 모델로 Vector로 변환한다.
4
Save변환된 Vector와 문서 조각을 Vector DB에 저장한다.

런타임 단계

런타임 단계는 사용자가 질문했을 때 실행되는 과정이다. 저장된 문서를 검색하고, 검색 결과를 LLM에 전달해 답변을 생성한다.

1
Question사용자의 질문을 받는다.
2
Retrieve질문과 가까운 문서 조각을 Vector DB에서 검색한다.
3
Generate검색된 문서 조각을 Context로 넣고 LLM이 답변을 생성한다.

기본 RAG는 이 구조만으로도 동작한다. 다만 실제 문서에서는 문서 로딩 품질, 검색 품질, 프롬프트 구성 때문에 답변 품질이 쉽게 흔들릴 수 있다. 그래서 LangChain으로 구현할 때는 문서 로딩 보정, 검색 Chain, 문서 결합 Chain, 질문 전처리 같은 단계가 추가된다.

RAG 문서 전처리 도구

RAG Agent를 만들 때는 문서 전처리 품질이 전체 답변 품질을 크게 좌우한다. 문서 변환 도구는 문서 종류와 비용 기준으로 나눠 선택한다.

상황우선 검토 도구
텍스트 기반 PDF를 빠르게 Markdown으로 변환PyMuPDF4LLM
PDF의 표, 레이아웃, 읽기 순서가 중요Docling
Office, HTML, 이미지 등 여러 파일 형식을 Markdown으로 통일MarkItDown
스캔 PDF, 이미지 기반 문서, 복잡한 표와 차트py-zerox
PDF를 이미지로 렌더링하거나 기본 텍스트를 추출poppler
Markdown, HTML, PDF 등을 LangChain Document로 로드unstructured
Tip
처음부터 Vision/OCR 기반 변환을 쓰면 비용과 시간이 커질 수 있다. 먼저 poppler, PyMuPDF4LLM, unstructured 같은 빠른 로더로 품질을 확인하고, 표나 이미지 의미가 깨지는 문서에만 Vision/OCR 도구를 적용하는 방식이 운영 비용을 줄인다.

PyMuPDF4LLM

PyMuPDF4LLM은 PDF를 Markdown, JSON, plain text로 변환하는 PyMuPDF 기반 도구다. Vision 모델을 호출하지 않고 로컬에서 빠르게 동작하기 때문에 텍스트 기반 PDF를 RAG 인덱싱용 Markdown으로 바꿀 때 먼저 검토하기 좋다.

uv add pymupdf4llm
import pymupdf4llm


markdown = pymupdf4llm.to_markdown("./document.pdf")

텍스트 추출이 잘 되는 PDF에서는 비용 대비 효율이 좋다. 반대로 스캔 PDF나 이미지로 들어간 표, 차트가 많은 문서는 OCR 또는 Vision 기반 도구가 필요할 수 있다.

Docling

Docling은 PDF, Office 문서, 이미지 등을 구조화된 문서 데이터로 변환하는 도구다. 레이아웃, 읽기 순서, 표 구조, OCR이 중요한 문서에서 유용하다. 문서를 Markdown으로 바로 내보내 RAG 파이프라인에 연결할 수 있다.

uv add docling
from docling.document_converter import DocumentConverter


converter = DocumentConverter()
result = converter.convert("./document.pdf")
markdown = result.document.export_to_markdown()

Docling은 구조 보존에 강점이 있지만 문서 크기와 옵션에 따라 처리 시간이 길어질 수 있다. 대량 인덱싱에서는 샘플 문서로 품질과 처리 시간을 먼저 비교한다.

MarkItDown

MarkItDown은 PDF, Word, PowerPoint, Excel, HTML, 이미지 등 다양한 파일을 Markdown으로 변환하는 경량 도구다. 문서 형식이 섞여 있고, 우선 Markdown으로 통일해 후속 파이프라인에 넣고 싶을 때 적합하다.

uv add markitdown
from markitdown import MarkItDown


md = MarkItDown()
result = md.convert("./document.docx")
markdown = result.text_content

정밀한 표 복원이나 OCR 품질이 핵심이면 전용 도구가 더 나을 수 있다. 하지만 사내 문서처럼 파일 형식이 다양하고 전처리 파이프라인을 단순화해야 할 때는 좋은 기본 선택지다.

py-zerox

py-zerox는 PDF나 이미지 문서를 Vision 모델 기반으로 Markdown으로 변환하는 라이브러리다. 표, 이미지, 스캔 문서가 많은 PDF를 RAG 대상으로 만들 때 단순 텍스트 추출보다 구조를 더 잘 보존할 수 있다.

uv add py-zerox
from pyzerox import zerox


result = await zerox(
    file_path="./document.pdf",
    model="gpt-4o-mini",
    output_dir="./output",
)

변환 결과 Markdown을 다시 Document로 읽고 splitter를 적용하면 RAG 전처리 파이프라인에 연결할 수 있다.

poppler

poppler는 PDF 렌더링과 텍스트 추출에 널리 쓰이는 도구 모음이다. Python에서는 pdf2image가 poppler를 사용해 PDF 페이지를 이미지로 변환한다. 스캔 PDF를 OCR하거나 Vision 모델에 페이지 이미지를 넘길 때 필요할 수 있다.

brew install poppler
uv add pdf2image
from pdf2image import convert_from_path


pages = convert_from_path("./document.pdf", dpi=200)
pages[0].save("./page-1.png")

unstructured

unstructured는 PDF, HTML, Markdown, Word 등 다양한 문서를 구조화된 element로 파싱하는 라이브러리다. LangChain에서는 UnstructuredMarkdownLoader 같은 Loader로 사용할 수 있다.

uv add "unstructured[md]" langchain-community
from langchain_community.document_loaders import UnstructuredMarkdownLoader


loader = UnstructuredMarkdownLoader("./document.md")
documents = loader.load()

문서 구조가 단순하면 기본 Loader로 충분하지만, 표와 섹션 구조가 검색 품질에 중요하면 Markdown 변환 후 unstructured로 읽는 방식을 검토한다.

검색 품질을 높이는 RAG 구성

고도화 방법

기본 RAG 구성에서 자주 만나는 문제는 다음과 같다.

  • 문서 안의 표가 일반 텍스트로 풀리면서 행과 열의 관계가 깨질 수 있다.
  • 문서 안의 그래프나 이미지가 텍스트로 추출되지 않아 Embedding 대상에서 빠질 수 있다.
  • 문서에서 사용하는 단어와 사용자의 질문 표현이 다르면 관련 문서를 검색하지 못할 수 있다.

예를 들어 PDF 문서의 세율표가 이미지나 복잡한 표로 들어 있으면 단순 PDF Loader만으로는 표 구조를 제대로 읽지 못할 수 있다. 이 경우 문서 로딩 단계를 다음처럼 고도화할 수 있다.

1
PDF Load원본 PDF를 읽는다.
2
Markdown 변환OCR 또는 Vision 모델 기반 도구로 PDF 페이지를 Markdown으로 변환해 표와 이미지의 의미를 최대한 보존한다.
3
Text 변환Markdown을 plain text로 변환한다.
4
Split변환된 텍스트를 검색하기 좋은 문서 조각으로 나눈다.
5
Embedding문서 조각을 Vector로 변환해 Vector DB에 저장한다.

소득세법 RAG 예

이처럼 문서 형태에 따라 로딩 방식을 보정한 뒤, 전체 RAG 흐름은 다음처럼 확장된다.

1
문서 읽기

샘플 문서는 국가법령정보센터의 소득세법을 기준으로 한다. Docx2txtLoader는 Word 문서를 LangChain의 Document 객체로 읽어온다. Document에는 본문인 page_content와 출처 같은 부가 정보인 metadata가 들어있다.

Note
문서가 PDF이고 표, 그래프, 이미지가 중요하다면 단순 텍스트 로더만으로는 충분하지 않을 수 있다. 이 경우 PDF를 Markdown으로 변환한 뒤 plain text로 정리하고, 그 결과를 분할해 Embedding하는 방식으로 로딩 품질을 높일 수 있다.
uv add langchain-community docx2txt
from langchain_community.document_loaders import Docx2txtLoader

loader = Docx2txtLoader("./document.docx")
data = loader.load()

load()Document 목록을 반환한다. 소득세법처럼 하나의 Word 파일을 읽으면 보통 파일 전체가 하나의 Document로 로드된다.

[
    Document(
        metadata={"source": "./document.docx"},
        page_content="소득세법\n\n[시행 2024. 1. 1.] ... 제1조(목적) 이 법은 개인의 소득에 대하여 ..."
    )
]
len(data)
# 1
2
문서 분할

긴 문서를 그대로 LLM에 넣으면 토큰 한도를 넘거나 답변 생성이 느려질 수 있다. RAG에서는 검색 단위가 너무 크면 관련 없는 내용이 섞이고, 너무 작으면 답변에 필요한 문맥이 끊긴다. RecursiveCharacterTextSplitter는 문단, 줄바꿈, 공백 순서로 가능한 한 자연스러운 경계를 유지하면서 문서를 나눈다.

uv add langchain-text-splitters
from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1500,
    chunk_overlap=200,
)

documents = loader.load_and_split(text_splitter=text_splitter)
  • chunk_size: 한 조각의 최대 문자 수
  • chunk_overlap: 앞뒤 조각이 겹치는 문자 수

분할 후에는 하나의 긴 Document가 여러 개의 작은 Document로 나뉜다. 소득세법 문서는 chunk_size=1500, chunk_overlap=200 기준으로 225개의 문서 조각으로 분할됐다.

[
    Document(
        metadata={"source": "./document.docx"},
        page_content="소득세법\n\n[시행 2024. 1. 1.] ... 제1조(목적) ..."
    ),
    Document(
        metadata={"source": "./document.docx"},
        page_content="1. 구성원 간 이익의 분배비율이 정하여져 있고 ..."
    ),
]
len(documents)
# 225
3
Chroma에 임베딩 저장

문서를 검색하려면 먼저 문서 조각을 벡터로 바꿔야 한다. OpenAIEmbeddings로 문서를 임베딩하고, Chroma에 저장한다. Chroma는 LangChain에서 연동하기 쉬운 오픈소스 Vector DB이며, 로컬 파일로 데이터를 유지하거나 별도 Chroma 서버 또는 Chroma Cloud와 연결해 사용할 수 있다.

uv add langchain-chroma
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

embedding = OpenAIEmbeddings(model="text-embedding-3-large")

database = Chroma.from_documents(
    documents=documents,
    embedding=embedding,
    persist_directory="./chroma",
    collection_name="documents",
)

# Chroma persist 데이터가 이미 존재하는 경우에는
# from_documents()로 다시 저장하지 않고 기존 저장소를 열어 사용한다.
# database = Chroma(
#     embedding_function=embedding,
#     persist_directory="./chroma",
#     collection_name="documents",
# )
4
유사도 검색

Vector DB에 문서를 저장한 뒤에는 질문과 가까운 문서를 검색할 수 있다. similarity_search()는 질문을 같은 임베딩 모델로 벡터화한 뒤, Chroma에 저장된 문서 벡터 중 가까운 문서를 찾는다. LangChain은 Vector Store 통합 인터페이스로 Vector DB 구현체를 추상화하므로 Chroma 외에 Pinecone, Redis, PGVector 같은 여러 Vector DB도 같은 방식으로 호출할 수 있다.

query = "질문 내용"

retrieved_docs = database.similarity_search(query, k=4)

검색 결과는 Document 목록이다. 각 Document에는 원문 조각인 page_content와 출처 정보인 metadata가 들어있다.

[
    Document(
        metadata={"source": "./document.docx"},
        page_content="제55조(세율) 거주자의 종합소득에 대한 소득세는 ..."
    ),
    Document(
        metadata={"source": "./document.docx"},
        page_content="제4조(소득의 구분) 거주자의 소득은 ..."
    ),
]

여기서 k는 검색할 문서 조각의 개수다. 값이 작으면 필요한 근거를 놓칠 수 있고, 값이 크면 관련 없는 문서가 Context에 섞일 수 있다.

5
검색 결과를 LLM에 전달

RAG의 핵심은 검색된 문서를 LLM 프롬프트의 Context로 함께 전달하는 것이다. 가장 단순한 구조는 검색 결과를 문자열로 합쳐 프롬프트에 넣는 방식이다.

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o")

context = "\n\n".join(doc.page_content for doc in retrieved_docs)

prompt = f"""
[Identity]
- [Context]를 참고해서 사용자 질문에 답변하세요.
- Context에 없는 내용은 추측하지 말고, 확인할 수 없다고 답변하세요.

[Context]
{"{context}"}

[Question]
{"{query}"}
"""

response = llm.invoke(prompt)
print(response.content)

llm.invoke()의 반환값은 AIMessage 객체다. 실제 답변 문자열은 content 필드에 들어있기 때문에 response.content로 꺼내 사용한다.

AIMessage(
    content="연봉 5천만원인 직장인의 소득세를 계산하기 위해서는 ...",
    response_metadata={...},
    usage_metadata={...},
)
6
Retrieval Chain으로 구성

LangChain에서는 retriever와 document combine chain을 연결해 RAG Chain을 만들 수 있다. 예전 예제에서 자주 보이는 RetrievalQA는 최신 LangChain에서는 권장되지 않으므로, create_retrieval_chain()create_stuff_documents_chain()을 사용한다.

uv add langchain-classic langchain-core
from langchain_classic.chains import create_retrieval_chain
from langchain_classic.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate

retriever = database.as_retriever(
    search_kwargs={"k": 4},
)

prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            """
아래 context를 참고해서 답변하세요.
context에 근거가 없으면 모른다고 답변하세요.

{context}
""",
        ),
        ("human", "{input}"),
    ]
)

combine_chain = create_stuff_documents_chain(llm, prompt)
chain = create_retrieval_chain(retriever, combine_chain)

result = chain.invoke({"input": "질문 내용"})
print(result["answer"])

create_retrieval_chain()의 실행 결과는 dictionary 형태다. answer에는 생성된 답변이 들어가고, context에는 답변 생성에 사용된 검색 문서가 들어간다.

Key설명
input사용자 질문
context검색된 Document 목록
answer검색 문서를 바탕으로 한 답변
{
    "input": "질문 내용",
    "context": [
        Document(
            metadata={"source": "./document.docx"},
            page_content="제55조(세율) ..."
        )
    ],
    "answer": "검색된 문서를 바탕으로 생성한 답변"
}
7
질문 전처리 추가

사용자가 쓰는 표현과 문서에 쓰인 표현이 다르면 검색 품질이 떨어질 수 있다. 이 경우 질문을 검색에 유리한 표현으로 바꾸는 전처리 Chain을 앞에 둘 수 있다.

Note
LCEL은 LangChain 컴포넌트를 | 연산자로 연결하는 문법이다. prompt | llm | parser처럼 작성하면 앞 단계의 출력이 다음 단계의 입력으로 차례대로 전달된다.
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

user_dictionary = ["사람을 나타내는 표현 -> 거주자"]

rewrite_prompt = ChatPromptTemplate.from_template(
    """
사용자의 질문을 보고, 사전을 참고해서 검색에 유리한 질문으로 변경하세요.
변경할 필요가 없으면 원래 질문을 그대로 응답하세요.

사전: {user_dictionary}
질문: {input}
"""
)

dictionary_chain = rewrite_prompt | llm | StrOutputParser()
tax_chain = {"input": dictionary_chain} | chain

dictionary_chain은 다음 순서로 동작한다.

  1. rewrite_prompt: 원래 질문과 사전을 사용해 프롬프트를 만든다.
  2. llm: 프롬프트를 보고 검색용 질문을 생성한다.
  3. StrOutputParser(): LLM이 반환한 AIMessage에서 문자열만 꺼낸다.

tax_chain = {"input": dictionary_chain} | chaindictionary_chain의 출력 문자열을 다음 RAG Chain의 input 값으로 넘긴다는 의미다.

예를 들어 사용자의 질문에 있는 표현을 문서에서 사용하는 표현으로 바꾸면 검색 결과가 달라질 수 있다.

# 원래 질문
"연봉 5천만원인 직장인의 소득세는 얼마인가요?"

# 전처리 후 질문
"연봉 5천만원인 거주자의 소득세는 얼마인가요?"
8
질문

앞에서 만든 tax_chain에 실제 질문을 넣으면 전처리된 질문, 검색된 Context, 답변이 함께 반환된다. 아래 예시는 "연봉 5천만원인 거주자의 소득세"를 질문한 결과다.

query = "연봉 5천만원인 직장인의 소득세는 얼마인가요?"

retrieval_response = tax_chain.invoke({"input": query})

retrieval_response
{
    "input": "연봉 5천만원인 거주자의 소득세는 얼마인가요?",
    "context": [
        Document(
            metadata={"source": "./document.docx"},
            page_content="제55조(세율) 거주자의 종합소득에 대한 소득세는 ... 1,400만원 초과 5,000만원 이하 | 84만원 + (1,400만원을 초과하는 금액의 15퍼센트) ..."
        ),
        Document(
            metadata={"source": "./document.docx"},
            page_content="제4조(소득의 구분) 거주자의 소득은 ... 종합소득 ..."
        )
    ],
    "answer": "연봉 5천만원인 거주자의 소득세를 계산하려면 종합소득 과세표준에 따른 세율을 적용해야 합니다. 5천만원은 \"1,400만원 초과 5,000만원 이하\" 구간에 해당하므로 다음과 같이 계산합니다.\n\n1. 1,400만원 초과 금액: 5,000만원 - 1,400만원 = 3,600만원\n2. 해당 구간의 세율 적용: 84만원 + (3,600만원 × 15%)\n3. 계산: 84만원 + 540만원 = 624만원\n\n따라서, 연봉 5천만원인 거주자의 소득세는 624만원입니다."
}

input에는 질문 전처리 결과가 들어간다. 원래 질문의 "직장인"이 소득세법 문서에서 사용하는 표현인 "거주자"로 바뀐 뒤 검색에 사용된다. 최종 답변은 answer에 들어가고, 답변의 근거로 사용된 문서 조각은 context에서 확인할 수 있다.

반응형