第六章:Go语言大模型调用框架 - Eino (文档处理与chunking策略)
1. chunking的重要性
在Embedding之前,还有一个关键步骤–把长文档切成一个个小块(Chunk)。这个过程叫Chunking,也叫文档分块或者文本分割。别小看这一步,Chunking策略的好坏直接决定了RAG的检索质量。切得太粗,一个Chunk里混了好几个主题,检索出来答非所问;切得太细,上下文信息被割裂,模型拿到的参考资料支离破碎,回答也好不到哪去。
2. chunking的策略
2.1 固定长度的分块
固定长度分块是最直观的策略–设定一个最大长度(比如500个字符),然后从头到尾按这个长度把文本切成一段一段的。为了避免上下文被硬切断,通常还会设置一个"重叠区"(Overlap),让相邻的两个块之间有一部分内容是重复的。
这种策略的优点是实现简单、行为可预测一一你能精确控制每个块的大小,不用担心出现特别大或特别小的块。缺点也很明显:它完全不管语义,可能把一句话从中间劈开,也可能把两个毫不相关的段落拼在一起。
package main
import (
"fmt"
"strings"
)
// FixedLengthTokenizer 固定长度的分词器
type FixedLengthTokenizer struct {
ChunkSize int // 每个块的最大字数
Overlap int // 相邻块之间的重叠字符数
}
// Split 按固定长度将文本分块
func (s *FixedLengthTokenizer) Split(text string) []string {
runes := []rune(text)
if len(runes) <= s.ChunkSize {
return []string{text}
}
var chunks []string
start := 0
for start < len(runes) {
end := start + s.ChunkSize
if end > len(runes) {
end = len(runes)
}
chunk := strings.TrimSpace(string(runes[start:end]))
if chunk != "" {
chunks = append(chunks, chunk)
}
start += s.ChunkSize - s.Overlap
}
return chunks
}
func main() {
doc := `Go语言的Channel是goroutine之间通信的桥梁。Channel分为有缓冲和无缓冲两种。
无缓冲Channel在发送和接收时都会阻塞,直到对端准备好。这种同步特性使得无缓冲Channel非常适合做goroutine之间的握手信号。
有缓冲Channel在缓冲区满之前不会阻塞发送操作。你可以把它想象成一个固定大小的队列,生产者往里放数据,消费者从里取数据。
使用select语句可以同时监听多个Channel的读写事件,配合default分支还能实现非阻塞的Channel操作。
当所有Channel都没有就绪时,select会随机选择一个可用的case执行。`
splitter := &FixedLengthTokenizer{
ChunkSize: 80,
Overlap: 20,
}
chunks := splitter.Split(doc)
for i, chunk := range chunks {
fmt.Printf("=== 块 %d(%d 字符)===\n%s\n\n", i+1, len([]rune(chunk)), chunk)
}
}
2.2 递归分块
递归分块相比固定长度分块的核心优势在于能够保持语义完整性,避免在句子或段落中间切断文本,从而提升下游检索与生成的质量;其实现原理是自顶向下按优先级递减的分隔符层级(如换行、句号等)递归拆分文本:优先使用高层级分隔符切分,若生成块仍超过长度限制则降级到更低层级分隔符继续切分,直至满足长度要求或降级到字符级。
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
// RecursiveSplitter 递归分块器
type RecursiveSplitter struct {
ChunkSize int // 每个块的最大字符数
Overlap int // 相邻块之间的重叠字符数
Separators []string // 分隔符列表,按优先级从高到低排列
}
// NewRecursiveSplitter 创建一个新的递归分块器
func NewRecursiveSplitter(chunkSize int, overlap int) *RecursiveSplitter {
return &RecursiveSplitter{
ChunkSize: chunkSize,
Overlap: overlap,
Separators: []string{"\n\n", "\n", "。", "!", "?", ";", ",", " "},
}
}
// Split 将文本分割成多个块
func (rs *RecursiveSplitter) Split(text string) []string {
if text == "" {
return nil
}
// 如果文本已经小于等于块大小,直接返回
if utf8.RuneCountInString(text) <= rs.ChunkSize {
return []string{text}
}
// 递归分割
return rs.splitRecursive(text, 0)
}
// splitRecursive 递归分割文本
func (rs *RecursiveSplitter) splitRecursive(text string, separatorIndex int) []string {
// 如果文本为空,返回空切片
if text == "" {
return nil
}
// 如果文本小于等于块大小,直接返回
if utf8.RuneCountInString(text) <= rs.ChunkSize {
return []string{text}
}
// 如果已经尝试完所有分隔符,使用字符级分割
if separatorIndex >= len(rs.Separators) {
return rs.splitByChar(text)
}
separator := rs.Separators[separatorIndex]
parts := strings.Split(text, separator)
// 如果没有找到分隔符,尝试下一个
if len(parts) == 1 {
return rs.splitRecursive(text, separatorIndex+1)
}
var chunks []string
var currentChunk strings.Builder
for i, part := range parts {
// 如果当前部分本身就超过块大小,递归处理
if utf8.RuneCountInString(part) > rs.ChunkSize {
// 先处理当前累积的内容
if currentChunk.Len() > 0 {
chunk := currentChunk.String()
if utf8.RuneCountInString(chunk) > rs.ChunkSize {
// 如果累积内容仍然太大,递归处理
subChunks := rs.splitRecursive(chunk, separatorIndex+1)
chunks = append(chunks, subChunks...)
currentChunk.Reset()
} else {
chunks = append(chunks, chunk)
currentChunk.Reset()
}
}
// 递归处理过长的部分
subChunks := rs.splitRecursive(part, separatorIndex+1)
chunks = append(chunks, subChunks...)
continue
}
// 计算添加当前部分后的长度
separatorLen := len(separator)
newLen := currentChunk.Len() + utf8.RuneCountInString(part)
if i > 0 {
newLen += separatorLen
}
// 如果添加当前部分会超过块大小
if currentChunk.Len() > 0 && newLen > rs.ChunkSize {
chunk := currentChunk.String()
// 累积内容太大,需要递归处理
if utf8.RuneCountInString(chunk) > rs.ChunkSize {
currentChunk.Reset()
currentChunk.WriteString(part)
subChunks := rs.splitRecursive(chunk, separatorIndex+1)
chunks = append(chunks, subChunks...)
} else {
chunks = append(chunks, chunk)
currentChunk.Reset()
currentChunk.WriteString(part)
}
} else {
// 添加当前部分到当前块
if currentChunk.Len() > 0 {
if i > 0 {
currentChunk.WriteString(separator)
}
}
currentChunk.WriteString(part)
}
}
// 处理最后累积的内容
if currentChunk.Len() > 0 {
chunk := currentChunk.String()
if utf8.RuneCountInString(chunk) <= rs.ChunkSize {
chunks = append(chunks, chunk)
} else {
// 最后一块太大,递归处理
subChunks := rs.splitRecursive(chunk, separatorIndex+1)
chunks = append(chunks, subChunks...)
}
}
return chunks
}
// splitByChar 使用字符级分割作为最后手段(确保不截断UTF-8字符)
func (rs *RecursiveSplitter) splitByChar(text string) []string {
if utf8.RuneCountInString(text) <= rs.ChunkSize {
return []string{text}
}
var chunks []string
runes := []rune(text)
start := 0
for start < len(runes) {
end := start + rs.ChunkSize
if end > len(runes) {
end = len(runes)
}
// 确保不在UTF-8字符中间截断
for end > start && end < len(runes) && utf8.RuneCountInString(string(runes[start:end])) > rs.ChunkSize {
// 回溯找到完整的字符
_, size := utf8.DecodeLastRuneInString(string(runes[start:end]))
end -= size
}
chunk := string(runes[start:end])
chunks = append(chunks, chunk)
// 如果已经达到文本末尾,停止
if end >= len(runes) {
break
}
// 移动起始位置,考虑重叠
start = end - rs.Overlap
if start < 0 {
start = 0
}
}
return chunks
}
func main() {
splitter := NewRecursiveSplitter(100, 0)
doc := `Go语言的Channel是goroutine之间通信的桥梁。Channel分为有缓冲和无缓冲两种。
无缓冲Channel在发送和接收时都会阻塞,直到对端准备好。这种同步特性使得无缓冲Channel非常适合做goroutine之间的握手信号。
有缓冲Channel在缓冲区满之前不会阻塞发送操作。你可以把它想象成一个固定大小的队列,生产者往里放数据,消费者从里取数据。
使用select语句可以同时监听多个Channel的读写事件,配合default分支还能实现非阻塞的Channel操作。当所有Channel都没有就绪时,select会随机选择一个可用的case执行。`
chunks := splitter.Split(doc)
fmt.Println("分块结果:")
fmt.Println("==========")
for i, chunk := range chunks {
fmt.Printf("块 %d (字符数: %d):\n%s\n----------\n", i+1, utf8.RuneCountInString(chunk), chunk)
}
}
输出的结果:
分块结果:
==========
块 1 (字符数: 49):
Go语言的Channel是goroutine之间通信的桥梁。Channel分为有缓冲和无缓冲两种。
----------
块 2 (字符数: 70):
无缓冲Channel在发送和接收时都会阻塞,直到对端准备好。这种同步特性使得无缓冲Channel非常适合做goroutine之间的握手信号。
----------
块 3 (字符数: 62):
有缓冲Channel在缓冲区满之前不会阻塞发送操作。你可以把它想象成一个固定大小的队列,生产者往里放数据,消费者从里取数据。
----------
块 4 (字符数: 100):
使用select语句可以同时监听多个Channel的读写事件,配合default分支还能实现非阻塞的Channel操作。当所有Channel都没有就绪时,select会随机选择一个可用的case执行。
----------
3. 文档的分块 (markdown文档)
Markdown文档切割通常采用基于标题层级(如#、##等)的结构化分割,结合代码块、表格等特殊语法的完整性保留,以及递归降级到段落、句子的混合策略,从而生成语义完整且带有层级路径元数据的文本块;这种切割方式在RAG检索中的核心意义在于,通过保留文档的原始结构信息和上下文依赖关系,使检索阶段能够利用标题路径进行精确的局部定位与全局消歧,同时确保嵌入向量能更准确地表征块的真实语义,显著提升检索召回率与生成答案的连贯性。
package main
import (
"fmt"
"strings"
)
// MarkdownChunk 一个 Markdown 分块,包含内容和标题层级信息
type MarkdownChunk struct {
Content string // 块内容
Metadata map[string]string // 元数据(包含各级标题)
}
// MarkdownSplitter 按 Markdown 标题层级分块
type MarkdownSplitter struct {
Headers []HeaderLevel // 要识别的标题层级
}
// HeaderLevel 标题层级定义
type HeaderLevel struct {
Prefix string // 标题前缀,如 "##"
Name string // 元数据中的 key,如 "h2"
}
// Split 按标题层级分块
func (s *MarkdownSplitter) Split(text string) []MarkdownChunk {
lines := strings.Split(text, "\n")
var chunks []MarkdownChunk
currentHeaders := make(map[string]string) // 当前生效的各级标题
var currentContent []string
for _, line := range lines {
matched := false
for i, header := range s.Headers {
prefix := header.Prefix + " "
if strings.HasPrefix(line, prefix) {
// 遇到了一个标题行,先把之前积累的内容保存为一个块
if len(currentContent) > 0 {
content := strings.TrimSpace(strings.Join(currentContent, "\n"))
if content != "" {
chunks = append(chunks, MarkdownChunk{
Content: content,
Metadata: copyMap(currentHeaders),
})
}
currentContent = nil
}
// 更新当前标题,并清除所有更低层级的标题
titleText := strings.TrimPrefix(line, prefix)
currentHeaders[header.Name] = strings.TrimSpace(titleText)
for j := i + 1; j < len(s.Headers); j++ {
delete(currentHeaders, s.Headers[j].Name)
}
matched = true
break
}
}
if !matched {
currentContent = append(currentContent, line)
}
}
// 处理最后一段内容
if len(currentContent) > 0 {
content := strings.TrimSpace(strings.Join(currentContent, "\n"))
if content != "" {
chunks = append(chunks, MarkdownChunk{
Content: content,
Metadata: copyMap(currentHeaders),
})
}
}
return chunks
}
func copyMap(m map[string]string) map[string]string {
cp := make(map[string]string, len(m))
for k, v := range m {
cp[k] = v
}
return cp
}
func main() {
doc := `# Go并发编程
## Channel基础
Channel是Go语言中goroutine之间通信的管道。它是类型安全的,一个chan int类型的Channel只能传输int类型的数据。Channel的零值是nil,必须用make函数初始化后才能使用。
## 有缓冲与无缓冲
### 无缓冲Channel
无缓冲Channel的发送和接收操作是同步的。发送方会阻塞,直到接收方从Channel中取走数据。这种特性常被用来做goroutine之间的同步信号。
### 有缓冲Channel
有缓冲Channel内部维护了一个固定大小的队列。只要队列没满,发送操作就不会阻塞。当队列满了之后,发送方会阻塞等待,直到接收方取走一个元素腾出空间。
## Select多路复用
select语句让你可以同时等待多个Channel操作。它的语法和switch很像,但每个case必须是一个Channel的读或写操作。当多个case同时就绪时,Go运行时会随机选择一个执行,这个设计是为了避免饥饿问题。`
splitter := &MarkdownSplitter{
Headers: []HeaderLevel{
{Prefix: "#", Name: "h1"},
{Prefix: "##", Name: "h2"},
{Prefix: "###", Name: "h3"},
},
}
chunks := splitter.Split(doc)
for i, chunk := range chunks {
fmt.Printf("=== 块 %d ===\n", i+1)
fmt.Printf("元数据: %v\n", chunk.Metadata)
fmt.Printf("内容: %s\n\n", chunk.Content)
}
}
按照标题分块,运行结果如下:
=== 块 1 ===
元数据: map[h1:Go并发编程 h2:Channel基础]
内容: Channel是Go语言中goroutine之间通信的管道。它是类型安全的,一个chan int类型的Channel只能传输int类型的数据。Channel的零值是nil,必须用make函数初始化后才能使用。
=== 块 2 ===
元数据: map[h1:Go并发编程 h2:有缓冲与无缓冲 h3:无缓冲Channel]
内容: 无缓冲Channel的发送和接收操作是同步的。发送方会阻塞,直到接收方从Channel中取走数据。这种特性常被用来做goroutine之间的同步信号。
=== 块 3 ===
元数据: map[h1:Go并发编程 h2:有缓冲与无缓冲 h3:有缓冲Channel]
内容: 有缓冲Channel内部维护了一个固定大小的队列。只要队列没满,发送操作就不会阻塞。当队列满了之后,发送方会阻塞等待,直到接收方取走一个元素腾出空间。
=== 块 4 ===
元数据: map[h1:Go并发编程 h2:Select多路复用]
内容: select语句让你可以同时等待多个Channel操作。它的语法和switch很像,但每个case必须是一个Channel的读或写操作。当多个case同时就绪时,Go运行时会随机选择一个执行,这个设计是为了避免饥饿问题。
更多推荐




所有评论(0)