| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- Spring
- 코딩
- ES
- 기술블로그
- 코드
- 그리디
- 데이터베이스
- 자바
- JPA
- spring boot
- framework
- 프레임워크
- 스프링
- 코딩테스트
- 그리디알고리즘
- 애자일기법
- 클린코드
- AI
- 알고리즘
- 백준
- kotlin
- Elasticsearch
- API
- Java
- database
- cleancode
- Baekjoon
- 개발자
- 엘라스틱서치
- 개발
- Today
- Total
튼튼발자 개발 성장기🏋️
LangChain이란 본문
RAG (Retrieval Augmented Generation)
LLM의 한계
- 환각
- 지식 컷오프
- 보안 및 개인화 불가
Retrieval (검색)
- 대규모 언어 모델(LLM)이 학습하지 않은 정보를 가져오는 것
- 질문과 가장 연관성이 높은 문서 조각들을 벡터 DB에서 찾음
Augmented (증강)
- 검색된 문서 조각을 사용자의 원래 질문과 합쳐서 "이 문서를 참고해서 질문에 답해줘"라는 식의
확장된프롬프트 생성
Generation (생성)
- 제공된 확실한 증거 데이터를 바탕으로 왜곡 없는 정확한 답변을 생성
Vector DB
Embedding
- 텍스트를 Vector로 변환하는 방법
- 문장이나 문서의 의미를 숫자 배열로 표현한다.
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는 크게 데이터 전처리 단계와 런타임 단계로 나눌 수 있다.
데이터 전처리 단계
데이터 전처리 단계는 원본 문서를 검색 가능한 상태로 만드는 과정이다. 애플리케이션 실행 전에 미리 수행해둘 수 있다.
Document 형태로 읽어온다.런타임 단계
런타임 단계는 사용자가 질문했을 때 실행되는 과정이다. 저장된 문서를 검색하고, 검색 결과를 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 |
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만으로는 표 구조를 제대로 읽지 못할 수 있다. 이 경우 문서 로딩 단계를 다음처럼 고도화할 수 있다.
소득세법 RAG 예
이처럼 문서 형태에 따라 로딩 방식을 보정한 뒤, 전체 RAG 흐름은 다음처럼 확장된다.
샘플 문서는 국가법령정보센터의 소득세법을 기준으로 한다. Docx2txtLoader는 Word 문서를 LangChain의 Document 객체로 읽어온다. Document에는 본문인 page_content와 출처 같은 부가 정보인 metadata가 들어있다.
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
긴 문서를 그대로 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
문서를 검색하려면 먼저 문서 조각을 벡터로 바꿔야 한다. 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",
# )
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에 섞일 수 있다.
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={...},
)
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": "검색된 문서를 바탕으로 생성한 답변"
}
사용자가 쓰는 표현과 문서에 쓰인 표현이 다르면 검색 품질이 떨어질 수 있다. 이 경우 질문을 검색에 유리한 표현으로 바꾸는 전처리 Chain을 앞에 둘 수 있다.
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은 다음 순서로 동작한다.
rewrite_prompt: 원래 질문과 사전을 사용해 프롬프트를 만든다.llm: 프롬프트를 보고 검색용 질문을 생성한다.StrOutputParser(): LLM이 반환한AIMessage에서 문자열만 꺼낸다.
tax_chain = {"input": dictionary_chain} | chain은 dictionary_chain의 출력 문자열을 다음 RAG Chain의 input 값으로 넘긴다는 의미다.
예를 들어 사용자의 질문에 있는 표현을 문서에서 사용하는 표현으로 바꾸면 검색 결과가 달라질 수 있다.
# 원래 질문
"연봉 5천만원인 직장인의 소득세는 얼마인가요?"
# 전처리 후 질문
"연봉 5천만원인 거주자의 소득세는 얼마인가요?"
앞에서 만든 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에서 확인할 수 있다.
'AI > LLM 서비스 개발' 카테고리의 다른 글
| 코드 1도 모르는 비개발자도 AI 비서 만드는 방법 (0) | 2026.05.21 |
|---|---|
| LLM 파인튜닝 실전: 금융 뉴스 분석기를 직접 만들어보자 (0) | 2026.04.01 |
| LoRA, QLoRA, SFT, DPO, vLLM, 멀티 LoRA 서빙 (0) | 2026.03.31 |
| AI 엔지니어의 문제 정의 능력과 데이터 전략 (0) | 2026.03.30 |
| 파인 튜닝 (1) | 2026.03.23 |