Skip to main content

RAG의 성패는 모델 호출 전에 결정된다

RAG를 처음 만들 때 많은 개발자가 가장 먼저 모델을 바꾸고, 프롬프트를 고치고, 임베딩 모델을 비교합니다. 물론 모두 중요합니다. 하지만 실제 프로젝트에서 더 자주 문제를 일으키는 곳은 훨씬 앞단입니다. 문서를 잘못 읽었거나, 필요 없는 메뉴와 푸터가 섞였거나, 중요한 문장이 청크 경계에서 잘려 나갔거나, 출처 메타데이터가 사라진 경우입니다.

RAG 파이프라인을 음식점에 비유하면 LLM은 요리사이고, 임베딩 모델은 재료를 찾는 창고 직원입니다. 그런데 처음 들어온 재료가 상했거나 라벨이 틀렸다면 아무리 훌륭한 요리사라도 좋은 요리를 만들기 어렵습니다. 그래서 이번 장의 핵심은 단순합니다. 좋은 RAG는 좋은 문서 적재에서 시작된다는 것입니다.

LangChain에서 문서 기반 애플리케이션의 기본 흐름은 다음처럼 생각할 수 있습니다.

외부 데이터
  → Document Loader
  → 전처리·정제
  → Text Splitter
  → Embedding
  → Vector Store
  → Retriever
  → LLM 답변 생성

이번 장에서는 이 중에서 Document Loader, 전처리, Text Splitter까지 다룹니다. 다음 장에서 이 청크들을 임베딩하고 벡터스토어에 저장하면서 본격적인 검색 기반 구조로 이어갈 것입니다.

문서 로딩과 텍스트 분할 전략

Document Loader는 세상의 문서를 LangChain의 공통 언어로 바꾼다

LangChain의 Document Loader는 PDF, CSV, 텍스트 파일, 웹페이지, 클라우드 드라이브, 협업 도구의 데이터를 읽어 공통 Document 형식으로 바꿉니다. 공식 문서에서도 Document Loader의 역할을 Slack, Notion, Google Drive 같은 서로 다른 소스를 LangChain의 Document 포맷으로 읽어 들이는 표준 인터페이스라고 설명합니다.1

이 말은 꽤 중요합니다. PDF를 읽든, CSV를 읽든, 웹페이지를 읽든, 다음 단계에서는 모두 같은 방식으로 다룰 수 있다는 뜻입니다.

from langchain_core.documents import Document

sample = Document(
    page_content='환불은 결제일로부터 7일 이내에 신청할 수 있습니다.',
    metadata={
        'source': 'docs/refund_policy.pdf',
        'page': 3,
        'section': '환불 정책',
        'updated_at': '2026-04-01',
    },
    id='refund_policy_p3_s1',
)

Document 객체에서 가장 중요한 필드는 세 가지입니다. page_content는 실제 검색과 답변 생성에 쓰일 본문입니다. metadata는 이 본문이 어디에서 왔는지 알려 주는 꼬리표입니다. id는 선택 사항이지만, 문서를 갱신하거나 삭제하거나 중복을 추적할 때 유용한 식별자입니다.

처음에는 page_content만 중요해 보입니다. 그러나 운영 환경으로 갈수록 metadata의 가치가 커집니다. 사용자가 "이 답변의 근거가 어디인가요?"라고 물었을 때 파일명, 페이지 번호, URL, 섹션명이 없다면 답변의 신뢰도를 보여 줄 수 없습니다. 또한 "최신 정책 문서만 검색하기", "보안 등급이 public인 문서만 검색하기", "특정 제품군 매뉴얼만 검색하기" 같은 필터 검색도 어려워집니다.

좋은 메타데이터는 나중에 검색 품질을 높이는 보조 엔진이 됩니다. 로딩 단계에서 최소한 다음 정보는 가능한 한 보존하는 습관을 들이는 것이 좋습니다.

  • 출처: 파일 경로, URL, Google Drive 파일 ID, Notion 페이지 ID

  • 위치: 페이지 번호, 행 번호, 섹션명, 제목, 헤더 계층

  • 문서 속성: 작성일, 수정일, 작성자, 버전, 문서 유형, 보안 등급

  • 처리 정보: 로딩 시각, 파서 종류, 청크 번호, 전체 청크 수

소스에 맞는 Loader 선택하기

문서 로딩에서 첫 번째 판단은 "무엇을 읽을 것인가?"입니다. 모든 문서를 하나의 로더로 처리하려고 하면 언젠가 문제가 생깁니다. PDF에는 PDF 로더가, CSV에는 CSV 로더가, 웹페이지에는 웹 로더가 필요합니다.

텍스트 파일: TextLoader

가장 단순한 출발점은 .txt, .md, .py 같은 텍스트 기반 파일입니다.

from langchain_community.document_loaders import TextLoader

loader = TextLoader('data/company_policy.txt', encoding='utf-8')
docs = loader.load()

print(len(docs))
print(docs[0].page_content[:200])
print(docs[0].metadata)

작은 텍스트 파일은 보통 하나의 Document로 로드됩니다. 여기서 encoding='utf-8'을 명시하는 습관은 생각보다 중요합니다. 한글 문서나 오래된 시스템에서 나온 파일은 인코딩 문제로 글자가 깨질 수 있습니다. 문자가 깨진 상태로 임베딩하면 검색도 깨진 의미를 기준으로 동작합니다.

PDF: PyPDFLoader

PDF는 RAG 프로젝트에서 가장 자주 등장하는 동시에 가장 자주 말썽을 일으키는 포맷입니다. 텍스트 중심의 일반 PDF라면 PyPDFLoader부터 시작해 볼 수 있습니다.

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader('docs/product_manual.pdf')
pages = loader.load()

print(len(pages))
print(pages[0].metadata)
print(pages[0].page_content[:300])

PDF 로더는 보통 페이지 단위의 Document를 반환합니다. 이때 metadata에는 파일 경로와 페이지 번호가 포함되는 경우가 많습니다. 페이지 번호는 나중에 답변 근거를 표시할 때 매우 유용합니다.

다만 PDF는 겉보기와 실제 텍스트 구조가 다를 수 있습니다. 사람이 볼 때는 2단 레이아웃이 자연스러워도 추출된 텍스트는 왼쪽 문단과 오른쪽 문단이 뒤섞일 수 있습니다. 표는 줄 단위로 찢어질 수 있고, 스캔 PDF는 OCR 없이는 텍스트가 거의 나오지 않을 수도 있습니다. 이런 경우에는 UnstructuredPDFLoader, PDFPlumberLoader, PyMuPDFLoader 같은 다른 파서를 검토해야 합니다. 중요한 것은 "PDF를 읽었다"가 아니라 "검색 가능한 의미 단위로 읽혔다"입니다.

CSV: CSVLoader

CSV는 문서라기보다 데이터에 가깝습니다. LangChain의 CSVLoader는 일반적으로 CSV의 각 행을 하나의 Document로 변환합니다.

from langchain_community.document_loaders import CSVLoader

loader = CSVLoader(
    file_path='data/faq.csv',
    encoding='utf-8',
    csv_args={'delimiter': ','},
)

docs = loader.load()

print(docs[0].page_content)
print(docs[0].metadata)

예를 들어 FAQ CSV에 question, answer, category 컬럼이 있다면 한 행이 하나의 질의응답 단위가 됩니다. 이 경우 무작정 800자 단위로 다시 자르는 것보다 행 자체를 의미 단위로 유지하는 편이 더 좋을 수 있습니다. CSV에서 중요한 것은 "몇 글자로 자를까?"보다 "한 행이 독립적인 의미를 갖는가?", "여러 행을 묶어야 하는가?", "어떤 컬럼을 메타데이터로 분리할 것인가?"입니다.

웹페이지: WebBaseLoader

웹페이지는 URL만 있으면 쉽게 읽을 수 있어 보이지만, 실제로는 노이즈가 가장 많은 소스 중 하나입니다. 메뉴, 광고, 관련 글, 쿠키 배너, 푸터, 댓글 영역이 본문과 함께 섞이기 때문입니다.

from langchain_community.document_loaders import WebBaseLoader

loader = WebBaseLoader([
    'https://example.com/docs/getting-started',
    'https://example.com/docs/pricing',
])

docs = loader.load()

for doc in docs:
    print(doc.metadata.get('source'))
    print(doc.page_content[:200])

WebBaseLoader는 웹 HTML을 가져와 텍스트로 변환하는 데 유용합니다. 하지만 로딩 결과를 그대로 임베딩하면 "로그인", "문의하기", "모든 권리 보유" 같은 반복 문구가 대량으로 들어갈 수 있습니다. 웹 문서는 반드시 본문 추출과 정제 단계를 함께 생각해야 합니다.

폴더 전체: DirectoryLoader

실전에서는 파일 하나가 아니라 폴더 전체를 처리하는 경우가 많습니다. 이때 DirectoryLoader를 사용하면 특정 패턴의 파일을 한 번에 읽을 수 있습니다.

from langchain_community.document_loaders import DirectoryLoader, TextLoader

loader = DirectoryLoader(
    'docs',
    glob='**/*.md',
    loader_cls=TextLoader,
    loader_kwargs={'encoding': 'utf-8'},
    show_progress=True,
)

docs = loader.load()
print(f'로드된 문서 수: {len(docs)}')

폴더를 처리할 때는 파일 확장자별로 다른 로더를 연결하는 구조를 자주 씁니다. 예를 들어 .pdf는 PyPDFLoader, .csv는 CSVLoader, .md는 TextLoader로 처리하는 식입니다. 사내 문서 시스템에서는 Google Drive, Notion, Slack, Confluence, SharePoint 같은 외부 저장소를 직접 읽어야 할 수도 있습니다. LangChain은 이런 다양한 소스에 대한 Document Loader 통합을 제공합니다.2

로딩 직후 반드시 검사해야 할 것들

문서 로딩을 끝냈다고 바로 청킹으로 넘어가면 안 됩니다. 이 단계에서 10분만 확인해도 이후 디버깅 시간을 몇 시간 줄일 수 있습니다. 다음 코드는 로딩 결과를 빠르게 점검하는 작은 도구입니다.

def inspect_docs(docs, n=3):
    print(f'문서 수: {len(docs)}')
    for i, doc in enumerate(docs[:n]):
        print('n' + '=' * 60)
        print(f'[{i}] length={len(doc.page_content)}')
        print('metadata:', doc.metadata)
        print(doc.page_content[:500])

inspect_docs(docs)

점검할 때는 단순히 문서 수만 보면 안 됩니다. 다음 질문을 직접 확인해야 합니다.

  • 본문이 비어 있거나 지나치게 짧은 문서가 있는가?

  • 한글, 특수문자, 표 기호, 코드 블록이 깨지지 않았는가?

  • PDF 페이지 순서와 문단 순서가 사람이 읽는 순서와 일치하는가?

  • 웹페이지에서 메뉴, 광고, 쿠키 배너, 푸터가 섞이지 않았는가?

  • source, page, url, title 같은 추적용 메타데이터가 남아 있는가?

  • 같은 문단이 여러 번 반복 로드되지 않았는가?

이런 검사는 검색 품질과 바로 연결됩니다. 예를 들어 웹사이트의 모든 페이지에 같은 푸터 문장이 들어 있다면, 그 문장은 임베딩 공간에서 불필요하게 강한 신호가 됩니다. 사용자가 제품 가격을 물었는데 푸터의 회사 주소가 검색되는 이상한 일이 생길 수 있습니다.

전처리: 임베딩 전에 문서를 씻어 내기

전처리는 문서를 예쁘게 꾸미는 작업이 아닙니다. 검색기가 잘못된 신호를 학습하지 않도록 노이즈를 제거하는 작업입니다. 특히 웹 문서와 PDF에서는 다음 문제가 자주 발생합니다.

  • 반복되는 내비게이션, 사이드바, 푸터 문구

  • 광고, 쿠키 배너, SNS 공유 문구

  • 불필요한 연속 공백과 과도한 줄바꿈

  • 깨진 문자, 제로폭 문자, OCR 오류

  • 중복 문단과 매우 짧은 의미 없는 문서

  • 주민등록번호, 전화번호, 이메일, 토큰 같은 민감정보

간단한 정제 함수부터 만들어 보겠습니다.

import re
from langchain_core.documents import Document

ZERO_WIDTH_CHARS = ['u200b', 'u200c', 'u200d', 'ufeff']


def clean_text(text: str) -> str:
    for ch in ZERO_WIDTH_CHARS:
        text = text.replace(ch, '')

    # 제어 문자 제거
    text = re.sub(r'[x00-x08x0Bx0Cx0E-x1F]', '', text)

    # 탭과 여러 공백 정리
    text = re.sub(r'[ t]+', ' ', text)

    # 과도한 줄바꿈 축소
    text = re.sub(r'n{3,}', 'nn', text)

    return text.strip()


def normalize_docs(docs):
    normalized = []
    for doc in docs:
        text = clean_text(doc.page_content)
        if len(text) < 50:
            continue
        normalized.append(
            Document(
                page_content=text,
                metadata=dict(doc.metadata),
                id=getattr(doc, 'id', None),
            )
        )
    return normalized

clean_docs = normalize_docs(docs)

중복 제거도 자주 필요합니다. 완전히 같은 텍스트가 여러 번 들어오면 검색 결과가 다양해지지 않고 같은 내용만 반복될 수 있습니다.

import hashlib


def dedupe_docs(docs):
    seen = set()
    result = []

    for doc in docs:
        key = hashlib.sha256(doc.page_content.encode('utf-8')).hexdigest()
        if key in seen:
            continue
        seen.add(key)
        result.append(doc)

    return result

clean_docs = dedupe_docs(clean_docs)

민감정보가 포함될 수 있는 문서라면 임베딩 전에 반드시 마스킹 정책을 정해야 합니다. 벡터스토어가 외부 관리형 서비스라면 더 중요합니다.

def mask_sensitive(text: str) -> str:
    # 이메일 마스킹
    text = re.sub(
        r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,}',
        '[EMAIL]',
        text,
    )

    # 한국 휴대전화 번호의 단순 예시
    text = re.sub(r'01[016789]-?d{3,4}-?d{4}', '[PHONE]', text)

    return text

정제의 목표는 원문을 없애는 것이 아니라 검색에 필요한 의미를 선명하게 만드는 것입니다. 법률 문서에서 조항 번호를 지우면 안 되고, API 문서에서 코드 블록을 무너뜨리면 안 됩니다. 반대로 광고 문구와 반복 푸터는 과감히 제거해야 합니다. 무엇을 남기고 무엇을 지울지는 항상 도메인에 따라 결정해야 합니다.

Text Splitter가 필요한 이유

LLM과 임베딩 모델은 긴 문서를 통째로 다루는 데 한계가 있습니다. 또 긴 문서 전체를 하나의 벡터로 만들면 한 문서 안에 여러 주제가 섞여 검색이 흐려집니다. LangChain의 Text Splitter는 큰 문서를 검색 가능한 작은 청크로 나누는 역할을 합니다. 공식 문서에서도 Text Splitter는 큰 문서를 모델 컨텍스트 한도 안에 들어가고 개별적으로 검색 가능한 작은 청크로 나누는 도구라고 설명합니다.3

청킹은 단순한 자르기가 아닙니다. 좋은 청크는 다음 조건을 만족해야 합니다.

  • 하나의 질문에 답할 만큼 충분한 문맥을 가진다.

  • 너무 많은 주제를 섞지 않아 검색 신호가 흐려지지 않는다.

  • 문장, 문단, 표, 코드 블록 같은 자연스러운 경계를 최대한 보존한다.

  • 원문 출처와 위치를 추적할 수 있도록 메타데이터를 유지한다.

예를 들어 "환불은 결제일로부터 7일 이내에 가능하다. 단, 디지털 콘텐츠를 이미 다운로드한 경우 환불이 제한된다."라는 문장이 있다고 해 봅시다. 청크가 "환불은 결제일로부터 7일 이내에 가능하다."에서 끊기면 사용자는 예외 조건을 보지 못할 수 있습니다. 이때 chunk_overlap이 문맥을 이어 주는 안전망 역할을 합니다.

RecursiveCharacterTextSplitter: 가장 먼저 익힐 기본기

일반 텍스트에서 가장 많이 쓰는 선택지는 RecursiveCharacterTextSplitter입니다. 이 splitter는 기본적으로 ['nn', 'n', ' ', ''] 같은 구분자를 순서대로 사용합니다. 먼저 문단 기준으로 나눠 보고, 그래도 너무 길면 줄바꿈, 그다음 공백, 마지막으로 문자 단위까지 내려갑니다. LangChain 공식 문서도 이 splitter를 일반 텍스트에 권장되는 방식으로 소개하며, 가능한 한 문단과 문장, 단어를 함께 유지하려고 시도한다고 설명합니다.4

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=120,
    separators=['nn', 'n', '. ', '? ', '! ', ' ', ''],
    length_function=len,
    is_separator_regex=False,
)

chunks = splitter.split_documents(clean_docs)

print(f'원문 Document 수: {len(clean_docs)}')
print(f'청크 수: {len(chunks)}')
print(chunks[0].page_content)
print(chunks[0].metadata)

여기서 세 가지 파라미터가 핵심입니다.

chunk_size는 각 청크의 최대 크기입니다. 기본적으로 위 코드에서는 len을 쓰므로 문자 수 기준입니다. 800이라고 해서 800토큰이라는 뜻이 아닙니다. 한글, 영어, 코드, 숫자 비율에 따라 문자 수와 토큰 수의 관계는 달라집니다.

chunk_overlap은 인접한 청크 사이에 겹쳐 넣을 분량입니다. 너무 작으면 경계에서 문맥이 끊기고, 너무 크면 같은 내용이 여러 번 저장되어 비용과 중복 검색이 늘어납니다. 일반적인 질의응답용 문서라면 chunk_size의 10~20% 정도에서 시작해 실험하는 편이 무난합니다.

separators는 어떤 경계를 우선 보존할지 정합니다. 문단을 가장 먼저 두고, 그다음 줄바꿈과 문장부호, 공백 순으로 내려가면 문서의 자연스러운 흐름을 비교적 잘 유지할 수 있습니다.

문자 기준과 토큰 기준을 구분하자

chunk_size=1000이라고 쓰면 왠지 모델 입력 토큰 1000개처럼 느껴집니다. 하지만 RecursiveCharacterTextSplitter의 기본 길이 함수는 문자 수입니다. 모델의 토큰 한도에 더 엄격하게 맞추고 싶다면 토큰 기반 splitter를 사용할 수 있습니다.

from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
    chunk_size=500,
    chunk_overlap=80,
)

token_chunks = text_splitter.split_documents(clean_docs)

토큰 기준 분할은 비용과 컨텍스트 한도를 관리할 때 유용합니다. 다만 한국어 문서에서는 사용하는 토크나이저에 따라 토큰 수가 예상과 다르게 늘어날 수 있습니다. 따라서 운영 전에는 실제 청크 몇 개를 골라 임베딩 모델과 LLM 기준으로 토큰 수를 확인하는 것이 좋습니다.

청크 크기는 정답이 아니라 가설이다

많은 사람이 "RAG에서 청크 크기는 몇이 좋나요?"라고 묻습니다. 아쉽지만 모든 문서에 통하는 숫자는 없습니다. 대신 출발점은 만들 수 있습니다.

짧은 FAQ나 고객지원 문서는 질문 하나와 답변 하나가 이미 좋은 의미 단위입니다. 이 경우 청크를 너무 잘게 나누면 오히려 답변 조건이 분리됩니다. 제품 매뉴얼이나 정책 문서는 섹션 단위가 중요합니다. 긴 보고서나 논문은 제목, 절, 문단 구조를 유지하되 너무 긴 절은 다시 나눠야 합니다. 코드 파일은 함수와 클래스가 기본 단위가 됩니다.

실무에서는 다음 식으로 접근하는 것이 안전합니다.

  • 먼저 문서 유형별 기본 청킹 규칙을 정한다.

  • 샘플 질문 20~50개를 만들어 검색 결과를 눈으로 확인한다.

  • 검색된 청크가 답변에 충분한지, 불필요한 내용이 많은지 평가한다.

  • chunk_size, chunk_overlap, separators를 조정한다.

  • 최종 답변 품질까지 함께 확인한다.

검색만 잘된다고 끝이 아닙니다. 너무 작은 청크는 검색 점수는 높아도 답변 생성에 필요한 주변 맥락이 부족할 수 있습니다. 반대로 너무 큰 청크는 답이 들어 있더라도 주변 잡음이 많아 모델이 핵심을 놓칠 수 있습니다.

문서 구조를 살리는 청킹 전략

모든 문서를 같은 chunk_size=800으로 자르는 것은 편하지만 최선은 아닙니다. 문서 유형별로 의미 단위가 다르기 때문입니다.

보고서·매뉴얼: 제목과 섹션을 우선한다

보고서, 매뉴얼, 정책 문서는 보통 제목 구조가 의미 구조입니다. "설치 방법", "요금 정책", "환불 예외", "장애 대응 절차" 같은 섹션 제목은 그 자체로 강력한 검색 힌트입니다. 가능하다면 마크다운 헤더나 HTML 헤더를 메타데이터로 보존하는 것이 좋습니다.

from langchain_text_splitters import MarkdownHeaderTextSplitter

headers_to_split_on = [
    ('#', 'h1'),
    ('##', 'h2'),
    ('###', 'h3'),
]

markdown_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on,
)

md_docs = markdown_splitter.split_text(markdown_text)

이렇게 나누면 각 청크의 메타데이터에 헤더 정보가 들어갈 수 있습니다. 이후 답변에서 "관리자 가이드 > 사용자 초대 > 권한 설정" 같은 식으로 근거를 보여 주기 쉬워집니다.

CSV: 행과 컬럼 의미를 유지한다

CSV는 문단 단위 청킹보다 행 단위 유지가 더 자연스러운 경우가 많습니다. FAQ, 상품 카탈로그, 티켓 로그, 주문 데이터는 한 행이 하나의 사건이나 지식 단위인 경우가 많기 때문입니다.

from langchain_core.documents import Document


def enrich_csv_metadata(docs):
    result = []
    for doc in docs:
        metadata = dict(doc.metadata)
        metadata['doc_type'] = 'faq_csv'
        metadata['source_name'] = 'customer_faq'
        result.append(Document(page_content=doc.page_content, metadata=metadata))
    return result

CSV의 모든 컬럼을 본문에 넣을 필요는 없습니다. 검색에 쓰일 설명형 컬럼은 page_content에 두고, 카테고리, 제품군, 날짜, 상태값처럼 필터링에 쓰일 정보는 metadata로 분리하는 편이 좋습니다.

코드 파일: 함수와 클래스를 단위로 본다

코드는 문단보다 함수와 클래스가 중요합니다. 함수 중간에서 청크가 잘리면 검색된 코드가 실행 의미를 잃습니다.

from langchain_text_splitters import PythonCodeTextSplitter

code_splitter = PythonCodeTextSplitter(
    chunk_size=1200,
    chunk_overlap=100,
)

code_chunks = code_splitter.create_documents(
    [python_source],
    metadatas=[{'source': 'src/payment/refund.py', 'doc_type': 'code'}],
)

코드 RAG에서는 함수명, 클래스명, 파일 경로, 모듈명, 테스트 파일 여부 같은 메타데이터가 매우 중요합니다. "환불 로직 어디 있어?"라는 질문에는 본문보다 파일 경로와 함수명이 더 강한 단서가 될 때도 많습니다.

웹페이지: 본문 중심으로 나눈다

웹페이지는 구조가 복잡합니다. HTML 헤더를 기준으로 나누는 것도 좋지만, 그 전에 본문을 잘 추출해야 합니다. 메뉴와 푸터가 섞인 상태에서 헤더 기준으로 나누면 의미 없는 청크가 많이 생깁니다.

웹 문서에서는 다음 순서를 추천합니다.

  • URL, 제목, 작성일, canonical URL을 메타데이터로 저장한다.

  • 본문 영역만 추출하고 내비게이션·광고·푸터를 제거한다.

  • 중복 문단과 짧은 문구를 제거한다.

  • 헤더나 문단 기준으로 청킹한다.

  • 최종 청크에서 URL과 섹션명을 유지한다.

메타데이터는 청크에도 이어져야 한다

split_documents()를 사용하면 보통 원본 Document의 메타데이터가 청크에 상속됩니다. 하지만 청크 번호와 전체 청크 수는 직접 추가해 두는 편이 좋습니다.

from langchain_core.documents import Document


def add_chunk_metadata(chunks):
    total_by_source = {}
    for chunk in chunks:
        source = chunk.metadata.get('source', 'unknown')
        total_by_source[source] = total_by_source.get(source, 0) + 1

    index_by_source = {}
    enriched = []

    for chunk in chunks:
        source = chunk.metadata.get('source', 'unknown')
        idx = index_by_source.get(source, 0)
        index_by_source[source] = idx + 1

        metadata = dict(chunk.metadata)
        metadata['chunk_index'] = idx
        metadata['chunk_total'] = total_by_source[source]

        enriched.append(
            Document(
                page_content=chunk.page_content,
                metadata=metadata,
                id=f'{source}::chunk-{idx}',
            )
        )

    return enriched

chunks = add_chunk_metadata(chunks)

이런 정보는 나중에 매우 유용합니다. 특정 답변이 product_manual.pdf의 17번째 청크에서 나왔다는 사실을 알 수 있고, 필요하다면 앞뒤 청크를 함께 가져와 문맥을 확장할 수도 있습니다.

로딩 실패와 품질 저하를 다루는 방법

운영 환경의 문서 파이프라인은 항상 예외를 만납니다. 파일이 깨져 있거나, 권한이 없거나, 웹페이지가 404를 반환하거나, PDF 텍스트가 비어 있거나, CSV 구분자가 예상과 다를 수 있습니다. 따라서 문서 적재 코드는 "성공하면 좋고 실패하면 중단"이 아니라 "일부 실패를 기록하고 계속 진행"하는 방식이 좋습니다.

from pathlib import Path
from langchain_community.document_loaders import TextLoader, PyPDFLoader, CSVLoader


def load_one_file(path: str):
    suffix = Path(path).suffix.lower()

    if suffix in ['.txt', '.md', '.py']:
        loader = TextLoader(path, encoding='utf-8')
    elif suffix == '.pdf':
        loader = PyPDFLoader(path)
    elif suffix == '.csv':
        loader = CSVLoader(path, encoding='utf-8')
    else:
        raise ValueError(f'지원하지 않는 파일 형식: {suffix}')

    return loader.load()


def load_files(paths):
    all_docs = []
    errors = []

    for path in paths:
        try:
            docs = load_one_file(path)
            all_docs.extend(docs)
        except Exception as e:
            errors.append({'path': path, 'error': str(e)})

    return all_docs, errors

에러 목록은 버리면 안 됩니다. 어떤 파일이 왜 실패했는지 기록해야 재처리할 수 있습니다. 대규모 인덱싱에서는 load()로 한 번에 모두 읽는 방식보다 lazy_load()로 문서를 하나씩 스트리밍 처리하는 방식도 고려해야 합니다. LangChain의 Document Loader는 공통적으로 load()와 대용량 처리에 유용한 lazy_load() 인터페이스를 제공합니다.5

작은 파이프라인으로 묶어 보기

이제 로딩, 정제, 청킹을 하나의 간단한 파이프라인으로 묶어 보겠습니다. 다음 코드는 다음 장에서 임베딩과 벡터스토어로 이어질 바로 직전 단계입니다.

from pathlib import Path
from langchain_core.documents import Document
from langchain_community.document_loaders import TextLoader, PyPDFLoader, CSVLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter


def load_by_extension(path: str):
    suffix = Path(path).suffix.lower()

    if suffix in ['.txt', '.md']:
        return TextLoader(path, encoding='utf-8').load()
    if suffix == '.pdf':
        return PyPDFLoader(path).load()
    if suffix == '.csv':
        return CSVLoader(path, encoding='utf-8').load()

    raise ValueError(f'지원하지 않는 확장자입니다: {suffix}')


def prepare_documents(paths):
    raw_docs = []
    errors = []

    for path in paths:
        try:
            docs = load_by_extension(path)
            for doc in docs:
                doc.metadata.setdefault('source', path)
                doc.metadata.setdefault('file_name', Path(path).name)
            raw_docs.extend(docs)
        except Exception as e:
            errors.append({'path': path, 'error': str(e)})

    cleaned = normalize_docs(raw_docs)
    cleaned = dedupe_docs(cleaned)

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=800,
        chunk_overlap=120,
        separators=['nn', 'n', '. ', '? ', '! ', ' ', ''],
    )

    chunks = splitter.split_documents(cleaned)
    chunks = add_chunk_metadata(chunks)

    return chunks, errors


paths = [
    'docs/refund_policy.pdf',
    'docs/product_manual.md',
    'data/faq.csv',
]

chunks, errors = prepare_documents(paths)

print(f'생성된 청크 수: {len(chunks)}')
print(f'실패한 파일 수: {len(errors)}')
print(chunks[0].metadata)
print(chunks[0].page_content[:300])

이 코드는 아직 완벽한 운영용 파이프라인은 아닙니다. 하지만 좋은 뼈대를 갖고 있습니다. 소스별 로더를 선택하고, 메타데이터를 보존하고, 정제하고, 중복을 제거하고, 구조를 최대한 유지하며 청킹합니다.

좋은 청크인지 확인하는 가장 빠른 방법

청킹 전략을 바꿨다면 반드시 결과를 눈으로 봐야 합니다. 벡터스토어에 넣은 뒤 검색 결과가 이상하다고 느끼면 이미 디버깅 비용이 커진 상태입니다.

def inspect_chunks(chunks, n=5):
    for i, chunk in enumerate(chunks[:n]):
        print('n' + '#' * 80)
        print(f'chunk {i}')
        print('metadata:', chunk.metadata)
        print(f'length: {len(chunk.page_content)}')
        print(chunk.page_content)

inspect_chunks(chunks)

확인할 기준은 명확합니다. 청크 하나만 읽었을 때 무슨 내용인지 이해되어야 합니다. 출처를 추적할 수 있어야 합니다. 제목과 본문이 지나치게 분리되어 있으면 안 됩니다. 표나 목록의 핵심 행이 잘리지 않아야 합니다. 같은 문장이 여러 청크에 과도하게 반복되지 않아야 합니다.

특히 다음과 같은 청크가 보이면 전략을 다시 조정해야 합니다.

  • "이전", "다음", "맨 위로", "로그인" 같은 UI 문구만 들어 있는 청크

  • 문장 중간에서 시작하고 문장 중간에서 끝나는 청크

  • 제목 없이 세부 설명만 있어 주제를 알 수 없는 청크

  • 표의 헤더와 값이 서로 다른 청크로 분리된 경우

  • 코드 함수의 선언부와 본문이 분리된 경우

  • 개인정보나 내부 토큰이 그대로 포함된 경우

문서 유형별 추천 출발점

처음부터 완벽한 청킹 규칙을 만들 필요는 없습니다. 대신 문서 유형별로 안전한 출발점을 정하고, 검색 평가를 통해 조정하면 됩니다.

문서 유형추천 로더청킹 기준주의할 점
일반 텍스트·마크다운TextLoader문단, 헤더, 문장제목 메타데이터 보존
PDF 매뉴얼PyPDFLoader 또는 PDF 전용 파서페이지, 섹션, 문단레이아웃 깨짐과 표 손실 확인
CSV FAQCSVLoader행 단위컬럼 일부는 메타데이터로 분리
웹페이지WebBaseLoader본문, 헤더, 문단메뉴·광고·푸터 제거
코드 파일TextLoader, PythonCodeTextSplitter함수, 클래스파일 경로와 심볼명 보존
사내 위키전용 Loader 또는 API페이지, 헤더권한과 최신 버전 관리

이 표는 정답표가 아니라 출발선입니다. RAG에서는 "우리 데이터에서 실제로 잘 검색되는가?"가 최종 기준입니다.

이번 장의 핵심 정리

문서 로딩과 청킹은 화려하지 않지만 RAG 품질의 바닥을 결정합니다. 모델을 바꾸기 전에 문서가 제대로 읽혔는지, 메타데이터가 살아 있는지, 불필요한 노이즈가 제거되었는지, 청크가 자연스러운 의미 단위를 유지하는지 확인해야 합니다.

이번 장에서 기억해야 할 내용은 다음과 같습니다.

  • Document Loader는 다양한 소스를 공통 Document 형식으로 변환한다.

  • page_content는 본문이고, metadata는 추적·필터링·근거 제시에 필요한 정보다.

  • RAG 파이프라인은 로딩, 정제, 청킹, 임베딩, 저장, 검색, 생성의 흐름으로 설계한다.

  • RecursiveCharacterTextSplitter는 일반 텍스트의 좋은 기본 선택지지만, 문서 유형별 전략이 필요하다.

  • 청크 크기와 overlap은 고정 정답이 아니라 평가를 통해 조정해야 하는 가설이다.

  • 웹과 PDF는 노이즈, 레이아웃 깨짐, 중복, 민감정보를 반드시 점검해야 한다.

다음 장에서는 이제 이 청크들을 숫자 벡터로 바꾸고, 벡터스토어에 저장하고, 사용자 질문과 의미적으로 가까운 문서를 찾아오는 검색 기반 구조를 만들 것입니다. 지금까지는 도서관에 책을 들여오고, 먼지를 털고, 알맞은 단위로 책갈피를 꽂는 과정이었습니다. 다음은 그 책갈피를 순식간에 찾아내는 검색 엔진을 만드는 단계입니다.

참고

1Document loader integrations - Docs by LangChain

2Document loader integrations - Docs by LangChain

3Text splitter integrations - Docs by LangChain

4Splitting recursively - Text splitter integration guide

5Document loader integrations - Docs by LangChain

책 목록

LangChain 실전 활용법: LLM 앱부터 RAG·에이전트·운영까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기