이미지 로딩 중...

RAG 시스템 2편 - 문서 전처리와 청킹 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 8. · 3 Views

RAG 시스템 2편 - 문서 전처리와 청킹 완벽 가이드

RAG 시스템의 핵심인 문서 전처리와 청킹 전략을 실무 관점에서 깊이 있게 다룹니다. 문서를 어떻게 분할하고, 메타데이터를 추출하며, 최적의 청크 크기를 결정하는지 단계별로 배워봅니다.


목차

  1. 문서 로더와 전처리 파이프라인 - 다양한 포맷의 문서를 통합 처리
  2. 문자 기반 청킹 - 고정 길이로 문서 분할
  3. 재귀적 문자 분할 - 계층적 구분자로 똑똑한 청킹
  4. 토큰 기반 청킹 - LLM 토큰 제한에 맞춘 정확한 분할
  5. 의미 기반 청킹 - 임베딩 유사도로 지능적 분할
  6. 메타데이터 추출 및 필터링 - 검색 정확도를 높이는 컨텍스트 정보
  7. 문서 구조 인식 청킹 - 제목과 섹션을 고려한 계층적 분할
  8. 청크 크기 최적화 실험 - 성능 지표로 최적값 찾기
  9. 청크 전후 문맥 확장 - 검색 후 주변 청크 병합
  10. 하이브리드 청킹 전략 - 문서 타입별 맞춤형 접근

1. 문서 로더와 전처리 파이프라인 - 다양한 포맷의 문서를 통합 처리

시작하며

여러분이 RAG 시스템을 구축하면서 PDF, Word, Markdown, HTML 등 다양한 형식의 문서를 처리해야 했던 적 있나요? 각 포맷마다 다른 라이브러리를 사용하고, 인코딩 문제로 깨진 텍스트를 마주하며, 메타데이터 추출 로직을 매번 새로 작성하느라 고생한 경험이 있을 겁니다.

이런 문제는 RAG 시스템 구축 초기에 가장 많이 마주치는 장애물입니다. 문서 포맷이 통일되지 않으면 전처리 파이프라인이 복잡해지고, 유지보수가 어려워지며, 새로운 포맷을 추가할 때마다 전체 시스템을 수정해야 하는 상황이 발생합니다.

바로 이럴 때 필요한 것이 문서 로더와 전처리 파이프라인입니다. LangChain과 같은 프레임워크가 제공하는 통합 문서 로더를 사용하면, 포맷에 관계없이 일관된 방식으로 문서를 처리할 수 있습니다.

개요

간단히 말해서, 문서 로더는 다양한 포맷의 파일을 읽어 텍스트와 메타데이터를 추출하는 추상화 계층입니다. 실무에서는 고객 문의 데이터베이스, 제품 매뉴얼, 기술 문서 등 여러 출처의 문서를 하나의 RAG 시스템에서 처리해야 합니다.

예를 들어, 고객 지원 챗봇을 만든다면 PDF 매뉴얼, HTML 도움말 페이지, Markdown 기술 문서를 모두 처리할 수 있어야 합니다. 기존에는 각 포맷마다 pypdf, python-docx, beautifulsoup 등 별도의 라이브러리를 사용하고 각각의 전처리 로직을 작성했다면, 이제는 통합된 인터페이스로 모든 문서를 처리할 수 있습니다.

문서 로더의 핵심 특징은 첫째, 포맷 독립적인 인터페이스 제공, 둘째, 자동 메타데이터 추출(파일명, 페이지 번호, 작성일 등), 셋째, 배치 처리와 스트리밍 지원입니다. 이러한 특징들이 중요한 이유는 대규모 문서 처리 시 일관성과 확장성을 보장하기 때문입니다.

코드 예제

from langchain.document_loaders import PyPDFLoader, UnstructuredMarkdownLoader, DirectoryLoader
from langchain.document_loaders.base import BaseLoader
from typing import List

# PDF 문서 로더 - 페이지별로 분리하여 로드
pdf_loader = PyPDFLoader("manual.pdf")
pdf_documents = pdf_loader.load()

# 디렉토리 전체를 처리하는 로더 - 여러 파일을 일괄 처리
directory_loader = DirectoryLoader(
    "docs/",
    glob="**/*.md",  # 모든 마크다운 파일
    loader_cls=UnstructuredMarkdownLoader,
    show_progress=True  # 진행상황 표시
)
all_documents = directory_loader.load()

# 각 문서는 page_content와 metadata를 포함
for doc in pdf_documents:
    print(f"페이지: {doc.metadata.get('page')}")
    print(f"내용 일부: {doc.page_content[:100]}")

설명

이것이 하는 일: 이 코드는 다양한 형식의 문서를 읽어들여 RAG 시스템에서 사용할 수 있는 표준화된 Document 객체로 변환합니다. 각 Document는 텍스트 내용(page_content)과 메타데이터(파일명, 페이지 번호 등)를 함께 보관합니다.

첫 번째 단계로, PyPDFLoader를 사용하여 PDF 파일을 로드합니다. 이 로더는 PDF를 페이지 단위로 자동 분할하며, 각 페이지의 텍스트를 추출하고 페이지 번호를 메타데이터로 저장합니다.

이렇게 하는 이유는 나중에 사용자에게 "이 정보는 매뉴얼 23페이지에 있습니다"와 같은 정확한 출처를 제공하기 위함입니다. 두 번째 단계로, DirectoryLoader를 사용해 특정 디렉토리의 모든 마크다운 파일을 일괄 처리합니다.

glob 패턴을 통해 원하는 파일만 선택적으로 로드할 수 있으며, show_progress=True 옵션으로 처리 진행상황을 모니터링할 수 있습니다. 내부적으로는 각 파일에 대해 UnstructuredMarkdownLoader가 실행되며, 헤더 구조, 코드 블록, 링크 등 마크다운의 구조적 정보도 함께 파싱됩니다.

마지막으로, 로드된 Document 객체들을 순회하며 메타데이터와 내용을 확인합니다. 각 Document는 딕셔너리 형태의 metadata 속성을 가지고 있어 .get() 메서드로 안전하게 접근할 수 있으며, page_content는 실제 텍스트 내용을 담고 있습니다.

여러분이 이 코드를 사용하면 새로운 문서 포맷을 추가할 때 로더만 교체하면 되므로 확장성이 뛰어나며, 모든 문서가 동일한 Document 구조로 변환되어 후속 처리 파이프라인(청킹, 임베딩 등)을 단순화할 수 있습니다. 또한 메타데이터를 통해 문서의 출처를 추적하고, 사용자에게 정확한 레퍼런스를 제공할 수 있어 RAG 시스템의 신뢰성이 크게 향상됩니다.

실전 팁

💡 대용량 PDF 처리 시 PyPDFLoader 대신 UnstructuredPDFLoader를 사용하면 테이블과 이미지 캡션도 함께 추출할 수 있습니다

💡 파일 인코딩 문제가 발생하면 loader 인스턴스 생성 시 encoding='utf-8' 파라미터를 명시적으로 지정하세요

💡 메타데이터에 문서 작성일, 버전, 작성자 정보를 추가하면 나중에 필터링이나 시간 기반 검색에 활용할 수 있습니다

💡 DirectoryLoader 사용 시 recursive=True 옵션을 주면 하위 디렉토리까지 재귀적으로 탐색하지만, 파일 수가 많으면 메모리 부족이 발생할 수 있으므로 배치 처리를 고려하세요

💡 로드한 문서의 page_content가 비어있는지 항상 검증하고, 빈 문서는 필터링하여 임베딩 비용을 절약하세요


2. 문자 기반 청킹 - 고정 길이로 문서 분할

시작하며

여러분이 10,000자짜리 기술 문서를 벡터 DB에 저장하려고 할 때, 전체를 하나의 청크로 만들어야 할까요, 아니면 여러 조각으로 나눠야 할까요? 하나로 저장하면 검색 정확도가 떨어지고, 너무 잘게 나누면 문맥이 손실되는 딜레마를 경험해본 적이 있을 겁니다.

이 문제는 RAG 시스템의 성능을 좌우하는 핵심 요소입니다. 청크 크기가 너무 크면 관련 없는 정보까지 포함되어 LLM의 토큰을 낭비하고, 너무 작으면 중요한 문맥이 분리되어 답변 품질이 떨어집니다.

실무에서는 검색 정확도와 문맥 유지 사이의 균형점을 찾는 것이 매우 중요합니다. 바로 이럴 때 필요한 것이 문자 기반 청킹 전략입니다.

고정된 문자 수로 문서를 분할하되, 청크 간 오버랩을 두어 문맥 손실을 최소화하는 방법을 배워봅시다.

개요

간단히 말해서, 문자 기반 청킹은 문서를 정해진 문자 수(예: 1000자)로 분할하되, 인접한 청크 간에 일부 내용을 중복(오버랩)시켜 문맥 연속성을 유지하는 기법입니다. 실무에서는 제품 매뉴얼, FAQ 문서, 기술 블로그처럼 구조가 일정하지 않은 텍스트를 처리할 때 가장 먼저 시도하는 방법입니다.

예를 들어, 고객 지원 문서를 RAG에 넣을 때 각 청크가 500~1000자 정도로 유지되면 검색 시 적절한 양의 컨텍스트를 제공할 수 있습니다. 기존에는 단순히 문자열을 자르는(substring) 방식을 사용했다면, 이제는 오버랩과 구분자(separator)를 활용하여 더 똑똑하게 분할할 수 있습니다.

특히 문장 중간이나 단어 중간에서 잘리는 것을 방지합니다. 이 방법의 핵심 특징은 첫째, 구현이 간단하고 빠르다는 점(복잡한 NLP 모델 불필요), 둘째, 청크 크기를 일정하게 유지하여 벡터 DB 인덱싱이 효율적이라는 점, 셋째, 오버랩을 통해 문맥 손실을 줄인다는 점입니다.

이러한 특징들이 중요한 이유는 대부분의 RAG 시스템에서 첫 번째 베이스라인으로 사용될 만큼 실용적이기 때문입니다.

코드 예제

from langchain.text_splitter import CharacterTextSplitter

# 문자 기반 텍스트 분할기 초기화
text_splitter = CharacterTextSplitter(
    separator="\n\n",  # 단락 구분자로 우선 분할 시도
    chunk_size=1000,   # 각 청크의 최대 문자 수
    chunk_overlap=200, # 인접 청크 간 중복 문자 수 (문맥 유지)
    length_function=len,  # 길이 계산 함수
    is_separator_regex=False  # 정규식이 아닌 일반 문자열 구분자
)

# 긴 문서를 청크로 분할
long_document = """
여기에 매우 긴 기술 문서 내용이 들어갑니다.
여러 단락으로 구성되어 있으며...
"""
chunks = text_splitter.create_documents([long_document])

# 각 청크 확인
for i, chunk in enumerate(chunks):
    print(f"청크 {i}: {len(chunk.page_content)}자")

설명

이것이 하는 일: 이 코드는 긴 문서를 검색과 임베딩에 적합한 크기의 청크들로 분할합니다. 각 청크는 독립적으로 벡터화되어 벡터 DB에 저장되며, 사용자 질문과 의미적으로 유사한 청크들이 검색됩니다.

첫 번째 단계로, CharacterTextSplitter를 설정합니다. separator="\n\n"은 먼저 단락 경계에서 문서를 나누려 시도한다는 의미입니다.

이렇게 하면 문장 중간에서 잘리는 것을 방지하고, 의미적으로 완결된 단위로 분할할 수 있습니다. chunk_size=1000은 각 청크의 최대 크기를 1000자로 제한하며, 이는 대부분의 임베딩 모델이 처리하기 적절한 크기입니다.

두 번째로 중요한 파라미터는 chunk_overlap=200입니다. 이것은 청크 A의 마지막 200자와 청크 B의 첫 200자가 중복된다는 의미입니다.

예를 들어, "회사의 환불 정책은 구매일로부터 30일 이내에..."라는 문장이 청크 경계에 걸쳐 있다면, 오버랩 덕분에 양쪽 청크 모두에서 완전한 문맥을 유지할 수 있습니다. 이는 검색 시 중요한 정보가 청크 경계에서 손실되는 것을 방지합니다.

세 번째 단계로, create_documents() 메서드를 호출하여 실제 분할을 수행합니다. 이 메서드는 문서 리스트를 받아 각각을 청크로 나누고, 각 청크를 별도의 Document 객체로 반환합니다.

내부적으로는 먼저 separator로 분할을 시도하고, 그 결과가 chunk_size를 초과하면 추가로 잘라냅니다. 여러분이 이 코드를 사용하면 수천 페이지의 문서도 몇 초 안에 일관된 크기의 청크로 변환할 수 있으며, 각 청크는 검색 품질을 유지하면서도 LLM의 컨텍스트 윈도우에 효율적으로 들어갑니다.

또한 chunk_overlap 덕분에 중요한 정보가 청크 경계에 걸쳐 있어도 손실 없이 검색할 수 있습니다. 실무에서는 chunk_size를 500~2000 사이에서 실험하며 최적값을 찾는 것이 일반적입니다.

실전 팁

💡 chunk_size는 임베딩 모델의 최대 토큰 수를 고려하여 설정하세요 - OpenAI text-embedding-ada-002는 8191토큰 제한이 있으므로 문자 수로는 약 6000자 이하가 안전합니다

💡 chunk_overlap은 일반적으로 chunk_size의 10~20% 정도가 적절하며, 너무 크면 중복 저장으로 인한 비용이 증가합니다

💡 기술 문서는 "\n\n" 구분자가 효과적이지만, 코드가 포함된 문서는 "```" 같은 코드 블록 경계도 고려하세요

💡 length_function을 len 대신 토큰 카운터로 교체하면(예: tiktoken) 더 정확한 크기 제어가 가능하지만 처리 속도는 느려집니다

💡 분할 후 각 청크의 실제 크기 분포를 히스토그램으로 확인하여 설정이 의도대로 작동하는지 검증하세요


3. 재귀적 문자 분할 - 계층적 구분자로 똑똑한 청킹

시작하며

여러분이 코드가 섞인 기술 문서를 청킹할 때, 단순 문자 기반 분할로는 코드 블록이 중간에 잘리거나, 함수 설명과 코드가 분리되는 문제를 겪어본 적 있나요? 문서의 구조를 무시하고 기계적으로 자르면 의미 단위가 파괴되어 RAG의 답변 품질이 크게 떨어집니다.

이 문제는 특히 프로그래밍 튜토리얼, API 문서, 기술 블로그처럼 코드와 설명이 혼재된 콘텐츠에서 심각합니다. 코드 블록의 일부만 검색되면 사용자에게 불완전한 정보를 제공하게 되고, 설명 없는 코드나 코드 없는 설명만 전달되어 혼란을 야기합니다.

바로 이럴 때 필요한 것이 재귀적 문자 분할(Recursive Character Splitting)입니다. 여러 단계의 구분자를 우선순위에 따라 시도하여, 문서의 자연스러운 구조를 최대한 보존하면서 청킹합니다.

개요

간단히 말해서, 재귀적 문자 분할은 단락, 문장, 단어 순서로 여러 구분자를 시도하며, 가장 의미 있는 경계에서 문서를 분할하는 지능형 청킹 전략입니다. 실무에서는 문서의 구조가 중요한 경우에 필수적입니다.

예를 들어, Python 튜토리얼 문서를 처리한다면 먼저 "```python" 코드 블록 경계로 분할을 시도하고, 그래도 크기가 초과하면 "\n\n"(단락), "\n"(줄), " "(공백) 순으로 재귀적으로 분할합니다. 이렇게 하면 코드 블록이 통째로 유지되면서도 크기 제한을 만족할 수 있습니다.

기존의 단순 문자 분할이 정해진 위치에서 무조건 자른다면, 재귀적 분할은 문서의 자연스러운 경계를 찾아가며 점진적으로 세분화합니다. 마치 나무를 베는 것처럼, 큰 가지부터 작은 가지 순서로 잘라나갑니다.

핵심 특징은 첫째, 구분자 우선순위를 커스터마이징할 수 있어 문서 타입에 맞게 조정 가능, 둘째, 의미 단위(단락, 문장)를 보존하여 검색 품질 향상, 셋째, 코드, 마크다운, HTML 등 구조화된 문서에 특히 효과적입니다. 이러한 특징들이 중요한 이유는 RAG 시스템이 단순히 텍스트를 저장하는 것이 아니라, 의미를 검색하는 시스템이기 때문입니다.

코드 예제

from langchain.text_splitter import RecursiveCharacterTextSplitter

# 재귀적 텍스트 분할기 - 여러 구분자를 우선순위대로 시도
text_splitter = RecursiveCharacterTextSplitter(
    separators=[
        "\n\n",  # 1순위: 단락 경계
        "\n",    # 2순위: 줄바꿈
        " ",     # 3순위: 공백
        ""       # 4순위: 문자 단위 (최후의 수단)
    ],
    chunk_size=1000,
    chunk_overlap=200,
    length_function=len,
)

# 코드가 포함된 문서 예제
code_document = """
# 데이터 전처리 함수

다음 함수는 텍스트를 정규화합니다.

def normalize_text(text: str) -> str:
    text = text.lower()
    text = re.sub(r'[^a-z0-9\\s]', '', text)
    return text.strip()

이 함수는 소문자 변환과 특수문자 제거를 수행합니다.
"""

chunks = text_splitter.create_documents([code_document])

설명

이것이 하는 일: 이 코드는 문서를 분할할 때 의미 있는 경계를 우선적으로 찾아 사용합니다. 구분자 리스트를 순서대로 시도하며, 첫 번째 구분자로 분할했을 때 청크 크기가 적절하면 그대로 사용하고, 너무 크면 다음 구분자로 재귀적으로 다시 분할합니다.

첫 번째 단계로, separators 리스트를 정의합니다. "\n\n"이 최우선이므로 먼저 단락 단위로 분할을 시도합니다.

만약 단락 하나가 1000자 이하라면 그대로 하나의 청크가 되고, 1000자를 초과하면 다음 구분자인 "\n"으로 다시 분할을 시도합니다. 이렇게 하는 이유는 큰 의미 단위를 최대한 보존하면서도 크기 제한을 지키기 위함입니다.

두 번째 단계에서, 코드 블록이 포함된 문서를 처리할 때의 이점이 드러납니다. 위 예제에서 "# 데이터 전처리 함수"부터 코드 설명까지의 내용이 "\n\n"으로 구분되어 있다면, 전체가 1000자 이내일 경우 하나의 완결된 청크로 유지됩니다.

코드 블록(python ... )도 내부에 "\n"은 많지만 "\n\n"으로는 분리되지 않으므로 통째로 보존됩니다.

이는 검색 시 "텍스트 정규화 함수"를 찾으면 설명과 코드가 함께 반환되어 사용자에게 완전한 정보를 제공할 수 있음을 의미합니다. 세 번째 단계로, 만약 특정 단락이 너무 길어 "\n"으로도 분할이 필요하다면, 문장 단위로 나누어집니다.

그래도 크기를 초과하면 공백(" ")으로, 최종적으로는 문자 단위("")까지 내려가지만, 실무에서는 거의 3단계(공백) 이전에 분할이 완료됩니다. 여러분이 이 코드를 사용하면 기술 문서, 블로그 포스트, API 레퍼런스처럼 구조화된 콘텐츠를 훨씬 더 자연스럽게 청킹할 수 있습니다.

코드 예제가 설명과 분리되지 않고, 리스트 항목이 중간에 잘리지 않으며, 제목과 본문이 함께 유지되어 검색 정확도가 크게 향상됩니다. 또한 separators를 문서 타입에 맞게 커스터마이징할 수 있어(예: HTML은 "</p>", "</div>" 추가) 범용성이 뛰어납니다.

실전 팁

💡 마크다운 문서 처리 시 separators에 "## ", "### " 같은 헤더 마커를 추가하면 섹션 단위로 깔끔하게 분할됩니다

💡 코드가 많은 문서는 RecursiveCharacterTextSplitter.from_language("python") 같은 언어별 프리셋을 사용하면 코드 구조를 더 잘 보존합니다

💡 separators 순서가 매우 중요합니다 - 큰 단위부터 작은 단위 순으로 배치해야 의미 보존 효과가 극대화됩니다

💡 chunk_overlap을 너무 작게 설정하면 재귀적 분할의 이점이 줄어들므로, 최소 chunk_size의 15% 이상을 권장합니다

💡 분할 결과를 샘플링하여 실제로 코드 블록이나 리스트가 잘 보존되는지 수동으로 검증하는 과정을 거치세요


4. 토큰 기반 청킹 - LLM 토큰 제한에 맞춘 정확한 분할

시작하며

여러분이 OpenAI API를 사용하여 RAG를 구축할 때, 청크를 1000자로 제한했는데도 "maximum context length exceeded" 에러가 발생한 적 있나요? 문자 수와 토큰 수는 다르기 때문에, 문자 기반 청킹만으로는 LLM의 토큰 제한을 정확히 준수할 수 없습니다.

이 문제는 특히 비용 최적화가 중요한 프로덕션 환경에서 심각합니다. 토큰 수를 정확히 제어하지 못하면 예상보다 많은 API 비용이 발생하거나, 컨텍스트 오버플로우로 인한 요청 실패가 반복됩니다.

영어는 평균 4자당 1토큰이지만, 한국어는 약 2자당 1토큰으로 언어마다 비율이 다르기 때문에 문자 수만으로는 예측이 불가능합니다. 바로 이럴 때 필요한 것이 토큰 기반 청킹입니다.

tiktoken 같은 토크나이저를 직접 사용하여 토큰 수를 정확히 계산하고, 모델의 제한에 맞춰 청크를 분할합니다.

개요

간단히 말해서, 토큰 기반 청킹은 문자 수가 아닌 실제 LLM 토큰 수를 기준으로 문서를 분할하여, 모델의 컨텍스트 윈도우를 최대한 활용하면서도 오버플로우를 방지하는 전략입니다. 실무에서는 GPT-4, Claude, Llama 등 특정 LLM 모델을 타겟으로 하는 RAG 시스템에서 필수적입니다.

예를 들어, OpenAI의 gpt-3.5-turbo는 4096 토큰 제한이 있으므로, 시스템 프롬프트(약 200토큰), 사용자 질문(평균 50토큰), 여유분(100토큰)을 제외하면 검색된 청크들은 총 3700토큰 이내여야 합니다. 청크 3개를 반환한다면 각 청크는 약 1200토큰 이하로 제한되어야 하는데, 이를 문자 수로 환산하는 것은 부정확하고 위험합니다.

기존의 문자 기반 청킹이 대략적인 추정에 의존한다면, 토큰 기반 청킹은 실제 모델이 사용하는 토크나이저를 직접 사용하여 정확한 토큰 수를 계산합니다. 이는 비용 예측과 제어를 가능하게 합니다.

핵심 특징은 첫째, 모델별 토크나이저를 사용하여 100% 정확한 토큰 수 계산, 둘째, 언어나 특수문자에 관계없이 일관된 청킹, 셋째, API 비용과 성능을 정밀하게 최적화할 수 있다는 점입니다. 이러한 특징들이 중요한 이유는 프로덕션 RAG 시스템에서 비용과 안정성이 직결되기 때문입니다.

코드 예제

import tiktoken
from langchain.text_splitter import RecursiveCharacterTextSplitter

# OpenAI의 토크나이저 로드 (gpt-3.5-turbo, gpt-4 사용)
encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")

# 토큰 수를 계산하는 함수
def tiktoken_len(text: str) -> int:
    tokens = encoding.encode(text)
    return len(tokens)

# 토큰 기반 텍스트 분할기
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,  # 500 토큰으로 제한
    chunk_overlap=50,  # 50 토큰 오버랩
    length_function=tiktoken_len,  # 문자 수 대신 토큰 수 사용
    separators=["\n\n", "\n", " ", ""]
)

# 다국어 문서도 정확히 처리
multilingual_doc = "한글과 English가 섞인 문서도 정확한 토큰 수로 분할됩니다. 🚀"
chunks = text_splitter.create_documents([multilingual_doc])

for chunk in chunks:
    token_count = tiktoken_len(chunk.page_content)
    print(f"토큰 수: {token_count}, 문자 수: {len(chunk.page_content)}")

설명

이것이 하는 일: 이 코드는 LLM이 실제로 사용하는 토큰 단위로 문서를 분할하여, 컨텍스트 오버플로우를 방지하고 API 비용을 정확히 예측 가능하게 만듭니다. tiktoken 라이브러리를 사용하여 OpenAI 모델과 동일한 방식으로 토큰을 계산합니다.

첫 번째 단계로, tiktoken.encoding_for_model()을 호출하여 특정 모델의 토크나이저를 로드합니다. "gpt-3.5-turbo"를 지정하면 cl100k_base 인코딩이 로드되며, 이는 GPT-3.5와 GPT-4가 사용하는 실제 토크나이저입니다.

이렇게 하는 이유는 모델과 정확히 동일한 기준으로 토큰을 세어야 런타임 에러를 방지할 수 있기 때문입니다. 두 번째로, tiktoken_len() 함수를 정의하여 텍스트를 토큰으로 인코딩하고 개수를 반환합니다.

encoding.encode()는 문자열을 토큰 ID 리스트로 변환하며, 예를 들어 "한글"은 [41283, 239]처럼 2개의 토큰으로 인코딩됩니다. 이 함수를 length_function 파라미터에 전달하여, 내부적으로 len() 대신 tiktoken_len()이 호출되도록 합니다.

세 번째 단계로, RecursiveCharacterTextSplitter를 생성하되 length_function=tiktoken_len을 지정합니다. 이제 chunk_size=500은 "500문자"가 아니라 "500토큰"을 의미하게 됩니다.

내부적으로 텍스트를 분할할 때마다 tiktoken_len()을 호출하여 현재 청크의 토큰 수를 확인하고, 500을 초과하면 다음 구분자로 재분할을 시도합니다. 마지막으로, 다국어 문서를 처리하는 예제를 보면 한글, 영어, 이모지가 섞여 있어도 정확한 토큰 수로 분할됩니다.

출력을 보면 문자 수와 토큰 수의 비율이 일정하지 않음을 확인할 수 있습니다 - 이것이 바로 토큰 기반 청킹이 필요한 이유입니다. 여러분이 이 코드를 사용하면 "이 청크는 정확히 N 토큰이므로, API 호출 시 $X의 비용이 발생한다"는 정확한 계산이 가능해집니다.

또한 gpt-3.5-turbo의 4096 토큰 제한을 절대 초과하지 않으므로 요청 실패가 사라지고, 컨텍스트 윈도우를 최대한 활용하여 더 많은 정보를 LLM에 전달할 수 있습니다. 한국어, 일본어, 중국어 같은 비라틴 문자에서 특히 문자 기반 청킹 대비 정확도가 크게 향상됩니다.

실전 팁

💡 tiktoken 설치 시 rust 컴파일러가 필요할 수 있으므로, 프로덕션 환경에서는 Docker 이미지에 미리 포함시키세요

💡 다른 모델 사용 시 해당 모델의 토크나이저를 사용해야 합니다 - Claude는 anthropic 토크나이저, Llama는 sentencepiece를 사용합니다

💡 토큰 계산은 문자 계산보다 10~100배 느리므로, 대용량 문서는 배치 처리하거나 캐싱을 고려하세요

💡 chunk_size를 모델의 최대 토큰보다 작게 설정하세요 - 예를 들어 4096 토큰 모델이라면 3000~3500 정도가 안전합니다 (시스템 프롬프트와 응답 공간 확보)

💡 비용 모니터링을 위해 전체 문서의 토큰 수를 사전에 계산하고, 예상 임베딩 비용을 산출하여 예산 초과를 방지하세요


5. 의미 기반 청킹 - 임베딩 유사도로 지능적 분할

시작하며

여러분이 고정 길이로 청킹한 결과, 하나의 주제가 여러 청크로 분산되거나 서로 다른 주제가 하나의 청크에 섞여있는 문제를 겪어본 적 있나요? "고객 환불 정책"에 대한 질문에 환불과 무관한 배송 정보가 함께 반환되어 LLM이 혼란스러운 답변을 생성하는 상황이 발생합니다.

이 문제는 문자 수나 토큰 수 같은 표면적인 기준만으로는 해결할 수 없는 근본적인 한계입니다. 문서는 길이가 아니라 의미로 분할되어야 하며, 의미적으로 관련된 내용은 함께 유지되고 관련 없는 내용은 분리되어야 검색 품질이 향상됩니다.

바로 이럴 때 필요한 것이 의미 기반 청킹(Semantic Chunking)입니다. 임베딩 모델을 사용하여 문장 간 유사도를 계산하고, 의미가 급격히 바뀌는 지점을 경계로 문서를 분할합니다.

개요

간단히 말해서, 의미 기반 청킹은 각 문장을 임베딩으로 변환한 후 인접 문장 간 코사인 유사도를 계산하여, 유사도가 급격히 떨어지는 지점(주제 전환점)에서 청크를 나누는 고급 기법입니다. 실무에서는 FAQ 문서, 뉴스 기사, 연구 논문처럼 하나의 문서 안에 여러 독립적인 주제가 섞여 있는 경우에 매우 효과적입니다.

예를 들어, 제품 매뉴얼에서 "설치 방법", "사용법", "문제 해결", "유지보수" 섹션이 명확한 제목 없이 이어진다면, 의미 기반 청킹이 자동으로 주제 경계를 감지하여 분할합니다. 기존의 고정 길이 청킹이 기계적으로 자른다면, 의미 기반 청킹은 문서를 "읽고 이해"하여 의미 있는 경계를 찾습니다.

마치 사람이 책을 읽으며 장면 전환을 감지하는 것과 유사합니다. 핵심 특징은 첫째, 주제별로 자연스럽게 분할되어 검색 정확도가 크게 향상, 둘째, 청크 크기가 불균등할 수 있지만 의미 완결성이 보장됨, 셋째, 제목이나 구분자가 없는 연속된 텍스트에서도 작동한다는 점입니다.

이러한 특징들이 중요한 이유는 RAG의 최종 목표가 "관련성 높은 컨텍스트만 전달"하는 것이기 때문입니다.

코드 예제

from langchain.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
import numpy as np

# 임베딩 모델 초기화 - 문장 간 유사도 계산용
embeddings = OpenAIEmbeddings(model="text-embedding-ada-002")

# 의미 기반 텍스트 분할기
semantic_chunker = SemanticChunker(
    embeddings=embeddings,
    breakpoint_threshold_type="percentile",  # 유사도 임계값 계산 방식
    breakpoint_threshold_amount=95  # 상위 5%의 유사도 하락 지점을 경계로 사용
)

# 여러 주제가 섞인 문서
mixed_topic_doc = """
우리 회사는 1995년에 설립되었습니다. 서울에 본사를 두고 있습니다.

환불 정책은 다음과 같습니다. 구매 후 30일 이내 전액 환불 가능합니다.
제품이 미개봉 상태여야 합니다.

배송은 주문 후 2-3일 소요됩니다. 제주도는 추가 1일이 필요합니다.
"""

chunks = semantic_chunker.create_documents([mixed_topic_doc])

for i, chunk in enumerate(chunks):
    print(f"청크 {i}: {chunk.page_content[:50]}...")

설명

이것이 하는 일: 이 코드는 문서의 각 문장을 벡터로 변환하고, 인접 문장 쌍의 유사도를 계산하여 유사도가 급격히 떨어지는 지점(즉, 주제가 바뀌는 지점)을 찾아 청크 경계로 사용합니다. 결과적으로 각 청크는 하나의 일관된 주제만 포함하게 됩니다.

첫 번째 단계로, OpenAIEmbeddings를 초기화하여 text-embedding-ada-002 모델을 로드합니다. 이 모델은 문장을 1536차원 벡터로 변환하며, 의미적으로 유사한 문장은 벡터 공간에서 가까이 위치합니다.

이렇게 하는 이유는 "환불 가능합니다"와 "전액 환불됩니다"는 단어는 다르지만 의미가 유사하므로 같은 청크에 속해야 하기 때문입니다. 두 번째로, SemanticChunker를 설정합니다.

breakpoint_threshold_type="percentile"은 유사도 하락의 상대적 크기를 기준으로 경계를 판단한다는 의미입니다. breakpoint_threshold_amount=95는 "전체 문장 쌍 중 유사도 하락이 상위 5%에 해당하는 지점"을 경계로 사용합니다.

예를 들어, 100개의 문장 쌍이 있고 대부분 유사도가 0.8~0.9인데, 특정 지점에서 0.4로 급락한다면 그곳이 주제 전환점으로 감지됩니다. 세 번째 단계로, mixed_topic_doc를 분할하면 실제로 회사 소개, 환불 정책, 배송 정보라는 세 가지 주제가 자동으로 분리됩니다.

내부적으로는 각 문장을 임베딩하고, 문장 N과 N+1의 코사인 유사도를 계산하며, 유사도가 임계값 이하로 떨어지면 그 사이를 청크 경계로 설정합니다. "서울에 본사를 두고 있습니다" 다음에 "환불 정책은..."이 나올 때 유사도가 크게 떨어지므로 여기서 분할됩니다.

네 번째로, 결과를 확인하면 각 청크가 주제적으로 일관됨을 알 수 있습니다. 청크 0은 회사 정보만, 청크 1은 환불만, 청크 2는 배송만 포함합니다.

이는 사용자가 "환불 가능한가요?"라고 질문할 때, 배송 정보가 섞이지 않은 순수한 환불 정보만 검색되어 LLM이 정확한 답변을 생성할 수 있게 만듭니다. 여러분이 이 코드를 사용하면 긴 FAQ 문서나 백과사전 항목처럼 여러 주제가 섞인 콘텐츠를 훨씬 정확하게 검색할 수 있습니다.

고정 길이 청킹 대비 검색 정밀도(precision)가 향상되고, 사용자는 질문과 정확히 관련된 정보만 받게 되어 답변 품질이 크게 개선됩니다. 다만 모든 문장을 임베딩해야 하므로 처리 시간과 비용이 증가하는 트레이드오프가 있습니다.

실전 팁

💡 의미 기반 청킹은 문장 수가 많을수록 임베딩 비용이 증가하므로, 먼저 단락 단위로 1차 분할 후 적용하면 비용을 절감할 수 있습니다

💡 breakpoint_threshold_type을 "standard_deviation"으로 변경하면 통계적으로 더 엄격한 경계 탐지가 가능하지만, 청크 수가 줄어들 수 있습니다

💡 임베딩 모델은 문서 언어에 맞춰 선택하세요 - 한국어 문서는 multilingual 모델이나 한국어 특화 모델(예: KoSimCSE)을 사용하면 정확도가 향상됩니다

💡 분할 결과를 육안으로 검토하여 실제로 주제 경계가 잘 감지되는지 확인하고, breakpoint_threshold_amount를 90~99 사이에서 튜닝하세요

💡 실시간 처리가 필요하다면 문서를 사전에 청킹하여 캐싱하고, 쿼리 시에는 미리 분할된 청크만 사용하세요


6. 메타데이터 추출 및 필터링 - 검색 정확도를 높이는 컨텍스트 정보

시작하며

여러분이 RAG 시스템에서 "2023년 Q4 매출 보고서"를 찾고 싶은데, 검색 결과에 2022년이나 2024년 보고서가 섞여 나온 적 있나요? 또는 "Python 버전"에 대한 질문에 JavaScript 관련 문서가 반환되어 답변이 엉뚱해진 경험이 있을 겁니다.

이 문제는 순수한 의미 검색만으로는 해결할 수 없는 구조적 한계입니다. "매출 증가"라는 텍스트는 2023년이든 2024년이든 의미적으로 유사하므로, 임베딩 검색만으로는 연도를 구분할 수 없습니다.

마찬가지로 "버전 업그레이드"라는 표현은 언어에 관계없이 비슷하게 임베딩됩니다. 바로 이럴 때 필요한 것이 메타데이터 기반 필터링입니다.

각 청크에 날짜, 카테고리, 태그, 출처 같은 구조화된 정보를 메타데이터로 붙여서 임베딩 검색과 메타데이터 필터를 조합하면 훨씬 정확한 검색이 가능합니다.

개요

간단히 말해서, 메타데이터는 청크의 내용 외에 문서의 속성 정보(날짜, 저자, 카테고리, 버전 등)를 구조화된 형태로 저장하여, 검색 시 필터 조건으로 활용할 수 있게 하는 기능입니다. 실무에서는 대규모 문서 컬렉션을 다룰 때 필수적입니다.

예를 들어, 기업 내부 지식베이스에서 "마케팅 부서의 2023년 문서 중 승인된 것만" 검색하거나, 기술 문서에서 "Python 3.10 이상에서만 유효한 정보"를 찾을 때 메타데이터 필터가 없다면 불가능합니다. 기존의 순수 임베딩 검색이 "의미적 유사성"만 본다면, 메타데이터 필터링은 "의미 + 조건"을 동시에 만족하는 청크를 찾습니다.

마치 SQL의 WHERE 절처럼, 검색 공간을 사전에 좁혀서 정확도와 속도를 모두 향상시킵니다. 핵심 특징은 첫째, 벡터 검색과 메타데이터 필터를 동시에 적용 가능(Hybrid Search), 둘째, 날짜 범위, 카테고리, 태그 등 다양한 필터 타입 지원, 셋째, 벡터 DB 인덱싱 시 메타데이터도 함께 저장되어 검색 성능에 영향 없음입니다.

이러한 특징들이 중요한 이유는 실제 비즈니스 요구사항은 단순 의미 검색을 넘어 복잡한 조건을 요구하기 때문입니다.

코드 예제

from langchain.schema import Document
from datetime import datetime

# 메타데이터를 포함한 문서 생성
documents = [
    Document(
        page_content="Python 3.11에서 성능이 10-60% 향상되었습니다.",
        metadata={
            "source": "release_notes.md",
            "language": "Python",
            "version": "3.11",
            "date": "2023-10-02",
            "category": "performance",
            "approved": True
        }
    ),
    Document(
        page_content="JavaScript ES2023에 새로운 Array 메서드가 추가되었습니다.",
        metadata={
            "source": "js_updates.md",
            "language": "JavaScript",
            "version": "ES2023",
            "date": "2023-06-15",
            "category": "features",
            "approved": True
        }
    )
]

# 벡터 DB에 저장 시 메타데이터도 함께 저장됨
# 검색 시 필터 조건 사용 예시 (Pinecone, Chroma 등에서 지원)
# results = vectorstore.similarity_search(
#     "성능 향상",
#     filter={"language": "Python", "date": {"$gte": "2023-01-01"}}
# )

설명

이것이 하는 일: 이 코드는 텍스트 내용(page_content) 외에 문서의 속성 정보를 메타데이터 딕셔너리로 저장하여, 나중에 벡터 DB에서 검색할 때 "의미적으로 유사하면서 특정 조건을 만족하는" 청크만 찾을 수 있게 합니다. 첫 번째 단계로, Document 객체를 생성할 때 page_content와 metadata를 모두 지정합니다.

metadata는 파이썬 딕셔너리 형태로, 키-값 쌍을 자유롭게 정의할 수 있습니다. 예를 들어 "source"는 파일명, "language"는 프로그래밍 언어, "version"은 버전, "date"는 작성일, "category"는 문서 분류, "approved"는 승인 여부를 나타냅니다.

이렇게 하는 이유는 검색 시 이 필드들을 조건으로 사용하여 관련성 높은 결과만 필터링하기 위함입니다. 두 번째로, 이 Document들을 벡터 DB(Pinecone, Chroma, Weaviate 등)에 저장하면 page_content는 임베딩되어 벡터 인덱스에 저장되고, metadata는 별도로 키-값 저장소에 저장됩니다.

대부분의 벡터 DB는 벡터 검색과 메타데이터 필터를 동시에 지원하므로, 검색 성능 저하 없이 조건을 적용할 수 있습니다. 세 번째 단계는 실제 검색 시입니다(주석 처리된 코드).

similarity_search()를 호출할 때 filter 파라미터로 조건을 지정하면, 먼저 메타데이터 필터를 적용하여 후보 청크를 좁힌 후, 그 안에서 임베딩 유사도를 계산합니다. 예를 들어 filter={"language": "Python", "date": {"$gte": "2023-01-01"}}는 "Python 언어이면서 2023년 이후 문서만" 검색하라는 의미입니다.

"$gte"는 "greater than or equal"의 약자로, MongoDB 스타일의 쿼리 문법을 사용합니다. 네 번째로, 사용자가 "성능 향상"이라고 질문하면 임베딩 검색으로는 Python과 JavaScript 문서 모두 매칭될 수 있지만, 메타데이터 필터로 "language": "Python"을 지정하면 Python 문서만 반환됩니다.

이는 LLM이 혼동 없이 정확한 답변을 생성하도록 돕습니다. 여러분이 이 코드를 사용하면 "지난달 승인된 마케팅 문서만", "버전 2.0 이상의 API 문서만" 같은 복잡한 검색 요구사항을 쉽게 구현할 수 있습니다.

또한 메타데이터에 문서 출처를 저장하면 LLM 답변 뒤에 "출처: release_notes.md, 2023-10-02"처럼 레퍼런스를 자동으로 추가하여 신뢰성을 높일 수 있습니다. 실무에서는 사용자 권한, 보안 레벨 같은 정보도 메타데이터로 저장하여 접근 제어를 구현하기도 합니다.

실전 팁

💡 메타데이터 필드명은 벡터 DB마다 예약어가 다르므로(예: Pinecone은 'id' 사용 불가), 공식 문서를 확인하고 일관된 네이밍 컨벤션을 사용하세요

💡 날짜 필터를 자주 사용한다면 ISO 8601 형식("YYYY-MM-DD")으로 저장하면 문자열 비교로도 범위 검색이 가능합니다

💡 메타데이터 크기가 너무 크면 벡터 DB 비용이 증가하므로, 꼭 필요한 필드만 저장하고 나머지는 외부 DB에서 조인하세요

💡 카테고리나 태그처럼 자주 필터링되는 필드는 벡터 DB에 인덱스를 생성하여 검색 속도를 최적화하세요

💡 메타데이터 추출을 자동화하려면 LLM을 사용하여 문서에서 날짜, 저자, 주제를 자동으로 파싱하는 파이프라인을 구축할 수 있습니다


7. 문서 구조 인식 청킹 - 제목과 섹션을 고려한 계층적 분할

시작하며

여러분이 기술 문서를 청킹했을 때, 제목 없이 본문만 저장되어 "이 설명이 어떤 기능에 대한 것인지" 알 수 없었던 적 있나요? 또는 "5.2.3 항목의 내용"을 찾고 싶은데, 섹션 번호가 사라져서 검색이 불가능했던 경험이 있을 겁니다.

이 문제는 문서의 계층 구조를 무시하고 평면적으로 분할할 때 발생합니다. 마크다운의 헤더(# ## ###), HTML의 제목 태그(<h1> <h2>), PDF의 목차 구조는 모두 중요한 컨텍스트 정보인데, 단순 텍스트 분할은 이를 버립니다.

결과적으로 사용자는 "어떤 챕터의 내용인지" 알 수 없는 단편적인 정보만 받게 됩니다. 바로 이럴 때 필요한 것이 문서 구조 인식 청킹입니다.

제목 계층을 파싱하여 각 청크에 "이 내용은 '3장 > 3.2절 > 성능 최적화' 섹션에 속한다"는 정보를 메타데이터로 추가합니다.

개요

간단히 말해서, 문서 구조 인식 청킹은 마크다운, HTML, PDF의 제목 계층(헤더)을 분석하여 각 청크가 어떤 섹션에 속하는지 추적하고, 상위 제목들을 메타데이터로 저장하는 기법입니다. 실무에서는 API 레퍼런스, 제품 매뉴얼, 교육 자료처럼 계층적으로 구성된 문서에서 필수적입니다.

예를 들어, Django 문서에서 "Models > QuerySets > filter() 메서드" 섹션의 내용을 청킹할 때, 청크에 "Models"와 "QuerySets"라는 상위 컨텍스트가 포함되지 않으면 "filter() 메서드"만으로는 무엇을 필터링하는지 알 수 없습니다. 기존의 평면적 청킹이 텍스트를 일차원적으로 자른다면, 구조 인식 청킹은 문서의 트리 구조를 반영하여 각 청크에 "부모 섹션" 정보를 삽입합니다.

마치 파일 시스템에서 파일이 어떤 디렉토리에 속하는지 경로를 기록하는 것과 같습니다. 핵심 특징은 첫째, 제목 계층을 자동으로 파싱하여 메타데이터화, 둘째, 각 청크에 상위 섹션 제목을 프리펜드하여 컨텍스트 보강, 셋째, 섹션별 검색이나 특정 챕터 필터링이 가능하다는 점입니다.

이러한 특징들이 중요한 이유는 문서의 구조가 곧 의미의 계층이기 때문입니다.

코드 예제

from langchain.text_splitter import MarkdownHeaderTextSplitter

# 마크다운 헤더 기반 분할기 - 제목 계층을 메타데이터로 저장
headers_to_split_on = [
    ("#", "Header1"),      # H1 제목
    ("##", "Header2"),     # H2 제목
    ("###", "Header3"),    # H3 제목
]

markdown_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on,
    strip_headers=False  # 청크 내용에 제목도 포함
)

# 계층적 마크다운 문서
markdown_doc = """
# RAG 시스템 가이드

## 1. 문서 전처리

### 1.1 텍스트 추출
PDF와 Word 파일에서 텍스트를 추출합니다.

### 1.2 정규화
특수문자를 제거하고 소문자로 변환합니다.

## 2. 청킹 전략

### 2.1 고정 길이 청킹
1000자 단위로 분할합니다.
"""

chunks = markdown_splitter.split_text(markdown_doc)

for chunk in chunks:
    print(f"메타데이터: {chunk.metadata}")
    print(f"내용: {chunk.page_content[:50]}...\n")

설명

이것이 하는 일: 이 코드는 마크다운 문서의 헤더 구조(#, ##, ###)를 분석하여 각 섹션을 별도 청크로 분할하고, 상위 제목들을 메타데이터로 자동 추가합니다. 결과적으로 각 청크는 "나는 어떤 챕터의 어떤 절에 속한다"는 정보를 가지게 됩니다.

첫 번째 단계로, headers_to_split_on 리스트를 정의합니다. 이는 [("헤더 마커", "메타데이터 키")] 형태로, "#"는 H1, "##"는 H2, "###"는 H3를 의미합니다.

예를 들어 ("##", "Header2")는 "## 1. 문서 전처리" 같은 H2 헤더를 만나면 여기서 청크를 나누고, "1.

문서 전처리"를 "Header2" 메타데이터로 저장하라는 의미입니다. 이렇게 하는 이유는 계층 구조를 메타데이터에 보존하여 나중에 검색이나 필터링에 활용하기 위함입니다.

두 번째로, MarkdownHeaderTextSplitter를 초기화할 때 strip_headers=False를 지정합니다. 이는 청크의 page_content에도 제목을 포함시킨다는 의미로, 임베딩 시 제목 텍스트도 함께 인코딩되어 검색 정확도를 높입니다.

예를 들어 "### 1.1 텍스트 추출"이라는 제목 아래 "PDF와 Word 파일에서..."라는 내용이 있다면, 청크는 "### 1.1 텍스트 추출\nPDF와 Word 파일에서..."를 포함합니다. 세 번째 단계로, split_text()를 호출하면 내부적으로 정규식으로 헤더를 감지하며 문서를 순회합니다.

"# RAG 시스템 가이드"를 만나면 Header1="RAG 시스템 가이드"로 저장하고, "## 1. 문서 전처리"를 만나면 Header2="1.

문서 전처리"를 추가하며, "### 1.1 텍스트 추출" 아래 내용은 {"Header1": "RAG 시스템 가이드", "Header2": "1. 문서 전처리", "Header3": "1.1 텍스트 추출"} 메타데이터를 가진 청크가 됩니다.

네 번째로, 결과를 확인하면 각 청크의 metadata에 계층 정보가 담겨있음을 볼 수 있습니다. 예를 들어 "PDF와 Word 파일에서..." 청크는 메타데이터로 상위 3단계 제목을 모두 포함하므로, 사용자가 "RAG 가이드의 전처리 섹션에서 텍스트 추출 방법"을 물어보면 이 청크가 정확히 매칭됩니다.

여러분이 이 코드를 사용하면 사용자에게 답변할 때 "이 정보는 '1. 문서 전처리 > 1.1 텍스트 추출' 섹션에서 가져왔습니다"처럼 정확한 출처를 제공할 수 있습니다.

또한 "2장만 검색", "성능 최적화 섹션만 검색" 같은 섹션 기반 필터링이 가능해져서, 큰 문서에서 특정 부분만 집중적으로 검색할 수 있습니다. 마크다운 기술 문서, 블로그 포스트, README 파일 등 헤더 구조가 명확한 콘텐츠에 최적화되어 있습니다.

실전 팁

💡 HTML 문서는 HTMLHeaderTextSplitter를 사용하면 <h1>, <h2>, <h3> 태그를 자동으로 파싱합니다

💡 strip_headers=True로 설정하면 메타데이터에만 제목을 저장하고 본문에서는 제거하여 중복을 줄일 수 있지만, 임베딩 품질은 다소 떨어질 수 있습니다

💡 제목이 너무 길면 메타데이터 크기가 커지므로, 제목을 50자 이내로 요약하는 후처리를 추가하세요

💡 PDF 문서는 헤더 감지가 어려우므로, PyMuPDF의 get_toc() 메서드로 목차를 추출하여 수동으로 메타데이터를 추가하는 방식을 고려하세요

💡 섹션별로 다른 청킹 전략을 적용하고 싶다면(예: 코드 섹션은 의미 기반, 설명 섹션은 고정 길이), 헤더로 먼저 분할한 후 각 청크에 적절한 splitter를 재귀적으로 적용하세요


8. 청크 크기 최적화 실험 - 성능 지표로 최적값 찾기

시작하며

여러분이 RAG 시스템을 배포했는데 사용자들이 "답변이 부정확하다", "필요한 정보가 빠졌다"고 불만을 제기한 적 있나요? 청크 크기를 500자로 했을 때는 문맥이 부족하고, 2000자로 했을 때는 관련 없는 정보가 너무 많아서 어느 쪽도 만족스럽지 않은 딜레마를 경험했을 겁니다.

이 문제는 "적절한 청크 크기"가 문서 타입, 사용자 질문 패턴, 사용하는 LLM 모델에 따라 다르기 때문에 발생합니다. FAQ는 작은 청크가, 기술 튜토리얼은 큰 청크가 유리하며, GPT-3.5는 짧은 컨텍스트를, GPT-4는 긴 컨텍스트를 더 잘 처리합니다.

감으로 정한 값은 거의 항상 최적이 아닙니다. 바로 이럴 때 필요한 것이 청크 크기 최적화 실험입니다.

여러 크기로 청킹한 후 검색 정확도(Precision, Recall)와 답변 품질을 정량적으로 측정하여, 데이터 기반으로 최적 크기를 결정합니다.

개요

간단히 말해서, 청크 크기 최적화는 다양한 chunk_size 값(예: 500, 1000, 1500, 2000)으로 실험하며 검색 성능 지표(MRR, NDCG 등)와 답변 품질 점수를 측정하여 최적값을 찾는 체계적인 프로세스입니다. 실무에서는 RAG 시스템을 프로덕션에 배포하기 전 필수적인 단계입니다.

예를 들어, 고객 지원 챗봇을 구축한다면 실제 고객 질문 100개를 샘플로 준비하고, 각 청크 크기에서 검색된 문서가 정답을 포함하는 비율(Recall@K)을 측정합니다. 크기를 늘리면 Recall은 올라가지만 Precision이 떨어지므로, 균형점을 찾아야 합니다.

기존의 "일단 1000자로 해보고 문제 생기면 조정"하는 방식은 비과학적이고 위험합니다. 대신 A/B 테스트처럼 여러 설정을 동시에 실험하고 객관적 지표로 비교하여, 재현 가능하고 설명 가능한 의사결정을 합니다.

핵심 특징은 첫째, 테스트 쿼리 셋과 정답 레이블을 준비하여 자동 평가 가능, 둘째, Precision, Recall, F1, MRR 등 표준 IR 지표 사용, 셋째, 청크 크기 외에도 오버랩, 분할 전략을 함께 실험하여 상호작용 효과를 파악합니다. 이러한 특징들이 중요한 이유는 RAG 성능이 청킹 전략에 의해 크게 좌우되기 때문입니다.

코드 예제

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
import numpy as np

# 여러 청크 크기로 실험
chunk_sizes = [500, 1000, 1500, 2000]
test_queries = ["문서 전처리 방법", "청킹 전략 비교", "임베딩 모델 선택"]
# ground_truth: 각 쿼리의 정답 문서 ID 리스트 (사전 레이블링)

results = {}
for size in chunk_sizes:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=size,
        chunk_overlap=int(size * 0.2)  # 크기의 20% 오버랩
    )
    chunks = splitter.create_documents([long_document])

    # 벡터 DB 생성 및 검색 테스트
    vectorstore = Chroma.from_documents(chunks, OpenAIEmbeddings())

    recalls = []
    for query in test_queries:
        retrieved = vectorstore.similarity_search(query, k=3)
        # Recall@3 계산: 상위 3개에 정답이 포함된 비율
        # recall = calculate_recall(retrieved, ground_truth[query])
        # recalls.append(recall)

    results[size] = {"avg_recall": np.mean(recalls)}
    print(f"청크 크기 {size}: 평균 Recall = {results[size]['avg_recall']:.2f}")

# 최적 크기 선택
best_size = max(results, key=lambda x: results[x]['avg_recall'])
print(f"\n최적 청크 크기: {best_size}자")

설명

이것이 하는 일: 이 코드는 동일한 문서를 여러 청크 크기로 분할하고, 각 설정에서 벡터 DB를 구축한 후 테스트 쿼리로 검색 품질을 측정하여 어떤 크기가 가장 좋은 성능을 내는지 정량적으로 비교합니다. 첫 번째 단계로, chunk_sizes 리스트에 실험할 크기들을 정의하고, test_queries에는 실제 사용자가 물어볼 법한 질문들을 준비합니다.

이상적으로는 실제 프로덕션 로그에서 샘플링한 질문 100~500개를 사용하면 더 정확한 결과를 얻을 수 있습니다. ground_truth는 각 쿼리에 대해 "어떤 문서가 정답인지" 사람이 레이블링한 데이터인데, 이것이 있어야 자동으로 정확도를 계산할 수 있습니다.

두 번째로, 각 chunk_size에 대해 반복문을 실행하며 RecursiveCharacterTextSplitter를 생성합니다. 주목할 점은 chunk_overlap을 크기의 20%로 동적으로 설정하여, 모든 실험에서 오버랩 비율을 일정하게 유지한다는 것입니다.

이렇게 하는 이유는 "청크 크기만의 효과"를 순수하게 측정하기 위함입니다(통제 변인). 만약 크기를 키울 때 오버랩을 고정하면 상대적 오버랩 비율이 줄어들어 결과가 왜곡됩니다.

세 번째 단계로, 각 크기로 분할한 청크들로 별도의 벡터 DB를 생성합니다. Chroma.from_documents()는 청크들을 임베딩하고 인덱싱하는데, 크기가 클수록 청크 개수가 적으므로 임베딩 시간과 저장 공간이 줄어듭니다.

이것도 중요한 트레이드오프입니다 - 작은 청크는 정확하지만 비용이 높고, 큰 청크는 저렴하지만 노이즈가 많습니다. 네 번째로, 각 테스트 쿼리에 대해 similarity_search(k=3)를 실행하여 상위 3개 청크를 검색하고, 이 중 ground_truth에 있는 정답 문서가 포함되었는지 확인합니다.

Recall@3는 "상위 3개에 정답이 하나라도 있으면 1, 없으면 0"으로 계산되며, 모든 쿼리의 평균을 내면 해당 청크 크기의 검색 품질을 나타내는 점수가 됩니다. 마지막으로, 모든 크기에 대한 실험이 끝나면 results 딕셔너리에서 avg_recall이 가장 높은 크기를 선택합니다.

예를 들어 1000자일 때 0.85, 1500자일 때 0.92, 2000자일 때 0.90이라면 1500자가 최적입니다. 여러분이 이 코드를 사용하면 "왜 이 크기를 선택했는가?"라는 질문에 데이터로 답할 수 있으며, 문서 타입이 바뀌거나 LLM을 업그레이드할 때마다 재실험하여 설정을 업데이트할 수 있습니다.

또한 청크 크기 외에 분할 전략(문자/토큰/의미), 임베딩 모델, 검색 알고리즘(유사도/MMR/하이브리드)도 함께 실험하여 종합적인 최적화가 가능합니다. 실무에서는 Precision과 Recall의 조화평균인 F1 스코어나, 순위를 고려한 MRR, NDCG 같은 고급 지표도 함께 사용합니다.

실전 팁

💡 ground_truth 레이블링은 시간이 많이 걸리므로, 처음에는 20~50개 쿼리로 시작하여 경향을 파악하고 점진적으로 확대하세요

💡 청크 크기 외에 chunk_overlap도 [10%, 20%, 30%]로 변화시켜 2차원 그리드 서치를 수행하면 더 정밀한 최적화가 가능합니다

💡 실험 결과를 pandas DataFrame으로 저장하고 seaborn으로 시각화하면 크기-성능 관계를 직관적으로 파악할 수 있습니다

💡 A/B 테스트 프레임워크를 도입하여 실제 프로덕션 트래픽의 일부를 새로운 청크 크기로 라우팅하고 사용자 만족도를 측정하세요

💡 LLM의 답변 품질도 함께 평가하려면 GPT-4를 심사위원으로 사용하여 답변의 정확성, 완전성, 간결성을 1~5점으로 자동 채점하는 LLM-as-a-judge 기법을 활용하세요


9. 청크 전후 문맥 확장 - 검색 후 주변 청크 병합

시작하며

여러분이 RAG 시스템에서 정확한 청크를 검색했는데, 그 청크만으로는 완전한 설명이 안 되어 LLM이 "정보가 불충분합니다"라고 답한 적 있나요? 예를 들어, "3단계: 데이터를 저장합니다"라는 청크를 검색했는데, 1단계와 2단계가 없어서 전체 프로세스를 이해할 수 없는 상황입니다.

이 문제는 청킹 시 의미 단위로 분할했더라도, 일부 정보는 전후 청크와 연결되어야만 완전한 이해가 가능하기 때문에 발생합니다. 특히 튜토리얼, 단계별 가이드, 연속적인 설명에서는 한 청크만으로는 문맥이 부족합니다.

바로 이럴 때 필요한 것이 청크 전후 문맥 확장입니다. 검색된 청크의 메타데이터에서 문서 ID와 청크 위치를 확인하고, 바로 앞뒤 청크를 추가로 가져와 병합하여 LLM에 전달합니다.

개요

간단히 말해서, 청크 전후 문맥 확장은 벡터 검색으로 찾은 청크만 사용하는 것이 아니라, 같은 문서의 이전/다음 청크를 추가로 가져와 합쳐서 더 완전한 컨텍스트를 LLM에 제공하는 기법입니다. 실무에서는 연속성이 중요한 콘텐츠에서 필수적입니다.

예를 들어, "Django 앱 배포 가이드"에서 3단계가 검색되었다면 1, 2단계도 함께 제공해야 사용자가 전체 흐름을 이해할 수 있습니다. 또는 코드 예제를 검색했는데 그 위에 중요한 import 문이나 설정이 있다면, 이전 청크를 포함해야 완전한 코드가 됩니다.

기존의 단순 검색이 "가장 유사한 청크 K개"만 반환한다면, 문맥 확장은 "가장 유사한 청크 + 그 주변 N개"를 반환하여 정보의 연속성을 보장합니다. 마치 책에서 특정 페이지를 찾았을 때 앞뒤 페이지도 함께 읽는 것과 같습니다.

핵심 특징은 첫째, 청크에 문서 ID와 위치 인덱스를 메타데이터로 저장 필요, 둘째, 검색 후 후처리 단계에서 주변 청크를 DB에서 추가 조회, 셋째, 토큰 제한 내에서 확장 범위를 동적으로 조정할 수 있다는 점입니다. 이러한 특징들이 중요한 이유는 RAG의 품질이 "정확한 청크 검색"뿐 아니라 "충분한 컨텍스트 제공"에도 달려있기 때문입니다.

코드 예제

from langchain.schema import Document

# 청킹 시 문서 ID와 청크 인덱스를 메타데이터에 저장
def create_chunks_with_context_info(documents, splitter):
    all_chunks = []
    for doc_id, doc in enumerate(documents):
        chunks = splitter.create_documents([doc.page_content])
        for chunk_idx, chunk in enumerate(chunks):
            chunk.metadata.update({
                "doc_id": doc_id,
                "chunk_index": chunk_idx,
                "total_chunks": len(chunks),
                "source": doc.metadata.get("source", "unknown")
            })
            all_chunks.append(chunk)
    return all_chunks

# 검색 후 주변 청크 병합
def expand_context(retrieved_chunks, all_chunks_dict, context_window=1):
    expanded = []
    for chunk in retrieved_chunks:
        doc_id = chunk.metadata["doc_id"]
        chunk_idx = chunk.metadata["chunk_index"]

        # 이전 청크 추가
        for i in range(chunk_idx - context_window, chunk_idx):
            if i >= 0:
                key = f"{doc_id}_{i}"
                if key in all_chunks_dict:
                    expanded.append(all_chunks_dict[key])

        # 현재 청크
        expanded.append(chunk)

        # 다음 청크 추가
        total = chunk.metadata["total_chunks"]
        for i in range(chunk_idx + 1, min(chunk_idx + context_window + 1, total)):
            key = f"{doc_id}_{i}"
            if key in all_chunks_dict:
                expanded.append(all_chunks_dict[key])

    return expanded

설명

이것이 하는 일: 이 코드는 청킹 단계에서 각 청크의 위치 정보를 메타데이터에 저장하고, 검색 후 해당 위치 정보를 사용하여 주변 청크를 추가로 조회하여 병합함으로써, LLM이 더 완전한 문맥을 받을 수 있도록 합니다. 첫 번째 단계로, create_chunks_with_context_info() 함수는 문서를 청킹할 때 각 청크에 doc_id(문서 식별자), chunk_index(문서 내 몇 번째 청크인지), total_chunks(해당 문서의 전체 청크 수)를 메타데이터로 추가합니다.

예를 들어 문서 A를 5개 청크로 분할하면, 3번째 청크는 {"doc_id": 0, "chunk_index": 2, "total_chunks": 5}를 가집니다. 이렇게 하는 이유는 나중에 "2번 청크의 앞뒤를 찾아라"는 명령을 실행하기 위함입니다.

두 번째로, all_chunks_dict는 모든 청크를 "doc_id_chunk_index" 키로 저장한 딕셔너리입니다. 예를 들어 "0_2"는 문서 0의 2번 청크를 의미합니다.

이는 O(1) 시간에 특정 위치의 청크를 조회하기 위한 자료구조입니다. 실무에서는 Redis나 벡터 DB 자체에 이런 인덱스를 저장합니다.

세 번째 단계로, expand_context() 함수는 검색된 청크들을 순회하며 각 청크의 앞뒤 context_window개 청크를 추가합니다. context_window=1이면 "이전 1개 + 현재 + 다음 1개" 총 3개를 반환하고, context_window=2면 총 5개를 반환합니다.

range(chunk_idx - context_window, chunk_idx)로 이전 청크를 찾고, range(chunk_idx + 1, min(...))로 다음 청크를 찾되, 문서 시작(i >= 0)과 끝(i < total_chunks)을 넘지 않도록 경계 조건을 체크합니다. 네 번째로, 실제 사용 시나리오를 보면 사용자가 "3단계가 뭔가요?"라고 물었을 때 벡터 검색이 "3단계: 데이터 저장" 청크를 찾으면, expand_context()가 "2단계: 데이터 검증"과 "4단계: 결과 반환"도 함께 가져와 LLM에 전달합니다.

그러면 LLM은 전체 흐름(2→3→4)을 보고 "3단계는 2단계의 검증된 데이터를 저장하는 과정입니다"처럼 문맥에 맞는 답변을 생성합니다. 여러분이 이 코드를 사용하면 튜토리얼, 단계별 가이드, 연속적인 설명에서 답변 품질이 크게 향상됩니다.

특히 "어떻게 하나요?" 같은 절차 질문에서 효과적입니다. 다만 context_window를 너무 크게 설정하면 토큰 제한을 초과하거나 관련 없는 정보가 섞일 수 있으므로, 보통 1~2 정도가 적절합니다.

또한 여러 청크가 검색되면 각각의 주변을 확장하므로 중복이 발생할 수 있는데, 이는 중복 제거 로직으로 해결할 수 있습니다.

실전 팁

💡 context_window는 고정값 대신, 검색된 청크의 크기와 LLM의 남은 토큰 수를 계산하여 동적으로 조정하면 효율적입니다

💡 주변 청크를 추가할 때 원래 검색된 청크와 구분하기 위해 "[CONTEXT]" 같은 마커를 삽입하면 LLM이 우선순위를 판단하는 데 도움이 됩니다

💡 여러 검색 결과가 같은 문서에서 나왔다면 확장 범위가 겹칠 수 있으므로, 병합 전에 중복 제거와 정렬을 수행하세요

💡 벡터 DB에 chunk_index를 메타데이터로 저장할 때 정수형으로 저장하면 range query가 가능하여 "chunk_index BETWEEN 5 AND 8" 같은 효율적인 조회가 가능합니다

💡 Parent Document Retriever 패턴을 사용하면 작은 청크로 검색하되 큰 부모 청크를 반환하여 문맥과 정확도를 동시에 확보할 수 있습니다


10. 하이브리드 청킹 전략 - 문서 타입별 맞춤형 접근

시작하며

여러분이 하나의 RAG 시스템에서 코드 파일, 마크다운 문서, PDF 보고서, JSON 데이터를 모두 처리해야 할 때, 모든 파일을 동일한 방식으로 청킹하면 어느 하나도 제대로 처리되지 않는 문제를 겪어본 적 있나요? 코드는 함수 단위로 분할해야 의미가 있고, 보고서는 섹션 단위가 적절하며, JSON은 객체 단위로 나눠야 하는데, 한 가지 전략만으로는 불가능합니다.

이 문제는 "One size fits all" 접근이 실제 다양한 데이터에서는 작동하지 않기 때문입니다. 각 문서 타입은 고유한 구조와 의미 단위를 가지고 있으며, 이를 무시하고 획일적으로 처리하면 정보가 파괴됩니다.

바로 이럴 때 필요한 것이 하이브리드 청킹 전략입니다. 문서의 확장자나 메타데이터를 보고 타입을 판별한 후, 각 타입에 최적화된 분할 전략을 선택적으로 적용합니다.

개요

간단히 말해서, 하이브리드 청킹은 문서 타입(코드, 마크다운, PDF, HTML 등)을 자동으로 감지하여 각각에 맞는 최적의 분할 전략을 적용하는 지능형 라우팅 시스템입니다. 실무에서는 다양한 소스의 데이터를 통합하는 엔터프라이즈 RAG에서 필수적입니다.

예를 들어, 개발자 문서 검색 시스템이라면 .py 파일은 함수/클래스 단위로, .md 파일은 헤더 단위로, .pdf 매뉴얼은 페이지 단위로, API 응답 JSON은 엔드포인트 단위로 분할해야 각각의 특성을 살릴 수 있습니다. 기존의 단일 전략 청킹이 "모든 문서를 1000자로 자른다"는 단순한 규칙이라면, 하이브리드 청킹은 "파이썬 파일이면 AST 기반 분할, 마크다운이면 헤더 기반 분할, 그 외는 재귀적 분할"처럼 조건부 로직을 사용합니다.

핵심 특징은 첫째, 파일 확장자, MIME 타입, 내용 패턴으로 자동 타입 감지, 둘째, 타입별 전용 splitter 매핑 테이블 유지, 셋째, 폴백(fallback) 전략으로 알 수 없는 타입도 처리 가능하다는 점입니다. 이러한 특징들이 중요한 이유는 실제 프로덕션 환경에서는 다양한 포맷이 혼재하기 때문입니다.

코드 예제

from langchain.text_splitter import (
    RecursiveCharacterTextSplitter,
    MarkdownHeaderTextSplitter,
    Language
)
from pathlib import Path

class HybridChunker:
    def __init__(self):
        # 타입별 splitter 매핑
        self.splitters = {
            "markdown": MarkdownHeaderTextSplitter(
                headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
            ),
            "python": RecursiveCharacterTextSplitter.from_language(
                language=Language.PYTHON,
                chunk_size=1000,
                chunk_overlap=200
            ),
            "javascript": RecursiveCharacterTextSplitter.from_language(
                language=Language.JS,
                chunk_size=1000,
                chunk_overlap=200
            ),
            "default": RecursiveCharacterTextSplitter(
                chunk_size=1000,
                chunk_overlap=200
            )
        }

    def detect_type(self, file_path: str) -> str:
        """파일 확장자로 타입 감지"""
        suffix = Path(file_path).suffix.lower()
        type_map = {
            ".md": "markdown",
            ".py": "python",
            ".js": "javascript",
            ".ts": "javascript",  # TypeScript도 JS 전략 사용
        }
        return type_map.get(suffix, "default")

    def chunk_document(self, file_path: str, content: str):
        """문서 타입에 맞는 청킹 수행"""
        doc_type = self.detect_type(file_path)
        splitter = self.splitters[doc_type]

        chunks = splitter.create_documents([content])

        # 타입 정보를 메타데이터에 추가
        for chunk in chunks:
            chunk.metadata["doc_type"] = doc_type
            chunk.metadata["source"] = file_path

        return chunks

# 사용 예시
chunker = HybridChunker()
md_chunks = chunker.chunk_document("guide.md", "# Title\n...")
py_chunks = chunker.chunk_document("script.py", "def foo():\n...")

설명

이것이 하는 일: 이 코드는 입력 파일의 확장자를 분석하여 문서 타입을 판별하고, 타입에 맞는 전용 splitter를 선택하여 청킹함으로써 각 포맷의 구조를 최대한 보존합니다. 결과적으로 코드는 함수 경계에서, 마크다운은 섹션 경계에서 분할됩니다.

첫 번째 단계로, HybridChunker 클래스의 __init__에서 타입별 splitter를 미리 생성하여 self.splitters 딕셔너리에 저장합니다. "markdown"은 MarkdownHeaderTextSplitter를 사용하여 헤더 기반 분할, "python"과 "javascript"는 RecursiveCharacterTextSplitter.from_language()를 사용하여 언어별 구분자(함수 정의, 클래스 정의 등)로 분할, "default"는 일반적인 재귀적 분할을 수행합니다.

이렇게 하는 이유는 각 언어와 포맷이 서로 다른 구조적 특성을 가지고 있기 때문입니다. 두 번째로, detect_type() 메서드는 Path(file_path).suffix로 확장자를 추출하고, type_map 딕셔너리에서 해당하는 타입을 반환합니다.

예를 들어 "docs/api.md"라면 ".md"를 추출하여 "markdown"을 반환합니다. 확장자가 매핑에 없으면 "default"를 반환하여 폴백 전략을 사용합니다.

실무에서는 여기에 내용 기반 감지(예: 첫 줄에 "#!/usr/bin/python"이 있으면 Python)를 추가할 수도 있습니다. 세 번째 단계로, chunk_document() 메서드는 detect_type()으로 타입을 감지한 후 self.splitters에서 적절한 splitter를 가져옵니다.

그리고 그 splitter의 create_documents()를 호출하여 청킹을 수행합니다. 중요한 점은 모든 splitter가 동일한 인터페이스(create_documents)를 제공하므로, 타입에 관계없이 통일된 방식으로 호출할 수 있다는 것입니다.

이는 Strategy 패턴의 좋은 예입니다. 네 번째로, 청킹 후 각 chunk의 메타데이터에 doc_type과 source를 추가합니다.

이렇게 하면 나중에 검색 시 "Python 코드만 검색" 같은 필터링이 가능하고, 사용자에게 "이 정보는 script.py에서 가져왔습니다"라고 출처를 알려줄 수 있습니다. 다섯 번째로, 실제 사용 예시를 보면 guide.md와 script.py를 각각 다른 전략으로 청킹합니다.

guide.md는 헤더 구조가 보존되어 섹션별로 분할되고, script.py는 함수나 클래스 경계에서 분할되어 코드의 의미 단위가 유지됩니다. 여러분이 이 코드를 사용하면 다양한 포맷이 혼재된 프로젝트(예: 개발 문서 저장소)에서 각 파일의 특성을 살려 청킹할 수 있으며, 새로운 타입이 추가되어도 self.splitters에 매핑만 추가하면 되므로 확장성이 뛰어납니다.

또한 타입별로 최적화된 전략을 사용하므로 검색 정확도와 답변 품질이 단일 전략 대비 크게 향상됩니다. 실무에서는 JSON, XML, CSV 같은 구조화된 데이터도 추가하여 더욱 포괄적인 시스템을 구축할 수 있습니다.

실전 팁

💡 언어별 splitter는 LangChain이 지원하는 Language enum에 Python, JS, Go, Rust, Java 등 20개 이상의 언어가 있으므로 필요에 따라 추가하세요

💡 MIME 타입 감지를 추가하면 확장자가 없는 파일도 처리할 수 있습니다 - python-magic 라이브러리를 사용하세요

💡 타입별로 다른 chunk_size를 사용하는 것도 고려하세요 - 코드는 작게(500), 문서는 크게(2000) 설정하면 각각의 특성에 맞습니다

💡 Git 저장소를 통째로 처리할 때는 .gitignore 패턴을 존중하여 node_modules, build 디렉토리 같은 불필요한 파일을 제외하세요

💡 타입 감지에 실패했을 때를 대비해 로깅을 추가하고, 주기적으로 로그를 분석하여 자주 등장하는 미지원 타입을 발견하면 매핑에 추가하세요


#RAG#문서전처리#청킹전략#LangChain#VectorDB#AI

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

카테고리: AI

언어: Python

태그: RAG, 문서전처리, 청킹전략, LangChain, VectorDB, AI

작성자: AI Generated

프리미엄 콘텐츠 - 3개월 무료 체험 가능