4、文本分割策略:固定长度分割、递归字符分割、语义分割的原理与代码实现

文本分割,说白了就是给大模型喂饭前的「切菜」环节。

你想想看,大模型的上下文窗口再大,也装不下一整本书。所以我们必须把长文本切成合适的小块,再送进去检索。这块做不好,RAG 系统的召回率直接崩盘。

我个人习惯把文本分割比作「切西瓜」——切得太碎,语义全碎了;切得太整,又塞不进模型嘴里。今天咱们就聊聊三种主流的切法:固定长度、递归字符、语义分割。

4.1 固定长度分割:最朴素,也最粗暴

固定长度分割,就是按字符数或 token 数硬切。比如每 512 个字符一刀切下去,不管句子完不完整。

原理:设定一个 chunk_size 和一个 overlap(重叠窗口),然后像切香肠一样逐段切割。

代码实现

def fixed_length_split(text, chunk_size=512, overlap=50):
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        chunks.append(chunk)
        start += chunk_size - overlap
    return chunks

# 示例
text = "这是一段很长的文本..." * 100
chunks = fixed_length_split(text, chunk_size=200, overlap=30)
print(f"切成了 {len(chunks)} 块")
print(f"第一块内容: {chunks[0][:50]}...")
⚠️ 避坑指南:我曾经在做一个法律文档检索项目时,直接用固定长度切。结果一个「不构成犯罪」被切成了「不构成」和「犯罪」两块。检索时用户问「是否构成犯罪」,系统只召回后半段,直接翻车。所以,固定长度只适合纯技术文档或日志,千万别用在法律、医疗等敏感领域。

4.2 递归字符分割:更聪明一点

递归字符分割,是 LangChain 里最常用的方法。它不按固定长度硬切,而是按「优先级」找分割点。

原理:先尝试用段落分隔符(\n\n)切,如果切出来的块太大,再用句号(。)切,还大就用逗号(,),最后才用空格或字符硬切。说白了,就是「先保语义,再保长度」。

代码实现

def recursive_character_split(text, chunk_size=500, chunk_overlap=50):
    separators = ["\n\n", "\n", "。", ",", " ", ""]
    chunks = []
    
    def _split(text, sep_idx):
        if len(text) <= chunk_size or sep_idx >= len(separators):
            return [text]
        
        sep = separators[sep_idx]
        parts = text.split(sep)
        
        result = []
        current = ""
        for part in parts:
            if len(current) + len(part) + len(sep) <= chunk_size:
                current += (sep if current else "") + part
            else:
                if current:
                    result.append(current)
                current = part
        
        if current:
            result.append(current)
        
        # 如果切出来的块还是太大,递归用下一个分隔符
        final = []
        for chunk in result:
            if len(chunk) > chunk_size:
                final.extend(_split(chunk, sep_idx + 1))
            else:
                final.append(chunk)
        
        return final
    
    return _split(text, 0)

# 示例
text = "第一章:概述\n\n这是第一段内容。这是第二句。这是第三句,后面还有更多。\n\n第二章:方法\n\n这里开始讲方法了。"
chunks = recursive_character_split(text, chunk_size=50, chunk_overlap=10)
for i, chunk in enumerate(chunks):
    print(f"块{i+1}: {chunk}")
💡 我的经验:递归字符分割是我在项目中用得最多的方法。它比固定长度好很多,但也不是万能的。比如处理 Markdown 或代码时,它会把代码块从中间切开,导致语法错误。我建议在分割前先做一层「结构化预处理」,比如把代码块、表格先提取出来单独处理。

4.3 语义分割:最智能,也最贵

语义分割,不是按字符切,而是按「意思」切。它用 embedding 模型判断句子之间的语义相似度,相似度低的地方就是分割点。

原理:把文本切成句子,然后计算相邻句子的 embedding 余弦相似度。如果相似度突然下降(比如从 0.9 降到 0.3),说明话题变了,这里就是一刀。

代码实现

from sentence_transformers import SentenceTransformer
import numpy as np

def semantic_split(text, model_name='paraphrase-multilingual-MiniLM-L12-v2', 
                   threshold=0.5, min_chunk_len=100, max_chunk_len=500):
    # 1. 先按句子切分
    sentences = text.replace('。', '。\n').replace('!', '!\n').replace('?', '?\n').split('\n')
    sentences = [s.strip() for s in sentences if s.strip()]
    
    # 2. 加载 embedding 模型
    model = SentenceTransformer(model_name)
    
    # 3. 计算句子 embedding
    embeddings = model.encode(sentences)
    
    # 4. 计算相邻句子相似度
    similarities = []
    for i in range(len(embeddings) - 1):
        sim = np.dot(embeddings[i], embeddings[i+1]) / (
            np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[i+1])
        )
        similarities.append(sim)
    
    # 5. 找到分割点(相似度低于阈值的地方)
    split_points = [0]
    for i, sim in enumerate(similarities):
        if sim < threshold:
            split_points.append(i + 1)
    split_points.append(len(sentences))
    
    # 6. 合并成 chunk,同时控制长度
    chunks = []
    for i in range(len(split_points) - 1):
        start = split_points[i]
        end = split_points[i + 1]
        chunk = ''.join(sentences[start:end])
        
        # 如果 chunk 太短,合并到上一个
        if chunks and len(chunk) < min_chunk_len:
            chunks[-1] += chunk
        # 如果 chunk 太长,递归切分
        elif len(chunk) > max_chunk_len:
            sub_chunks = fixed_length_split(chunk, chunk_size=max_chunk_len, overlap=20)
            chunks.extend(sub_chunks)
        else:
            chunks.append(chunk)
    
    return chunks

# 示例
text = "今天天气真好。我们去公园散步吧。\n\n机器学习是一种人工智能技术。它通过数据训练模型。深度学习是机器学习的一个分支。"
chunks = semantic_split(text, threshold=0.4)
for i, chunk in enumerate(chunks):
    print(f"语义块{i+1}: {chunk}")
🔑 核心要点:语义分割的效果取决于 embedding 模型的质量。我建议用 BAAI/bge-large-zh-v1.5shibing624/text2vec-base-chinese 这类中文专用模型。阈值一般设在 0.3~0.6 之间,具体要看你的数据分布。嗯,这里要注意:语义分割的计算成本比前两种高很多,不适合实时处理超长文本。

4.4 三种方法对比

方法 优点 缺点 适用场景
固定长度分割 速度快、实现简单 破坏语义、容易切碎关键信息 日志分析、纯技术文档
递归字符分割 兼顾语义和长度、可控性好 对结构化内容(代码、表格)不友好 通用文档、新闻文章
语义分割 语义完整、检索精度高 计算成本高、依赖模型质量 学术论文、法律文书、长篇小说
💡 实战建议:我一般会先用递归字符分割做 baseline,然后根据召回率评估结果,决定是否升级到语义分割。如果预算有限,可以先用固定长度分割 + 适当 overlap(比如 10%~20%)来兜底。记住,没有完美的分割策略,只有最适合你数据的策略。

好了,文本分割这块就聊到这儿。下一章咱们聊聊 embedding 模型的选择和优化,那可是 RAG 系统的「翻译官」——选对了,检索效率翻倍;选错了,嗯,你懂的。