Files
Enterprise-Knowledge-Base/internal/infrastructure/chunker/header_tracker.go
T
wizardchen ce7df624fb feat(chunker): implement header tracking for Markdown tables during chunking
- Added headerTracker to preserve context of Markdown table headers across split chunks.
- Implemented logic to prepend headers to subsequent chunks after the first.
- Enhanced merging logic to ensure headers are not duplicated in overlapping content.
- Added comprehensive tests for header tracking and chunking behavior with Markdown tables.
2026-04-01 13:38:40 +08:00

162 lines
5.1 KiB
Go

// Package chunker - header_tracker.go implements context-preserving header
// tracking for document chunking, ported from docreader/splitter/header_hook.py.
//
// When a large Markdown table is split across multiple chunks, each chunk after
// the first would lose the table header context. The headerTracker detects table
// headers and signals the merge logic to prepend them to subsequent chunks.
package chunker
import (
"regexp"
"sort"
"strings"
)
// headerTrackerHook defines a pattern pair for detecting contextual headers.
// When startPattern matches a unit's text, that text becomes an "active header".
// The header stays active until endPattern matches a subsequent unit.
type headerTrackerHook struct {
startPattern *regexp.Regexp
endPattern *regexp.Regexp
priority int
}
// defaultHeaderHooks returns header tracking hooks matching the Python defaults
// in docreader/splitter/header_hook.py.
var defaultHeaderHooks = []headerTrackerHook{
{
// Markdown table: header row + separator row (e.g. "| A | B |\n| --- | --- |\n")
startPattern: regexp.MustCompile(`(?si)^\s*(?:\|[^|\n]*)+[\r\n]+\s*(?:\|\s*:?-{3,}:?\s*)+\|?[\r\n]+$`),
// Empty/whitespace line or a line that doesn't start with | or whitespace
endPattern: regexp.MustCompile(`(?si)^\s*$|^\s*[^|\s].*$`),
priority: 15,
},
}
// tableRowPattern matches a single Markdown table row: "| cell | cell | ... |\n"
var tableRowPattern = regexp.MustCompile(`(?m)^\s*(?:\|[^|\n]*)+\|\s*$`)
// headerTracker maintains the state of active headers across split units.
type headerTracker struct {
hooks []headerTrackerHook
activeHeaders map[int]string // priority -> header text
endedHeaders map[int]bool // priorities that have been ended
pendingExtend map[int]bool // headers with empty column names awaiting first data row
}
func newHeaderTracker() *headerTracker {
return &headerTracker{
hooks: defaultHeaderHooks,
activeHeaders: make(map[int]string),
endedHeaders: make(map[int]bool),
pendingExtend: make(map[int]bool),
}
}
// update checks split text for header start/end markers and updates internal state.
func (ht *headerTracker) update(split string) {
// 1. Check for header-end markers among currently active headers
for _, hook := range ht.hooks {
if _, active := ht.activeHeaders[hook.priority]; active {
if hook.endPattern.MatchString(split) {
ht.endedHeaders[hook.priority] = true
delete(ht.activeHeaders, hook.priority)
delete(ht.pendingExtend, hook.priority)
}
}
}
// 2. If a header has an empty column-name row (e.g. "||"), replace it with
// a proper Markdown table header using the first data row as column names.
//
// Before: "||" + "| --- | --- |\n"
// After: "| col1 | col2 |\n" + "| --- | --- |\n"
for p := range ht.pendingExtend {
if _, active := ht.activeHeaders[p]; active && tableRowPattern.MatchString(split) {
sep := extractSeparatorLine(ht.activeHeaders[p])
ht.activeHeaders[p] = split + sep
}
delete(ht.pendingExtend, p)
}
// 3. Check for new header-start markers (only for hooks that are neither active nor ended)
for _, hook := range ht.hooks {
if _, active := ht.activeHeaders[hook.priority]; active {
continue
}
if ht.endedHeaders[hook.priority] {
continue
}
if loc := hook.startPattern.FindString(split); loc != "" {
ht.activeHeaders[hook.priority] = loc
if isEmptyTableHeaderRow(loc) {
ht.pendingExtend[hook.priority] = true
}
}
}
// 4. If all headers ended, clear the ended set so future tables can be tracked
if len(ht.activeHeaders) == 0 {
for k := range ht.endedHeaders {
delete(ht.endedHeaders, k)
}
}
}
// getHeaders returns all active headers concatenated, sorted by priority descending.
func (ht *headerTracker) getHeaders() string {
if len(ht.activeHeaders) == 0 {
return ""
}
type entry struct {
priority int
text string
}
entries := make([]entry, 0, len(ht.activeHeaders))
for p, t := range ht.activeHeaders {
entries = append(entries, entry{p, t})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].priority > entries[j].priority
})
parts := make([]string, len(entries))
for i, e := range entries {
parts[i] = e.text
}
return strings.Join(parts, "\n")
}
// isEmptyTableHeaderRow checks if the header row (the line before the separator)
// contains only pipes and whitespace — meaning the column names are empty.
// This is common with MarkItDown and similar converters that produce tables like:
//
// ||
// | --- | --- |
// | real column A | real column B |
func isEmptyTableHeaderRow(header string) bool {
idx := strings.IndexByte(header, '\n')
if idx < 0 {
return false
}
row := strings.TrimSpace(header[:idx])
for _, r := range row {
if r != '|' && r != ' ' && r != '\t' {
return false
}
}
return true
}
// extractSeparatorLine returns the separator line (e.g. "| --- | --- |\n") from
// a table header string. It looks for the line containing "---".
func extractSeparatorLine(header string) string {
for _, line := range strings.Split(header, "\n") {
if strings.Contains(line, "---") {
return line + "\n"
}
}
return ""
}