Skip to content

Split View: RAG 청킹 전략 완전 가이드: 나이브 분할부터 RAPTOR까지

|

RAG 청킹 전략 완전 가이드: 나이브 분할부터 RAPTOR까지

왜 청킹이 RAG 품질의 70%를 결정하는가

"Garbage in, garbage out."

RAG 시스템을 처음 구축해보면 이 말을 뼈저리게 느끼게 된다. 청킹이 나쁘면 아무리 좋은 임베딩 모델, 아무리 비싼 LLM을 써도 소용없다.

청킹(chunking)이란 긴 문서를 검색 가능한 작은 단위로 나누는 작업이다. 단순하게 들리지만, 여기서 내리는 결정 하나하나가 최종 RAG 성능에 엄청난 영향을 준다.

왜 청킹이 이렇게 중요한가?

임베딩의 정보 밀도 문제: 임베딩 모델은 하나의 벡터로 전체 청크를 표현한다. 청크가 너무 크면 핵심 의미가 희석되고, 청크가 너무 작으면 맥락이 사라진다.

검색 vs. 생성의 트레이드오프: 검색에는 작고 집중된 청크가 유리하고, 생성에는 충분한 컨텍스트가 필요하다. 이 둘을 동시에 만족시키는 게 청킹 전략의 핵심이다.

문서 구조 파괴 문제: 단순히 글자 수로 자르면 문장 중간, 목록 중간이 잘려나가 의미가 완전히 깨진다.

이 글에서는 5가지 청킹 전략을 실제 코드와 함께 살펴보고, 언제 무엇을 써야 하는지 판단 기준을 제시한다.

전략 1: Fixed-size Chunking (가장 단순)

가장 기본적인 방법이다. 일정 토큰/문자 수 단위로 자르되, overlap을 줘서 경계에서 잘리는 문제를 완화한다.

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,        # 토큰 수 기준 (대략 문자 수의 75%)
    chunk_overlap=50,      # 겹치는 토큰 수
    separators=["\n\n", "\n", ".", " ", ""]
    # 위에서부터 우선순위: 단락 > 줄 > 문장 > 단어 > 문자
)

chunks = splitter.split_text(document)
print(f"총 {len(chunks)}개 청크 생성됨")
print(f"첫 번째 청크: {chunks[0][:100]}...")

RecursiveCharacterTextSplitterseparators 리스트를 순서대로 시도한다. 먼저 \n\n(단락)으로 자르려 하고, 청크가 너무 크면 \n으로, 그래도 크면 .으로 자른다.

장점: 구현이 단순하고 빠르다. 청크 크기가 예측 가능하다.

단점: 의미 단위를 고려하지 않는다. "이 제품의 가격은 10만원이며" 에서 잘릴 수 있다. overlap이 있어도 완전한 해결책은 아니다.

언제 쓰는가: 빠른 프로토타입, 문서 구조가 일정한 경우, 비용이 중요할 때.

전략 2: Semantic Chunking (의미 기반)

임베딩 유사도를 이용해 의미가 변하는 지점에서 자르는 방법이다. 문장 간 임베딩 거리가 급증하는 지점을 경계로 삼는다.

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai.embeddings import OpenAIEmbeddings

splitter = SemanticChunker(
    OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",  # 또는 "standard_deviation", "interquartile"
    breakpoint_threshold_amount=95           # 상위 5% 거리 변화를 경계로 사용
)

# 내부 동작:
# 1. 각 문장을 임베딩
# 2. 인접 문장 간 코사인 거리 계산
# 3. 거리가 임계값 이상인 지점에서 분할

chunks = splitter.create_documents([long_document])

장점: 의미 단위로 나눠져 각 청크의 내용이 일관되다. 검색 정확도가 눈에 띄게 향상된다.

단점: 임베딩 API 호출 비용이 든다. 속도가 느리다. 짧은 문서에는 과도할 수 있다.

언제 쓰는가: 주제가 다양하게 섞인 긴 문서, 의미 경계가 불명확한 문서, 검색 정확도가 중요한 프로덕션 시스템.

실제 성능 차이

경험상 fixed-size vs. semantic chunking을 비교하면, 동일한 문서 코퍼스에서 semantic chunking이 retrieval precision을 약 15-20% 높인다. 특히 "A인데 B는 아닌" 같은 미묘한 질의에서 차이가 크다.

전략 3: Document-Structure-Aware Chunking

문서의 구조(헤딩, 목록, 코드 블록 등)를 보존하면서 자르는 방법이다.

from langchain.text_splitter import MarkdownHeaderTextSplitter

# Markdown 헤딩 기반 분할
headers_to_split_on = [
    ("#", "h1"),
    ("##", "h2"),
    ("###", "h3"),
]

md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on,
    strip_headers=False  # 헤딩을 청크 내에 유지
)

md_header_splits = md_splitter.split_text(markdown_document)

# 각 청크에 메타데이터로 헤딩 정보가 포함됨
# md_header_splits[0].metadata = {'h1': '제품 소개', 'h2': '주요 기능'}
# 이 메타데이터를 필터링에 활용 가능

# 헤딩으로 나눈 후 크기가 너무 크면 추가로 분할
from langchain.text_splitter import RecursiveCharacterTextSplitter

secondary_splitter = RecursiveCharacterTextSplitter(
    chunk_size=512, chunk_overlap=50
)

final_chunks = secondary_splitter.split_documents(md_header_splits)

HTML 문서의 경우:

from langchain.document_loaders import BSHTMLLoader
from bs4 import BeautifulSoup

# HTML 태그 구조를 보존하면서 파싱
# article, section, div.content 등의 의미 있는 태그 단위로 분할

핵심 인사이트: 문서 구조 정보를 메타데이터로 보존해두면, 나중에 "제품 소개 섹션에서만 검색"하는 필터링이 가능해진다. 이게 단순 fixed-size 청킹과의 결정적 차이다.

언제 쓰는가: Markdown 문서, 기술 문서, 잘 구조화된 HTML 페이지, 섹션 기반 필터링이 필요한 경우.

전략 4: Hierarchical (Parent-Child) Chunking

이 전략이 내가 가장 좋아하는 방법이다. 정확한 검색과 충분한 컨텍스트, 두 마리 토끼를 잡는 접근이다.

핵심 아이디어: 작은 청크로 검색하고, 그 청크의 부모(더 큰 청크)를 LLM에게 전달한다.

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 부모 청크: 큰 컨텍스트 (LLM에게 전달)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)

# 자식 청크: 작고 정확한 단위 (임베딩 및 검색에 사용)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)

# 저장소: 부모 문서 보관
docstore = InMemoryStore()  # 프로덕션에서는 Redis나 DB 사용

# 벡터 스토어: 자식 청크의 임베딩 저장
vectorstore = FAISS.from_documents([], embedding_model)

retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=docstore,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# 문서 추가 시 자동으로 부모-자식 계층 생성
retriever.add_documents(documents)

# 검색 시:
# 1. 쿼리와 유사한 자식 청크 찾기 (작고 정확한 검색)
# 2. 해당 자식의 부모 ID 조회
# 3. 부모 청크 반환 (풍부한 컨텍스트)
results = retriever.get_relevant_documents("검색 쿼리")

왜 이게 효과적인가:

검색 단계에서는 200토큰짜리 작은 청크를 사용하므로 임베딩이 집중되어 있다. "A 제품의 출시 날짜는?"이라는 질문에 정확히 그 정보가 들어있는 작은 청크를 찾는다.

생성 단계에서는 그 청크의 부모인 2000토큰 청크를 LLM에게 넘긴다. LLM은 답변 주변의 맥락을 충분히 가지고 답한다.

실전 팁: InMemoryStore는 프로토타입에만 쓰자. 프로덕션에서는 Redis나 PostgreSQL을 docstore로 사용해야 서버 재시작 시에도 데이터가 유지된다.

전략 5: RAPTOR (재귀적 추상 처리)

RAPTOR는 "Recursive Abstractive Processing for Tree-Organized Retrieval"의 약자다. 2024년 Stanford에서 발표한 방법으로, 문서의 트리 계층 구조를 자동으로 만들어낸다.

핵심 아이디어: 비슷한 청크들을 클러스터링하고, 각 클러스터를 요약해서 상위 노드로 만든다. 이것을 재귀적으로 반복한다.

원본 청크들 (Leaf Nodes):
[제품A 기능1] [제품A 기능2] [제품B 기능1] [제품B 비교]

클러스터링 + 요약 (Level 1):
[제품A 개요: 기능1, 기능2 요약] [제품B 개요: 기능1, 비교 요약]

더 높은 수준 요약 (Level 2):
[전체 제품군 비교 요약]
# RAPTOR 구현 (개념 코드)
from sklearn.mixture import GaussianMixture
import numpy as np

def build_raptor_tree(chunks, embeddings, levels=3):
    """
    RAPTOR 트리를 재귀적으로 구성.
    각 레벨에서 클러스터링 후 요약 생성.
    """
    tree = {'level_0': chunks}

    current_chunks = chunks
    current_embeddings = embeddings

    for level in range(1, levels + 1):
        # 1. GMM으로 클러스터링 (BIC로 최적 k 선택)
        n_clusters = max(1, len(current_chunks) // 5)
        gm = GaussianMixture(n_components=n_clusters, random_state=42)
        gm.fit(current_embeddings)
        labels = gm.predict(current_embeddings)

        # 2. 각 클러스터 내 청크들을 합쳐서 요약
        summaries = []
        for cluster_id in range(n_clusters):
            cluster_chunks = [
                current_chunks[i]
                for i in range(len(current_chunks))
                if labels[i] == cluster_id
            ]
            combined_text = "\n\n".join(cluster_chunks)
            summary = llm.summarize(combined_text)  # LLM으로 요약
            summaries.append(summary)

        tree[f'level_{level}'] = summaries

        # 다음 레벨 입력으로 현재 레벨 요약 사용
        current_chunks = summaries
        current_embeddings = embed(summaries)

    return tree

장점:

  • 구체적인 질문("제품A의 가격은?")과 추상적인 질문("어떤 제품군이 있나요?") 모두 잘 처리한다
  • 계층적 검색이 가능해서 쿼리 수준에 맞는 계층에서 검색할 수 있다

단점:

  • 구축 시간이 길다 (클러스터링 + LLM 요약 반복)
  • 비용이 높다 (요약을 위한 LLM 호출이 많음)
  • 구현 복잡도가 높다

언제 쓰는가: 매우 큰 문서 코퍼스, 다양한 추상화 수준의 질문이 예상되는 경우, 비용보다 품질이 중요한 엔터프라이즈 프로덕션.

청크 크기 선택 가이드

청크 크기적합한 상황주요 위험
128-256 토큰정확한 사실 질의, 단답형컨텍스트 부족으로 LLM이 오답 생성
512-1024 토큰일반적인 QA, 설명형 질의노이즈 증가, 관련성 희석
2048+ 토큰긴 추론 필요, 분석형 질의임베딩 품질 저하, 핵심 정보 희석

경험칙: 대부분의 경우 512토큰이 가장 무난하다. 특수한 이유가 없으면 여기서 시작하자.

실전 청킹 평가 방법

청킹 전략을 바꿀 때마다 "느낌"으로 판단하지 말고 정량적으로 측정하자.

from ragas.metrics import context_precision, context_recall
from ragas import evaluate
from datasets import Dataset

# 평가 데이터셋: 질문 + 정답 쌍
eval_questions = [
    "제품의 반품 기간은?",
    "배송비는 얼마인가?",
    # ...
]
ground_truths = [
    "30일 이내 반품 가능",
    "5만원 이상 무료 배송",
    # ...
]

# 각 청킹 전략으로 RAG 실행 후 결과 수집
def evaluate_chunking_strategy(strategy_name, retriever):
    results = []
    for q, gt in zip(eval_questions, ground_truths):
        retrieved_docs = retriever.get_relevant_documents(q)
        contexts = [doc.page_content for doc in retrieved_docs]
        results.append({
            "question": q,
            "contexts": contexts,
            "ground_truth": gt
        })

    dataset = Dataset.from_list(results)
    scores = evaluate(dataset, metrics=[context_precision, context_recall])

    print(f"\n=== {strategy_name} ===")
    print(f"Context Precision: {scores['context_precision']:.3f}")
    print(f"Context Recall:    {scores['context_recall']:.3f}")
    return scores

# 각 전략 비교
evaluate_chunking_strategy("Fixed-size 512", fixed_retriever)
evaluate_chunking_strategy("Semantic", semantic_retriever)
evaluate_chunking_strategy("Parent-Child", parent_child_retriever)

전략 선택 요약

상황권장 전략
빠른 프로토타입Fixed-size (512 토큰)
구조화된 Markdown/HTML 문서Document-Structure-Aware
주제가 다양한 긴 문서Semantic Chunking
정확한 검색 + 풍부한 컨텍스트 필요Parent-Child
대규모 코퍼스, 다양한 질문 유형RAPTOR
예산이 많고 최고 품질 필요RAPTOR + Parent-Child 혼합

마지막으로

청킹은 "설정하고 잊어버리는" 게 아니다. 서비스가 성장하고 문서 유형이 다양해지면 청킹 전략도 같이 진화해야 한다. 정기적으로 평가 데이터셋을 만들고, 스코어를 측정하고, 개선하는 사이클을 만들자.

다음 포스트에서는 청킹 이후의 핵심인 Hybrid Search (BM25 + 벡터 검색)를 다룬다. 아무리 좋은 청킹을 해도 검색이 나쁘면 말짱 도루묵이다.

RAG Chunking Strategies: From Naive Splitting to RAPTOR

Why Chunking Determines 70% of RAG Quality

"Garbage in, garbage out."

When you first build a RAG system, you feel this statement viscerally. Bad chunking means no embedding model, no matter how good, and no LLM, no matter how expensive, can save you.

Chunking is the process of splitting long documents into smaller units that can be searched. It sounds simple, but every decision you make here has an outsized impact on the final RAG performance.

Why does chunking matter so much?

Information density of embeddings: Embedding models represent an entire chunk as a single vector. If the chunk is too large, the core meaning gets diluted. If it's too small, context disappears.

Retrieval vs. generation trade-off: Small, focused chunks are better for search; the generation step needs sufficient context. The art of chunking is satisfying both simultaneously.

Document structure destruction: Splitting purely by character count cuts through sentences and lists, completely breaking meaning.

This post walks through five chunking strategies with real code and gives you clear criteria for when to use each one.

Strategy 1: Fixed-Size Chunking (The Simplest)

The most basic approach: split at a fixed token/character count with overlap to mitigate boundary cut issues.

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,        # in tokens (roughly 75% of character count)
    chunk_overlap=50,      # overlapping tokens at boundaries
    separators=["\n\n", "\n", ".", " ", ""]
    # Priority order: paragraph > line > sentence > word > character
)

chunks = splitter.split_text(document)
print(f"Generated {len(chunks)} chunks")
print(f"First chunk preview: {chunks[0][:100]}...")

RecursiveCharacterTextSplitter tries each separator in order. It first tries to split on \n\n (paragraph breaks), then \n if chunks are still too large, then . and so on down the list.

Pros: Simple to implement, fast, predictable chunk sizes.

Cons: Ignores semantic boundaries. Can cut mid-sentence. Overlap helps but doesn't fully solve the problem.

When to use: Quick prototypes, documents with regular structure, when cost matters most.

Strategy 2: Semantic Chunking

Uses embedding similarity to find split points where the meaning shifts. Sentence boundaries where embedding distance spikes become chunk boundaries.

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai.embeddings import OpenAIEmbeddings

splitter = SemanticChunker(
    OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",  # or "standard_deviation", "interquartile"
    breakpoint_threshold_amount=95           # split at top 5% distance changes
)

# Internal logic:
# 1. Embed each sentence
# 2. Calculate cosine distance between adjacent sentences
# 3. Split where distance exceeds the threshold

chunks = splitter.create_documents([long_document])

Pros: Chunks are semantically coherent — each chunk is "about one thing." Retrieval precision improves noticeably.

Cons: Incurs embedding API costs. Slower. Overkill for short documents.

When to use: Long documents that cover multiple topics, documents with unclear section boundaries, production systems where search accuracy is critical.

Real-World Performance Difference

From experience comparing fixed-size vs. semantic chunking on the same document corpus: semantic chunking improves retrieval precision by roughly 15-20%. The improvement is most noticeable on nuanced queries like "A but not B."

Strategy 3: Document-Structure-Aware Chunking

Splits while preserving the document's structure (headings, lists, code blocks, etc.).

from langchain.text_splitter import MarkdownHeaderTextSplitter

# Split by Markdown headings
headers_to_split_on = [
    ("#", "h1"),
    ("##", "h2"),
    ("###", "h3"),
]

md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on,
    strip_headers=False  # keep heading text in chunks
)

md_header_splits = md_splitter.split_text(markdown_document)

# Each chunk now has heading metadata:
# md_header_splits[0].metadata = {'h1': 'Product Overview', 'h2': 'Key Features'}
# This metadata enables filtered retrieval!

# If any heading-based chunk is still too large, split further
from langchain.text_splitter import RecursiveCharacterTextSplitter

secondary_splitter = RecursiveCharacterTextSplitter(
    chunk_size=512, chunk_overlap=50
)

final_chunks = secondary_splitter.split_documents(md_header_splits)

Key insight: Preserving document structure as metadata enables filtered search. You can search "only within the Installation section" instead of across everything. This is the critical difference from naive fixed-size chunking.

When to use: Markdown documentation, technical docs, well-structured HTML pages, any use case that needs section-based filtering.

Strategy 4: Hierarchical (Parent-Child) Chunking

This is my personal favorite strategy. It captures both precise retrieval and rich context generation.

Core idea: Use small chunks for searching, but return the parent (larger) chunk to the LLM for generation.

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter

# Parent chunks: large context windows (sent to LLM)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)

# Child chunks: small, focused units (used for embedding and search)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)

# Document store: holds the parent documents
docstore = InMemoryStore()  # use Redis or a DB in production

# Vector store: holds child chunk embeddings
vectorstore = FAISS.from_documents([], embedding_model)

retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=docstore,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# Adding documents automatically creates the parent-child hierarchy
retriever.add_documents(documents)

# At query time:
# 1. Find child chunks similar to the query (small, precise search)
# 2. Look up the parent ID for each matching child
# 3. Return the parent chunk (rich context for the LLM)
results = retriever.get_relevant_documents("What is the return policy?")

Why this works so well:

During retrieval, 200-token child chunks are used, so the embeddings are focused and specific. A query like "What is the release date of Product A?" finds exactly the small chunk containing that information.

During generation, the parent chunk (2000 tokens) is passed to the LLM. The LLM has ample context around the answer to reason correctly.

Production tip: InMemoryStore is only for prototypes. In production, use Redis or PostgreSQL as the docstore so data persists across server restarts.

Strategy 5: RAPTOR (Recursive Abstractive Processing)

RAPTOR stands for Recursive Abstractive Processing for Tree-Organized Retrieval, published by Stanford in 2024. It automatically builds a tree hierarchy from your documents.

Core idea: Cluster similar chunks together, summarize each cluster into a parent node, and repeat recursively.

Leaf Nodes (original chunks):
[Product A Feature 1] [Product A Feature 2] [Product B Feature 1] [Product B Comparison]

Level 1 (cluster + summarize):
[Product A Summary: Feature 1, Feature 2 combined] [Product B Summary: Feature 1, comparison]

Level 2 (higher abstraction):
[Full Product Line Overview]
# RAPTOR implementation (conceptual code)
from sklearn.mixture import GaussianMixture
import numpy as np

def build_raptor_tree(chunks, embeddings, levels=3):
    """
    Recursively build a RAPTOR tree.
    At each level: cluster chunks, then summarize each cluster.
    """
    tree = {'level_0': chunks}

    current_chunks = chunks
    current_embeddings = embeddings

    for level in range(1, levels + 1):
        # 1. Cluster using GMM (select optimal k via BIC)
        n_clusters = max(1, len(current_chunks) // 5)
        gm = GaussianMixture(n_components=n_clusters, random_state=42)
        gm.fit(current_embeddings)
        labels = gm.predict(current_embeddings)

        # 2. Combine chunks in each cluster and summarize
        summaries = []
        for cluster_id in range(n_clusters):
            cluster_chunks = [
                current_chunks[i]
                for i in range(len(current_chunks))
                if labels[i] == cluster_id
            ]
            combined_text = "\n\n".join(cluster_chunks)
            summary = llm.summarize(combined_text)  # LLM call
            summaries.append(summary)

        tree[f'level_{level}'] = summaries

        # Use current level summaries as input for the next level
        current_chunks = summaries
        current_embeddings = embed(summaries)

    return tree

Pros:

  • Handles both specific ("What is the price of Product A?") and abstract ("What product categories exist?") questions well
  • Hierarchical retrieval — search at the right abstraction level for each query

Cons:

  • Slow to build (clustering + LLM summarization, repeated)
  • Expensive (many LLM calls for summarization)
  • Complex to implement correctly

When to use: Very large document corpora, expected queries at various abstraction levels, enterprise production where quality outweighs cost.

Chunk Size Selection Guide

Chunk SizeBest ForMain Risk
128-256 tokensPrecise factual lookups, short answersInsufficient context causes LLM errors
512-1024 tokensGeneral QA, explanatory queriesIncreasing noise, diluted relevance
2048+ tokensComplex reasoning, analytical queriesDegraded embedding quality, key info diluted

Rule of thumb: 512 tokens works well for most cases. Start here unless you have a specific reason to deviate.

Measuring Chunking Quality

Don't rely on gut feeling when changing chunking strategies. Measure quantitatively.

from ragas.metrics import context_precision, context_recall
from ragas import evaluate
from datasets import Dataset

# Evaluation dataset: question + ground truth pairs
eval_questions = [
    "What is the return period?",
    "How much is shipping?",
    # ...
]
ground_truths = [
    "Returns accepted within 30 days",
    "Free shipping on orders over $50",
    # ...
]

def evaluate_chunking_strategy(strategy_name, retriever):
    results = []
    for q, gt in zip(eval_questions, ground_truths):
        retrieved_docs = retriever.get_relevant_documents(q)
        contexts = [doc.page_content for doc in retrieved_docs]
        results.append({
            "question": q,
            "contexts": contexts,
            "ground_truth": gt
        })

    dataset = Dataset.from_list(results)
    scores = evaluate(dataset, metrics=[context_precision, context_recall])

    print(f"\n=== {strategy_name} ===")
    print(f"Context Precision: {scores['context_precision']:.3f}")
    print(f"Context Recall:    {scores['context_recall']:.3f}")
    return scores

# Compare strategies head-to-head
evaluate_chunking_strategy("Fixed-size 512", fixed_retriever)
evaluate_chunking_strategy("Semantic", semantic_retriever)
evaluate_chunking_strategy("Parent-Child", parent_child_retriever)

Strategy Selection Summary

SituationRecommended Strategy
Quick prototypeFixed-size (512 tokens)
Structured Markdown/HTML docsDocument-Structure-Aware
Long docs covering many topicsSemantic Chunking
Need precise retrieval + rich contextParent-Child
Large corpus with diverse query typesRAPTOR
High budget, maximum qualityRAPTOR + Parent-Child hybrid

Final Thoughts

Chunking is not "set it and forget it." As your service grows and document types diversify, your chunking strategy should evolve with it. Build the habit of periodically creating evaluation datasets, measuring scores, and iterating.

The next post covers Hybrid Search (BM25 + vector search) — the other half of the retrieval equation. Even perfect chunking can't save you if the search itself is weak.