Files
rag-local/kb/service/chunk_service.go
T
2026-08-07 16:07:20 +08:00

443 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"regexp"
"strings"
"unicode/utf8"
"rag-local/common"
"rag-local/kb/consts"
"rag-local/kb/dao"
"rag-local/kb/model/domain"
"rag-local/kb/model/entity"
"github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive"
"github.com/cloudwego/eino-ext/components/document/transformer/splitter/semantic"
"github.com/cloudwego/eino/components/document"
eembedding "github.com/cloudwego/eino/components/embedding"
"github.com/cloudwego/eino/schema"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
// 中文分块分隔符:先段落/换行,再句读标点;runeLen 保证按字计数(与标题策略一致)
var chunkSeparators = []string{"\n\n", "\n", "。", "", "", "", "", " ", ""}
var runeLen = utf8.RuneCountInString
// SplitAuto 内置组合分块管线(无需用户选择策略),按文档内容自动分派:
// 1. 结构识别(条文/条款/章回/编号等,行首命中达标)→ 结构化切分,带上下文前缀
// 2. 标题感知(# / 标题 / 第X章 行首)→ 标题并入段落切分
// 3. 语义分块(无结构无标题,需 embedder)→ 按句子语义切分,超限块递归兜底
// 4. 递归兜底(embedder 不可用或语义失败)→ 中文分隔符递归切分
//
// 返回的 unitPattern/ctxPattern 仅结构识别路径有值(自动识别出的结构模式,供落库展示)。
func (s *chunkService) SplitAuto(ctx context.Context, text string, chunkSize, overlap int, embedder eembedding.Embedder) (chunks []string, unitPattern, ctxPattern string, err error) {
if unit, ctxPat, ok := s.DetectStructure(text); ok {
g.Log().Infof(ctx, "分块管线:结构识别命中(%s / %s),按结构单元切分", unit, ctxPat)
return s.splitStructured(text, unit, ctxPat, chunkSize, overlap), unit, ctxPat, nil
}
if hasHeadings(text) {
g.Log().Infof(ctx, "分块管线:未识别结构,含标题行,走标题感知切分")
return s.SplitText(text, chunkSize, overlap), "", "", nil
}
if embedder != nil {
g.Log().Infof(ctx, "分块管线:无结构无标题,走语义切分")
sp, spErr := semantic.NewSplitter(ctx, &semantic.Config{
Embedding: embedder,
BufferSize: 1,
MinChunkSize: chunkSize / 2,
Separators: chunkSeparators[:len(chunkSeparators)-2],
LenFunc: runeLen,
})
if spErr != nil {
g.Log().Warningf(ctx, "构建语义分块器失败,回退递归分块: %v", spErr)
} else {
semanticChunks, tErr := s.transformText(ctx, sp, text)
if tErr != nil {
g.Log().Warningf(ctx, "语义分块失败,回退递归分块: %v", tErr)
} else if len(semanticChunks) > 0 {
// chunkSize 作为安全上限:语义分块可能产出巨块(长连续语义段),超限块递归二次切分兜底(不重叠)
chunks, err = s.capChunks(ctx, semanticChunks, chunkSize)
return chunks, "", "", err
}
}
}
g.Log().Infof(ctx, "分块管线:无结构无标题,语义分块不可用,走递归切分")
chunks, err = s.splitRecursive(ctx, text, chunkSize, overlap)
return chunks, "", "", err
}
// hasHeadings 文本是否含标题行(Markdown # / 标题 / 第X章),标题感知分块保留标题结构
func hasHeadings(text string) bool {
for _, line := range strings.Split(text, "\n") {
if isHeading(strings.TrimSpace(line)) {
return true
}
}
return false
}
// 结构标记候选:单元模式按优先级排列(条文 > 章回 > 小节 > 章节 > 篇部 > 数字编号 > 括号序号)
var (
unitPatternCandidates = []string{
`第[零一二三四五六七八九十百千]+条`,
`第[零一二三四五六七八九十百千]+回`,
`第[零一二三四五六七八九十百千]+节`,
`第[零一二三四五六七八九十百千]+章`,
`第[零一二三四五六七八九十百千]+[篇部]`,
`[一二三四五六七八九十]{1,3}[、.]`,
`\d{1,3}[、.]`,
`[((][一二三四五六七八九十\d]{1,3}[)]`,
}
ctxPatternCandidates = []string{
`第[零一二三四五六七八九十百千]+[篇部]`,
`第[零一二三四五六七八九十百千]+章`,
`第[零一二三四五六七八九十百千]+节`,
}
minUnitHits = 5 // 行首命中至少 5 次才认定是结构单元(排除正文偶然提及)
minCtxHits = 3 // 上下文标记命中下限
)
// DetectStructure 自动识别文本的结构单元与上下文标记:统计各候选模式在行首的出现次数,
// 命中数达到阈值且单元优先顺序靠前者胜出;上下文取单元之外层级最高的候选。未识别出单元时 ok=false。
func (s *chunkService) DetectStructure(text string) (unit, ctx string, ok bool) {
count := func(pat string) int {
return len(regexp.MustCompile(`(?m)^\s*`+pat).FindAllStringIndex(text, -1))
}
hits := make(map[string]int, len(unitPatternCandidates)+len(ctxPatternCandidates))
for _, p := range unitPatternCandidates {
hits[p] = count(p)
}
for _, p := range ctxPatternCandidates {
hits[p] = count(p)
}
for _, p := range unitPatternCandidates {
if hits[p] >= minUnitHits {
unit = p
break
}
}
if unit == "" {
return "", "", false
}
for _, p := range ctxPatternCandidates {
if p == unit {
continue
}
if hits[p] >= minCtxHits {
ctx = p
break
}
}
return unit, ctx, true
}
// splitStructured 结构单元切分:按单元模式切分(标记保留在块内),上下文模式命中的整行作为前缀;
// 相邻短单元合并到 chunkSize/2(上限 300 字),超长单元按句兜底切分(后续片段补「(标记 续)」前缀)。
func (s *chunkService) splitStructured(text, unitPattern, ctxPattern string, chunkSize, overlap int) []string {
matches := regexp.MustCompile(`(?m)^\s*(`+unitPattern+`)`).FindAllStringIndex(text, -1)
if len(matches) == 0 {
return s.SplitText(text, chunkSize, overlap)
}
mergeMin := chunkSize / 2
if mergeMin > 300 {
mergeMin = 300
}
if mergeMin < 1 {
mergeMin = 1
}
var ctxMatches [][]int
if ctxPattern != "" {
ctxMatches = regexp.MustCompile(`(?m)^\s*(`+ctxPattern+`)`).FindAllStringIndex(text, -1)
}
// ctxBefore 返回 pos 之前最近的上下文标记整行(如「第三章 劳动合同的解除」)
ctxBefore := func(pos int) string {
cur := ""
for _, m := range ctxMatches {
if m[0] >= pos {
break
}
lineEnd := strings.IndexByte(text[m[0]:], '\n')
if lineEnd < 0 {
lineEnd = len(text) - m[0]
}
cur = strings.TrimSpace(text[m[0] : m[0]+lineEnd])
}
return cur
}
out := make([]string, 0, 16)
var buf []string
bufLen := 0
bufCtx := ""
flush := func() {
if len(buf) == 0 {
return
}
body := strings.Join(buf, "\n")
if bufCtx != "" {
body = "【" + bufCtx + "】" + body
}
out = append(out, body)
buf, bufLen, bufCtx = nil, 0, ""
}
for i, m := range matches {
start := m[1]
end := len(text)
if i+1 < len(matches) {
end = matches[i+1][0]
}
marker := strings.TrimSpace(text[m[0]:m[1]])
content := strings.TrimSpace(text[start:end])
if content == "" {
continue
}
item := marker + " " + content
ctx := ctxBefore(m[0])
if bufLen > 0 && (ctx != bufCtx || bufLen+runeLen(item) > chunkSize) {
flush()
}
if runeLen(item) > chunkSize {
pieces := s.SplitText(item, chunkSize, overlap)
if len(pieces) > 0 {
if ctx != "" {
pieces[0] = "【" + ctx + "】" + pieces[0]
}
out = append(out, pieces[0])
for _, p := range pieces[1:] {
out = append(out, ""+marker+" 续)"+p)
}
}
continue
}
buf = append(buf, item)
bufLen += runeLen(item)
bufCtx = ctx
if bufLen >= mergeMin {
flush()
}
}
flush()
return out
}
// splitRecursive 递归字符分块:按分隔符列表递归切分到目标大小,保留 overlap
func (s *chunkService) splitRecursive(ctx context.Context, text string, chunkSize, overlap int) ([]string, error) {
sp, err := recursive.NewSplitter(ctx, &recursive.Config{
ChunkSize: chunkSize,
OverlapSize: overlap,
Separators: chunkSeparators,
LenFunc: runeLen,
KeepType: recursive.KeepTypeEnd,
})
if err != nil {
return nil, gerror.Wrap(err, "构建递归分块器失败")
}
return s.transformText(ctx, sp, text)
}
// capChunks 超过 maxSize 的块用递归分块器二次切分,防止语义分块产出巨块
func (s *chunkService) capChunks(ctx context.Context, chunks []string, maxSize int) ([]string, error) {
if maxSize <= 0 {
return chunks, nil
}
out := make([]string, 0, len(chunks))
for _, c := range chunks {
if runeLen(c) <= maxSize {
out = append(out, c)
continue
}
sub, err := s.splitRecursive(ctx, c, maxSize, 0)
if err != nil {
return nil, err
}
out = append(out, sub...)
}
return out, nil
}
func (s *chunkService) transformText(ctx context.Context, sp document.Transformer, text string) ([]string, error) {
docs, err := sp.Transform(ctx, []*schema.Document{{ID: "0", Content: text}})
if err != nil {
return nil, gerror.Wrap(err, "分块失败")
}
out := make([]string, 0, len(docs))
for _, d := range docs {
if c := strings.TrimSpace(d.Content); c != "" {
out = append(out, c)
}
}
return out, nil
}
var ChunkService = &chunkService{}
type chunkService struct{}
// SplitText 文本分块:标题感知 + 固定大小回退,超长段落按句号/换行切分并保留重叠
func (s *chunkService) SplitText(text string, chunkSize, overlap int) []string {
if chunkSize <= 0 {
chunkSize = consts.DefaultChunkSize
}
if overlap < 0 {
overlap = consts.DefaultChunkOverlap
}
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
paragraphs := splitParagraphs(text)
var merged []string
var cur strings.Builder
curRunes := 0
for _, p := range paragraphs {
pRunes := utf8.RuneCountInString(p)
if cur.Len() > 0 && curRunes+pRunes > chunkSize {
merged = append(merged, cur.String())
cur.Reset()
curRunes = 0
}
cur.WriteString(p)
cur.WriteString("\n\n")
curRunes += pRunes + 2
}
if cur.Len() > 0 {
merged = append(merged, cur.String())
}
var chunks []string
for _, c := range merged {
if len(c) <= chunkSize {
chunks = append(chunks, strings.TrimSpace(c))
continue
}
chunks = append(chunks, forceSplit(c, chunkSize, overlap)...)
}
return chunks
}
// splitParagraphs 按空行/标题切段;# 标题行并入其后的段落(标题保留在段首,供检索回显)
func splitParagraphs(text string) []string {
lines := strings.Split(text, "\n")
var paras []string
var cur []string
flush := func() {
if len(cur) > 0 {
paras = append(paras, strings.Join(cur, "\n"))
cur = nil
}
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
flush()
continue
}
if isHeading(trimmed) && len(cur) > 0 {
flush()
}
cur = append(cur, trimmed)
}
flush()
return paras
}
func isHeading(line string) bool {
return strings.HasPrefix(line, "#") ||
strings.HasPrefix(line, "标题") || strings.HasPrefix(line, "第") && strings.Contains(line, "章")
}
// forceSplit 超长文本按可读位置切分(rune 安全,避免切在 UTF-8 中间),重叠 overlap 字
func forceSplit(text string, chunkSize, overlap int) []string {
runes := []rune(text)
var result []string
for len(runes) > chunkSize {
limit := min(chunkSize, len(runes))
cut := lastCutPoint(runes[:limit])
if cut < chunkSize/2 {
cut = chunkSize
}
if chunk := strings.TrimSpace(string(runes[:cut])); chunk != "" {
result = append(result, chunk)
}
runes = runes[max(0, cut-overlap):]
}
if chunk := strings.TrimSpace(string(runes)); chunk != "" {
result = append(result, chunk)
}
return result
}
// lastCutPoint 在 limit 内找最后一个可读切点(句号/问号/感叹号/分号/换行/英文标点),找不到返回 -1
func lastCutPoint(runes []rune) int {
cut := -1
for i, r := range runes {
switch r {
case '。', '', '', '', '\n', '.', '!', '?', ';':
cut = i + 1
}
}
return cut
}
// List 分块列表
func (s *chunkService) List(ctx context.Context, documentId int64, page, pageSize int) ([]*entity.Chunk, int, error) {
return dao.Chunk.ListByDocument(ctx, documentId, page, pageSize)
}
// InsertAll 写入文档的全部分块;embedder 非空时批量向量化写入 vec0,否则仅写 FTS5
func (s *chunkService) InsertAll(ctx context.Context, datasetId, documentId int64, chunks []string, embedder eembedding.Embedder) error {
for start := 0; start < len(chunks); start += consts.EmbedBatchSize {
end := min(start+consts.EmbedBatchSize, len(chunks))
var vecs [][]float64
if embedder != nil {
v, err := embedder.EmbedStrings(ctx, chunks[start:end])
if err != nil {
return gerror.Wrap(err, "分块向量化失败")
}
vecs = v
}
for j, content := range chunks[start:end] {
vecJson := ""
if len(vecs) > 0 && j < len(vecs) {
vecJson = domain.VecJsonF64(vecs[j])
}
if _, err := dao.Chunk.InsertWithVec(ctx, datasetId, documentId, start+j+1, content, "", "", vecJson, 0); err != nil {
return err
}
}
}
return dao.Document.UpdateFields(ctx, documentId, g.Map{
"chunk_count": len(chunks),
})
}
// Update 编辑分块文本:重新分词 FTS 索引,数据集绑定 embedding 配置时同步重新向量化
func (s *chunkService) Update(ctx context.Context, id int64, content string) error {
chunk, err := dao.Chunk.GetOne(ctx, id)
if err != nil {
return err
}
if chunk == nil {
return gerror.New("分块不存在")
}
vecJson := ""
if cfgId, err := dao.Dataset.GetEmbeddingCfgId(ctx, chunk.DatasetId); err == nil && cfgId > 0 {
if em, err := BuildEmbedder(ctx, cfgId); err == nil {
if vecs, err := em.EmbedStrings(ctx, []string{content}); err != nil {
g.Log().Warningf(ctx, "re-embed chunk %d failed: %v", id, err)
} else if len(vecs) > 0 {
vecJson = domain.VecJsonF64(vecs[0])
}
} else {
g.Log().Warningf(ctx, "build embedder failed: %v", err)
}
}
return dao.Chunk.UpdateContent(ctx, id, content, vecJson, common.Tokenize(content))
}
// DeleteByDocument 删除文档全部数据(chunk + vec + fts
func (s *chunkService) DeleteByDocument(ctx context.Context, documentId int64) error {
return dao.Chunk.DeleteByDocument(ctx, documentId)
}