Files

915 lines
28 KiB
Go
Raw Permalink Normal View History

// Package chunker implements text splitting for document chunking.
//
// Ported from the Python docreader/splitter/splitter.py recursive text splitter.
package chunker
import (
"regexp"
"strings"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/infrastructure/docparser"
)
// Chunk represents a piece of split text with position tracking.
//
// Content holds exactly the text from the original document between Start
// and End (rune offsets), so End-Start == utf8.RuneCountInString(Content).
// This invariant is relied on by document-reconstruction code paths
// (knowledge.go:2278+ for summary generation, UI highlighting, etc.).
//
// ContextHeader is a separately-tracked context string (e.g. a Markdown
// heading breadcrumb) that should be prepended at embedding/retrieval time
// but is NOT part of Content. Keeping the two apart preserves the
// position invariant while still letting embedding pipelines see the
// section context.
type Chunk struct {
Content string
ContextHeader string
Seq int
Start int
End int
}
// EmbeddingContent returns the text that should be fed to the embedding
// model — the ContextHeader prepended (when set) plus the chunk content.
// Use this where Content alone would lose semantic context (Tier-1 chunks).
//
// Content is returned verbatim from the source document (the End-Start
// rune-count invariant requires that), but for embedding we trim the
// surrounding whitespace so leading/trailing newlines from boundary slices
// don't dilute the embedded vector or waste tokens. Inner whitespace is
// preserved.
func (c Chunk) EmbeddingContent() string {
body := strings.TrimSpace(c.Content)
if c.ContextHeader == "" {
return body
}
return c.ContextHeader + "\n\n" + body
}
// ImageRef is an image reference found within a chunk's content.
type ImageRef struct {
OriginalRef string
AltText string
Start int // offset within the chunk content
End int
}
// SplitterConfig configures the text splitter. Strategy and TokenLimit are
// honored by the strategy entry point in strategy.go; the legacy SplitText
// path uses only ChunkSize/Overlap/Separators.
type SplitterConfig struct {
ChunkSize int
ChunkOverlap int
Separators []string
// Strategy selects an adaptive tier. Empty = legacy (backwards-compatible).
// See strategy.go for valid values.
Strategy string
// TokenLimit caps chunk size in approximate tokens. 0 = use ChunkSize chars.
TokenLimit int
// Languages hints multilingual heuristic patterns. Empty = auto-detect.
Languages []string
}
// Default chunk sizing constants. Single source of truth for the entire
// chunker package and (via knowledge.go::buildSplitterConfig) the
// knowledge service. The frontend KnowledgeBaseEditorModal mirrors these
// numbers in its initial form state — keep them in sync if you change
// either value here.
//
// DefaultChunkSize = 512 chars: ~100–130 English tokens / ~300 Chinese
// tokens. Validated as a strong baseline by the Vecta Feb-2026 benchmark
// across 50 academic papers. Use 200–400 for FAQ-style atomic content,
// 1000–2000 for narrative / argumentative documents.
//
// DefaultChunkOverlap = 80 chars (≈15% of DefaultChunkSize): community-
// recommended sweet spot between recall (an answer split across a
// boundary needs overlap to be retrievable) and storage cost. Use 0 for
// strictly atomic data (FAQ, JSON records), 150–200 for long narratives
// where reasoning crosses chunks.
//
// MIGRATION NOTE: Prior versions had three different overlap defaults
// (Go DefaultConfig: 64, knowledge.go buildSplitterConfig: 50, Python
// docreader: 100). All consolidated to 80 here.
//
// Existing knowledge bases that stored ChunkOverlap=0 in the DB pick
// this 80 up on next re-index; their previously-indexed embeddings will
// not match new ones bit-for-bit. Recall stays similar but search
// ranking can shift slightly. To freeze the old behavior on a per-KB
// basis, explicitly set ChunkingConfig.ChunkOverlap to 64 before
// re-indexing.
const (
DefaultChunkSize = 512
DefaultChunkOverlap = 80
)
// DefaultConfig returns sensible defaults.
func DefaultConfig() SplitterConfig {
return SplitterConfig{
ChunkSize: DefaultChunkSize,
ChunkOverlap: DefaultChunkOverlap,
Separators: []string{"\n\n", "\n", "。"},
}
}
// protectedPatterns are regex patterns for content that must not be split.
var protectedPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?s)\$\$.*?\$\$`), // LaTeX block math
regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`), // Markdown images
regexp.MustCompile(`\[[^\]]*\]\([^)]+\)`), // Markdown links
regexp.MustCompile("(?m)[ ]*(?:\\|[^|\\n]*)+\\|[\\r\\n]+\\s*(?:\\|\\s*:?-{3,}:?\\s*)+\\|[\\r\\n]+"), // Table header+separator
regexp.MustCompile("(?m)[ ]*(?:\\|[^|\\n]*)+\\|[\\r\\n]+"), // Table rows
regexp.MustCompile("(?s)```(?:\\w+)?[\\r\\n].*?```"), // Fenced code blocks
regexp.MustCompile("`[^`\\r\\n]+`"), // Markdown inline code
}
type span struct {
start, end int
}
// protectedSpansRune converts byte-offset protected spans to rune offsets
// in a single forward pass over text. Used by callers that work in rune
// space (e.g. the heuristic splitter) to avoid choosing chunk boundaries
// that cut through protected content. byteSpans must be sorted by start
// (protectedSpans guarantees this).
func protectedSpansRune(text string, byteSpans []span) []span {
if len(byteSpans) == 0 {
return nil
}
out := make([]span, 0, len(byteSpans))
runeIdx := 0
byteIdx := 0
for _, s := range byteSpans {
for byteIdx < s.start && byteIdx < len(text) {
_, size := utf8.DecodeRuneInString(text[byteIdx:])
byteIdx += size
runeIdx++
}
startRune := runeIdx
for byteIdx < s.end && byteIdx < len(text) {
_, size := utf8.DecodeRuneInString(text[byteIdx:])
byteIdx += size
runeIdx++
}
out = append(out, span{start: startRune, end: runeIdx})
}
return out
}
// protectedSpans finds all non-overlapping protected regions in text.
func protectedSpans(text string) []span {
type match struct {
start, end int
}
var all []match
for _, pat := range protectedPatterns {
locs := pat.FindAllStringIndex(text, -1)
for _, loc := range locs {
if loc[1]-loc[0] > 0 {
all = append(all, match{loc[0], loc[1]})
}
}
}
if len(all) == 0 {
return nil
}
// Sort by start, then by length descending
for i := 1; i < len(all); i++ {
for j := i; j > 0; j-- {
if all[j].start < all[j-1].start ||
(all[j].start == all[j-1].start && (all[j].end-all[j].start) > (all[j-1].end-all[j-1].start)) {
all[j], all[j-1] = all[j-1], all[j]
} else {
break
}
}
}
// Remove overlaps
var result []span
lastEnd := 0
for _, m := range all {
if m.start >= lastEnd {
result = append(result, span{m.start, m.end})
lastEnd = m.end
}
}
return result
}
// splitUnit is a piece of text with its original position.
type splitUnit struct {
text string
start, end int
}
// splitBySeparators splits text by separators in priority order, recursively
// applying the next separator to any piece that is still larger than
// chunkSize. Mirrors the recursive priority semantics of the Python
// reference splitter (docreader/splitter/splitter.py:_split): if `\n\n`
// produces a piece that's still too big, `\n` (and subsequent separators)
// are applied within that piece — not to the whole text.
//
// chunkSize == 0 disables the recursion guard; callers that don't care
// about size budget (e.g. a final mergeUnits-style pass) pass 0.
func splitBySeparators(text string, separators []string, chunkSize int) []string {
if text == "" || len(separators) == 0 {
return []string{text}
}
if chunkSize > 0 && runeLen(text) <= chunkSize {
return []string{text}
}
for i, sep := range separators {
if sep == "" {
continue
}
re := regexp.MustCompile("(" + regexp.QuoteMeta(sep) + ")")
splits := re.Split(text, -1)
matches := re.FindAllString(text, -1)
if len(matches) == 0 {
continue
}
var pieces []string
for j, s := range splits {
if s != "" {
pieces = append(pieces, s)
}
if j < len(matches) && matches[j] != "" {
pieces = append(pieces, matches[j])
}
}
if len(pieces) <= 1 {
continue
}
// Recursively split any piece that is still too large with the
// remaining (lower-priority) separators.
var out []string
remaining := separators[i+1:]
for _, p := range pieces {
if chunkSize > 0 && runeLen(p) > chunkSize && len(remaining) > 0 {
out = append(out, splitBySeparators(p, remaining, chunkSize)...)
} else {
out = append(out, p)
}
}
return out
}
return []string{text}
}
// runeLen returns the number of runes in s.
func runeLen(s string) int {
return utf8.RuneCountInString(s)
}
// SplitText splits text into chunks with overlap, respecting protected patterns.
func SplitText(text string, cfg SplitterConfig) []Chunk {
if text == "" {
return nil
}
chunkSize := cfg.ChunkSize
chunkOverlap := cfg.ChunkOverlap
separators := cfg.Separators
if chunkSize <= 0 {
chunkSize = 512
}
if chunkOverlap < 0 {
chunkOverlap = 0
}
// Step 1: Find protected spans
protected := protectedSpans(text)
// Step 2: Split non-protected regions by separators, keep protected as atomic units.
// chunkSize is forwarded so splitBySeparators can recursively apply lower-priority
// separators to oversize pieces (Python-parity recursive split).
units := buildUnitsWithProtection(text, protected, separators, chunkSize)
// Step 3: Merge units into chunks with overlap
return mergeUnits(units, chunkSize, chunkOverlap)
}
// buildUnitsWithProtection splits text into units, preserving protected spans as atomic.
// Start/End positions in the returned units are rune offsets (not byte offsets),
// because downstream merge logic indexes content via []rune slicing.
// If a protected span exceeds maxProtectedSize, it will be forcibly split to prevent
// creating chunks that are too large for downstream processing (e.g., embedding APIs).
// chunkSize is forwarded to splitBySeparators so recursive splitting can keep pieces
// under the budget when one separator alone leaves a piece oversize.
func buildUnitsWithProtection(text string, protected []span, separators []string, chunkSize int) []splitUnit {
const maxProtectedSize = 7500 // Maximum size for a protected unit (留余量给标题等)
var units []splitUnit
bytePos := 0
runePos := 0
for _, p := range protected {
if p.start > bytePos {
pre := text[bytePos:p.start]
parts := splitBySeparators(pre, separators, chunkSize)
runeOffset := runePos
for _, part := range parts {
partRuneLen := runeLen(part)
units = append(units, splitUnit{
text: part,
start: runeOffset,
end: runeOffset + partRuneLen,
})
runeOffset += partRuneLen
}
runePos += runeLen(pre)
}
protText := text[p.start:p.end]
protRuneLen := runeLen(protText)
// If protected content is too large, forcibly split it
if protRuneLen > maxProtectedSize {
// Split into smaller chunks at line breaks or spaces
runes := []rune(protText)
offset := 0
for offset < len(runes) {
chunkEnd := offset + maxProtectedSize
if chunkEnd > len(runes) {
chunkEnd = len(runes)
} else {
// Try to break at a newline or space
for i := chunkEnd - 1; i > offset && i > chunkEnd-200; i-- {
if runes[i] == '\n' || runes[i] == ' ' {
chunkEnd = i + 1
break
}
}
}
chunkText := string(runes[offset:chunkEnd])
chunkLen := chunkEnd - offset
units = append(units, splitUnit{
text: chunkText,
start: runePos + offset,
end: runePos + offset + chunkLen,
})
offset = chunkEnd
}
} else {
// Normal case: keep protected content as a single unit
units = append(units, splitUnit{
text: protText,
start: runePos,
end: runePos + protRuneLen,
})
}
runePos += protRuneLen
bytePos = p.end
}
if bytePos < len(text) {
remaining := text[bytePos:]
parts := splitBySeparators(remaining, separators, chunkSize)
runeOffset := runePos
for _, part := range parts {
partRuneLen := runeLen(part)
units = append(units, splitUnit{
text: part,
start: runeOffset,
end: runeOffset + partRuneLen,
})
runeOffset += partRuneLen
}
}
return units
}
// mergeUnits combines split units into chunks with overlap tracking.
// Enforces an absolute maximum chunk size to prevent exceeding downstream limits (e.g., embedding APIs).
// Active contextual headers (e.g., Markdown table headers) are prepended to new
// chunks so that every chunk carries its own header context.
func mergeUnits(units []splitUnit, chunkSize, chunkOverlap int) []Chunk {
if len(units) == 0 {
return nil
}
const absoluteMaxSize = 7500
ht := newHeaderTracker()
var chunks []Chunk
var current []splitUnit
curLen := 0
for _, u := range units {
uLen := runeLen(u.text)
// If this single unit exceeds absolute max, force split it further
if uLen > absoluteMaxSize {
// Flush current chunk if any
if len(current) > 0 {
chunks = append(chunks, buildChunk(current, len(chunks)))
current = nil
curLen = 0
}
// Update header state even for oversized units
ht.update(u.text)
// Split this oversized unit into smaller chunks
runes := []rune(u.text)
offset := 0
for offset < len(runes) {
chunkEnd := offset + absoluteMaxSize
if chunkEnd > len(runes) {
chunkEnd = len(runes)
} else {
for i := chunkEnd - 1; i > offset && i > chunkEnd-200; i-- {
if runes[i] == '\n' || runes[i] == ' ' {
chunkEnd = i + 1
break
}
}
}
chunkText := string(runes[offset:chunkEnd])
chunks = append(chunks, Chunk{
Content: chunkText,
Seq: len(chunks),
Start: u.start + offset,
End: u.start + chunkEnd,
})
offset = chunkEnd
}
continue
}
// Update header tracking
ht.update(u.text)
// Flush at table boundary so the next table is not merged into a chunk
// that still carries the previous table's prepended header context.
if ht.headerEndedThisUnit && len(current) > 0 {
chunks = append(chunks, buildChunk(current, len(chunks)))
current = nil
curLen = 0
}
headers := ht.getHeaders()
headersLen := runeLen(headers)
if headersLen > chunkSize {
headers = ""
headersLen = 0
}
// If adding this unit (plus reserving space for headers in a potential
// next chunk) would exceed chunk size, flush the current chunk.
if curLen+uLen+headersLen > chunkSize && len(current) > 0 {
chunks = append(chunks, buildChunk(current, len(chunks)))
// Keep overlap from the end of current
current, curLen = computeOverlap(current, chunkOverlap, chunkSize, uLen)
// Shrink overlap further if needed to fit headers + next unit
if headers != "" && headersLen+uLen <= chunkSize {
for len(current) > 0 && curLen+uLen+headersLen > chunkSize {
curLen -= runeLen(current[0].text)
current = current[1:]
}
// Prepend headers if the column-name context is not already present
// in the overlap or the next unit being added.
overlapText := unitsText(current)
if !headerAlreadyPresent(headers, overlapText, u.text) &&
!headerColumnMismatch(headers, u.text) {
startPos := u.start
if len(current) > 0 {
startPos = current[0].start
}
hUnit := splitUnit{text: headers, start: startPos, end: startPos}
current = append([]splitUnit{hUnit}, current...)
curLen += headersLen
}
}
}
// Check if adding this unit would exceed absolute max
if curLen+uLen > absoluteMaxSize {
if len(current) > 0 {
chunks = append(chunks, buildChunk(current, len(chunks)))
current = nil
curLen = 0
}
}
current = append(current, u)
curLen += uLen
}
// Flush remaining
if len(current) > 0 {
chunks = append(chunks, buildChunk(current, len(chunks)))
}
return chunks
}
// unitsText concatenates the text of all units.
func unitsText(units []splitUnit) string {
var sb strings.Builder
for _, u := range units {
sb.WriteString(u.text)
}
return sb.String()
}
// headerAlreadyPresent returns true if the column-name row from the header
// is already present in the overlap or the next unit, preventing duplication.
func headerAlreadyPresent(headers, overlapText, unitText string) bool {
// Fast path: full header already in overlap or unit
if strings.Contains(overlapText, headers) || strings.Contains(unitText, headers) {
return true
}
// Extract the column-name row (first meaningful non-separator line).
// For a rewritten header like "| col1 | col2 |\n| --- | --- |\n",
// the first line is the column names.
colRow := headerColumnRow(headers)
if colRow == "" {
return false
}
return strings.Contains(overlapText, colRow) || strings.Contains(unitText, colRow)
}
// headerColumnRow extracts the column-name line from a header string.
// Returns empty string if the header has no meaningful column names.
func headerColumnRow(header string) string {
for _, line := range strings.Split(header, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.Contains(line, "---") {
continue
}
// Skip lines that are only pipes/whitespace (empty header rows)
onlyPipes := true
for _, r := range line {
if r != '|' && r != ' ' && r != '\t' {
onlyPipes = false
break
}
}
if !onlyPipes {
return line
}
}
return ""
}
func buildChunk(units []splitUnit, seq int) Chunk {
var sb strings.Builder
for _, u := range units {
sb.WriteString(u.text)
}
return Chunk{
Content: sb.String(),
Seq: seq,
Start: units[0].start,
End: units[len(units)-1].end,
}
}
// computeOverlap returns the semantic suffix to keep for overlap and its total
// rune length.
//
// The configured overlap is a hard upper bound, not a raw character slice.
// Boundary detection may inspect up to four additional runes immediately
// before that tail window so a separator cut by the window boundary remains
// visible. A candidate is eligible only when its last rune is at position -1
// relative to the original window, or later, so retained content never exceeds
// the overlap limit. Eligible boundaries use this priority:
//
// 1. paragraph break (\n\n / \r\n\r\n)
// 2. line break (\n / \r\n)
// 3. sentence end (。?!, ". ", "? ", "! ")
//
// Priority wins first; for boundaries with the same priority, the earliest
// one in the window wins so the useful overlap is as large as possible. The
// next chunk starts immediately after the selected separator. If the window
// has no valid semantic boundary, no overlap is retained. This keeps overlap
// deterministic and bounded while allowing a boundary inside a large split
// unit (the previous implementation could only retain whole units and often
// collapsed to zero overlap for ordinary paragraphs).
const semanticOverlapLookbehind = 4 // rune length of the longest separator: \r\n\r\n
func computeOverlap(current []splitUnit, chunkOverlap, chunkSize, nextLen int) ([]splitUnit, int) {
if chunkOverlap <= 0 {
return nil, 0
}
// Overlap is part of the next chunk's total size. When the incoming unit
// is already close to the chunk budget, shrink the search window rather
// than creating an oversized chunk.
maxOverlap := chunkOverlap
if remaining := chunkSize - nextLen; remaining < maxOverlap {
maxOverlap = remaining
}
if maxOverlap <= 0 {
return nil, 0
}
window := semanticOverlapWindow(current, maxOverlap+semanticOverlapLookbehind)
if len(window) == 0 {
return nil, 0
}
windowText := unitsText(window)
// Coordinates before originalWindowStart are the lookbehind region. With
// end-exclusive offsets, requiring boundaryEnd >= originalWindowStart is
// equivalent to requiring the separator's final rune to be at -1 or later.
originalWindowStart := runeLen(windowText) - maxOverlap
if originalWindowStart < 0 {
originalWindowStart = 0
}
boundaryEnd, ok := findSemanticOverlapBoundaryEndingAtOrAfter(windowText, originalWindowStart)
if !ok {
return nil, 0
}
overlap := trimUnitsPrefix(window, boundaryEnd)
overlapLen := 0
for _, u := range overlap {
overlapLen += runeLen(u.text)
}
if overlapLen <= 0 || overlapLen > maxOverlap || strings.TrimSpace(unitsText(overlap)) == "" {
return nil, 0
}
return overlap, overlapLen
}
// semanticOverlapWindow returns at most maxLen source-backed runes from the
// tail of current. It may slice inside the first retained splitUnit so a
// semantic boundary inside a large paragraph remains discoverable. Synthetic
// zero-width units (for example repeated table headers) form a hard barrier:
// crossing them would break the Start/End-to-Content source invariant.
func semanticOverlapWindow(current []splitUnit, maxLen int) []splitUnit {
if maxLen <= 0 || len(current) == 0 {
return nil
}
remaining := maxLen
reversed := make([]splitUnit, 0, len(current))
for i := len(current) - 1; i >= 0 && remaining > 0; i-- {
u := current[i]
uLen := runeLen(u.text)
if uLen == 0 {
continue
}
// Header markers contain generated text but occupy no source range.
// Do not include or cross them when deriving an overlap suffix.
if u.start == u.end || u.end-u.start != uLen {
break
}
if uLen <= remaining {
reversed = append(reversed, u)
remaining -= uLen
continue
}
runes := []rune(u.text)
start := uLen - remaining
reversed = append(reversed, splitUnit{
text: string(runes[start:]),
start: u.start + start,
end: u.end,
})
remaining = 0
}
if len(reversed) == 0 {
return nil
}
window := make([]splitUnit, len(reversed))
for i := range reversed {
window[len(reversed)-1-i] = reversed[i]
}
return window
}
type semanticOverlapBoundary struct {
start int
end int
priority int
}
// findSemanticOverlapBoundary returns the rune offset immediately after the
// selected separator. Boundaries inside protected Markdown/code/math regions
// are ignored, and a boundary is invalid when only whitespace follows it.
func findSemanticOverlapBoundary(text string) (int, bool) {
return findSemanticOverlapBoundaryEndingAtOrAfter(text, 0)
}
// findSemanticOverlapBoundaryEndingAtOrAfter applies the normal priority and
// earliest-position rules only to candidates whose end-exclusive rune offset
// is at least minEnd. Filtering before comparison prevents an earlier but
// ineligible lookbehind separator from hiding a later eligible boundary.
func findSemanticOverlapBoundaryEndingAtOrAfter(text string, minEnd int) (int, bool) {
runes := []rune(text)
if len(runes) == 0 {
return 0, false
}
if minEnd < 0 {
minEnd = 0
}
if minEnd > len(runes) {
return 0, false
}
protected := protectedSpansRune(text, protectedSpans(text))
insideProtected := func(pos int) bool {
for _, p := range protected {
if pos < p.start {
return false
}
if pos >= p.start && pos < p.end {
return true
}
}
return false
}
hasMeaningfulTail := func(end int) bool {
return end >= 0 && end < len(runes) && strings.TrimSpace(string(runes[end:])) != ""
}
var best semanticOverlapBoundary
found := false
consider := func(start, end, priority int) {
if start < 0 || end <= start || end < minEnd || end > len(runes) ||
insideProtected(start) || !hasMeaningfulTail(end) {
return
}
candidate := semanticOverlapBoundary{start: start, end: end, priority: priority}
if !found || candidate.priority < best.priority ||
(candidate.priority == best.priority && candidate.start < best.start) {
best = candidate
found = true
}
}
// Mark paragraph-break runes so their component newlines are not also
// emitted as lower-priority line-break candidates.
paragraphRune := make([]bool, len(runes))
for i := 0; i < len(runes); i++ {
switch {
case i+3 < len(runes) && runes[i] == '\r' && runes[i+1] == '\n' &&
runes[i+2] == '\r' && runes[i+3] == '\n':
consider(i, i+4, 1)
for j := i; j < i+4; j++ {
paragraphRune[j] = true
}
i += 3
case i+1 < len(runes) && runes[i] == '\n' && runes[i+1] == '\n':
consider(i, i+2, 1)
paragraphRune[i], paragraphRune[i+1] = true, true
i++
}
}
for i := 0; i < len(runes); i++ {
if paragraphRune[i] {
continue
}
if runes[i] == '\r' && i+1 < len(runes) && runes[i+1] == '\n' && !paragraphRune[i+1] {
consider(i, i+2, 2)
i++
continue
}
if runes[i] == '\n' {
consider(i, i+1, 2)
}
}
for i := 0; i < len(runes); i++ {
switch runes[i] {
case '。', '?', '!':
consider(i, i+1, 3)
case '.', '?', '!':
if i+1 < len(runes) && runes[i+1] == ' ' {
consider(i, i+2, 3)
}
}
}
if !found {
return 0, false
}
return best.end, true
}
// trimUnitsPrefix removes prefixLen source runes while preserving the
// remaining units' original positions. prefixLen is relative to unitsText.
func trimUnitsPrefix(units []splitUnit, prefixLen int) []splitUnit {
if prefixLen <= 0 {
out := make([]splitUnit, len(units))
copy(out, units)
return out
}
remaining := prefixLen
out := make([]splitUnit, 0, len(units))
for _, u := range units {
uLen := runeLen(u.text)
if remaining >= uLen {
remaining -= uLen
continue
}
if remaining > 0 {
runes := []rune(u.text)
u.text = string(runes[remaining:])
u.start += remaining
remaining = 0
}
out = append(out, u)
}
return out
}
// ParentChildResult holds the two-level chunking output.
// Parent chunks provide context (large window), child chunks are used for
// embedding/retrieval (small window). Each child carries its ParentIndex so
// the caller can wire up ParentChunkID after DB insertion.
type ParentChildResult struct {
Parents []Chunk
Children []ChildChunk
}
// ChildChunk extends Chunk with a reference to its parent.
type ChildChunk struct {
Chunk
ParentIndex int // index into ParentChildResult.Parents
}
// SplitTextParentChild performs two-level chunking:
// 1. Split text into large parent chunks (parentCfg).
// 2. Split each parent into smaller child chunks (childCfg) for embedding.
//
// The child Seq is globally unique across the entire document.
func SplitTextParentChild(text string, parentCfg, childCfg SplitterConfig) ParentChildResult {
parents := SplitText(text, parentCfg)
if len(parents) == 0 {
return ParentChildResult{}
}
var newParents []Chunk
var children []ChildChunk
childSeq := 0
for _, parent := range parents {
subs := SplitText(parent.Content, childCfg)
parentIndex := -1
if len(subs) > 1 || (len(subs) == 1 && subs[0].Content != parent.Content) {
parentIndex = len(newParents)
newParents = append(newParents, parent)
}
for _, sub := range subs {
// Adjust offsets: sub positions are relative to parent content,
// shift to document-level offsets.
// Use additive shift (not Content-length based) so that chunks with
// prepended context headers keep correct positional tracking.
sub.Seq = childSeq
sub.Start += parent.Start
sub.End += parent.Start
children = append(children, ChildChunk{
Chunk: sub,
ParentIndex: parentIndex,
})
childSeq++
}
}
return ParentChildResult{Parents: newParents, Children: children}
}
// ExtractImageRefs extracts markdown image references from text.
// The URL group supports one level of balanced parentheses so that URLs
// like https://example.com/item_(abc)/123 are captured in full.
var imageRefPattern = regexp.MustCompile(`!\[([^\]]*)\]\(([^()\s]*(?:\([^)]*\)[^()\s]*)*)\)`)
func ExtractImageRefs(text string) []ImageRef {
text = docparser.UnwrapLinkedImages(text)
matches := imageRefPattern.FindAllStringSubmatchIndex(text, -1)
var refs []ImageRef
for _, m := range matches {
refs = append(refs, ImageRef{
OriginalRef: text[m[4]:m[5]], // group 2: URL
AltText: text[m[2]:m[3]], // group 1: alt text
Start: m[0],
End: m[1],
})
}
return refs
}