Files
rag-local/kb/service/chunk_service.go
T
2026-08-05 13:39:37 +08:00

246 lines
7.4 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"
"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
// SplitByStrategy 按数据集分块策略分块;semantic 需要 embedder,其余策略可传 nil
func (s *chunkService) SplitByStrategy(ctx context.Context, strategy, text string, chunkSize, overlap int, embedder eembedding.Embedder) ([]string, error) {
switch strategy {
case consts.ChunkStrategyRecursive:
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)
case consts.ChunkStrategySemantic:
if embedder == nil {
return nil, gerror.New("语义分块需要向量模型")
}
sp, err := semantic.NewSplitter(ctx, &semantic.Config{
Embedding: embedder,
BufferSize: 1,
MinChunkSize: chunkSize / 2,
Separators: chunkSeparators[:len(chunkSeparators)-2],
LenFunc: runeLen,
})
if err != nil {
return nil, gerror.Wrap(err, "构建语义分块器失败")
}
return s.transformText(ctx, sp, text)
default:
return s.SplitText(text, chunkSize, overlap), 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),
"status": consts.DocumentStatusDone,
})
}
// 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)
}