청크 분할 메커니즘(Chunking)
청크 분할은 문서를 검색 단위로 나눕니다. 작은 청크는 주제에 집중하는 데 도움이 되고, 큰 청크는 더 많은 문맥을 유지합니다. 청크 크기와 중첩 범위는 문서 구조와 검색 결과에 맞춰 조정해야 합니다.
먼저 UI 기본 설정(청크 512자, 중첩 80자, 적응형 전략)을 사용한 뒤 다음 상황에 따라 조정할 수 있습니다.
| 상황 | 권장 사항 |
|---|---|
| 답변에 문맥이 부족하거나 정보가 불완전함 | chunk_size를 늘리거나 부모-자식 청크를 활성화(자식 청크 검색, 부모 청크로 답변) |
| 검색된 청크와 질문의 관련성이 낮음 | chunk_size를 줄여 각 청크의 주제를 더 집중시킴 |
| 항목형 자료(FAQ, 사전, 매개변수 표) | 중첩을 0으로 설정하여 인접 항목 간 내용 혼입 방지 |
| 긴 서술형 자료(보고서, 논문) | 중첩을 150–200으로 늘려 청크 간 의미 연결 유지 |
| 청크 결과 미리보기 | POST /api/v1/chunker/preview로 미리보기, 데이터베이스에 쓰지 않음 |
청크 설정을 변경한 뒤 기존 문서에 새 설정을 적용하려면 다시 파싱해야 합니다.
매개변수 빠른 참조와 튜닝 권장 사항
| 시나리오 | strategy | chunk_size | chunk_overlap | 기타 |
|---|---|---|---|---|
| 일반 문서(권장 시작점) | auto | 512 | 80 | — |
| 구조화된 기술 문서 / 매뉴얼 | auto(heading 선택) | 512–1024 | 80 | 탐색 경로 자동 적용 |
| OCR PDF / 일반 텍스트 책 | auto(heuristic 선택) | 512–1024 | 80–150 | languages로 언어를 지정하면 오판 감소 |
| 긴 서술 / 논증형 문서 | auto | 1000–2000 | 150–200 | 부모-자식 청크 병용 가능 |
| 정밀 검색 + 긴 문맥 | 모두 가능 | — | — | enable_parent_child=true, parent 4096 / child 384 |
| FAQ / 원자적 레코드 | 해당 없음(FAQ KB는 항목별 청크) | — | 0 | FAQIndexMode로 답변 인덱싱 여부 제어 |
| token 상한이 엄격한 embedding 모델 | 모두 가능 | — | — | token_limit을 설정하면 문자 예산 자동 환산 |
| 구버전 동작 재현 | legacy | 기존 값 | 명시적으로 64 설정 | ChunkingConfig(KB 수준, 업로드별 재정의 가능)의 마이그레이션 주의 사항 참고 |
청크 분할 메커니즘 참고
WeKnora의 청크 분할은 Go 측(internal/infrastructure/chunker 패키지)에서 수행하며, ‘문서 프로파일링 → 계층별 전략 → 결과 검증 → 단계별 폴백’ 적응형 아키텍처를 사용합니다. Python의 docreader/splitter/에는 같은 계통의 재귀 분할기를 docreader sidecar용으로 유지합니다(프로덕션 주 경로는 Go 구현이며, docreader/splitter/splitter.py 주석에 양쪽 기본값이 일치한다고 명시).
설정 모델
ChunkingConfig(KB 수준, 업로드별 재정의 가능)
internal/types/knowledgebase.go:
| 필드 | 유형 | 기본값 | 설명 |
|---|---|---|---|
chunk_size | int | 512(문자) | 단일 청크 목표 크기. 영어 약 100–130 token / 중국어 약 300 token. FAQ형 원자적 내용은 200–400, 긴 서술형 문서는 1000–2000 권장 |
chunk_overlap | int | 80(약 15%) | 인접 청크의 중첩 문자 수. 원자적 데이터는 0, 긴 서술은 150–200 가능. chunk_size/2를 넘으면 절반으로 제한 |
separators | []string | ["\n\n", "\n", "。"] | 재귀 분할의 구분자 우선순위 목록 |
strategy | string | ""(= legacy) | 청크 전략: auto / heading / heuristic / recursive / legacy. 적응형 전략: 세 Tier와 폴백 체인 참고 |
token_limit | int | 0(비활성화) | 근사 token 수 상한으로 청크 크기 제한. >0이면 언어별 문자 예산으로 환산해 더 작은 값 사용(안전 계수 0.9) |
languages | []string | 빈 값(자동 감지) | 휴리스틱 모드의 언어 힌트. 예: ["zh"], ["en","de"] |
enable_parent_child | bool | false | 부모-자식(2단계) 청크 활성화. 부모-자식 청크(Parent-Child / 다중 세분성) 참고 |
parent_chunk_size | int | 4096 | 부모 청크 크기(부모-자식 모드 전용) |
child_chunk_size | int | 384 | 자식 청크 크기(부모-자식 모드 전용). 자식 overlap은 child_size/5(약 20%)로 고정 |
parser_engine_rules | []ParserEngineRule | 빈 값 | 파일 유형 → 파싱 엔진 라우팅. xlsx_first_row_as_header 같은 파서 수준 스위치 포함(청크가 아닌 파싱 설정이지만 같은 구조에 위치) |
table_metadata_instructions | string | 빈 값 | CSV/Excel 표 요약 생성 시 업무 지침 |
기본값의 단일 출처는 chunker 패키지 상수(splitter.go)입니다.
const (
DefaultChunkSize = 512
DefaultChunkOverlap = 80
)마이그레이션 주의(소스 코드 주석 내용): 과거에는 Go DefaultConfig 64, knowledge.go 50, Python docreader 100으로 overlap 기본값이 세 가지였으나 현재는 80으로 통일되었습니다. 기존 KB의 DB에
ChunkOverlap=0이 저장되어 있으면 인덱스 재구축 시 80을 사용하므로 embedding이 이전 값과 비트 단위로 일치하지 않습니다.
SplitterConfig(런타임 설정)
서비스 계층은 buildSplitterConfigFromChunking(knowledge_process.go)으로 ChunkingConfig를 chunker.SplitterConfig{ChunkSize, ChunkOverlap, Separators, Strategy, TokenLimit, Languages}에 매핑합니다. 이후 chunker 패키지의 ensureDefaults가 보정합니다.
TokenLimit > 0이면charBudget = CharsForTokenLimit(TokenLimit, lang)을 계산하고ChunkSize보다 작으면 대체합니다(tokens.go, 문자/Token 비율: en 4.0, de 4.5, zh 1.7, mixed 3.0, 안전 계수 0.9 적용. 청크가 embedding 모델의 token 상한을 넘지 않도록 보장).ChunkOverlap > ChunkSize/2이면ChunkSize/2로 제한합니다.
IndexingStrategy와 청크의 관계
internal/types/indexing_strategy.go의 네 스위치는 청크 결과가 어떤 파이프라인으로 흐를지 결정합니다.
type IndexingStrategy struct {
VectorEnabled bool // 벡터 인덱스
KeywordEnabled bool // BM25 키워드 인덱스
WikiEnabled bool // Wiki 생성
GraphEnabled bool // 그래프 추출
}NeedsChunks()(하나라도 활성화)이 false이면 청크 분할이 필요하지 않습니다.NeedsEmbedding()(vector || keyword)이 false이면 청크를 DB에만 쓰고BatchIndex를 건너뜁니다(processChunks의skipStage(StageEmbedding)).- Wiki / Graph는 모두 텍스트 chunk를 입력으로 받아 후처리 단계에서 사용합니다.
적응형 전략: 세 Tier와 폴백 체인
공개 진입점은 chunker.Split(text, cfg) / chunker.SplitWithDiagnostics(strategy.go)입니다. cfg.Strategy 값과 해석(resolveChainWithProfile):
| Strategy 값 | 시도 체인(Tier Chain) | 설명 |
|---|---|---|
auto | 프로파일러가 결정하며 [heading, heuristic, legacy]의 부분 시퀀스 가능 | 권장값, 문서 구조에 따라 자동 선택 |
heading | [heading, legacy] | 제목 기반 분할 강제, 실패 시 legacy 폴백 |
heuristic | [heuristic, legacy] | 휴리스틱 분할 강제 |
recursive | [legacy] | recursive는 legacy의 공개 별칭 |
legacy / ""(빈 값) | [legacy] | 기존 재귀 분할기, 하위 호환 기본값 |
각 Tier의 출력은 Validator(validator.go)를 통과해야 채택되며, 그렇지 않으면 다음 Tier로 이동합니다. legacy는 최종 대안이므로 검증에 실패해도 그 결과를 반환합니다(빈 결과를 반환하지 않음).
Validator 거부 규칙:
| 규칙 | 거부 이유 문자열 |
|---|---|
| 출력 없음 | no chunks produced |
문서가 2*chunkSize를 넘는데 청크 1개만 생성 | single chunk for large document |
| 마지막이 아닌 <50자 청크가 전체의 1/4을 넘고 >2개 | too many tiny chunks |
최대 청크가 chunkSize/4 미만(과도한 단편화) | all chunks far below target size |
최대 청크가 2*chunkSize 초과(예산 무시) | chunk exceeds 2x target size |
문서 프로파일링(profiler.go)
ProfileDocument(text)는 한 번의 스캔으로 DocProfile을 생성합니다. 총 문자/행 수, 행 길이 평균과 분산, Markdown 수준별 제목 수, 번호가 붙은 절 수, 짧은 대문자 행 수, 연속 빈 행 구간, 페이지 나눔 문자 \f 수, 수평 구분선 수, 독일어/영어/중국어 장 표시 수, 바닥글 행 수, 표/코드 포함 여부, 코드 비율, 언어 감지 결과를 포함합니다(앞 4096바이트 표본을 DetectLanguage가 CJK/라틴 문자 비율로 zh/de/en/mixed 판정).
SelectStrategy(profile)는 시도 체인을 구성합니다.
// Tier 1 후보: Markdown 제목 구조
if p.MdHeadingTotal >= 3 && p.HeadingDensity() > 0.005 && p.DominantHeadingLevel() > 0 {
chain = append(chain, TierHeading)
}
// Tier 2 후보: 휴리스틱 경계
if p.HeuristicMarkerTotal() >= 5 || p.FormFeedCount > 0 ||
p.GermanChapterCount+p.EnglishChapterCount+p.ChineseChapterCount > 0 {
chain = append(chain, TierHeuristic)
}
chain = append(chain, TierLegacy) // 항상 최종 대안DominantHeadingLevel은 주 분할 수준을 선택합니다. ‘≥3번 나타나는 가장 얕은 수준’(문서의 실제 구조 골격)을 우선 선택하며, 없으면 등장한 가장 깊은 수준을 선택합니다.
청크 분할 결정 흐름도
세 가지 청크 알고리즘 상세
Tier 1: 제목 인식 분할(heading_splitter.go)
적합한 문서: 올바른 Markdown 제목 구조가 있는 문서(기술 문서, 내보낸 Word/북마크가 있는 PDF 등).
알고리즘:
DominantHeadingLevel을 주 수준으로 삼아findHeadingBoundaries가level <= primaryLevel인 모든 제목 행을 구간 경계로 찾습니다(fenced code 안의 가짜 제목은 건너뜀). 경계가 ≤1개이면 바로SplitText로 폴백합니다.HeadingHierarchy(heading_hierarchy.go)는 6단계 제목 스택을 유지합니다. level-N 제목을 넣으면 ≥N인 모든 수준을 꺼내며,BreadcrumbWithHashes()는"# 제1장\n## 1.2절"같은 탐색 경로를 출력합니다.- 각 section:
탐색 경로 길이 + 2 + 구간 길이 <= ChunkSize이면 전체 구간을 하나의 Chunk로 만들고 탐색 경로는ContextHeader에 저장합니다(Content에는 넣지 않음).- 너무 길면 구간 내부를
SplitText로 다시 나눕니다. 각 자식 청크는sectionBreadcrumbs+breadcrumbAtOffset으로 ‘해당 오프셋에서 유효한 가장 깊은 제목 경로’를 얻어 ContextHeader로 사용합니다(구간 안의###/####하위 제목이 구간 수준 제목으로 뭉개지지 않음).
coalesceTinyChunks: 인접하고ChunkSize/2미만(하한 200)이며 제목 접두사를 공유하고 위치가 연속된(cur.End == next.Start) 작은 청크를 병합합니다. FAQ처럼 짧은 절로 구성된 문서가 "too many tiny chunks" 때문에 전체적으로 legacy로 떨어지는 것을 방지합니다.
위치 불변식: End - Start == utf8.RuneCountInString(Content)는 항상 성립합니다(탐색 경로는 Content에 포함하지 않음). 문서 복원과 UI 강조 표시는 이 조건에 의존합니다.
Tier 2: 휴리스틱 경계 분할(heuristic_splitter.go + patterns.go)
적합한 문서: Markdown 제목은 없지만 인식 가능한 구조 단서가 있는 문서(OCR PDF, 일반 텍스트 매뉴얼, 스캔 도서 등).
먼저 모든 후보 경계를 스캔합니다(같은 오프셋에서는 우선순위가 가장 높은 것만 유지).
| 경계 유형 | 정규식(patterns.go) | 우선순위 |
|---|---|---|
페이지 나눔 문자 \f | FormFeedPattern | 100 |
번호가 붙은 절(1.2.3 제목, IV. Results) | NumberedSectionPattern | 90 |
장/절 표시(Chapter 3 / Kapitel 2 / 第一章, 第3节) | EnglishChapterPattern / GermanChapterPattern / ChineseChapterPattern(Languages 힌트로 선택, 빈 값이면 모두 사용) | 85 |
| 짧은 대문자 행 제목 | AllCapsHeadingPattern | 70 |
시각적 구분선(---, ===, ***) | VisualSeparatorPattern | 60 |
바닥글(Page 3 of 10 / Seite 3 von 10 / 页码 3) | PageFooterPattern | 50 |
| 연속 ≥3개 줄바꿈 | ExcessiveBlanksPattern | 40 |
이후:
dropBoundsInsideSpans: 보호 구간(표/코드 블록/수식, Tier 3: 재귀 분할 legacy(splitter.go, Python 이식) 참고) 내부의 경계를 버리고 가장자리에 정렬된 경계는 유지합니다.- 탐욕적 채우기: 경계를 따라 내용을 누적하며, 누적량이
ChunkSize를 넘고 이미 ≥max(ChunkSize/4, 50)의 내용이 있으면 Chunk 하나를 확정합니다. - 두 경계 사이의 초대형 구간은
SplitText에 재귀적으로 넘깁니다. - overlap 정렬:
applyOverlapAligned는[curEnd-2*overlap, curEnd)창에서 가장 가까운 의미 경계에 우선 맞추고, 다음으로 줄바꿈에 맞춰 다음 청크가 단어 중간에서 시작하지 않게 합니다.
Tier 3: 재귀 분할 legacy(splitter.go, Python 이식)
docreader/splitter/splitter.py에서 이식한 기본 구현으로, 모든 Tier의 최종 대안이자 ‘구간 내부 재분할’ 엔진입니다. 세 단계로 구성됩니다.
Step 1 — 보호 구간 식별(protectedSpans). 다음 내용은 중간에서 나누지 않습니다.
var protectedPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?s)\$\$.*?\$\$`), // LaTeX 블록 수식
regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`), // Markdown 이미지
regexp.MustCompile(`\[[^\]]*\]\([^)]+\)`), // Markdown 링크
/* 헤더+구분 행 */ /* 표 데이터 행 */ // Markdown 표
regexp.MustCompile("(?s)```(?:\\w+)?[\\r\\n].*?```"), // fenced 코드 블록
}maxProtectedSize = 7500 rune을 넘는 보호 구간(초대형 표/코드 블록)은 embedding API 제한을 넘지 않도록 줄바꿈이나 공백에서 강제로 나눕니다.
Step 2 — 재귀 구분(splitBySeparators): Separators 우선순위(기본 \n\n → \n → 。)에 따라 나누고, 여전히 ChunkSize를 넘는 조각에는 다음 수준 구분자를 재귀 적용합니다(Python _split과 의미가 같으며 구분자는 조각에 유지).
Step 3 — 병합과 중첩(mergeUnits): 작은 단위를 청크로 조립합니다. curLen + uLen + headersLen > chunkSize이면 청크를 확정하고, computeOverlap이 현재 청크 끝부분을 가져와 다음 청크 시작으로 사용합니다. 절대 상한은 absoluteMaxSize = 7500입니다.
computeOverlap은 고정 길이 문자 슬라이스가 아닌 의미 단위 접미부를 가져옵니다.
ChunkOverlap은 목표값이 아닌 엄격한 상한입니다. 청크 끝의min(ChunkOverlap, ChunkSize - 다음 단위 길이)개 문자를 창으로 잡고, 추가로 앞의 4자를 더 살펴봅니다(semanticOverlapLookbehind, 가장 긴 구분자\r\n\r\n의 길이). 구분자가 창 경계에서 잘려 보이지 않는 것을 방지합니다. 후보 경계의 마지막 문자는 창 안에 있어야 하므로(원래 창 기준 ≥ -1) 유지되는 내용이 상한을 넘지 않습니다.- 경계 우선순위: 문단 구분(
\n\n) > 줄바꿈(\n) > 문장 끝(。,?,!, 영어./?/!. 영어 문장 부호 뒤에는 공백이 필요하여3.14,v1.2가 잘리지 않게 함). 우선순위가 같으면 창 안에서 가장 앞선 경계를 선택해 유효 중첩을 최대화합니다. - 창은 단일
splitUnit내부로 들어갈 수 있습니다(기존 구현은 단위 전체만 유지할 수 있어 일반 문단의 중첩이 자주 0이 됨). 다만 헤더 표시 같은start == end인 너비 0 합성 단위를 넘지 않아Start/End오프셋과 Content의 대응 불변식을 유지합니다. - 보호 구간(코드 블록, 인라인 코드
` `, 수식, 표, 이미지/링크) 내부의 구분자는 경계로 사용하지 않습니다. 경계 뒤에 공백만 남는 경우도 허용하지 않습니다. - 창 안에서 유효한 의미 경계를 찾지 못하면 단어 중간이 잘리지 않도록 중첩을 유지하지 않습니다.
이 버전에서는 인라인 코드 `foo`를 보호 정규식 목록에 추가하여 백틱 내부에서 나누지 않도록 했습니다.
표 처리: 헤더 추적(header_tracker.go)
큰 Markdown 표를 여러 청크로 나누면 후속 청크에서 열 이름 문맥이 사라집니다. headerTracker(docreader/splitter/header_hook.py에서 이식)는 이 문제를 해결합니다.
- ‘헤더 행 + 구분 행’(
| A | B |+| --- | --- |)을 활성 헤더로 감지하며, 표가 끝날 때까지(빈 행 /|로 시작하지 않는 행) 활성 상태를 유지합니다. mergeUnits가 새 청크를 확정할 때 활성 헤더가 중첩 영역/다음 단위에 없고 열 수가 일치하면(headerAlreadyPresent/headerColumnMismatch), 헤더를start==end인 너비 0 단위로 새 청크 앞에 추가합니다. 모든 표 조각에 열 이름이 포함됩니다.- 빈 헤더(MarkItDown에서 흔한
||+|---|---|)는 첫 데이터 행으로 열 이름을 보완합니다(pendingExtend). - 표 경계 인식: 청크 끝의
\n\n뒤에 새 표 행이 나타나거나 새 행의 열 수가 헤더와 다르면, 기존 헤더를 종료하고 청크를 강제로 확정합니다(headerEndedThisUnit). 이전 표의 헤더가 다음 표에 섞이지 않도록 합니다.
또한 OCR 엔진(PaddleOCR-VL 등)이 출력한 인라인 HTML 표는 파싱 단계에서 docparser/html_table_normalizer.go의 normalizeHTMLTables가 GFM Markdown 표로 변환합니다(rowspan/colspan이 있으면 표현 속성만 제거). 따라서 위 보호 및 헤더 추적 로직을 거치며 chunker가 잘게 쪼개지 않습니다.
이미지 처리
- Markdown 이미지 참조
는 보호 패턴이므로 절대 잘리지 않습니다. chunker.ExtractImageRefs(text)(splitter.go)는 괄호 한 단계 중첩을 지원하는 정규식으로 청크 안의 이미지 참조를 추출하여,processChunks가 chunk ↔ 이미지 관계를 만들도록 합니다.- 각 이미지는 멀티모달 단계에서
image_caption/image_ocr두 자식 Chunk를 생성하고(ParentChunkID는 텍스트 청크를 가리킴) 별도로 인덱싱합니다. 이미지의 의미로 검색할 수 있으며, 일치하면 원문 청크로 돌아갑니다.
문맥 헤더(ContextHeader)
Chunk.ContextHeader는 Content와 분리 저장하는 문맥 문자열(제목 탐색 경로)입니다.
// internal/types/chunk.go
// ContextHeader는 인덱싱 시 앞에 붙이는 Markdown 제목 탐색 경로입니다.
// 나중에 내용을 편집해도 같은 인덱스 입력을 재구성할 수 있도록 영속화합니다.
ContextHeader string `json:"-" gorm:"type:text"`
func (c *Chunk) EmbeddingContent() string {
body := strings.TrimSpace(c.Content)
if c.ContextHeader == "" { return body }
return c.ContextHeader + "\n\n" + body
}설계 핵심:
- embedding에만 영향을 주며 원문에는 영향 없음:
processChunks는 인덱스 내용을지식 제목 + "\n" + chunk.EmbeddingContent()로 구성하여 벡터에 장/절 문맥을 담습니다. Content는 원문을 문자 그대로 자른 슬라이스로 유지되므로StartAt/EndAt오프셋 불변식이 성립합니다. chunks.context_header열에 영속화(migration000078). 초기 버전은 메모리 필드(gorm:"-")여서 인덱싱 후 버렸습니다. 청크 수동 편집을 도입한 후에는 단일 청크 재인덱싱 시 같은 인덱스 입력을 재현해야 하므로 DB 저장으로 바꿨습니다.json:"-"는 그대로여서 API 응답에는 여전히 반환하지 않습니다.- 부모-자식 청크에서는
mergeBreadcrumbs(strategy.go)가 부모/자식 탐색 경로를 병합하고 첫 행 중복을 제거하여 자식 청크가 부모보다 상세한 경로를 갖게 합니다.
부모-자식 청크(Parent-Child / 다중 세분성)
EnableParentChild = true이면 2단계 청크를 활성화합니다(chunker.SplitParentChild, 전략 인식 버전. legacy 버전은 SplitTextParentChild).
- 먼저
parentCfg(기본 4096자, 설정된 overlap 재사용, Strategy 상속)로 부모 청크를 만듭니다. - 각 부모 청크를
childCfg(기본 384자, overlap = 자식 크기/5, Strategy 상속)로 다시 나눠 자식 청크를 만듭니다. - 자식 청크의
Seq는 문서 전체에서 연속하며,Start/End는 문서 수준 오프셋으로 이동하고ParentIndex는 부모 청크를 가리킵니다. 어떤 부모에서 자신과 완전히 같은 자식 하나만 나오면 중복을 피하기 위해 부모를 저장하지 않습니다(ParentIndex = -1).
서비스 측 DB 저장 규칙(knowledge_process.go의 processChunks):
- 부모 청크 →
ChunkTypeParentText, DB에만 저장하고 벡터 인덱스에는 넣지 않음. 부모 간에는PreChunkID/NextChunkID연결 리스트를 구성합니다. - 자식 청크 →
ChunkTypeText+ParentChunkID. 임베딩/인덱싱되는 유일한 단위입니다. - 검색에서 자식이 일치하면 부모 내용을 반환합니다. 작은 창의 정밀 매칭 + 큰 창의 문맥을 결합합니다.
buildParentChildConfigs는 Strategy를 반드시 전달해야 한다고 강조합니다. 전달하지 않으면 빈 Strategy가 legacy tier로 해석되어 부모-자식 청크의 제목 정렬과 ContextHeader 탐색 경로가 조용히 사라집니다.
FAQ 청크의 특수성
FAQ 지식 베이스는 어떤 청크 알고리즘도 거치지 않습니다. 각 질문-답변 쌍 자체가 ChunkTypeFAQ Chunk 하나입니다(knowledge_faq.go). Content는 buildFAQChunkContent가 인덱스 모드에 따라 생성합니다.
builder.WriteString(fmt.Sprintf("Q: %s\n", meta.StandardQuestion))
// Similar Questions: 하나씩 나열
// 부정 예시(NegativeQuestions)는 Content에 쓰지 않음 — 인덱싱하면 안 됨
if mode == types.FAQIndexModeQuestionAnswer && len(meta.Answers) > 0 {
// Answers: 하나씩 나열
}- 구조화된 데이터는
Chunk.Metadata(FAQChunkMetadata)에 저장하고,ContentHash(정규화한 SHA256)는 가져오기 중복 제거와 복제 증분 동기화에 사용합니다. - 인덱스 모드:
question_only/question_answer(KB 수준FAQIndexMode). 질문 인덱스 모드:combined(표준 질문+유사 질문을 벡터 하나로) /separate(각 유사 질문을 독립 벡터로, 증분 업데이트 지원). - FAQ에
chunk_overlap = 0을 권장하는 일반 원칙은 여기서 자연스럽게 충족됩니다. 항목 간 중첩이 없습니다.
수집 파이프라인과의 연결
knowledge_process.go의 호출 체인:
processDocument
└─ convert() // docreader → Markdown
└─ imageResolver.ResolveAndStore() // 이미지 저장, URL 재작성
└─ buildSplitterConfigFromChunking() // ChunkingConfig → SplitterConfig
└─ chunker.Split / SplitParentChild // 이 문서에서 설명한 알고리즘
└─ processChunks() // Chunk 행 생성, EmbeddingContent → BatchIndex청크 단계에는 독립 Span(StageChunking, chunks_planned/chunks_written/total_text_chars 기록)이 있으며 실패 오류 코드는 ErrCodeChunkingFailed입니다.
디버깅: POST /api/v1/chunker/preview(chunker_debug.go)
읽기 전용 미리보기 엔드포인트입니다. KB 편집기의 ‘청크 디버깅 패널’에서 매개변수를 바꾸기 전에 예시 텍스트를 시험 분할하는 데 사용합니다. DB에 쓰지 않고, embedding을 생성하지 않으며, 텍스트 로그도 기록하지 않습니다.
요청 본문:
{
"text": "예시 텍스트…",
"chunking_config": {
"chunk_size": 512, "chunk_overlap": 80,
"separators": ["\n\n", "\n", "。"],
"strategy": "auto", "token_limit": 0, "languages": ["zh"],
"enable_parent_child": false,
"parent_chunk_size": 4096, "child_chunk_size": 384
}
}enable_parent_child: true를 전달하면 미리보기는 자식 청크(실제 검색에서 일치하는 단위와 동일)를 반환하고, 진단 정보는 부모 청크 분할 단계에서 가져옵니다. 미리보기와 수집은 chunker.NormalizeSplitterConfig() 및 chunker.DeriveParentChildConfigs()로 설정을 도출하는 로직을 공유하여 ‘미리보기는 정상인데 수집 결과는 다른’ 문제를 방지합니다. 초기 미리보기는 항상 단일 단계로 시험 분할했으므로 부모-자식 청크를 켠 지식 베이스에서는 미리보기와 실제 결과가 달랐습니다.
응답(PreviewChunkingResponse):
| 필드 | 설명 |
|---|---|
selected_tier | 최종 선택된 Tier(heading/heuristic/legacy) |
tier_chain | 이번 시도 체인 |
rejected | 거부된 각 Tier와 Validator의 이유(TierRejection{tier, reason}) |
profile | 전체 DocProfile(auto에서는 전략 선택 과정에서 생성, 명시적 전략에서는 필요 시 추가 계산) |
chunks[] | 청크별 seq/start/end/size_chars/size_tokens_approx/context_header/content |
stats | count/avg_chars/min_chars/max_chars/stddev_chars. 전체 청크 집합으로 계산하며 잘린 경우 truncated_to 포함 |
보호 장치(상수): 입력 상한 previewMaxChars = 64k rune(413 반환), 반환 청크 수 상한 previewMaxChunks = 500(통계는 전체 기준), 타임아웃 previewTimeout = 5s입니다. splitter는 context를 받지 않으므로 타임아웃 후 handler는 504를 반환하지만 작업 goroutine은 자연스럽게 끝까지 실행됩니다. 64k 상한이 주 보호 장치입니다. 진단 정보는 chunker.SplitWithDiagnostics가 생성하며 그 JSON 구조는 공개 API의 일부입니다.
라우트 등록(internal/router/router.go):
g.apiKeyRoute(r, http.MethodPost, "/chunker/preview",
apiKeyRetrieve(apiKeyIngest(apiKeyFullAccess())), g.Viewer(), handler.PreviewChunking)Python 측 청크 분할기(docreader/splitter/)
docreader/splitter/splitter.py의 TextSplitter는 Go legacy 구현의 원형이며 docreader sidecar와 함께 유지됩니다.
- 기본값은 Go와 일치합니다.
DEFAULT_CHUNK_SIZE = 512,DEFAULT_CHUNK_OVERLAP = 80. 생성자의 기본 구분자는["\n", "。", " "]이며 마지막에 문자 단위 분할을 최종 대안으로 사용합니다. - 같은 보호 정규식(수식/이미지/링크/표 헤더/표 행/코드 블록)을 사용합니다.
_split(재귀 구분) →_split_protected+_join(보호 구간 분리) →_merge(중첩 병합 +HeaderTracker헤더 앞에 추가). (start, end, text)튜플을 생성하고"".join(splits) == text로 완전 복원을 검증합니다.restore_text는 중첩을 제거하여 복원하는 알고리즘을 보여 줍니다.docreader/splitter/header_hook.py의HeaderTracker는 Goheader_tracker.go와 동작이 같습니다(헤더 인식, 빈 헤더 보완, 열 수 불일치 시 종료).
구현 참고
관련 소스 코드:
| 모듈 | 파일 |
|---|---|
| 전략 진입점과 폴백 체인 | internal/infrastructure/chunker/strategy.go |
| 문서 프로파일링 | internal/infrastructure/chunker/profiler.go |
| Tier 1 제목 기반 분할 | internal/infrastructure/chunker/heading_splitter.go, heading_hierarchy.go |
| Tier 2 휴리스틱 분할 | internal/infrastructure/chunker/heuristic_splitter.go, patterns.go |
| Tier 3 재귀 분할(legacy) | internal/infrastructure/chunker/splitter.go |
| 헤더 추적 | internal/infrastructure/chunker/header_tracker.go |
| 결과 검증 | internal/infrastructure/chunker/validator.go |
| Token 추정 | internal/infrastructure/chunker/tokens.go |
| 설정 구조 | internal/types/knowledgebase.go(ChunkingConfig), internal/types/indexing_strategy.go |
| 파이프라인 통합 | internal/application/service/knowledge_process.go(buildSplitterConfigFromChunking / buildParentChildConfigs / processChunks) |
| 디버깅 엔드포인트 | internal/handler/chunker_debug.go(POST /api/v1/chunker/preview) |
| Python 측 | docreader/splitter/splitter.py, docreader/splitter/header_hook.py |