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运行时会随机选择一个执行,这个设计是为了避免饥饿问题。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐