본문으로 바로 가기

청크 분할 메커니즘(Chunking)

청크 분할은 문서를 검색 단위로 나눕니다. 작은 청크는 주제에 집중하는 데 도움이 되고, 큰 청크는 더 많은 문맥을 유지합니다. 청크 크기와 중첩 범위는 문서 구조와 검색 결과에 맞춰 조정해야 합니다.

먼저 UI 기본 설정(청크 512자, 중첩 80자, 적응형 전략)을 사용한 뒤 다음 상황에 따라 조정할 수 있습니다.

상황권장 사항
답변에 문맥이 부족하거나 정보가 불완전함chunk_size를 늘리거나 부모-자식 청크를 활성화(자식 청크 검색, 부모 청크로 답변)
검색된 청크와 질문의 관련성이 낮음chunk_size를 줄여 각 청크의 주제를 더 집중시킴
항목형 자료(FAQ, 사전, 매개변수 표)중첩을 0으로 설정하여 인접 항목 간 내용 혼입 방지
긴 서술형 자료(보고서, 논문)중첩을 150–200으로 늘려 청크 간 의미 연결 유지
청크 결과 미리보기POST /api/v1/chunker/preview로 미리보기, 데이터베이스에 쓰지 않음

청크 설정을 변경한 뒤 기존 문서에 새 설정을 적용하려면 다시 파싱해야 합니다.

매개변수 빠른 참조와 튜닝 권장 사항

시나리오strategychunk_sizechunk_overlap기타
일반 문서(권장 시작점)auto51280
구조화된 기술 문서 / 매뉴얼auto(heading 선택)512–102480탐색 경로 자동 적용
OCR PDF / 일반 텍스트 책auto(heuristic 선택)512–102480–150languages로 언어를 지정하면 오판 감소
긴 서술 / 논증형 문서auto1000–2000150–200부모-자식 청크 병용 가능
정밀 검색 + 긴 문맥모두 가능enable_parent_child=true, parent 4096 / child 384
FAQ / 원자적 레코드해당 없음(FAQ KB는 항목별 청크)0FAQIndexMode로 답변 인덱싱 여부 제어
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_sizeint512(문자)단일 청크 목표 크기. 영어 약 100–130 token / 중국어 약 300 token. FAQ형 원자적 내용은 200–400, 긴 서술형 문서는 1000–2000 권장
chunk_overlapint80(약 15%)인접 청크의 중첩 문자 수. 원자적 데이터는 0, 긴 서술은 150–200 가능. chunk_size/2를 넘으면 절반으로 제한
separators[]string["\n\n", "\n", "。"]재귀 분할의 구분자 우선순위 목록
strategystring""(= legacy)청크 전략: auto / heading / heuristic / recursive / legacy. 적응형 전략: 세 Tier와 폴백 체인 참고
token_limitint0(비활성화)근사 token 수 상한으로 청크 크기 제한. >0이면 언어별 문자 예산으로 환산해 더 작은 값 사용(안전 계수 0.9)
languages[]string빈 값(자동 감지)휴리스틱 모드의 언어 힌트. 예: ["zh"], ["en","de"]
enable_parent_childboolfalse부모-자식(2단계) 청크 활성화. 부모-자식 청크(Parent-Child / 다중 세분성) 참고
parent_chunk_sizeint4096부모 청크 크기(부모-자식 모드 전용)
child_chunk_sizeint384자식 청크 크기(부모-자식 모드 전용). 자식 overlap은 child_size/5(약 20%)로 고정
parser_engine_rules[]ParserEngineRule빈 값파일 유형 → 파싱 엔진 라우팅. xlsx_first_row_as_header 같은 파서 수준 스위치 포함(청크가 아닌 파싱 설정이지만 같은 구조에 위치)
table_metadata_instructionsstring빈 값CSV/Excel 표 요약 생성 시 업무 지침

기본값의 단일 출처는 chunker 패키지 상수(splitter.go)입니다.

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)으로 ChunkingConfigchunker.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의 네 스위치는 청크 결과가 어떤 파이프라인으로 흐를지 결정합니다.

go
type IndexingStrategy struct {
    VectorEnabled  bool // 벡터 인덱스
    KeywordEnabled bool // BM25 키워드 인덱스
    WikiEnabled    bool // Wiki 생성
    GraphEnabled   bool // 그래프 추출
}
  • NeedsChunks()(하나라도 활성화)이 false이면 청크 분할이 필요하지 않습니다.
  • NeedsEmbedding()(vector || keyword)이 false이면 청크를 DB에만 쓰고 BatchIndex를 건너뜁니다(processChunksskipStage(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]recursivelegacy의 공개 별칭
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)는 시도 체인을 구성합니다.

go
// 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 등).

알고리즘:

  1. DominantHeadingLevel을 주 수준으로 삼아 findHeadingBoundarieslevel <= primaryLevel인 모든 제목 행을 구간 경계로 찾습니다(fenced code 안의 가짜 제목은 건너뜀). 경계가 ≤1개이면 바로 SplitText로 폴백합니다.
  2. HeadingHierarchy(heading_hierarchy.go)는 6단계 제목 스택을 유지합니다. level-N 제목을 넣으면 ≥N인 모든 수준을 꺼내며, BreadcrumbWithHashes()"# 제1장\n## 1.2절" 같은 탐색 경로를 출력합니다.
  3. 각 section:
    • 탐색 경로 길이 + 2 + 구간 길이 <= ChunkSize이면 전체 구간을 하나의 Chunk로 만들고 탐색 경로는 ContextHeader에 저장합니다(Content에는 넣지 않음).
    • 너무 길면 구간 내부를 SplitText로 다시 나눕니다. 각 자식 청크는 sectionBreadcrumbs + breadcrumbAtOffset으로 ‘해당 오프셋에서 유효한 가장 깊은 제목 경로’를 얻어 ContextHeader로 사용합니다(구간 안의 ###/#### 하위 제목이 구간 수준 제목으로 뭉개지지 않음).
  4. 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)우선순위
페이지 나눔 문자 \fFormFeedPattern100
번호가 붙은 절(1.2.3 제목, IV. Results)NumberedSectionPattern90
장/절 표시(Chapter 3 / Kapitel 2 / 第一章, 第3节)EnglishChapterPattern / GermanChapterPattern / ChineseChapterPattern(Languages 힌트로 선택, 빈 값이면 모두 사용)85
짧은 대문자 행 제목AllCapsHeadingPattern70
시각적 구분선(---, ===, ***)VisualSeparatorPattern60
바닥글(Page 3 of 10 / Seite 3 von 10 / 页码 3)PageFooterPattern50
연속 ≥3개 줄바꿈ExcessiveBlanksPattern40

이후:

  • 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). 다음 내용은 중간에서 나누지 않습니다.

go
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.gonormalizeHTMLTables가 GFM Markdown 표로 변환합니다(rowspan/colspan이 있으면 표현 속성만 제거). 따라서 위 보호 및 헤더 추적 로직을 거치며 chunker가 잘게 쪼개지 않습니다.

이미지 처리

  • Markdown 이미지 참조 ![alt](url)는 보호 패턴이므로 절대 잘리지 않습니다.
  • chunker.ExtractImageRefs(text)(splitter.go)는 괄호 한 단계 중첩을 지원하는 정규식으로 청크 안의 이미지 참조를 추출하여, processChunks가 chunk ↔ 이미지 관계를 만들도록 합니다.
  • 각 이미지는 멀티모달 단계에서 image_caption / image_ocr 두 자식 Chunk를 생성하고(ParentChunkID는 텍스트 청크를 가리킴) 별도로 인덱싱합니다. 이미지의 의미로 검색할 수 있으며, 일치하면 원문 청크로 돌아갑니다.

문맥 헤더(ContextHeader)

Chunk.ContextHeader는 Content와 분리 저장하는 문맥 문자열(제목 탐색 경로)입니다.

go
// 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 열에 영속화(migration 000078). 초기 버전은 메모리 필드(gorm:"-")여서 인덱싱 후 버렸습니다. 청크 수동 편집을 도입한 후에는 단일 청크 재인덱싱 시 같은 인덱스 입력을 재현해야 하므로 DB 저장으로 바꿨습니다. json:"-"는 그대로여서 API 응답에는 여전히 반환하지 않습니다.
  • 부모-자식 청크에서는 mergeBreadcrumbs(strategy.go)가 부모/자식 탐색 경로를 병합하고 첫 행 중복을 제거하여 자식 청크가 부모보다 상세한 경로를 갖게 합니다.

부모-자식 청크(Parent-Child / 다중 세분성)

EnableParentChild = true이면 2단계 청크를 활성화합니다(chunker.SplitParentChild, 전략 인식 버전. legacy 버전은 SplitTextParentChild).

  1. 먼저 parentCfg(기본 4096자, 설정된 overlap 재사용, Strategy 상속)로 부모 청크를 만듭니다.
  2. 각 부모 청크를 childCfg(기본 384자, overlap = 자식 크기/5, Strategy 상속)로 다시 나눠 자식 청크를 만듭니다.
  3. 자식 청크의 Seq는 문서 전체에서 연속하며, Start/End는 문서 수준 오프셋으로 이동하고 ParentIndex는 부모 청크를 가리킵니다. 어떤 부모에서 자신과 완전히 같은 자식 하나만 나오면 중복을 피하기 위해 부모를 저장하지 않습니다(ParentIndex = -1).

서비스 측 DB 저장 규칙(knowledge_process.goprocessChunks):

  • 부모 청크 → ChunkTypeParentText, DB에만 저장하고 벡터 인덱스에는 넣지 않음. 부모 간에는 PreChunkID/NextChunkID 연결 리스트를 구성합니다.
  • 자식 청크 → ChunkTypeText + ParentChunkID. 임베딩/인덱싱되는 유일한 단위입니다.
  • 검색에서 자식이 일치하면 부모 내용을 반환합니다. 작은 창의 정밀 매칭 + 큰 창의 문맥을 결합합니다.

buildParentChildConfigs는 Strategy를 반드시 전달해야 한다고 강조합니다. 전달하지 않으면 빈 Strategy가 legacy tier로 해석되어 부모-자식 청크의 제목 정렬과 ContextHeader 탐색 경로가 조용히 사라집니다.

FAQ 청크의 특수성

FAQ 지식 베이스는 어떤 청크 알고리즘도 거치지 않습니다. 각 질문-답변 쌍 자체가 ChunkTypeFAQ Chunk 하나입니다(knowledge_faq.go). Content는 buildFAQChunkContent가 인덱스 모드에 따라 생성합니다.

go
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을 생성하지 않으며, 텍스트 로그도 기록하지 않습니다.

요청 본문:

json
{
  "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
statscount/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):

go
g.apiKeyRoute(r, http.MethodPost, "/chunker/preview",
    apiKeyRetrieve(apiKeyIngest(apiKeyFullAccess())), g.Viewer(), handler.PreviewChunking)

Python 측 청크 분할기(docreader/splitter/)

docreader/splitter/splitter.pyTextSplitter는 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.pyHeaderTracker는 Go header_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

WeKnora v0.8.0 소스 코드 기반 한국어 번역 · MIT License