feat(go): add length split strategy with overlap to chunk pipeline (#16047)

### What problem does this PR solve?

The Go ingestion chunk pipeline's `SplitOperator`
(`internal/ingestion/chunk/split.go`) supported only `sentence`, `char`,
and `paragraph` strategies, but not **fixed-size (length) chunking with
overlap** — the canonical RAG strategy for bounding chunk length while
preserving cross-boundary context.

This adds a `length` strategy alongside the existing ones, configurable
via DSL `params`:

- `chunk_size` — target window size in **runes** (rune-aware:
multi-byte/CJK text is windowed by character, never split mid-rune).
- `overlap` — runes carried from the end of each window into the next.

The window advances by `chunk_size - overlap`. `chunk_size` falls back
to a default (256) when unset/non-positive, and `overlap` is clamped to
`[0, chunk_size-1]` so the window always advances and the operation
terminates. Implementation follows the existing
`splitByChar`/`splitByParagraph` pattern and reuses `DetectLanguage` for
chunk metadata.

It also adds `split_test.go` — the first unit tests for the `chunk`
package — covering basic windowing, overlap, overlap
clamping/termination, rune-awareness (CJK), default sizing, no-overlap
reconstruction, empty input, and DSL param parsing.

Validation: `gofmt` clean, `go vet ./internal/ingestion/chunk/` clean,
`go build` ok, `go test ./internal/ingestion/chunk/` — all tests pass.

Closes #16046

### Type of change

- [x] New Feature (non-breaking change which adds functionality)

Co-authored-by: bittoby <218712309+bittoby@users.noreply.github.com>
This commit is contained in:
Crystora
2026-07-06 13:14:21 +07:00
committed by GitHub
parent c4166a91e0
commit 4effc242fd
2 changed files with 275 additions and 1 deletions

View File

@@ -23,9 +23,17 @@ import (
)
type SplitOperator struct {
strategy string
strategy string
boundaries []string
keepSeparators bool
chunkSize int
overlap int
}
// defaultLengthChunkSize is the window size (in runes) used by the "length"
// strategy when no positive chunk_size is configured.
const defaultLengthChunkSize = 256
func NewSplitOperator(config map[string]interface{}) (*SplitOperator, error) {
op := &SplitOperator{}
@@ -35,9 +43,49 @@ func NewSplitOperator(config map[string]interface{}) (*SplitOperator, error) {
}
}
if params, ok := config["params"].(map[string]interface{}); ok {
if b, ok := params["boundaries"]; ok {
if boundStrs, ok := b.([]interface{}); ok {
for _, bs := range boundStrs {
if s, ok := bs.(string); ok {
op.boundaries = append(op.boundaries, s)
}
}
}
}
if ks, ok := params["keep_separators"]; ok {
if b, ok := ks.(bool); ok {
op.keepSeparators = b
}
}
// chunk_size / overlap drive the "length" strategy. JSON numbers
// decode as float64, so accept that alongside the integer types.
if cs, ok := params["chunk_size"]; ok {
op.chunkSize = toInt(cs)
}
if ov, ok := params["overlap"]; ok {
op.overlap = toInt(ov)
}
}
return op, nil
}
// toInt coerces a DSL numeric value (float64 from JSON, or a native integer)
// to int. Any other type yields 0.
func toInt(v interface{}) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case int64:
return int(n)
default:
return 0
}
}
func (o *SplitOperator) Prepare(ctx *ChunkContext) error {
return nil
}
@@ -56,6 +104,8 @@ func (o *SplitOperator) Execute(ctx *ChunkContext) error {
ctx.SplitChunks = o.splitByChar(text)
case "paragraph":
ctx.SplitChunks = o.splitByParagraph(text)
case "length":
ctx.SplitChunks = o.splitByLength(text)
default:
ctx.SplitChunks = o.splitSentences(text)
}
@@ -71,6 +121,13 @@ func (o *SplitOperator) String() string {
var buf strings.Builder
buf.WriteString("split:\n")
fmt.Fprintf(&buf, " strategy: %q\n", o.strategy)
fmt.Fprintf(&buf, " boundaries:\n")
for _, r := range o.boundaries {
fmt.Fprintf(&buf, " - %q\n", r)
}
fmt.Fprintf(&buf, " keep_separators: %t\n", o.keepSeparators)
fmt.Fprintf(&buf, " chunk_size: %d\n", o.chunkSize)
fmt.Fprintf(&buf, " overlap: %d\n", o.overlap)
return buf.String()
}
@@ -145,6 +202,60 @@ func (o *SplitOperator) splitByChar(text string) []ChunkData {
return chunks
}
// splitByLength splits text into fixed-size, rune-aware windows of chunkSize
// runes, carrying overlap runes from the end of each window into the start of
// the next. This is the canonical fixed-size-with-overlap chunking used by RAG
// pipelines to bound chunk length while preserving cross-boundary context.
//
// chunkSize defaults to defaultLengthChunkSize when not positive. overlap is
// clamped to [0, chunkSize-1] so the window always advances by at least one
// rune and the function terminates. Sizing is by rune count (not bytes), so
// multi-byte (e.g. CJK) text is windowed by character.
func (o *SplitOperator) splitByLength(text string) []ChunkData {
chunkSize := o.chunkSize
if chunkSize <= 0 {
chunkSize = defaultLengthChunkSize
}
overlap := o.overlap
if overlap < 0 {
overlap = 0
}
if overlap >= chunkSize {
overlap = chunkSize - 1
}
step := chunkSize - overlap
runes := []rune(text)
if len(runes) == 0 {
return nil
}
var chunks []ChunkData
for start := 0; start < len(runes); start += step {
end := start + chunkSize
if end > len(runes) {
end = len(runes)
}
content := string(runes[start:end])
chunks = append(chunks, ChunkData{
Content: content,
Size: end - start,
Index: len(chunks),
Metadata: map[string]interface{}{
"language": DetectLanguage(content),
},
})
if end == len(runes) {
break
}
}
return chunks
}
func (o *SplitOperator) splitByParagraph(text string) []ChunkData {
paragraphs := strings.Split(text, "\n")
chunks := make([]ChunkData, 0, len(paragraphs))