RAG에서의 Chunking 전략 비교: 대안, 트레이드오프 및 예시

RAG에서의 청크링 전략 비교

Page content

Chunking은 Retrieval ‑ Augmented Generation (RAG)에서 가장 과소평가되는 하이퍼파라미터입니다: 이는 조용히 LLM이 “보는” 내용을 결정하며, 데이터의 인gestion 비용을 얼마나 많이 증가시키는지, 그리고 각 답변당 LLM의 컨텍스트 창을 얼마나 많이 소모하는지를 결정합니다.

이 기사는 chunking을 엔지니어링 최적화 문제로 다룹니다: 목표를 정의하고, 전략을 선택한 후 측정하고 반복합니다.

RAG 아키텍처에 처음 접하는 경우, 먼저 Retrieval-Augmented Generation (RAG) Tutorial: Architecture, Implementation, and Production Guide에서 시작하세요.

테이블 위의 색상 테트리스

TL;DR (집행 요약)

RAG 시스템은 조각을 검색하고 문서는 아닙니다. 따라서 chunking은 검색 단위, 임베딩 비용 단위, 그리고 보여줄 수 있는 또는 인용할 수 있는 증거의 단위를 정의합니다. 원래 RAG의 구성에서 검색은 생성에 필요한 문장을 제공하며, 문장 경계는 사실상 chunk 경계입니다.

좋은 chunking 전략은 다음 사이에서 파레토 최적 경계를 찾습니다: 검색 품질(증거의 회수/정확도), 일관성(조각이 해석 가능해야 함), 비용(임베딩, 저장, 쿼리 지연). 전역적으로 최적의 chunk 크기나 방법은 존재하지 않으며, 생산 시스템은 종종 전략을 혼합합니다 (예: PDF에 대한 구조 인식 chunking + 서술문에 대한 의미 인식 분할 + 코드에 대한 AST chunking).

대부분의 “문서 QA” 및 내부 지식 베이스에 대해서는 구조 존중 재귀 분리기가 안전한 기본값이며, 경계 손실을 줄이기 위해 격차가 작은 겹침을 사용합니다. 이는 임베딩 저장소와 메타데이터 필터링 및 선택적 재정렬이 지원되는 임베딩 저장소에 의해 지원됩니다. LangChain의 RecursiveCharacterTextSplitter는 이 계층 분리자 아이디어의 일반적인 구현이며, 겹침은 관련된 컨텍스트가 경계에서 잘릴 때 정보 손실을 줄이기 위해 존재합니다.

문서가 강한 구조를 가지고 있다면 (제목, 표, 목록, 설명이 있는 PDF), 요소 기반 / 구조 인식 chunking은 토큰 수 분할보다 우수할 수 있으며 더 적은 조각을 생성합니다. 2024년 SEC 제출물에 대한 연구는 요소 유형 기반 chunking이 RAG 결과를 개선하고 구조 무관 방법에 비해 조각 수(그리고 벡터 수)를 약간 절반으로 줄였다고 보고했습니다. 이는 인덱싱 비용을 줄이고 쿼리 지연 시간을 개선할 수 있습니다.

더 많은 초기 계산 비용을 감당할 수 있다면, 의미 chunking (임베딩 유사도를 사용하여 주제 전환을 기준으로 분할)은 서술문 및 혼합 주제 페이지의 검색 정확도를 크게 향상시킬 수 있습니다. 오래된 주제 분할 알고리즘인 TextTiling은 일반적인 원리를 보여줍니다: 강한 어휘/의미 전환은 경계 후보로 좋습니다.

매우 길고 내부적으로 교차 참조되는 자료 (정책, RFC, 표준, 대규모 매뉴얼)에 대해서는 계층 chunking + 계층 검색/병합 (부모/자식 노드)를 사용하여 필요 시 더 큰 연속 컨텍스트를 복구할 수 있습니다. LlamaIndex의 계층 노드 파서는 세분화에서 세분화까지의 조각 계층을 생성하고, AutoMergingRetriever는 충분한 관련 자식 노드가 검색될 때 검색 시 자식 노드를 부모 노드로 병합할 수 있습니다.

Chunking 목표 및 트레이드오프

Chunking은 단순히 “임베딩 모델에 맞도록 텍스트를 분할"하는 것이 아닙니다. 여러 후속 및 운영 행동을 제어합니다.

검색 세분화 vs 검색 노이즈. 작은 조각은 정확한 문장이 포함된 답변이 검색될 가능성이 높아집니다 (고정된 top-k에서 높은 회수 가능성). 그러나 더 많은 벡터를 생성하여 인덱스 크기를 증가시키고, 때로는 의미상 유사하지만 실제로 증거가 아닌 “근접 매치"를 표면화할 수 있습니다 (정확도 감소). DPR과 같은 밀집 검색기는 QA에서 효과적으로 문단을 검색하도록 설계되었으며, 이는 끝에서 끝까지 QA 성능에 문단 경계가 중요하다는 것을 강조합니다.

컨텍스트 일관성 vs 경계 손실. 일관된 조각은 LLM이 올바르게 추론하고, 정의, 제약 조건, 전제 조건과 같은 완전한 로컬 컨텍스트를 제공하여 환상 생성을 줄입니다. 겹침은 경계 손실을 줄이지만 중복 텍스트를 생성하여, 중복 제거/병합을 하지 않으면 중복된 검색 결과와 길어진 프롬프트 길이로 이어질 수 있습니다.

임베딩 및 인덱싱 비용. 임베딩 비용은 일반적으로 임베딩된 토큰 수에 비례하며, 인덱싱 시간은 조각 수에 비례합니다 (벡터 DB 쓰기 오버헤드 포함). OpenAI 임베딩의 경우, 요청에 입력당 최대 토큰 제한(모든 임베딩 모델에 8192 토큰)과 요청당 입력의 총 토큰 수의 최대 제한(300,000 토큰)이 있습니다. 대규모 코로포라의 경우, Batch API는 비동기 처리 및 24시간 이내의 처리 시간으로 비용을 약 50% 줄일 수 있습니다. 이는 백필 및 주기적 재인덱싱에 유용합니다.

벡터 인덱스 크기, RAM, 지연 시간. 더 많은 조각은 더 많은 벡터와 잠재적으로 더 많은 메모리와 더 느린 쿼리를 의미합니다 (인덱스 유형에 따라 다름). FAISS는 명확히 인덱스 설계를 검색 시간, 검색 품질, 인덱싱된 벡터당 메모리 사이의 트레이드오프로 설정하고, 빠른 정확하고 근사 검색을 위한 GPU 구현도 제공합니다.

후속 LLM 프롬프트 길이 / 컨텍스트 창 사용. 검색기의 출력은 프롬프트 예산이 됩니다. 일관된 “충분한” 컨텍스트를 검색하는 chunking 전략은 답변 품질을 향상시키고 비용을 줄일 수 있습니다. 반대로 겹침과 너무 큰 조각은 프롬프트 길이를 증가시킵니다. 실제로는 다음과 같이 조정합니다: (조각 크기, 겹침, top-k, 재정렬/병합)을 함께 조정합니다.

업데이트/인덱싱 비용 및 중복 제거. Chunking은 데이터를 갱신하는 비용에 영향을 미칩니다. 작은 조각은 부분 업데이트가 더 저렴하게 만들지만, 중복 또는 유사한 조각이 증가하면 중복 제거가 더 어렵습니다.

Chunking이 RAG 워크플로우에서 어떤 위치에 있는가

RAG 워크플로우에서의 chunking

Chunking 전략 및 대안

다음은 현대 RAG에서 주로 만나는 chunking 가족입니다. 실제로는 종종 두 가지를 혼합합니다: 구조 우선 chunking (문서 경계 존중) + 토큰 예산 강제 (임베딩 및 프롬프트 예산에 맞게 조각 생성).

고정 크기 chunking

무엇인지. 문자 또는 토큰을 기준으로 텍스트를 동일한 크기의 블록으로 분할합니다.

왜 존재하는가. 간단하고 빠르며 예측 가능하며 병렬 처리하기 쉽습니다. 또한 스트리밍 인덱싱에서 전체 문서 컨텍스트가 없을 때 가장 쉬운 전략입니다.

실패하는 경우. 경계(문장, 섹션, 코드 블록)를 무시하므로 정의를 끊거나 “질문/답변 쌍"을 조각 사이로 분할하여 검색 오류를 증가시킬 수 있습니다.

운영 프로필. 가장 낮은 인덱싱 복잡도; 예측 가능한 조각 수; 가장 쉬운 캐싱. 그러나 일반적으로 경계 손실을 피하기 위해 겹침(아래 참조)이 필요합니다.

겹침 chunking

무엇인지. 연속된 조각이 고정된 겹침 영역을 공유하는 전략입니다 (예: 조각 크기의 10–20%). 겹침은 많은 프레임워크에서 표준이며, 컨텍스트가 분할될 때 정보 손실을 줄이기 때문입니다.

왜 중요한가. 겹침은 효과적으로 “부드러운 경계"이며, 경계를 넘는 사실을 검색에 포착하게 합니다.

비용 및 함정. 임베딩된 토큰 수가 증가하고, 인덱스 내 중복 텍스트가 증가하며, 중복된 조각이 여러 개 검색되는 위험이 있습니다. 중복을 제거하지 않는 한, 이는 재정렬 시 (예: 출처 오프셋 병합 또는 MMR 사용) 여러 거의 동일한 조각을 검색할 수 있습니다.

문장 및 단락 기반 chunking

무엇인지. 문장 또는 단락 경계에서 텍스트를 분할하고, 문장/단락을 조각으로 패킹하여 토큰 예산 내로 만듭니다.

엔지니어들이 좋아하는 이유. 자연어의 일관성을 향상시키며, 일반적인 구두 기호 및 간격이 있는 문서에 대해 안정적입니다.

도구. NLTK의 sent_tokenize()는 기본적으로 Punkt 문장 경계 탐지기를 사용하고, spaCy는 Sentencizer와 같은 규칙 기반 문장 경계 도구를 제공합니다 (전체 의존 모델 없이 문장 분할을 원할 경우 유용합니다).

실패 모드. 비표준 기호 (로그, 채팅 전사), 표, 코드, 부록 목록은 문장 분할 가정을 무너뜨릴 수 있습니다.

슬라이딩 윈도우 chunking

무엇인지. 고정된 윈도우 크기와 단계(스트라이드)를 사용하여 조각을 생성합니다. 이는 “체계적인 겹침” 버전의 chunking입니다.

좋은 경우. 시간 시리즈 텍스트, 전사, 채팅 로그, 회의록—관련 사실이 로컬 이웃에 걸쳐 나타날 수 있고, 강력한 회수를 원하는 경우.

나쁜 경우. 중복을 증가시키며, 대규모에서 비용이 많이 들 수 있습니다. 또한 중복된 윈도우를 병합하지 않는 한, 중복된 윈도우를 검색하는 경향이 있습니다.

재귀 / 구분자 계층 chunking

무엇인지. 큰 “자연” 구분자(예: \n\n for 단락)로 시작하여, 크기 예산을 지키기 위해 필요할 때만 더 작은 단위(문장, 공백)로 재귀적으로 분할합니다. LangChain은 이 행동을 명시적으로 문서화합니다: 재귀 분리기는 큰 단위를 유지하려고 노력하고, 단위가 여전히 조각 크기를 초과할 경우에만 작은 구분자로 돌아갑니다.

왜 강력한 기본값인지. 구조를 존중하지만 복잡한 문서 파싱이 필요하지 않습니다. Markdown, HTML-as-text, 문서에 대한 실용적인 최적 지점입니다.

중요한 조정 레버. chunk_size, chunk_overlap, length_function(문자 vs 토큰), 그리고 다국어 코드베이스를 위한 사용자 정의 구분자.

의미 (임베딩 인식) chunking

무엇인지. 의미 표현(임베딩)을 사용하여 주제 전환을 감지하고 유사도가 떨어지는 지점에서 분할합니다. 이는 TextTiling과 같은 고전적인 분할 아이디어와 유사합니다. 이는 어휘 일관성의 변화를 사용하여 하위 주제 경계를 찾습니다.

왜 크기 기반 chunking보다 우수할 수 있는가. 임의의 토큰 수에서 분할을 중단하는 대신 주제 경계와 정렬하여, 여러 주제 문서(블로그, 설계 문서, 티켓, 사고 보고서)의 검색 정확도를 향상시킬 수 있습니다.

비용. 최종 조각 임베딩 전에 문장 수준 또는 단락 수준 임베딩을 추가로 필요할 수 있습니다. 중간 임베딩을 재사용하지 않는 한, 임베딩 호출이 두 배 또는 세 배 증가할 수 있습니다.

실용적인 트릭. “의미 인식 패킹”: 문장 임베딩을 한 번 계산하고, 문장을 주제 일관성 있는 세그먼트로 그룹화한 후, 각 최종 세그먼트를 임베딩합니다.

계층 chunking (부모/자식)

무엇인지. 다중 세분화 표현을 생성합니다: 대략적인 부모 조각(예: 섹션 크기)과 더 세부적인 자식 조각(예: 단락 크기). LlamaIndex의 계층 노드 파서는 기본적으로 “대략에서 세부” 계층을 생성합니다(예: 2048 → 512 → 128 토큰 범위), 그리고 AutoMergingRetriever는 충분한 관련 자식 조각이 검색될 때 검색 시 자식 노드를 부모 노드로 병합할 수 있습니다.

왜 도움이 되는가. “작은 조각을 위한 회수"와 “큰 조각을 위한 일관성” 사이에서 선택하지 않고, 둘 다 저장하고 쿼리 시 선택합니다.

비용. 더 복잡한 인덱싱 및 검색 논리, 더 많은 저장 공간(다중 세분화를 저장하기 때문).

적응형 / LLM 기반 chunking

무엇인지. LLM을 사용하여 조각 경계를 결정하고(선택적으로 요약이나 컨텍스트 헤더를 생성). Weaviate는 LLM 기반 chunking을 명확히 설명하며, LLM이 의미적으로 일관된 조각을 생성하도록 하며, 고정 규칙이나 임베딩 유사도에 의존하지 않습니다.

언제 가치가 있는가. 정확성이 비용을 우세하게 하는 고가치 코로포라(법률, 준수, 지원 운영 매뉴얼) 및 문서가 혼잡하고 이질적이며 나쁜 분할이 있는 경우.

위험. 비용, 지연, 비결정성. 캐싱, 결정적 디코딩, 회귀 테스트가 필요합니다(평가 섹션 참조).

구조 및 요소 기반 chunking (문서는 단순 텍스트가 아님)

무엇인지. 문서 이해 레이어를 사용하여 문서를 요소(제목, 단락, 목록, 표, 설명)로 파싱한 후, 이 요소를 사용하여 chunking을 수행합니다. Unstructured의 chunking 함수는 명확히 메타데이터 및 문서 요소(파티셔닝에 의해 생성됨)를 사용하여 RAG용 조각을 생성합니다. Docling의 HierarchicalChunker는 감지된 문서 요소별로 조각을 생성하고, 헤더/설명과 같은 구조적 메타데이터를 첨부합니다.

최근 연구의 증거. 2024년 SEC 제출물에 대한 연구는 단락만 기반으로 한 chunking이 문서 구조를 무시한다고 주장하고, 구조적 요소에 기반한 chunking을 제안하며, 구조 무관 접근법보다 RAG 결과가 개선되고 조각/벡터 수가 줄어들었다고 보고했습니다.

다중 모달성에 중요한 이유. 표, 그림, 설명은 종종 사실을 포함합니다. “평탄화"하여 단순 텍스트로 변환하면 검색이 활용할 수 있는 신호를 파괴할 수 있습니다.

코드 인식 chunking (AST/구조)

무엇인지. 코드를 구문 단위(함수, 클래스, 모듈)로 chunking하고, 선택적으로 문서 문자열 및 주석을 포함합니다.

왜 중요한가. 고정 크기 토큰 분할은 일반적으로 함수를 반으로 자르고 문서 문자열을 구현에서 분리하게 되며, 코드 검색 및 “이 함수를 설명해 주세요” RAG 사용 사례에 좋지 않습니다.

구현 옵션. 파이썬의 경우 내장 ast 모듈이 충분할 수 있습니다. 다국어 레포지토리의 경우 tree-sitter 기반 chunker가 일반적입니다.

평가 차원 및 chunking 전략 비교 방법

chunking은 시스템 구성 요소로 평가되어야 합니다.

검색 품질 지표

검색 레이어에 대한 표준 IR 지표를 사용합니다:

  • Recall@k / Precision@k: top-k에 금기 증거가 포함되었나요?
  • MRR / nDCG: 금기 증거가 높은 순위에 있나요?

BEIR는 작업/도메인에 걸쳐 IR 평가를 위한 널리 사용되는 이질적 벤치마크이며, 희소, 밀집, 후기 상호작용, 재정렬 접근법 사이의 트레이드오프를 강조합니다.

chunking은 이 지표에 영향을 미칩니다. 왜냐하면 이는 “관련된 검색 항목"이 무엇인지 정의하기 때문입니다.

종합 RAG 답변 품질 지표

QA 또는 어시스턴트를 구축하는 경우, 검색 지표는 필수적이지만 충분하지 않습니다. 또한 다음이 필요합니다:

  • 컨텍스트 회수 / 정확도: 검색된 컨텍스트가 관련 증거를 포함하고 노이즈를 피하는지.
  • 신뢰성: 생성된 답변이 검색된 컨텍스트에 의해 지원되는지.

RAGAS는 “신뢰성” 및 기타 RAG 지향 지표에 대한 구체적인 정의와 구현을 제공합니다.

시스템 비용 및 성능 차원

chunking은 이러한 레버를 변화시킵니다:

지연 시간 (p50/p95). 벡터 수와 후처리가 증가할수록 쿼리 지연 시간이 증가합니다. 벡터 인덱스도 중요합니다: FAISS 인덱스 유형은 검색 시간, 품질, 메모리, 학습/추가 시간 사이에서 트레이드오프를 제공합니다.

임베딩 비용 및 처리량. OpenAI 임베딩은 토큰당 청구되며, 임베딩 API는 입력당 및 요청당 명확한 제한을 가지고 있습니다. 오프라인 인덱싱의 경우, 배치 API는 비실시간 처리 시간을 대가로 비용을 줄이고 처리량을 높입니다.

인덱스 크기 및 메모리. 대략적으로, N 개의 float32 벡터 및 d 차원을 저장하는 데 ~4 * N * d 바이트가 필요합니다 (원시 벡터에 더해 메타데이터 및 인덱스 오버헤드 포함). chunking은 N에 영향을 미칩니다. 임베딩 차원은 d에 영향을 미치며, OpenAI의 임베딩 API는 dimensions 파라미터를 통해 출력 차원을 조절할 수 있습니다.

LLM 프롬프트 예산. 더 큰 조각과 겹침은 프롬프트 토큰을 증가시킵니다. 이는 지연 시간 및 비용을 증가시키고, 모델이 일부 컨텍스트에 더 적은 주의를 지불하는 “중간에서 잃는” 유형의 실패 모드를 증가시킬 수 있습니다. 실제로는 일반적으로 다음과 같이 수행합니다:

  1. 작은 조각을 검색,
  2. 병합/중복 제거,
  3. 선택적으로 요약,
  4. 압축된 증거 세트를 LLM에 전달합니다.

업데이트/인덱싱 비용. 더 작은 조각은 부분 재임베딩을 가능하게 하지만 책keeping을 증가시킵니다. 스트리밍 인덱싱의 경우, 결정적, 점진적 chunking(고정 또는 슬라이딩 윈도우)을 선호하고 안정적인 ID(문서 ID, 오프셋 범위, 해시)를 첨부합니다.

실험 설계: 실용적인 벤치마크 루프

재현 가능한 chunking 벤치마크는 일반적으로 다음과 같은 구성 요소를 포함합니다:

  • 고정된 코로포라 스냅샷 + 고정된 쿼리 집합과 금기 증거(또는 최소한 예상 답변 범위)가 있는.
  • 고정된 임베딩 모델 및 벡터 인덱스 구성.
  • “검색만” 평가 (recall@k, nDCG)와 “RAG” 평가 (신뢰성, 답변 관련성)가 포함된.
  • 비용 텔레메트리: #chunks, 임베딩된 토큰, $/월 저장, p95 쿼리 지연, 프롬프트 토큰.

Unstructured SEC-제출물 논문은 검색 지향 지표와 QA 정확도 측정을 통해 chunking 전략을 평가하는 좋은 예시입니다.

실용 지침, 의사결정 매트릭스, 권장 기본값

놀랄 정도로 잘 작동하는 권장 기본값

일반 문서 QA에 대한 “일일 1” 전략으로 강력한 전략이 필요하다면:

  1. 경량 파싱: 제목과 기본 메타데이터(소스, 섹션 제목, URL/경로, 타임스탬프)를 유지합니다.
  2. 재귀 구분자 분리기로 chunking (단락 → 문장 → 단어), 격차가 작은 겹침을 사용합니다.
  3. 강력한 일반 임베딩 모델로 임베딩합니다.
  4. 메타데이터 (문서 ID, 섹션, ACL)를 사용하여 인덱싱하고, 검색 시 중복 제거합니다.
  5. 평가가 틈을 보여줄 경우, 재정렬 또는 계층 병합을 추가합니다.

이것은 일반적인 RAG 프레임워크가 chunking 겹침 및 구조 존중 분할을 설명하는 방식과 일치합니다.

어떤 chunking 방법을 사용해야 하는가 - 의사결정 매트릭스

어떤 chunking 방법을 사용해야 하는가 - 다이어그램

사용 사례 권장 chunking 기본값 조정해야 할 주요 파라미터 일반적인 실패 모드 업그레이드 경로
문서에 대한 짧은 형식 QA (FAQ, 내부 위키) 재귀/구분자 chunking + 겹침 chunk_size, 겹침, top-k 경계에서의 교차 문장 증거 누락 의미 chunking 또는 재정렬 추가
정책, 표준, 매뉴얼에 대한 긴 형식 QA 계층 chunking + 병합 검색기 부모/자식 크기, 병합 임계값 작은 조각 검색; LLM이 전체 컨텍스트를 갖지 못함 자동 병합/계층 검색
문서/섹션별 요약 구조 인식 조각 (섹션) 섹션 감지, 최대 토큰 요약이 섹션 간 링크를 누락 계층 요약 + 섹션 그래프
코드 검색 및 “이 함수를 설명해 주세요” AST/함수 수준 조각 문서 문자열/주석 포함, 최대 토큰 함수 분할; 시그니처/사용 누락 레포 인식 계층 (모듈→클래스→함수)
다중 모달 PDF (표, 그림) 요소 기반 chunking (제목/표/설명 인식) 표 직렬화, 설명 병합 표 내용이 손실되거나 손상됨 Docling/Unstructured + 구조화 직렬화기 사용
스트리밍 인덱싱 (로그, 채팅, 티켓) 슬라이딩 윈도우 또는 고정 크기 토큰 윈도우, 스트라이드, 중복 제거 중복 윈도우 과도한 검색 배치에 의미 경계 감지 추가

chunking - 질적 성능 비교

이를 “예상 변경 방향"으로 간주하되, 자신의 데이터로 검증해야 합니다.

전략 검색 정확도 잠재력 검색된 컨텍스트 일관성 인덱싱 복잡도 벡터 수 / 인덱스 크기 임베딩 비용 쿼리 지연 영향 최적 사용 사례
고정 크기 (겹침 없음) 중간 낮음 낮음 중간 낮음 중간 빠른 프로토타입, 동질적 텍스트
고정 크기 + 겹침 중간–높음 중간 낮음 높음 중간–높음 중간–높음 경계 손실이 영향을 주는 QA
문장/단락 패킹 높음(문학) 높음 중간 중간 중간 중간 문서, 기사, 깔끔한 문학
슬라이딩 윈도우 높은 회수 중간 중간 매우 높음 매우 높음 높음 전사, 로그, 채팅
재귀/구분자 높음 높음 중간 중간 중간 중간 “기본” 문서 RAG
의미 chunking 높음–매우 높음 높음 높음 중간 높음 중간 다중 주제 페이지, 서술문 텍스트
계층 (부모/자식) 매우 높음 매우 높음 높음 높음 높음 중간 긴 매뉴얼 / 표준
LLM 기반/적응형 매우 높음 매우 높음 매우 높음 중간 매우 높음 중간–높음 고위험 코로포라
요소-/구조 기반 높음–매우 높음 높음 높음 낮음–중간 중간 중간 PDF, 보고서, 표, 혼합 레이아웃
코드 인식 (AST) 높음(코드) 높음 중간 중간 중간 중간 코드 검색, 레포 어시스턴트

DevOps 및 하드웨어 참고 사항 (종종 간과됨)

chunking 선택은 필요한 인프라의 양에 영향을 미칩니다:

  • 작은 조각 → 더 많은 벡터 → 더 큰 인덱스 및 더 많은 RAM/디스크. 자체 호스팅 FAISS의 경우, 이는 샤딩 또는 디스크 기반 인덱스를 강제할 수 있습니다.
  • 로컬에서 임베딩하는 경우, 임베딩 처리량은 GPU 스케줄링 문제가 되며, API를 통해 임베딩하는 경우, 토큰량은 FinOps 문제가 됩니다 (배치 API는 백필에 친절합니다).
  • 일부 엔진 (FAISS)은 GPU 가속 검색을 제공하며, 이는 RAM 제한 CPU에서 GPU 메모리 및 PCIe 전송량으로 비용을 이동시킬 수 있습니다.
  • 구조 인식 파싱 (PDF 레이아웃, OCR, 표 추출)은 일반적으로 CPU 제한이며, 스캔 문서의 경우 임베딩 비용을 훨씬 초과할 수 있으므로 별도로 예산을 계획해야 합니다.

청크링 - Python 참조 구현

모든 예시는 읽기 쉽고 실행 가능한 방식으로 설계되었습니다. API 키나 실행 중인 DB가 필요한 경우, 코드에서 명확하게 나타나 있습니다.

공유 유틸리티: 토큰 계산 및 안정적인 청크 ID

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from typing import Any, Iterable, Optional

from transformers import AutoTokenizer  # pip install transformers

@dataclass(frozen=True)
class Chunk:
    text: str
    meta: dict[str, Any]

def sha1_id(*parts: str) -> str:
    h = hashlib.sha1()
    for p in parts:
        h.update(p.encode("utf-8"))
        h.update(b"\x1e")
    return h.hexdigest()

# 사용 중인 LLM/embedding 토큰화와 대략적으로 일치하는 토큰화기를 사용하세요.
TOKENIZER = AutoTokenizer.from_pretrained("bert-base-uncased")

def token_len(text: str) -> int:
    return len(TOKENIZER.encode(text, add_special_tokens=False))

고정 크기 토큰 청크링

def chunk_fixed_tokens(
    text: str,
    *,
    chunk_size: int = 512,
) -> list[Chunk]:
    token_ids = TOKENIZER.encode(text, add_special_tokens=False)
    out: list[Chunk] = []

    for i in range(0, len(token_ids), chunk_size):
        window = token_ids[i : i + chunk_size]
        chunk_text = TOKENIZER.decode(window)
        out.append(
            Chunk(
                text=chunk_text,
                meta={"strategy": "fixed_tokens", "start_token": i, "end_token": i + len(window)},
            )
        )
    return out

고정 크기 + 중첩 슬라이딩 윈도우

def chunk_sliding_window(
    text: str,
    *,
    window_tokens: int = 512,
    stride_tokens: int = 384,  # 슬라이드 크기가 작을수록 중첩이 많아짐
) -> list[Chunk]:
    assert 1 <= stride_tokens <= window_tokens, "슬라이드는 (0, window] 범위 내에 있어야 함"
    token_ids = TOKENIZER.encode(text, add_special_tokens=False)
    out: list[Chunk] = []

    start = 0
    while start < len(token_ids):
        end = min(start + window_tokens, len(token_ids))
        window = token_ids[start:end]
        out.append(
            Chunk(
                text=TOKENIZER.decode(window),
                meta={"strategy": "sliding_window", "start_token": start, "end_token": end},
            )
        )
        if end == len(token_ids):
            break
        start += stride_tokens

    return out

문장 기반 청크링 (NLTK) + 토큰 예산 패킹

# pip install nltk
import nltk
from nltk.tokenize import sent_tokenize

nltk.download("punkt", quiet=True)

def chunk_by_sentences_nltk(
    text: str,
    *,
    max_tokens: int = 512,
    overlap_sentences: int = 1,
) -> list[Chunk]:
    sents = [s.strip() for s in sent_tokenize(text) if s.strip()]
    out: list[Chunk] = []

    buf: list[str] = []
    buf_tokens = 0

    def flush():
        nonlocal buf, buf_tokens
        if not buf:
            return
        chunk_text = " ".join(buf).strip()
        out.append(
            Chunk(
                text=chunk_text,
                meta={"strategy": "sentences_nltk", "sent_count": len(buf)},
            )
        )
        # 중첩을 위해 마지막 N 문장을 유지
        if overlap_sentences > 0:
            buf = buf[-overlap_sentences:]
            buf_tokens = token_len(" ".join(buf))
        else:
            buf, buf_tokens = [], 0

    for s in sents:
        s_tokens = token_len(s)
        # 단일 문장이 예산을 초과하면 해당 범위에 고정 크기 청크링으로 전환
        if s_tokens > max_tokens:
            flush()
            out.extend(chunk_fixed_tokens(s, chunk_size=max_tokens))
            continue

        if buf_tokens + s_tokens > max_tokens and buf:
            flush()

        buf.append(s)
        buf_tokens += s_tokens

    flush()
    return out

문장 기반 청크링 (spaCy) - 규칙 기반 또는 모델 기반 문장 경계 감지 시 사용

# pip install spacy
# python -m spacy download en_core_web_sm
import spacy

def chunk_by_sentences_spacy(
    text: str,
    *,
    max_tokens: int = 512,
) -> list[Chunk]:
    # 가벼운 규칙 기반 분할 (의존성 분석 없음), sentencizer 사용
    nlp = spacy.blank("en")
    nlp.add_pipe("sentencizer")  # 규칙 기반 문장 경계 감지
    doc = nlp(text)

    sents = [sent.text.strip() for sent in doc.sents if sent.text.strip()]
    return chunk_by_sentences_nltk(" ".join(sents), max_tokens=max_tokens, overlap_sentences=1)

재귀적 구분자 청크링 (LangChain)

# pip install langchain-text-splitters
from langchain_text_splitters import RecursiveCharacterTextSplitter

def chunk_recursive_langchain(
    text: str,
    *,
    chunk_size: int = 1200,
    chunk_overlap: int = 150,
) -> list[Chunk]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=token_len,  # 토큰 인식 예산
        separators=["\n\n", "\n", ". ", " ", ""],  # 콘텐츠에 맞게 커스터마이징 (예: 코드)
    )
    pieces = splitter.split_text(text)

    return [
        Chunk(text=p, meta={"strategy": "recursive_langchain", "chunk_size": chunk_size, "overlap": chunk_overlap})
        for p in pieces
    ]

임베딩 유사도를 통한 의미적 청크링 (OpenAI 임베딩)

이 접근법은 후보 단위(문장/단락)에 대한 임베딩을 계산한 후, 의미적 “중단점"을 찾습니다.

# pip install openai numpy
import os
import numpy as np
from openai import OpenAI

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENAI_API_KEY"))

def embed_texts_openai(texts: list[str], *, model: str = "text-embedding-3-small") -> np.ndarray:
    # 대규모 배치 처리 시 요청 토큰 한도와 배치 크기 한도를 준수해야 합니다.
    resp = client.embeddings.create(model=model, input=texts)
    embs = np.array([d.embedding for d in resp.data], dtype=np.float32)
    return embs

def cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
    denom = (np.linalg.norm(a) * np.linalg.norm(b)) + 1e-8
    return float(np.dot(a, b) / denom)

def chunk_semantic(
    text: str,
    *,
    max_tokens: int = 800,
    breakpoint_threshold: float = 0.70,
) -> list[Chunk]:
    # 1) 문장 후보로 시작
    sents = [s.strip() for s in sent_tokenize(text) if s.strip()]
    if len(sents) <= 1:
        return [Chunk(text=text, meta={"strategy": "semantic", "note": "no_split"})]

    # 2) 각 문장에 임베딩
    embs = embed_texts_openai(sents)

    # 3) 유사도 감소 계산
    sims = [cosine_sim(embs[i], embs[i + 1]) for i in range(len(sents) - 1)]

    # 4) 중단점 기준으로 구간 생성
    out: list[Chunk] = []
    buf: list[str] = []
    buf_tokens = 0

    for i, s in enumerate(sents):
        # 문장 추가
        s_tok = token_len(s)
        if buf and buf_tokens + s_tok > max_tokens:
            out.append(Chunk(text=" ".join(buf), meta={"strategy": "semantic", "reason": "max_tokens"}))
            buf, buf_tokens = [], 0
        buf.append(s)
        buf_tokens += s_tok

        # 문장 i 이후 중단점 결정 (다음 문장과의 유사도 기준)
        if i < len(sims) and sims[i] < breakpoint_threshold:
            out.append(
                Chunk(
                    text=" ".join(buf),
                    meta={"strategy": "semantic", "reason": "sim_drop", "sim_to_next": sims[i]},
                )
            )
            buf, buf_tokens = [], 0

    if buf:
        out.append(Chunk(text=" ".join(buf), meta={"strategy": "semantic", "reason": "final"}))

    return out

계층적 청크링 + 검색 결과 병합 (LlamaIndex)

# pip install llama-index llama-index-llms-openai
from llama_index.core import Document, StorageContext, VectorStoreIndex
from llama_index.core.node_parser import HierarchicalNodeParser, get_leaf_nodes
from llama_index.core.retrievers import AutoMergingRetriever
from llama_index.core.storage.docstore import SimpleDocumentStore

def build_hierarchical_index(text: str):
    docs = [Document(text=text)]

    node_parser = HierarchicalNodeParser.from_defaults()  # 기본값은 군집에서 세분화 크기
    nodes = node_parser.get_nodes_from_documents(docs)

    docstore = SimpleDocumentStore()
    docstore.add_documents(nodes)
    storage_context = StorageContext.from_defaults(docstore=docstore)

    leaf_nodes = get_leaf_nodes(nodes)
    base_index = VectorStoreIndex(leaf_nodes, storage_context=storage_context)

    base_retriever = base_index.as_retriever(similarity_top_k=6)
    retriever = AutoMergingRetriever(base_retriever, storage_context, verbose=True)
    return retriever

PDF 청크링 (Docling)

# pip install docling
# 참고: PDF 파싱 품질은 환경(폰트, OCR 등)에 따라 달라질 수 있습니다.
from docling.document_converter import DocumentConverter
from docling.transforms.chunker.hierarchical_chunker import HierarchicalChunker

def chunk_pdf_docling(pdf_path: str) -> list[Chunk]:
    converter = DocumentConverter()
    doc = converter.convert(pdf_path).document  # DoclingDocument
    chunker = HierarchicalChunker()
    doc_chunks = list(chunker.chunk(doc))

    out: list[Chunk] = []
    for c in doc_chunks:
        # c.text는 직렬화된 청크 내용; c.meta는 구조 정보
        out.append(Chunk(text=c.text, meta={"strategy": "docling_hierarchical", **dict(c.meta)}))
    return out

Python 코드 인식 청크링 (AST)

import ast

def chunk_python_by_ast(code: str, *, filepath: str = "<memory>") -> list[Chunk]:
    tree = ast.parse(code)
    out: list[Chunk] = []

    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            start = getattr(node, "lineno", None)
            end = getattr(node, "end_lineno", None)
            if start is None or end is None:
                continue

            lines = code.splitlines()
            snippet = "\n".join(lines[start - 1 : end])

            kind = "class" if isinstance(node, ast.ClassDef) else "function"
            name = getattr(node, "name", "<anon>")
            out.append(
                Chunk(
                    text=snippet,
                    meta={
                        "strategy": "python_ast",
                        "kind": kind,
                        "name": name,
                        "filepath": filepath,
                        "start_line": start,
                        "end_line": end,
                    },
                )
            )

    return out

인덱싱 예시

FAISS (로컬) — 최소한의, 빠른 기준

# pip install faiss-cpu numpy
import numpy as np
import faiss

def build_faiss_index(vectors: np.ndarray) -> faiss.Index:
    # vectors: shape (N, d), dtype float32
    d = vectors.shape[1]
    index = faiss.IndexFlatIP(d)  # 내적; 정규화된 벡터로 코사인 유사도
    faiss.normalize_L2(vectors)
    index.add(vectors)
    return index

def faiss_search(index: faiss.Index, query_vec: np.ndarray, k: int = 5):
    q = query_vec.astype(np.float32).reshape(1, -1)
    faiss.normalize_L2(q)
    scores, ids = index.search(q, k)
    return ids[0].tolist(), scores[0].tolist()

Chroma (로컬) — RAG 프로토타이핑을 위한 간단한 지속성

# pip install chromadb
import chromadb

def build_chroma_collection(chunks: list[Chunk], embeddings: np.ndarray, *, path: str = "./chroma_store"):
    client = chromadb.PersistentClient(path=path)
    col = client.get_or_create_collection(name="docs")

    ids = [sha1_id(c.meta.get("strategy", "chunk"), str(i), c.text[:50]) for i, c in enumerate(chunks)]
    col.upsert(
        ids=ids,
        documents=[c.text for c in chunks],
        metadatas=[c.meta for c in chunks],
        embeddings=embeddings.tolist(),
    )
    return col

Weaviate (자체 호스팅 / 클라우드) — 사용자 제공 벡터 사용

# pip install weaviate-client
import weaviate
from weaviate.classes.config import Configure

def weaviate_upsert_self_provided(
    chunks: list[Chunk],
    embeddings: np.ndarray,
):
    client = weaviate.connect_to_local()  # 또는 connect_to_weaviate_cloud(...)
    try:
        collection = client.collections.create(
            name="Chunk",
            vector_config=Configure.Vectors.self_provided(),  # 사용자가 벡터 제공
        )

        with collection.batch.dynamic() as batch:
            for c, v in zip(chunks, embeddings):
                batch.add_object(
                    properties={"text": c.text, **{f"m_{k}": str(vv) for k, vv in c.meta.items()}},
                    vector=v.tolist(),
                )

        # near-vector로 쿼리
        q = embeddings[0]
        res = collection.query.near_vector(near_vector=q.tolist(), limit=5)
        for obj in res.objects:
            print(obj.properties.get("text")[:200])
    finally:
        client.close()

일부 원본 문서

  • Patrick Lewis 등, “Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks”, NeurIPS 2020; arXiv:2005.11401.
  • Vladimir Karpukhin 등, “Dense Passage Retrieval for Open-Domain Question Answering”, EMNLP 2020; arXiv:2004.04906.
  • OpenAI API 참조: 임베딩 생성 (/v1/embeddings) — 토큰 한도 (입력당 8192; 요청당 총 300k) 및 dimensions 파라미터.
  • OpenAI 배치 API 가이드 + OpenAI API 가격 페이지 (24시간 이내 처리로 약 50% 비용 절감).
  • LangChain 문서: RecursiveCharacterTextSplitter 및 splitters 통합 가이드 (청크 크기/중첩, 재귀적 구분자 계층).
  • LlamaIndex 문서: HierarchicalNodeParser 및 AutoMergingRetriever (군집에서 세분화 노드; 검색 시 병합).
  • Weaviate 블로그: “Chunking Strategies to Improve LLM RAG Pipeline Performance” (LLM 기반 청크링 설명 및 트레이드오프).
  • Docling 문서: HierarchicalChunker는 문서 요소 구조로부터 청크를 생성하고 헤더/캡션 메타데이터를 첨부합니다.
  • Jimeno Yepes 등, “Financial Report Chunking for Effective Retrieval Augmented Generation” (arXiv:2402.05131v3, 2024).
  • Marti A. Hearst, “TextTiling: Segmenting Text into Multi-Paragraph Subtopic Passages”, Computational Linguistics, 1997 (ACL Anthology: J97-1003).
  • FAISS 문서 / GitHub 저장소: 검색 시간, 품질, 메모리 간의 트레이드오프; 선택적 GPU 지원.
  • Nandan Thakur 등, “BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models”, NeurIPS 2021; arXiv:2104.08663.
  • RAGAS 문서: “Faithfulness” 지표 및 관련 RAG 평가 지표.
  • NLTK 문서: nltk.tokenize.sent_tokenize - Punkt 기반 권장 문장 토큰화기.
  • spaCy API 문서: Sentencizer - 의존성 분석 없이 규칙 기반 문장 경계 감지.

기타 유용한 링크