This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/knowledge/chunker.go
2026-02-26 15:59:26 +00:00

245 lines
6.7 KiB
Go

package knowledge
import (
"strings"
"unicode/utf8"
)
// ── Configuration ────────────────────────────
// ChunkConfig controls how text is split into chunks.
type ChunkConfig struct {
ChunkSize int // target chars per chunk (default 1000)
ChunkOverlap int // overlap chars between adjacent chunks (default 200)
Separators []string // split hierarchy, tried in order
}
// DefaultChunkConfig returns sensible defaults for general documents.
// ~250 tokens per chunk at ~4 chars/token.
func DefaultChunkConfig() ChunkConfig {
return ChunkConfig{
ChunkSize: 1000,
ChunkOverlap: 200,
Separators: []string{"\n\n", "\n", ". ", " "},
}
}
// ── Output ───────────────────────────────────
// Chunk is a single text segment with position metadata.
type Chunk struct {
Content string // chunk text
Index int // ordinal within document (0-based)
TokenCount int // estimated token count (~chars/4)
Metadata map[string]any // extensible: page, heading, etc.
}
// ── Splitter ─────────────────────────────────
// SplitText splits text into overlapping chunks using recursive
// character splitting. It tries separators in order, splitting on
// the first one that produces segments smaller than ChunkSize.
// Segments that are still too large are recursively split with the
// next separator.
func SplitText(text string, cfg ChunkConfig) []Chunk {
if cfg.ChunkSize <= 0 {
cfg.ChunkSize = 1000
}
if cfg.ChunkOverlap < 0 {
cfg.ChunkOverlap = 0
}
if cfg.ChunkOverlap >= cfg.ChunkSize {
cfg.ChunkOverlap = cfg.ChunkSize / 5
}
if len(cfg.Separators) == 0 {
cfg.Separators = DefaultChunkConfig().Separators
}
text = strings.TrimSpace(text)
if text == "" {
return nil
}
// If text fits in one chunk, return it directly.
if utf8.RuneCountInString(text) <= cfg.ChunkSize {
return []Chunk{{
Content: text,
Index: 0,
TokenCount: estimateTokens(text),
Metadata: map[string]any{},
}}
}
// Recursively split, then merge into overlapping windows.
segments := recursiveSplit(text, cfg.Separators, cfg.ChunkSize)
return mergeSegments(segments, cfg.ChunkSize, cfg.ChunkOverlap)
}
// recursiveSplit tries each separator in order. For the first separator
// that actually splits the text, it checks if each piece fits within
// chunkSize. Pieces that are still too large recurse with the remaining
// separators. Pieces that fit are kept as-is.
func recursiveSplit(text string, separators []string, chunkSize int) []string {
if utf8.RuneCountInString(text) <= chunkSize {
return []string{text}
}
if len(separators) == 0 {
// No separators left — hard split by character count.
return hardSplit(text, chunkSize)
}
sep := separators[0]
rest := separators[1:]
parts := splitKeepSep(text, sep)
if len(parts) <= 1 {
// This separator didn't split anything — try next.
return recursiveSplit(text, rest, chunkSize)
}
var result []string
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if utf8.RuneCountInString(part) <= chunkSize {
result = append(result, part)
} else {
// Still too large — recurse with remaining separators.
result = append(result, recursiveSplit(part, rest, chunkSize)...)
}
}
return result
}
// splitKeepSep splits text on sep. Unlike strings.Split, the separator
// is kept at the end of each segment (except the last) to preserve
// context like sentence-ending periods.
func splitKeepSep(text, sep string) []string {
parts := strings.Split(text, sep)
if len(parts) <= 1 {
return parts
}
result := make([]string, 0, len(parts))
for i, part := range parts {
if i < len(parts)-1 {
result = append(result, part+sep)
} else if part != "" {
result = append(result, part)
}
}
return result
}
// hardSplit cuts text into pieces of at most chunkSize runes.
// Last resort when no separator works.
func hardSplit(text string, chunkSize int) []string {
runes := []rune(text)
var result []string
for i := 0; i < len(runes); i += chunkSize {
end := i + chunkSize
if end > len(runes) {
end = len(runes)
}
result = append(result, string(runes[i:end]))
}
return result
}
// mergeSegments combines small segments into chunks up to chunkSize,
// then applies overlap between adjacent chunks.
func mergeSegments(segments []string, chunkSize, overlap int) []Chunk {
if len(segments) == 0 {
return nil
}
// First pass: merge small adjacent segments into windows up to chunkSize.
var merged []string
var current strings.Builder
for _, seg := range segments {
segLen := utf8.RuneCountInString(seg)
curLen := utf8.RuneCountInString(current.String())
if curLen > 0 && curLen+segLen > chunkSize {
// Flush current buffer.
merged = append(merged, strings.TrimSpace(current.String()))
current.Reset()
}
if current.Len() > 0 {
current.WriteString(" ")
}
current.WriteString(seg)
}
if current.Len() > 0 {
merged = append(merged, strings.TrimSpace(current.String()))
}
if overlap <= 0 || len(merged) <= 1 {
// No overlap needed — convert directly to Chunks.
chunks := make([]Chunk, len(merged))
for i, text := range merged {
chunks[i] = Chunk{
Content: text,
Index: i,
TokenCount: estimateTokens(text),
Metadata: map[string]any{},
}
}
return chunks
}
// Second pass: create overlapping chunks.
// Each chunk includes up to `overlap` chars from the end of the
// previous chunk as prefix context.
chunks := make([]Chunk, 0, len(merged))
for i, text := range merged {
if i > 0 {
prev := merged[i-1]
suffix := overlapSuffix(prev, overlap)
if suffix != "" {
text = suffix + " " + text
}
}
chunks = append(chunks, Chunk{
Content: text,
Index: i,
TokenCount: estimateTokens(text),
Metadata: map[string]any{},
})
}
return chunks
}
// overlapSuffix returns the last `n` characters of s, breaking at a
// word boundary to avoid splitting mid-word.
func overlapSuffix(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
start := len(runes) - n
suffix := string(runes[start:])
// Try to break at a space to avoid mid-word splits.
if idx := strings.Index(suffix, " "); idx >= 0 && idx < len(suffix)/2 {
suffix = suffix[idx+1:]
}
return suffix
}
// estimateTokens gives a rough token count. Most tokenizers average
// ~4 characters per token for English text.
func estimateTokens(s string) int {
n := utf8.RuneCountInString(s)
tokens := n / 4
if tokens == 0 && n > 0 {
tokens = 1
}
return tokens
}