모델이 모르는 데이터를 연결하는 첫 단계
LLM은 학습 시점 이후의 사내 규정, 고객 문의 이력, 제품 매뉴얼, 회의록을 기본적으로 알지 못합니다. 업무용 앱을 만들려면 모델에게 질문만 보내는 것으로는 부족합니다. 먼저 우리 데이터를 읽고, 검색하기 좋은 형태로 정리한 뒤, 답변 생성 단계에 함께 넣어야 합니다.
LangChain에서 이 출발점은 Document입니다. Document는 크게 두 부분으로 구성됩니다.
page_content: 모델이 읽을 본문 텍스트metadata: 출처, 문서 유형, 작성일, 권한 같은 부가 정보
즉 PDF, 웹페이지, CSV, 데이터베이스 행처럼 형식이 다른 자료도 일단 Document로 바꾸면 이후 단계에서 같은 방식으로 처리할 수 있습니다. LangChain 문서 로더는 외부 소스의 데이터를 Document 형식으로 읽어 오는 표준 인터페이스를 제공하며, 일반적으로 load()로 한 번에 읽거나 lazy_load()로 큰 데이터를 순차적으로 읽을 수 있습니다.1
이번 장의 목표는 답변 생성이 아닙니다. 좋은 답변을 만들기 전에 좋은 재료를 준비하는 것입니다.
AI 생성 이미지
여러 형식의 문서를 Document로 불러오기
먼저 필요한 패키지를 설치합니다. PDF, 웹페이지, 텍스트 분할을 함께 다루기 위한 최소 구성입니다.
pip install langchain langchain-community langchain-text-splitters pypdf beautifulsoup4가장 단순한 로딩 코드는 다음과 같습니다.
from langchain_community.document_loaders import PyPDFLoader, CSVLoader, WebBaseLoader, TextLoader
pdf_docs = PyPDFLoader('data/product_manual.pdf').load()
md_docs = TextLoader('data/policy.md', encoding='utf-8').load()
csv_docs = CSVLoader('data/faq.csv', encoding='utf-8').load()
web_docs = WebBaseLoader('https://example.com/help').load()
all_docs = pdf_docs + md_docs + csv_docs + web_docs
print(type(all_docs[0]))
print(all_docs[0].page_content[:200])
print(all_docs[0].metadata)로더마다 만들어 내는 Document의 단위는 다릅니다. PDF 로더는 보통 페이지 단위 정보를 메타데이터에 담습니다. CSV 로더는 행 하나를 하나의 문서처럼 다룰 수 있습니다. 웹 로더는 URL을 출처로 남깁니다. 중요한 점은 로더가 모든 문제를 해결해 주지는 않는다는 것입니다. 추출된 텍스트가 깨졌는지, 표가 제대로 읽혔는지, 필요 없는 메뉴와 푸터가 섞였는지는 반드시 확인해야 합니다.
데이터베이스도 같은 원리로 처리할 수 있습니다. 아래 예시는 SQLite의 articles 테이블을 직접 읽어 Document로 바꾸는 방식입니다. 전용 로더를 쓰는 것도 가능하지만, 업무 시스템에서는 어떤 컬럼을 본문으로 쓰고 어떤 컬럼을 메타데이터로 남길지 직접 정하는 편이 더 안전합니다.
import sqlite3
from langchain_core.documents import Document
conn = sqlite3.connect('data/company.db')
conn.row_factory = sqlite3.Row
try:
rows = conn.execute('''
SELECT id, title, body, department, updated_at, access_level
FROM articles
WHERE status = 'published'
''').fetchall()
finally:
conn.close()
db_docs = []
for row in rows:
title = row['title'] or ''
body = row['body'] or ''
db_docs.append(
Document(
page_content='nn'.join([title, body]),
metadata={
'source': 'company.db:articles',
'doc_id': 'article-{}'.format(row['id']),
'title': title,
'department': row['department'],
'updated_at': row['updated_at'],
'access_level': row['access_level'],
'doc_type': 'database_article',
},
)
)여기서 핵심은 데이터의 원래 위치를 잃지 않는 것입니다. 나중에 검색 결과가 맞는지 검증하거나, 답변에 출처를 표시하거나, 사용자의 권한에 맞는 문서만 검색하려면 메타데이터가 필요합니다.
메타데이터는 나중의 검색 품질을 결정한다
초보 단계에서는 본문만 잘 넣으면 된다고 생각하기 쉽습니다. 하지만 실무 RAG 앱에서는 메타데이터 설계가 검색 품질과 운영 안정성을 좌우합니다.
추천하는 기본 메타데이터는 다음과 같습니다.
| 필드 | 예시 | 용도 |
|---|---|---|
source | data/policy.md, URL | 출처 표시, 오류 추적 |
doc_id | policy-2026-hr-001 | 중복 제거, 버전 관리 |
doc_type | pdf, markdown, faq, db_article | 문서 유형별 필터링 |
title | 휴가 정책 | 검색 결과 표시 |
created_at / updated_at | 2026-07-01 | 최신 문서 우선 검색 |
department | hr, sales | 부서별 범위 제한 |
access_level | public, internal, confidential | 권한 필터링 |
page / row | 3, 15 | 원문 위치 추적 |
메타데이터는 많을수록 좋은 것이 아닙니다. 검색과 운영에 쓰지 않을 정보는 넣지 않는 편이 낫습니다. 특히 주민등록번호, 전화번호, 개인 이메일, 고객 식별자 같은 민감정보를 메타데이터에 그대로 넣으면 검색 인덱스와 로그에 남을 수 있습니다.
문서를 불러온 뒤 공통 메타데이터를 보강하는 함수를 두면 편합니다.
from datetime import datetime, timezone
def enrich_metadata(docs, *, doc_type, department, access_level):
loaded_at = datetime.now(timezone.utc).isoformat()
for doc in docs:
doc.metadata.setdefault('source', 'unknown')
doc.metadata['doc_type'] = doc_type
doc.metadata['department'] = department
doc.metadata['access_level'] = access_level
doc.metadata['loaded_at'] = loaded_at
doc.metadata['char_count'] = len(doc.page_content)
return docs
pdf_docs = enrich_metadata(
pdf_docs,
doc_type='pdf_manual',
department='product',
access_level='internal',
)이렇게 해 두면 이후 벡터스토어에 저장할 때도 같은 메타데이터가 함께 들어갑니다. 다음 장에서 다룰 리트리버는 이 정보를 이용해 "인사팀 문서만", "공개 문서만", "2026년 이후 문서만" 검색하는 식의 필터링을 할 수 있습니다.
문서를 그대로 넣지 않고 나누는 이유
긴 문서를 그대로 모델에 넣으면 두 가지 문제가 생깁니다. 첫째, 모델의 컨텍스트 길이를 초과할 수 있습니다. 둘째, 검색 단계에서 질문과 관련 없는 긴 문서 전체가 선택되어 답변 품질이 떨어질 수 있습니다.
그래서 문서를 작은 조각, 즉 청크로 나눕니다. LangChain의 텍스트 분할기는 큰 문서를 검색과 모델 입력에 적합한 작은 단위로 나누기 위해 사용됩니다.2
가장 자주 쓰는 분할기는 RecursiveCharacterTextSplitter입니다. 이 분할기는 문단, 줄바꿈, 문장, 단어처럼 큰 경계부터 가능한 한 유지하려고 시도합니다. 그래도 chunk_size를 넘으면 더 작은 단위로 내려가며 나눕니다. 단순히 글자 수만 자르는 것보다 문맥이 덜 깨지는 편입니다.
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=120,
)
chunks = splitter.split_documents(all_docs)
print(len(all_docs), 'documents')
print(len(chunks), 'chunks')
print(chunks[0].page_content[:300])
print(chunks[0].metadata)여기서 chunk_size=800은 각 청크의 목표 길이입니다. 기본 설정에서는 대체로 문자 수 기준으로 생각하면 됩니다. 토큰 기준으로 엄밀히 맞춰야 하는 경우에는 토큰 기반 분할기를 검토해야 합니다.
chunk_overlap=120은 인접한 청크 사이에 일부 내용을 겹치게 한다는 뜻입니다. 문서가 잘리는 경계에 중요한 설명이 걸려 있으면 검색 결과가 어색해질 수 있습니다. overlap은 이런 문제를 줄여 줍니다. 대신 같은 문장이 여러 청크에 반복 저장되므로 임베딩 비용과 저장 공간이 늘어납니다.
chunk_size와 chunk_overlap 실험하기
청크 크기에는 정답이 없습니다. 문서 종류와 질문 패턴에 따라 달라집니다. 제품 매뉴얼처럼 절차가 긴 문서는 너무 작게 나누면 답변 근거가 부족해집니다. FAQ처럼 짧고 독립적인 문서는 작게 나누는 편이 검색 정확도에 유리할 수 있습니다.
간단한 실험 코드를 만들어 보겠습니다.
from langchain_text_splitters import RecursiveCharacterTextSplitter
def preview_split(docs, chunk_size, chunk_overlap):
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
)
chunks = splitter.split_documents(docs)
lengths = [len(chunk.page_content) for chunk in chunks]
if not lengths:
return {
'chunk_size': chunk_size,
'chunk_overlap': chunk_overlap,
'chunk_count': 0,
'avg_length': 0,
'max_length': 0,
}
return {
'chunk_size': chunk_size,
'chunk_overlap': chunk_overlap,
'chunk_count': len(chunks),
'avg_length': round(sum(lengths) / len(lengths), 1),
'max_length': max(lengths),
}
for size, overlap in [(400, 40), (800, 120), (1200, 200)]:
print(preview_split(all_docs, size, overlap))실험할 때는 다음 기준으로 판단합니다.
검색 결과가 질문의 근거 문장을 포함하는가?
답변에 필요한 표, 조건, 예외 조항이 한 청크 안에 들어오는가?
청크 수가 지나치게 늘어 비용이 커지지 않는가?
같은 내용이 overlap 때문에 너무 많이 반복되지 않는가?
처음에는 chunk_size를 600~1200 사이, chunk_overlap을 청크 크기의 10~20% 정도로 두고 시작해도 좋습니다. 이후 실제 질문 세트를 만들어 검색 결과를 눈으로 확인하면서 조정합니다. 중요한 것은 "큰 값이 좋다" 또는 "작은 값이 좋다"가 아니라, 질문에 답하는 데 충분한 문맥을 가장 적은 비용으로 가져오는 균형입니다.
Markdown 문서는 제목 구조를 활용할 수 있습니다. 예를 들어 사내 정책 문서가 ## 휴가, ## 보안, ## 비용 정산처럼 나뉘어 있다면 헤더 정보를 메타데이터로 보존하는 편이 좋습니다. LangChain은 Markdown 헤더 기준 분할도 제공하며, 헤더 정보는 청크의 메타데이터로 유지할 수 있습니다.3
from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
markdown_text = open('data/policy.md', encoding='utf-8').read()
header_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[('#', 'h1'), ('##', 'h2'), ('###', 'h3')]
)
section_docs = header_splitter.split_text(markdown_text)
size_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=100,
)
chunks = size_splitter.split_documents(section_docs)이 방식은 "비용 정산 규정 중 영수증 예외 조건"처럼 특정 섹션을 찾아야 할 때 유용합니다.
수집 파이프라인에서 반드시 처리할 문제
문서 로딩 코드는 처음에는 간단합니다. 그러나 실제 운영에서는 같은 파일이 두 번 들어오고, 인코딩이 깨지고, 민감정보가 섞이고, 일부 파일만 실패하는 일이 자주 생깁니다. 따라서 처음부터 작은 수집 파이프라인 형태로 만드는 것이 좋습니다.
아래 예시는 핵심 처리만 담은 간단한 구조입니다.
import hashlib
import re
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
def normalize_text(text):
text = text.replace('x00', ' ')
text = re.sub(r's+', ' ', text)
return text.strip()
def redact_sensitive(text):
text = re.sub(r'bd{6}-d{7}b', '[REDACTED_ID]', text)
text = re.sub(r'bd{2,3}-d{3,4}-d{4}b', '[REDACTED_PHONE]', text)
text = re.sub(r'[w.-]+@[w.-]+', '[REDACTED_EMAIL]', text)
return text
def content_hash(text):
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def clean_documents(docs):
cleaned = []
seen = set()
for doc in docs:
text = normalize_text(doc.page_content)
text = redact_sensitive(text)
if len(text) < 30:
continue
digest = content_hash(text)
if digest in seen:
continue
seen.add(digest)
metadata = dict(doc.metadata)
metadata['content_hash'] = digest
metadata['char_count'] = len(text)
cleaned.append(Document(page_content=text, metadata=metadata))
return cleaned
cleaned_docs = clean_documents(all_docs)
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=120,
)
chunks = splitter.split_documents(cleaned_docs)이 코드가 처리하는 문제는 네 가지입니다.
첫째, 중복 제거입니다. 같은 문서가 여러 폴더에 있거나, 웹페이지의 본문과 인쇄용 페이지가 동시에 수집될 수 있습니다. 중복이 많으면 검색 결과가 한 출처로 쏠리고 비용도 증가합니다.
둘째, 인코딩과 깨진 문자 처리입니다. 특히 오래된 CSV, 윈도우 환경에서 저장된 텍스트, 복사된 PDF 텍스트는 공백과 특수문자가 불규칙할 수 있습니다. 로더에서 encoding='utf-8'을 지정하고, 실패하는 파일은 cp949 같은 후보를 별도로 처리하는 전략이 필요할 수 있습니다. 다만 위 예시처럼 모든 공백을 하나로 합치면 Markdown의 제목 구조나 표, 코드 블록의 모양이 사라질 수 있습니다. 그런 문서에서는 줄바꿈을 일부 보존하는 정규화 함수를 따로 두는 편이 좋습니다.
셋째, 민감정보 제거입니다. 내부 문서를 검색 인덱스에 넣는 순간, 그 정보는 검색 결과와 로그를 통해 노출될 수 있습니다. 정규식 기반 제거는 완벽하지 않지만 최소한의 안전장치로 유용합니다. 중요한 시스템에서는 별도 개인정보 탐지 도구와 사람 검토 절차를 함께 두어야 합니다.
넷째, 작은 찌꺼기 문서 제거입니다. PDF에서 페이지 번호, 푸터, 메뉴명만 추출된 짧은 조각은 검색 품질을 떨어뜨립니다. 너무 짧은 문서는 버리거나 상위 문서와 합치는 편이 좋습니다.
외부 데이터 연결의 기준
이 장에서 만든 흐름은 다음과 같이 정리할 수 있습니다.
외부 소스를 로더로 읽어
Document로 바꾼다.출처, 날짜, 문서 유형, 권한 정보를 메타데이터로 남긴다.
본문을 정규화하고 중복과 민감정보를 제거한다.
검색하기 좋은 크기로 문서를 나눈다.
청크 수, 평균 길이, 검색 결과 샘플을 확인하며 설정을 조정한다.
LLM 앱의 품질은 모델만으로 결정되지 않습니다. 모델이 읽는 데이터가 낡았거나, 출처가 없거나, 권한 정보가 빠져 있거나, 잘못 잘린 청크라면 좋은 답변을 기대하기 어렵습니다.
다음 장에서는 이렇게 준비한 청크를 임베딩으로 바꾸고 벡터스토어에 저장한 뒤, 질문과 관련 있는 문서를 찾아오는 리트리버를 다룹니다. 지금 만든 문서 처리 흐름이 바로 RAG의 기반입니다.
참고
1LangChain document loader integrations
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.