본문으로 건너뛰기

청킹 전략

청킹(chunking)은 문서를 임베딩·검색 단위로 잘게 나누는 과정이다. 청크를 작게 잡으면 검색 정밀도는 좋아지지만 반환되는 텍스트가 문맥 없이 짧게 끊기고, 크게 잡으면 문맥은 살지만 벡터 하나에 여러 주제가 섞여 검색 정밀도가 떨어진다 — 청킹 전략은 결국 이 트레이드오프를 어떤 기준으로 완화할지에 대한 선택이다.

chunking.strategy(settings.yaml, KB 단위 오버라이드 가능)로 세 가지 전략 중 하나를 고른다.

전략분할 기준추가 비용적합한 문서
recursive글자 수(토큰) — 구분자 캐스케이드로 문장 경계를 존중없음대부분의 일반 문서, 기본값
semantic문장 간 임베딩 거리(주제 전환)청킹 시점에 임베딩 API 호출문단 경계가 뚜렷하지 않거나 주제 전환이 잦은 문서
hierarchical위 두 전략과 별개 축 — 여러 크기 레벨을 동시에 유지Postgres 저장 공간 증가, 검색 시 auto-merge 단계 추가길고 구조화된 문서(매뉴얼, 계약서 등)

세 전략의 차이를 아래 원문 하나로 비교한다. 세 문단은 각각 "하이브리드 검색", "리랭킹", "임베딩"이라는 서로 다른 주제를 다루며, 각 전략이 이 경계를 어떻게(또는 어떻게 못) 알아채는지가 이어지는 예시의 관찰 포인트다.

하이브리드 검색은 Dense 벡터와 Sparse 벡터를 결합해 검색 정확도를 높이는 방식이다.
Dense 벡터는 의미 기반 유사도를 포착하고, Sparse 벡터는 키워드 일치를 포착한다.
두 점수는 RRF(Reciprocal Rank Fusion)로 통합되어 최종 순위가 결정된다.

리랭킹은 검색 후보 결과에 대해 정밀한 재순위를 수행하는 단계다.
Jina API 같은 외부 리랭커를 사용하거나, 자체 호스팅한 Cohere 호환 서버를 사용할 수 있다.
리랭커가 비활성화된 경우 RRF 점수를 그대로 최종 순위로 사용한다.

임베딩은 텍스트를 벡터로 변환하는 과정이다.
Dense 임베딩은 Ollama(bge-m3)나 OpenAI 모델을 사용하고, Sparse 임베딩은 BM25 기반 TF 인코더를 사용한다.
두 벡터는 각각 Qdrant에 저장되어 하이브리드 검색에 활용된다.

Recursive

chunking.strategy: "recursive" (기본값). LlamaIndex SentenceSplitter를 그대로 사용한다. 동작은 구분자 캐스케이드 하나로 요약된다 — 원문을 문단 → 문장 → 절 → 단어 순으로 점점 더 잘게 쪼개다가, 쪼갠 단위들을 chunk_size를 넘지 않는 선까지 다시 그리디하게 합친다. chunk_size/chunk_overlap는 글자 수가 아니라 토큰 수(tiktoken cl100k_base 기준)로 세므로, 아래 예제에서 보이는 글자 수와 실제 토큰 수는 다를 수 있다.

Document


구분자 캐스케이드로 원문을 잘게 쪼갠다
1) 문단 구분자 "\n\n\n" (줄바꿈 3개)
2) 문장 구분자 (마침표 등 문장 종결 부호)
3) 절 구분자 (쉼표 등 secondary_chunking_regex)
4) 공백(단어) 구분자

쪼갠 단위를 chunk_size까지 그리디하게 병합
│ 단위 하나가 이미 chunk_size를 넘으면 다음 캐스케이드 레벨로 재귀 진입 → "recursive"

각 청크 시작 위치를 chunk_overlap만큼 앞으로 되돌려 다음 청크 시작점으로 사용

이름의 "recursive"는 이 캐스케이드에서 온다 — 문단 단위로 쪼갠 결과가 여전히 chunk_size보다 크면 문장 단위로, 그래도 크면 절 단위로, 그래도 크면 단어 단위로 재귀적으로 더 잘게 쪼갠다 (pipeline/steps/chunk.py_build_parser). 다음 청크는 처음부터 새로 시작하지 않고 직전 청크 끝에서 chunk_overlap만큼 뒤로 물러난 지점부터 시작해, 청크 경계에서 문맥이 완전히 끊기지 않게 한다.

여기서 문단 구분자가 "\n\n\n"(줄바꿈 3개)이라는 점이 중요하다 — 위 원문처럼 문단 사이가 빈 줄 하나("\n\n")로만 구분된 일반적인 문서는 이 레벨에서 걸리지 않고 곧바로 문장 단위 캐스케이드로 넘어간다. chunk_size=60, chunk_overlap=10으로 위 원문을 실제로 분할하면 다음과 같은 청크가 나온다.

chunk 0: "하이브리드 검색은 Dense 벡터와 Sparse 벡터를 결합해 검색 정확도를 높이는 방식이다."
chunk 1: "Dense 벡터는 의미 기반 유사도를 포착하고, Sparse 벡터는 키워드 일치를 포착한다."
chunk 2: "두 점수는 RRF(Reciprocal Rank Fusion)로 통합되어 최종 순위가 결정된다."
chunk 3: "리랭킹은 검색 후보 결과에 대해 정밀한 재순위를 수행하는 단계다."
chunk 4: "Jina API 같은 외부 리랭커를 사용하거나, 자체 호스팅한 Cohere 호환 서버를 사용할 수 있다."
chunk 5: "리랭커가 비활성화된 경우 RRF 점수를 그대로 최종 순위로 사용한다.

임베딩은 텍스트를 벡터로 변환하는 과정이다."
chunk 6: "Dense 임베딩은 Ollama(bge-m3)나 OpenAI 모델을 사용하고, Sparse 임베딩은 BM25 기반 TF 인코더를 사용한다."
chunk 7: "두 벡터는 각각 Qdrant에 저장되어 하이브리드 검색에 활용된다."

대부분 문장 하나 = 청크 하나로 나뉘지만, chunk 5는 "리랭킹" 문단의 마지막 문장과 "임베딩" 문단의 첫 문장을 하나로 합쳤다. 두 문장을 합친 텍스트는 64글자라 얼핏 chunk_size(60)를 넘어 보이지만, 토큰 수로는 57토큰이라 여전히 60 이내다 — 그리디 병합 로직은 이 토큰 수만 보고 그대로 묶어버린 것이다. 문장 경계는 존중하지만 그 문장이 어떤 주제에 속하는지는 전혀 모른다 — 이 한계가 바로 아래 semantic 전략의 존재 이유다.

chunk_overlap이 실제로 어떻게 나타나는지는 하나의 문장이 chunk_size보다 길어 단어 단위까지 재귀적으로 쪼개질 때 드러난다. chunk_size=15, chunk_overlap=5로 문장 하나 ("Dense 벡터는 의미 기반 유사도를 포착하고, Sparse 벡터는 키워드 일치를 포착한다.")를 분할하면 이런 청크가 나온다.

chunk 0: "Dense 벡터는 의미 기반"
chunk 1: "기반 유사도를 포착하고, Sparse"
chunk 2: "Sparse 벡터는 키워드 일치를"
chunk 3: "일치를 포착한다."

chunk 0의 끝("기반")이 chunk 1의 시작과 겹치고, chunk 1의 끝("Sparse")이 chunk 2의 시작과 겹친다 — 인접한 청크끼리 경계 문맥을 공유하게 하는 chunk_overlap의 동작이 그대로 보인다.


Semantic

chunking.strategy: "semantic". LlamaIndex SemanticSplitterNodeParser를 사용한다. 핵심 가정은 "문장 사이 임베딩 거리가 클수록 주제가 바뀌었을 가능성이 높다"는 것이다 — 청킹 시점에 임베딩 모델(build_embed_model())을 호출하므로, recursive/hierarchical과 달리 청킹 자체가 API 비용을 발생시킨다.

Document


문장 단위로 1차 분할 (recursive와 동일한 문장 분리기 사용)

각 문장을 buffer_size만큼 앞뒤 문장과 묶어 "결합 문장" 생성 (buffer_size=1 → 앞뒤 1개씩)

결합 문장 전체를 embed_model로 임베딩 (API 호출 발생)

인접한 결합 문장 임베딩 간 코사인 거리 계산

거리 값들의 semantic_threshold 백분위수를 breakpoint 기준으로 계산
│ (semantic_threshold=0.8 → 80th percentile)

기준을 넘는 지점만 분할 경계로 채택 → 그 경계로 원래 문장들을 묶어 청크 생성

문장 하나만으로 임베딩하면 노이즈가 커서, buffer_size만큼 앞뒤 문장을 묶은 "결합 문장" 단위로 임베딩해 신호를 안정화한다(buffer_size=1이면 문장 i의 결합 문장은 문장 i-1, i, i+1을 이어 붙인 텍스트). 이렇게 얻은 결합 문장 임베딩들을 순서대로 비교해 코사인 거리를 구하고, 그 거리 분포에서 semantic_threshold(기본 0.8) 백분위수를 넘는 지점만 실제 분할 경계로 채택한다 — 전체 거리 중 상위 20%에 해당하는, 유난히 튀는 지점만 주제 전환으로 판단하겠다는 뜻이다 (_build_parserbreakpoint_percentile_threshold=int(semantic_threshold * 100)). recursive처럼 chunk_size로 크기를 강제하지 않으므로, 결과 청크의 길이는 그 문단이 실제로 몇 문장인지에 따라 달라진다.

같은 원문, semantic_threshold=0.8(기본값)로 bge-m3 임베딩을 사용해 실제로 얻은 문장 간 거리는 다음과 같다(문장 순서대로, 다음 문장과의 거리).

문장다음 문장과의 거리
"하이브리드 검색은 ... 방식이다."0.057
"Dense 벡터는 ... 포착한다."0.126
"두 점수는 RRF ... 결정된다."0.207
"리랭킹은 ... 수행하는 단계다."0.089
"Jina API ... 사용할 수 있다."0.156
"리랭커가 비활성화된 ... 사용한다."0.173
"임베딩은 ... 변환하는 과정이다."0.159
"Dense 임베딩은 ... 사용한다."0.102

80th percentile 기준값은 약 0.168이다. 이 값을 넘는 두 지점(굵게 표시, 0.207과 0.173)만 분할 경계로 채택되고, 나머지는 경계로 인정되지 않는다. 그 결과 청크는 다음과 같이 나뉜다.

chunk 0: "하이브리드 검색은 ... 결정된다." (첫 문단 3문장 그대로)
chunk 1: "리랭킹은 ... 사용한다." (둘째 문단 3문장 그대로)
chunk 2: "임베딩은 ... 활용된다." (셋째 문단 3문장 그대로)

recursive 예제의 chunk 5(리랭킹 문단 마지막 문장 + 임베딩 문단 첫 문장이 합쳐짐)와 달리, semantic은 두 문장 사이의 임베딩 거리(0.173)가 유난히 크다는 걸 근거로 정확히 그 경계에서 끊었다 — chunk_size 같은 길이 제한 없이도 결과가 문단 경계와 정확히 일치한다.


Hierarchical

chunking.strategy: "hierarchical". LlamaIndex HierarchicalNodeParser로 하나의 문서를 여러 크기 레벨로 동시에 분할하고, 검색 시 매칭된 하위 청크(leaf)를 상위 청크(ancestor)로 자동 병합(auto-merge)해 반환한다. chunk_size가 다른 두 전략과 달리 리스트다(예: [600, 200, 60], 큰 것 → 작은 것 순, 마지막 값이 leaf 크기).

인제스트: 여러 레벨을 동시에 저장

Document


HierarchicalNodeParser(chunk_sizes=[600, 200, 60], ...) 로 N-level 분할
│ 레벨마다 SentenceSplitter로 분할 (recursive와 동일한 캐스케이드), leaf 레벨에만 chunk_overlap 적용

get_leaf_nodes() 로 최하위 레벨만 추출 → 실제 임베딩·검색 대상(leaf)
│ 나머지 레벨(root, mid)은 ParentChunk로 변환, 임베딩하지 않음

embed() leaf만 임베딩


upsert() ┌─ Postgres: parent_chunks에 ancestor 저장 (자기참조 트리, root부터 순서대로)
└─ Qdrant: leaf 벡터 저장, payload에 parent_chunk_id(바로 위 레벨 하나)만 포함

leaf만 임베딩·검색 대상이고, root/mid 레벨(ancestor)은 Postgres parent_chunks 테이블에 텍스트로만 저장된다 — Qdrant는 벡터가 필요 없는 데이터를 담지 않는다는 원칙 때문이다.

검색: auto-merge로 문맥 복원

query


index.as_retriever().retrieve(query) 기존과 동일 — leaf 결과 반환 (parent_chunk_id 포함)


[retrieval.auto_merge.enabled] _auto_merge_parents()
│ 레벨을 하나씩 올라가며 반복:
│ 같은 parent_chunk_id를 가진 leaf/이미 병합된 결과를 그룹핑
│ (그룹 내 매칭 개수) / (그 parent의 child_count) >= merge_threshold 이면 parent 텍스트로 치환
│ 미달이면 그 레벨에서 확정(더 이상 위로 올리지 않음)

RRF 병합 / 리랭킹 (기존과 동일)

검색은 항상 leaf 단위로 먼저 일어나고, "이번 질의에서 같은 부모 아래 leaf가 여러 개 매칭됐다"는 사실 자체를 문맥 확장의 근거로 삼는다 — 매칭 개수 비율이 merge_threshold(기본 0.5) 이상인 그룹만 부모 텍스트 전체로 치환되고, 치환된 결과는 그 부모의 부모(한 레벨 더 위)를 기준으로 다시 그룹핑되어 같은 판정을 반복한다.

이미 이번 레벨에서 병합에 실패한 결과는 다음 레벨 재시도 대상에서 제외된다 — 그러지 않으면 이미 확정된 결과가 상위 레벨에서 엉뚱하게 다시 채택될 수 있다.

병합 여부는 임베딩 유사도가 아니라 구조적 인접성(같은 parent 아래 몇 개가 매칭됐는가)만 본다 — 목적이 "비슷한 청크 압축"이 아니라 "이미 매칭된 leaf 주변의 문맥 복원"이기 때문이다.

chunk_size=[600, 200, 60](3-level)로 위 원문(문서 전체가 하나의 root)을 분할하면 다음과 같은 트리가 만들어진다.

g0 (root, child_count=3) -- 문서 전체
├── p0 (문단1, child_count=3) -- "하이브리드 검색" 문단
│ ├── leaf c11 "하이브리드 검색은 ... 방식이다."
│ ├── leaf c12 "Dense 벡터는 ... 포착한다."
│ └── leaf c13 "두 점수는 ... 결정된다."
├── p1 (문단2, child_count=3) -- "리랭킹" 문단
│ ├── leaf c21 "리랭킹은 ... 단계다."
│ ├── leaf c22 "Jina API ... 수 있다."
│ └── leaf c23 "리랭커가 비활성화된 ... 사용한다."
└── p2 (문단3, child_count=3) -- "임베딩" 문단
├── leaf c31 "임베딩은 ... 과정이다."
├── leaf c32 "Dense 임베딩은 ... 사용한다."
└── leaf c33 "두 벡터는 ... 활용된다."

질의 "하이브리드 검색이랑 RRF 설명해줘"로 검색하면(KB 전체 top_k=5, 다른 문서 청크 2개 포함) 다음 결과를 얻는다.

top_k=5 결과: [doc_X_chunk(0.90), c11(0.88), c13(0.85), doc_Y_chunk(0.80), c22(0.75)]

merge_threshold=0.5로 auto-merge를 적용하면 두 라운드에 걸쳐 병합이 진행된다.

  • 1단계 (leaf → 문단): p0는 매칭 2개(c11, c13) / child_count 3 = 0.67 ≥ 0.5 → p0 텍스트로 병합. p1은 매칭 1개(c22) / child_count 3 = 0.33 < 0.5 → 병합하지 않고 그대로 확정. p2는 매칭 자체가 없어 그룹이 생기지 않는다.
  • 2단계 (문단 → root): 1단계에서 병합된 결과(p0)만 다음 라운드에 남는다. g0 기준으로는 매칭 1개(병합된 p0) / child_count 3(p0/p1/p2) = 0.33 < 0.5 → 더 병합하지 않고 종료.

최종 결과는 4개다 — c11/c13이 p0(하이브리드 검색 문단 전체) 텍스트로 치환되고, c22는 임계값 미달로 원래 문장 그대로 남으며, g0(문서 전체)까지는 확장되지 않는다.

[doc_X_chunk(0.90), p0_merged(0.865, "하이브리드 검색은 ... 결정된다."), doc_Y_chunk(0.80), c22(0.75)]

이 질의가 문서 전체 주제가 아니라 "하이브리드 검색" 섹션 하나에만 해당한다는 점이 병합 범위에 그대로 반영된 것이다 — p1/p2가 매칭되지 않았기 때문에 g0(문서 전체)까지 확장할 근거가 부족하다고 판단했다.


요약

recursivesemantichierarchical
분할 기준글자 수(토큰)문장 간 임베딩 거리여러 크기 레벨 동시 유지
청킹 시 임베딩 API 호출없음있음없음(leaf는 이후 embed 단계에서 임베딩)
검색 시 추가 동작없음없음auto-merge(레벨별 Postgres 조회)
결과 크기 예측 가능성높음(chunk_size 고정)문단 길이에 따라 가변병합 여부에 따라 가변(leaf 몇 개 ~ 문서 전체)
저장 공간기준기준과 동일레벨 수만큼 원문이 중첩 저장되어 증가