Files
Agent-System/internal/controlcenter/docs/chunk.go
T
weichen 63f1fa5f87 feat(control-center): P2 document vector store — ingestion, chunking, embedding, hybrid search
- internal/controlcenter/embed: OpenAI-compatible /embeddings client
  (batch<=16, 30s timeout, 5xx/network retried once, 4xx not), Embedder
  interface, deterministic offline Stub, pgvector literal formatting
- internal/controlcenter/docs: .doc/**/*.md sync into cc_documents with
  sha256 diffing and vanished-file removal; YAML front matter -> jsonb,
  title fallback, taxonomy from first .doc dir; heading-based chunking
  with paragraph bisection over ~1800 est. tokens; chunk_hash idempotent
  inserts (unchanged chunks keep embeddings); embedding failures degrade
  to NULL vectors without failing the sync; IndexArtifact for
  cc_stage_artifacts; StartPeriodic ticker (CC_DOC_SYNC_SECONDS)
- server: GET /api/v1/docs (taxonomy/q filters), /api/v1/docs/{id},
  /api/v1/docs/search (semantic|keyword|hybrid, ts_rank+ILIKE keyword
  fallback, 0.6/0.4 max-normalized fusion, degraded flag when embedding
  unavailable); async artifact indexing hooked into agent artifacts and
  external-submit handlers
- archtest module-boundaries allowlist updated for new packages
2026-09-17 09:47:29 +00:00

137 lines
3.2 KiB
Go

package docs
import (
"strings"
)
// maxChunkTokens bounds one chunk; oversized sections are bisected by
// paragraph until they fit (or cannot be split further).
const maxChunkTokens = 1800
// Chunk is one embeddable piece of a document.
type Chunk struct {
Seq int
Heading string
Content string
}
// chunkMarkdown splits md at ATX heading lines; content before the first
// heading becomes a chunk with an empty heading. Oversized sections are then
// bisected by paragraph. Heading text is kept inside the content so the
// embedding carries it.
func chunkMarkdown(md string) []Chunk {
var chunks []Chunk
for _, sec := range splitSections(md) {
chunks = append(chunks, bisect(sec.heading, sec.content)...)
}
for i := range chunks {
chunks[i].Seq = i
}
return chunks
}
type section struct {
heading string
content string
}
func splitSections(md string) []section {
var secs []section
var cur section
var lines []string
flush := func() {
body := strings.TrimSpace(strings.Join(lines, "\n"))
if cur.heading == "" && body == "" {
lines = nil
return
}
cur.content = strings.TrimSpace(cur.heading + "\n" + body)
if cur.content != "" {
secs = append(secs, cur)
}
lines = nil
}
for _, line := range strings.Split(md, "\n") {
if h, ok := atxHeading(line); ok {
flush()
cur = section{heading: h}
continue
}
lines = append(lines, line)
}
flush()
return secs
}
// atxHeading returns the heading line (with # marks) for ATX headings.
func atxHeading(line string) (string, bool) {
t := strings.TrimSpace(line)
if !strings.HasPrefix(t, "#") {
return "", false
}
i := 0
for i < len(t) && t[i] == '#' {
i++
}
if i > 6 || i >= len(t) || t[i] != ' ' {
return "", false
}
return t, true
}
// bisect splits an oversized section by paragraphs, halving recursively; a
// single oversized paragraph is cut by rune count.
func bisect(heading, content string) []Chunk {
if estimateTokens(content) <= maxChunkTokens {
return []Chunk{{Heading: heading, Content: content}}
}
paras := strings.Split(content, "\n\n")
if len(paras) > 1 {
mid := len(paras) / 2
left := bisect(heading, strings.TrimSpace(strings.Join(paras[:mid], "\n\n")))
right := bisect(heading, strings.TrimSpace(strings.Join(paras[mid:], "\n\n")))
return append(left, right...)
}
// single huge paragraph: hard cut on a rune boundary
runes := []rune(content)
half := len(runes) / 2
for half > 0 && half < len(runes)-1 && runes[half] != ' ' && runes[half] != '\n' && runes[half] != '。' {
half++
}
if half <= 0 || half >= len(runes) {
half = len(runes) / 2
}
left := bisect(heading, strings.TrimSpace(string(runes[:half])))
right := bisect(heading, strings.TrimSpace(string(runes[half:])))
return append(left, right...)
}
// estimateTokens approximates token count: CJK runes cost 2, ASCII runs cost
// ceil(len/4). Same heuristic as internal/kb.
func estimateTokens(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 1
}
tokens, asciiRun := 0, 0
flush := func() {
if asciiRun > 0 {
tokens += (asciiRun + 3) / 4
asciiRun = 0
}
}
for _, r := range s {
if r <= 127 {
asciiRun++
continue
}
flush()
tokens += 2
}
flush()
if tokens < 1 {
return 1
}
return tokens
}