RAG에서 Chunk 크기를 정하는 기준

RAG에서 Chunk 크기를 정하는 기준

한눈에 보기

Chunk가 너무 작으면 문장의 주어와 조건, 표의 header와 본문이 끊기고 너무 크면 하나의 vector에 여러 주제가 섞인다. 하지만 최적 크기는 하나의 숫자가 아니다. 검색에 사용할 작은 child와 답변에 제공할 큰 parent를 분리할 수도 있고, API 문서와 회의록은 서로 다른 경계를 사용해야 한다. 실제 질문 집합으로 retrieval recall, 근거 완결성, 최종 답변, latency와 index 비용을 함께 비교해 결정한다.

목차

Chunk 크기에 정답 숫자가 없는 이유

RAG를 처음 만들면 흔히 다음과 같은 질문을 한다.

Chunk는 256 token이 좋을까?
512 token이 좋을까?
Overlap은 10%면 될까?

숫자를 하나 정하면 구현은 쉬워진다. 하지만 같은 512 token도 문서에 따라 의미가 다르다.

질문의 범위도 다르다.

좁은 질문: ERR-1042의 원인은 무엇인가?
넓은 질문: 계정 잠금 정책과 해제 절차를 단계별로 설명해 줘.
비교 질문: Basic과 Pro 요금제의 보존 기간 차이는?

첫 질문은 오류 코드가 포함된 작은 단위가 유리할 수 있다. 두 번째는 원인, 조건과 절차가 이어진 넓은 문맥이 필요하다. 세 번째는 서로 다른 section을 여러 개 찾아 조합해야 한다.

최적화 대상부터 정한다

Chunk size를 독립 변수 하나로 보지 않는다. parser × boundary strategy × size × overlap × embedding × retriever × top-k × context assembly 조합이 실제 RAG 품질을 만든다.

Chunk는 검색 단위이자 문맥 단위다

단순한 pipeline에서는 같은 chunk를 embedding하고 검색하며 그대로 model에 전달한다.

flowchart LR
    D[Document] --> C[Chunks]
    C --> E[Embeddings]
    Q[Query] --> R[Retriever]
    E --> R
    R --> K[Top-k Chunks]
    K --> L[LLM Context]

이 구조에서 chunk 크기는 두 역할을 동시에 결정한다.

  1. 검색 표현 단위: 하나의 vector가 어떤 의미를 대표하는가
  2. 생성 근거 단위: 검색 결과 한 건이 model에 얼마나 많은 문맥을 제공하는가

작게 만들면 embedding은 좁은 의미를 선명하게 표현할 수 있지만 답변에 필요한 앞뒤 문맥이 부족하다. 크게 만들면 답변 근거는 풍부하지만 vector 하나가 여러 의미를 평균내는 문제가 생긴다.

작은 검색 단위의 장점 + 큰 문맥 단위의 장점

두 목적이 충돌한다면 같은 chunk로 해결하려 하지 말고 parent-child retrieval이나 sentence window처럼 검색 단위와 반환 단위를 분리할 수 있다.

너무 작은 Chunk에서 잃는 것

다음 원문을 문장 단위로 자른다고 하자.

[1] 이 제한은 관리자 계정에는 적용되지 않는다.
[2] 일반 사용자는 5회 실패하면 30분 동안 로그인할 수 없다.
[3] 단, SSO 계정은 외부 IdP의 잠금 정책을 따른다.

Query가 “SSO 계정은 몇 분 동안 잠기나?”라면 [3]만 검색될 수 있다. 그러나 외부 IdP를 따라 구체 시간이 이 문서에 없다는 사실을 답하려면 [2]와의 대비와 문서 범위를 알아야 한다.

작은 chunk는 다음 요소를 잘 잃는다.

Chunk: "그 경우에는 이전 값을 사용한다."

문장 자체는 embedding할 수 있지만 그 경우이전 값이 무엇인지 알 수 없다. 검색에는 잡혀도 답변 근거로 쓸 수 없는 조각이다.

작은 Chunk 수가 많으면 Top-k도 다시 봐야 한다

동일한 top-k=5를 유지하면 큰 chunk 전략보다 model에 전달되는 전체 정보량이 크게 줄어든다. Chunk 전략 비교에서는 총 context token budget을 맞추지 않으면 공정하지 않다.

너무 큰 Chunk에서 섞이는 것

반대로 하나의 긴 section을 그대로 embedding한다고 하자.

Chunk A
  - 계정 생성
  - 비밀번호 정책
  - SSO 연동
  - 계정 잠금
  - 탈퇴와 데이터 삭제

질문이 계정 잠금에 관한 것이어도 vector에는 다른 주제의 표현이 함께 압축된다. 검색에 성공해도 model context의 대부분이 불필요한 내용이다.

큰 chunk의 문제는 다음과 같다.

context budget = 4,000 tokens

chunk 1,000 tokens -> 최대 4개 후보
chunk   250 tokens -> 최대 16개 후보

작은 chunk 16개가 무조건 더 좋은 것은 아니다. 후보가 여러 문서에 흩어지면 문맥 조립이 어려워진다. 크기는 recall과 coherence, 다양성 사이의 trade-off다.

문서 구조를 먼저 보존한다

고정 길이 자르기 전에 parser가 heading, paragraph, list, table과 code fence를 제대로 복원했는지 확인한다. PDF text 순서가 깨졌다면 chunk size를 조정해도 해결되지 않는다.

원본 PDF 화면
  왼쪽 column 1 -> 2 -> 3
  오른쪽 column A -> B -> C

잘못 추출된 text
  1 -> A -> 2 -> B -> 3 -> C

이 상태에서 semantic chunking을 해도 섞인 문장을 의미 있게 분리할 수 없다. Chunking보다 앞단의 document parsing과 normalization이 품질 상한을 정한다.

권장하는 기본 순서는 다음과 같다.

flowchart LR
    F[File] --> P[구조 인식 Parser]
    P --> N[정규화]
    N --> B[의미 경계 후보]
    B --> S[최대 Size로 분할]
    S --> M[Metadata 부착]
    M --> I[Index]

Heading path를 유지하며 paragraph를 합치고, 최대 token을 넘을 때만 더 작은 경계로 내려가는 recursive strategy가 실용적인 baseline이다.

H1 section
  -> H2 section
    -> paragraph
      -> sentence
        -> token window

큰 의미 단위를 보존하되 embedding model의 입력 한도와 실험 size를 넘으면 안전한 하위 경계에서 나눈다.

문서 종류에 따라 경계를 다르게 잡는다

모든 corpus에 하나의 splitter를 적용하기보다 문서 type을 분류한다.

문서 우선 경계 함께 보존할 것
API reference endpoint 또는 method path, request, response, error
운영 Runbook 절차와 단계 묶음 전제 조건, rollback, warning
FAQ 질문-답변 한 쌍 category, product version
정책 문서 조항과 하위 항목 예외, 효력일, 정의
회의록 agenda 또는 topic 발언자, 결정, action item
코드 함수·class·module signature, imports, file path
표 전체 또는 row group header, unit, caption
채팅 thread 또는 issue author, timestamp, reply relation

API 문서를 단순 paragraph로 나누면 request schema와 response example이 다른 chunk로 갈 수 있다.

POST /orders
  description
  authentication
  request schema
  validation errors
  response schema

Endpoint가 최대 크기를 넘으면 description과 authentication을 공통 parent로 두고 request, error, response subchunk로 나눈다. 검색 결과에는 endpoint path와 method를 metadata로 반복 부착한다.

회의록은 발언 순서보다 결정 단위가 중요할 수 있다. Decision, Owner, Due date를 함께 보존하지 않으면 “누가 언제까지 하기로 했는가”에 답하기 어렵다.

Token Character Sentence를 구분한다

설정의 chunkSize: 500이 무엇을 뜻하는지 확인해야 한다.

# 서로 전혀 다른 설정이다.
chunk_size_characters: 500
chunk_size_tokens: 500
chunk_size_sentences: 5

Character는 계산이 빠르지만 언어와 tokenizer에 따라 실제 model token 수가 달라진다. Sentence는 문법 경계를 보존하지만 기술 문서의 긴 문장 하나가 매우 커질 수 있다. Token은 model 한도와 비용 계산에 직접적이지만 문장 중간을 자를 수 있다.

실용적인 방식은 의미 경계로 먼저 나누고 token 수로 최대값을 강제하는 것이다.

function withinBudget(text: string, maxTokens: number): boolean {
  return tokenizer.encode(text).length <= maxTokens;
}

Embedding model과 generation model이 서로 다른 tokenizer를 사용할 수 있다. 색인 size는 embedding tokenizer로 측정하고, 최종 context 조립은 generation tokenizer로 다시 계산한다.

평균만 보지 않는다

Chunk token 수의 p50, p90, p99와 너무 작은 chunk 비율을 본다. 평균 400이어도 제목 한 줄 chunk가 수천 개이거나 표 하나가 8,000 token이면 문제가 숨는다.

Overlap은 경계 문제를 완전히 해결하지 않는다

Overlap은 경계 양쪽 문맥을 중복시킨다.

chunk 1: tokens   0 ... 499
chunk 2: tokens 450 ... 949
chunk 3: tokens 900 ... 1399

장점은 문장이 경계에서 끊기거나 답변 span이 두 chunk에 걸릴 때 hit 가능성을 높이는 것이다. 하지만 비용이 있다.

Overlap을 고정 비율로 주기 전에 구조 경계가 제대로 잡혔는지 확인한다. Heading과 paragraph를 보존하면 무작정 20% overlap을 넣을 필요가 줄어든다.

type ChunkPolicy = {
  targetTokens: number;
  maxTokens: number;
  overlapTokens: number;
  minTokens: number;
};

const baseline: ChunkPolicy = {
  targetTokens: 400,
  maxTokens: 600,
  overlapTokens: 40,
  minTokens: 80,
};

숫자는 설명용 baseline이다. 최근 연구에서도 특정 실험에서는 overlap의 이익이 측정되지 않고 비용만 늘어난 결과가 있으므로, corpus별 evaluation 없이 필수 설정으로 취급하지 않는다.

검색 Chunk와 반환 Context를 분리한다

작게 검색하고 큰 parent를 반환하는 방식은 앞서 본 trade-off를 줄인다.

flowchart LR
    D[Section Parent] --> C1[Child 1]
    D --> C2[Child 2]
    D --> C3[Child 3]
    Q[Query] --> V[Child Vector Search]
    C1 --> V
    C2 --> V
    C3 --> V
    V --> P[Parent 또는 Neighbor 확장]
    P --> L[LLM Context]

Child는 embedding에 유리한 좁은 passage이고 parent는 답변에 필요한 완결된 section이다.

type IndexedChunk = {
  chunkId: string;
  parentId: string;
  ordinal: number;
  text: string;
  tokenCount: number;
  embedding: number[];
};

type ParentSection = {
  parentId: string;
  headingPath: string[];
  text: string;
  sourceId: string;
};

검색된 child의 parent 전체가 너무 크면 앞뒤 neighbor만 확장할 수 있다.

hit ordinal = 7
window       = chunks 6, 7, 8

여러 child가 같은 parent를 가리키면 parent를 한 번만 넣어 중복을 제거한다. Parent 확장은 recall을 높이지만 irrelevant context도 늘릴 수 있으므로 최종 token budget 안에서 reranking한다.

Metadata로 잘린 문맥을 복구한다

Chunk text에 heading path와 source metadata를 함께 저장하면 짧은 passage의 범위를 설명할 수 있다.

{
  "chunkId": "doc-17:section-4:chunk-2",
  "sourceId": "account-policy-v8",
  "title": "계정 보안 정책",
  "headingPath": ["로그인", "계정 잠금", "SSO 예외"],
  "effectiveFrom": "2026-04-01",
  "product": "enterprise",
  "language": "ko",
  "ordinal": 2,
  "previousChunkId": "doc-17:section-4:chunk-1",
  "nextChunkId": "doc-17:section-4:chunk-3"
}

Metadata는 세 가지 역할을 한다.

  1. Filter: 제품, tenant, 날짜와 권한 범위를 먼저 제한한다.
  2. Context: 제목과 section 범위를 model과 embedding에 제공한다.
  3. Citation: 원문 문서와 위치를 추적한다.

본문 앞에 제목을 붙여 embedding할 수도 있다.

문서: 계정 보안 정책
경로: 로그인 > 계정 잠금 > SSO 예외

SSO 계정은 외부 IdP의 잠금 정책을 따른다.

다만 반복 prefix가 모든 vector를 비슷하게 만들 수 있다. Prefix 포함 여부도 evaluation 대상으로 둔다.

표 코드 이미지 문서는 별도로 처리한다

표를 row마다 자르면 header와 단위를 반복 부착한다.

Plan=Pro | Retention=90 | Unit=days | Effective=2026-04-01

표 전체가 작다면 하나로 보존하고, 크다면 caption·header·row group을 결합한다. 병합 cell과 각주는 parser 단계에서 명시적으로 펼친다.

코드

코드는 token window보다 AST 경계가 유용하다.

type CodeChunk = {
  filePath: string;
  symbolName: string;
  symbolKind: "function" | "class" | "method";
  signature: string;
  imports: string[];
  code: string;
};

함수가 너무 크면 내부 block을 나누되 signature, class name과 doc comment를 각 child에 제공한다. 단순한 예시 코드이며 언어별 parser가 필요하다.

이미지와 도표

PDF의 diagram을 text extraction에서 버리면 chunk size로 복구할 수 없다. Caption, OCR, 주변 paragraph와 필요한 경우 vision description을 하나의 multimodal artifact로 연결한다.

{
  "kind": "figure",
  "page": 12,
  "caption": "요청 승인 상태 전이",
  "imageArtifactId": "img-82",
  "surroundingSectionId": "section-approval-flow"
}

Embedding 모델과 Context Window의 영향

Embedding model의 최대 입력보다 큰 chunk는 truncate되거나 오류가 난다. 더 위험한 경우는 pipeline이 조용히 뒤쪽을 잘라 색인하는 것이다.

if (tokens.length > embeddingModel.maxInputTokens) {
  throw new Error(`chunk exceeds embedding limit: ${tokens.length}`);
}

한도 안에 들어간다고 표현 품질이 같은 것은 아니다. 긴 passage의 여러 의미가 하나의 fixed-size vector에 압축될 수 있다. Query와 document의 입력 format, 언어, domain도 영향을 준다.

Generation model의 context window가 커졌다고 chunk를 무조건 크게 만들 이유도 없다. 검색 정확도와 근거 위치 문제는 여전히 남는다. 큰 window는 더 많은 parent와 source를 조립할 여지를 줄 뿐이다.

Late Chunking 연구는 긴 문서 token을 먼저 문맥화한 뒤 pooling 단계에서 chunk를 나누어 주변 문맥을 반영하는 방법을 제안한다. 일반적인 독립 chunk embedding의 문맥 손실을 줄이는 선택지지만, 사용하는 embedding stack의 지원과 corpus 평가가 필요하다.

재구성한 구조 기반 Chunker

다음은 Markdown section과 paragraph를 보존하면서 최대 token을 제한하는 개념 예시다. 실제 parser 구현을 가져온 것이 아니다.

type Block = {
  kind: "heading" | "paragraph" | "list" | "code" | "table";
  text: string;
  headingPath: string[];
  sourceOffset: { start: number; end: number };
};

type Chunk = {
  text: string;
  headingPath: string[];
  sourceOffsets: Array<{ start: number; end: number }>;
  tokenCount: number;
};
function chunkBlocks(
  blocks: Block[],
  policy: ChunkPolicy,
  countTokens: (text: string) => number,
): Chunk[] {
  const chunks: Chunk[] = [];
  let current: Block[] = [];

  for (const block of blocks) {
    const candidate = [...current, block];
    const candidateText = renderBlocks(candidate);

    if (countTokens(candidateText) <= policy.maxTokens) {
      current = candidate;
      continue;
    }

    if (current.length > 0) {
      chunks.push(toChunk(current, countTokens));
      current = [];
    }

    if (countTokens(block.text) > policy.maxTokens) {
      chunks.push(...splitOversizedBlock(block, policy, countTokens));
    } else {
      current = [block];
    }
  }

  if (current.length > 0) chunks.push(toChunk(current, countTokens));
  return mergeTinyNeighbors(chunks, policy.minTokens, policy.maxTokens);
}

실제 구현에서는 heading이 바뀔 때 current를 flush할지, code와 table을 어떤 fallback으로 나눌지 정책이 더 필요하다. sourceOffsets를 보존하면 citation과 원문 확인이 가능하다.

작은 trailing chunk를 무조건 이전 chunk와 합치면 다른 section이 섞일 수 있다. 같은 heading parent 안에서만 병합한다.

function mayMerge(left: Chunk, right: Chunk, maxTokens: number): boolean {
  return (
    left.headingPath.join("/") === right.headingPath.join("/") &&
    left.tokenCount + right.tokenCount <= maxTokens
  );
}

평가 질문을 만드는 방법

Chunk 실험은 실제 사용자 질문과 정답 근거가 있어야 한다. 정답 문자열만 두지 말고 어느 source span이 필요한지도 표시한다.

type RetrievalCase = {
  id: string;
  query: string;
  answer?: string;
  requiredEvidence: Array<{
    sourceId: string;
    startOffset: number;
    endOffset: number;
  }>;
  expectedAbstention: boolean;
  category: "exact" | "local" | "multi-section" | "comparison" | "missing";
};

질문 유형을 골고루 포함한다.

Synthetic question을 만들 수 있지만 model이 chunk text의 표현을 그대로 사용하면 실제 사용자 query보다 쉬워진다. 검색 로그에서 익명화한 질문과 domain expert가 만든 hard case를 섞는다.

Retrieval과 답변을 따로 평가한다

최종 답이 틀렸을 때 retrieval이 실패했는지 generator가 근거를 잘못 사용했는지 구분해야 한다.

flowchart LR
    Q[Evaluation Query] --> R[Retriever]
    R --> E{필수 Evidence 검색?}
    E -- 아니오 --> RF[Retrieval Failure]
    E -- 예 --> G[Generator]
    G --> A{답변과 인용 정확?}
    A -- 아니오 --> GF[Generation Failure]
    A -- 예 --> P[Pass]

Retrieval 단계에서 볼 수 있는 지표는 다음과 같다.

지표 의미
Hit@k Top-k 중 필요한 evidence가 하나라도 있는가
Recall@k 필요한 evidence span을 얼마나 찾았는가
Precision@k 검색 결과 중 관련 chunk 비율
MRR 첫 관련 결과가 얼마나 위에 있는가
nDCG@k 여러 relevance 수준과 순위를 반영
evidence coverage 답변의 모든 claim 근거가 포함됐는가

Chunk가 크면 정답 span을 조금 포함하기만 해도 hit로 계산되어 좋아 보일 수 있다. Chunk 전체에서 relevant token 비율이나 evidence density를 함께 보면 긴 chunk의 false advantage를 줄일 수 있다.

End-to-end에서는 answer correctness, faithfulness, citation accuracy, abstention과 latency를 본다. LLM judge만 의존하지 않고 exact fact, source span과 사람이 검토한 subset을 사용한다.

비용과 증분 색인을 함께 계산한다

Chunk size를 줄이면 chunk 수와 vector 수가 늘어난다.

index cost
  = embedding input tokens
  + vector count × storage
  + metadata storage
  + indexing requests

동일 문서 token 수라면 overlap이 없을 때 embedding input token 총량은 비슷할 수 있지만 vector 수가 증가한다. Overlap과 반복 metadata prefix가 있으면 token도 늘어난다.

Query 비용도 달라진다.

query cost
  = candidate retrieval
  + reranking candidates
  + context assembly tokens
  + generation tokens

작은 chunk를 100개 rerank하는 방식은 recall을 높일 수 있지만 latency와 비용이 커진다. 큰 chunk 5개와 비교할 때 최종 context token budget과 candidate 수를 따로 기록한다.

증분 색인 안정성도 중요하다. Fixed token window는 문서 앞에 문장 하나가 추가되면 뒤쪽 경계와 chunk ID가 모두 바뀔 수 있다. Section과 content hash 기반 ID를 사용하면 변경된 section만 다시 색인하기 쉽다.

const chunkId = sha256([
  sourceStableId,
  headingPath.join("/"),
  normalizedChunkText,
].join("\n"));

실험 절차와 결과표 예시

한 번에 모든 변수를 바꾸면 원인을 알 수 없다. Baseline을 고정하고 단계별로 비교한다.

1. Parser output을 sample 문서에서 수동 확인
2. 평가 query와 evidence span 분리
3. Embedding model과 retriever 고정
4. Size 200 / 400 / 800 token 비교
5. 최선 size에서 fixed / structure-aware 비교
6. Overlap 0 / 40 / 80 비교
7. Parent-child와 reranker 추가 효과 비교
8. 최종 답변과 운영 비용 평가
9. Held-out query로 최종 확인

결과는 하나의 accuracy만 보지 않는다.

정책 Hit@5 Evidence Recall 답변 정확도 Citation 정확도 p95 지연 Index Vector 수
200 fixed 0.84 0.72 0.68 0.76 620ms 120,000
400 structural 0.88 0.81 0.77 0.86 590ms 67,000
800 structural 0.82 0.78 0.74 0.71 560ms 38,000
250 child + parent 0.91 0.87 0.81 0.84 710ms 102,000
표의 숫자는 설명용 가상 결과다

실제 corpus에서 동일한 경향이 나온다는 뜻이 아니다. 비교할 column과 trade-off를 보여 주기 위한 예시다.

Category별 결과도 분리한다. Exact ID 질문은 keyword retrieval 영향이 더 크고 multi-section 질문은 parent expansion의 영향을 더 받을 수 있다. 전체 평균만 보면 작은 유형의 심각한 실패가 숨는다.

운영 중 Chunk 정책을 바꾸는 방법

Chunk 정책은 index schema의 일부다. 운영 중에 바꾸면 기존과 새 vector가 섞이지 않도록 version을 둔다.

{
  "indexVersion": "knowledge-v12",
  "parserVersion": "markdown-4",
  "chunkPolicyVersion": "structural-400-v3",
  "embeddingModel": "example-embedding-v2",
  "createdAt": "2026-05-19T00:00:00Z"
}

새 정책은 별도 index에 backfill하고 동일 evaluation과 shadow query로 비교한다.

flowchart LR
    Q[Live Query] --> O[Old Index]
    Q -. shadow .-> N[New Index]
    O --> A[실제 답변]
    N --> M[비교 Metrics]
    O --> M

승격 전 확인할 항목은 다음과 같다.

운영 query에서 “정답이 바로 옆 chunk에 있었음”, “heading이 없어서 주체를 잃음” 같은 failure label을 수집하면 다음 chunk 정책 실험으로 연결할 수 있다.

마무리

RAG의 chunk 크기는 embedding model의 최대 token에서 임의의 숫자를 고르는 문제가 아니다. 문서가 가진 의미 경계, 사용자가 묻는 질문 범위, 검색 단위와 model에 반환할 문맥 단위를 함께 설계해야 한다.

작게 검색하는 것과 충분한 문맥을 제공하는 것이 충돌하면 하나의 chunk 크기로 타협하지 말고 child 검색과 parent 확장을 분리한다.

실무에서는 다음 순서로 접근할 수 있다.

  1. Chunking 전에 parser가 heading, 표, code와 읽기 순서를 보존하는지 확인한다.
  2. API, FAQ, 정책, 코드와 회의록에 서로 다른 구조 경계를 사용한다.
  3. 의미 단위로 먼저 나누고 embedding token 수로 최대값을 제한한다.
  4. 평균뿐 아니라 chunk size 분포와 너무 작은 조각 비율을 본다.
  5. Overlap을 필수값으로 두지 않고 실제 경계 질문으로 평가한다.
  6. 작은 child 검색과 parent 또는 neighbor 반환을 검토한다.
  7. Heading path, version, source와 권한 metadata를 보존한다.
  8. 표·코드·이미지를 일반 paragraph와 다르게 처리한다.
  9. 실제 질문과 정답 evidence span을 가진 평가 집합을 만든다.
  10. Retrieval recall과 최종 답변·인용을 별도로 평가한다.
  11. 비교할 때 top-k가 아니라 최종 context token budget도 맞춘다.
  12. Latency, vector 수와 증분 재색인 비용을 함께 본다.
  13. Parser·chunk·embedding version을 index에 기록한다.
  14. 새 정책을 별도 index와 shadow query로 검증한 뒤 전환한다.

좋은 chunk는 단순히 비슷한 길이의 문자열이 아니다. 사용자의 질문으로 발견할 수 있고, 발견된 뒤에는 조건과 출처를 잃지 않은 채 답변의 근거가 되는 단위다.

참고 자료

관련 노트