245 lines
6.7 KiB
Go
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
|
|
}
|