文本切片

上一章把 PDF、Word、Excel、PPT、HTML、Markdown 等文件统一还原成了 Markdown 文本。拿到这段文本,还是不能直接交给 Embedding 模型:模型有输入长度上限,把整篇文章压进一个向量,等于把十几个主题揉成一团,检索时既分不清彼此的边界,召回后又白白占掉一大段上下文。

所以向量化之前,要把长文本拆成若干块,也就是常说的 Chunk。每块既不能太长,也不能太短:太长会混入多个主题,让一句话的语义被稀释;太短则把一句完整的话从中间切开,前后关系直接丢失。微软 Kernel Memory 曾经自带 PlainTextChunkerMarkDownChunker,但官方已经停止维护,继续依赖它有风险。MoAI 在把文件提取切到自研项目的同时,也把切片逻辑一并收回,统一放到 Maomi.ToMarkdownTextSplit 模块里,模型分片则由独立的 Maomi.ToMarkdown.Agent 包承担。

NuGet:Maomi.ToMarkdown(主库,内置 TextSplit)
NuGet:Maomi.ToMarkdown.Token(可选,按 Token 计数)
NuGet:Maomi.ToMarkdown.Agent(可选,基于模型语义分片)
源码:https://github.com/whuanle/maomi.tomarkdown


与文件提取一样,切片只做一件事:输入一段文本和分片参数,输出一组分块,除此之外一概不管。TextSplit 是一组无状态的静态扩展方法,直接作用在 string 上,返回 IReadOnlyList<TextChunk>。分块之后怎么生成元数据、怎么向量化、存到哪个库,都不属于这一层。

MoAI 项目设计预览:

image-20260825094201442


用什么做标尺

写切片最容易想到按字符截取,比如每 500 个字符切一段。实现简单,但它和模型真正看到的输入并不是一回事。模型处理的是 Token,同样长度的中文、英文、数字和代码,编码出的 Token 数量差异很大。按字符切容易出现两种情况:有些块看着不长,换算成 Token 却超过了模型上限;有些块明明还能再装,却因为字符数到顶被提前切开。


Maomi.ToMarkdown 把“用什么计量”抽象成了一个接口 ITextSizeCounter,它只有一个方法,统计一段文本占多少单位:

public interface ITextSizeCounter
{
    int Count(string text);
}


实现必须保证 Count() 对文本长度单调不减,因为分片算法在按预算兜底时,要用这个性质通过二分法定位边界。主库默认提供 CharacterSizeCounter,按字符数统计,零外部依赖,无需任何配置。不装额外包时,chunkSizechunkOverlap 都以字符为单位:

using Maomi.ToMarkdown.TextSplit;

string text = ...; // 例如上一章抽取出的 Markdown

var chunks = text.SplitRecursive(500, 80);


如果希望按 Token 切分,再引入可选的 Maomi.ToMarkdown.Token 包。它基于 SharpToken(tiktoken 兼容)实现了 TiktokenSizeCounter,可以用枚举指定编码,也可以直接传模型名由它自动映射:

using Maomi.ToMarkdown.TextSplit;
using Maomi.ToMarkdown.Token;

var counter = new TiktokenSizeCounter(TiktokenEncoding.O200kBase);   // 按编码
var counter2 = new TiktokenSizeCounter("gpt-4o");                   // 按模型名

// 每个分片方法的最后一个参数都是可选的 ITextSizeCounter
var chunks = text.SplitRecursive(500, 80, sizeCounter: counter);

foreach (var chunk in chunks)
{
    Console.WriteLine($"[{chunk.ChunkIndex}] {counter.Count(chunk.ChunkText)} tokens");
}

TiktokenSizeCounter 同样接受编码名(如 cl100k_baseo200k_base)。传了计数器之后,chunkSize 就代表每个块允许的 Token 数,而不是字符数了。

这里要尽量让切片的计量方式和 Embedding 模型的实际编码保持一致。否则,切片器算出的长度和模型真正编码后的长度对不上,轻则浪费输入空间,重则在向量化时超过模型上限。当然,Token 只约束块的大小,不保证语义完整。遇到特别长的句子、代码块,或者完全没有分隔符的内容,切片器仍然可能继续往下拆。


常见分片方式

TextSplit 提供五种静态扩展方法,本质是同一套递归引擎配上不同的分隔符集合;SplitByMarkdown 则是独立的一条 Markdown 感知路径,内部复用引擎处理超长代码块。

方法说明默认重叠单位
SplitRecursive通用递归分片,段落→句子→词→字符逐级回退,语义保持最好字符
SplitFixedSize忽略语义,按固定长度直接切分字符
SplitBySentence在句子边界尽量长地切分句子
SplitByParagraph在段落(空行)边界尽量长地切分段落
SplitByMarkdown保护围栏代码块,优先在结构边界切分字符

固定长度分片

SplitFixedSize 是所有方法里最简单的。它把分隔符集合设成只有一个空字符串,等于宣告没有自然边界,然后按 chunkSize 一刀切。底层用一个二分查找,找到让每段都不超预算的最大边界,保证块大小非常均匀。

var chunks = text.SplitFixedSize(500, 80);


优点是可预测、速度快,每一块长度基本一致,适合对块大小有硬性要求的场景。缺点是完全不看语义,常把一句话从中间切开,标题、列表、代码块一概不管。对 RAG 来说它是纯粹的兜底,通常不会作为主力策略,除非整篇文本已经乱到找不到任何像样的边界。


递归分片

SplitRecursive 是通用推荐的那一种,思路源自社区常见的递归切分(比如 LangChain 的 RecursiveCharacterTextSplitter)。

它维护一组从大到小的分隔符,并对中文做了适配,把中文标点()也当作候选边界:

// 依次是:段落、换行、句子、词、字符级兜底
private static readonly string[] DefaultSeparators =
{
    "\r\n\r\n", "\n\n", "\r\n", "\n",
    "。", "!", "?", ";", ".", ". ", ...,
    ", ", ",", " ",
    ""
};


切分逻辑是:先用段落分隔,切完若某一段仍超预算,就用下一级分隔符(例如句子)递归处理,直到退回字符级。过程中保留分隔符,让它在切出的块末尾附着,保证不丢任何字符。

var chunks = text.SplitRecursive(500, 80);


优点是在语义完整和长度可控之间取了相对均衡的点,适合绝大部分文本。缺点是它靠的是分隔符列表,不是真正理解语义;如果一段话超长又没有标点,最终还是会在字符处硬切。chunkSize 也要按语料反复调,太大会混主题,太小会碎成渣。


句子分片

SplitBySentence 把一句话当作最小完整单位。它的分隔符集合去掉段落和换行,从句子边界开始:中文以 。!?;. 断句,英文以 . ! ? ; 断句,断完仍超预算再退回词和字符。默认重叠单位也切成了 Sentence,也就是重叠 N 句话。

var chunks = text.SplitBySentence(500);


优点是切出的块基本都是一句完整的话,检索出来容易读懂。缺点是需要文本里有可靠的句号;如果是代码、日志、JSON,或某一句话本身超长,还是会落到词级、字符级兜底。


段落分片

SplitByParagraph 把空行当作主要边界,优先在段落之间切分,块通常较大,但语义最完整。默认重叠单位是 Paragraph,重叠的内容也是 N 个整段。

var source = text.SplitByParagraph(1000, 1, OverlapUnit.Paragraph);

优点是不会拆散完整的论述单元,适合段落清晰、条理分明的文档,大块语义密度高、冗余少。缺点是如果文档没有明显的空行(列表、代码、紧凑排版),段落边界不明显;一旦某个段落本身比预算还长,又会退回句子甚至字符,导致块大小波动很大。


Markdown 感知分片

这一节值得单独讲,因为 Maomi.ToMarkdown 上一章输出的正是 Markdown。SplitByMarkdown 不像纯文本那样只认换行和标点,它先扫描出围栏代码块,把代码块当作不可分割的原子单元,再把剩余的普通文本按空行、标题、句子标记打包。打包时优先落在段落空行和标题附近,让每个块尽可能落在结构完整的位置。

var chunks = text.SplitByMarkdown(600, 60);


它对应的实现在 MarkdownSplitter 里,逻辑分两步:第一步把文本切成一组原子,普通段落和围栏代码块分别对待;第二步贪婪地把这些原子装进 Token 预算。代码块在预算允许时会完整保留,不会被拦腰切断;只有当单个代码块本身超过 chunkSize 时,才按行拆开,这是为了满足 Token 预算而不得不做的取舍。

优点是对结构敏感,标题、列表、表格、代码都不容易被切碎,和从文件提取出 Markdown的链路天然衔接,是 MoAI 语料库最合适的默认策略。缺点是解析依赖格式正确,遇到没闭合的围栏或混乱的 Markdown,会退化成普通文本分片;而且它同样不能保证语义必然完整,只是一套按 markdown 格式切分的规则。

顺带说一句,Maomi.ToMarkdown 的分块并不像 Kernel Memory 那样自动附加 ChunkHeader。需要文档标题或来源信息的话,可以在切片前拼进文本,也可以像下一章那样,把标题、提纲当作派生元数据挂上去。上一章已经尽量把标题还原成了 Markdown 里的 ###,所以这一步通常不用再单独做。


重叠

只限制最大长度还不够。假设第一段结尾是 “该配置将在下一节生效”,下一段从具体配置项开始,如果两段完全没有重叠,第二段就失去了 “该配置” 指代的对象。重叠会把上一个 Chunk 末尾的一部分复制到下一个 Chunk 开头,让相邻文本保留少量上下文。

Chunk 1:……认证服务会生成访问令牌,该令牌包含用户身份
Chunk 2:                         该令牌包含用户身份,并在十分钟后过期……
                                 └────── Overlap ──────┘


重叠由 chunkOverlap 和控制单位的 OverlapUnit 决定。OverlapUnit 提供三种:Character 按固定字符数重叠且会在词边界停下,Sentence 重叠 N 句,Paragraph 重叠 N 段。分块结果 TextChunk 会把这些区分开,Text 含有重叠后的完整文本,ChunkText 是去掉重叠的原始部分,OverlapText 是重叠进来的一段;另外还有基于原始文本的 ChunkIndexStartPosition/EndPosition 以及行列坐标。

var chunks = text.SplitRecursive(500, 80);                          // 按字符重叠 80
var source = text.SplitRecursive(1000, 1, OverlapUnit.Sentence);   // 重叠最后 1 句


重叠不是越大越好。重叠内容会被重复向量化,占用更多存储,检索时也可能同时召回几段内容相近的 Chunk。通常先设一个较小的值,再根据文档结构和召回效果调整。


基于模型语义分片

规则分片再精细,也始终是一套固定分隔符,分不出 “这一节” 和 “那一节” 在语义上的真正边界。要更进一步,就得让模型来读一遍原文,把语义完整的块之间标记出来,这就是 Maomi.ToMarkdown.Agent 包里的 ModelChunker

它的做法是:把原文连同提示词一起交给模型,让模型原样重述文本,同时要求它在相邻两个语义完整的块之间插入一个分隔符(默认 <|CHUNK|>),随后在分隔符处切分并映射成 TextChunk。提示词里反复强调不得总结、翻译、改写或添加任何评论,避免模型把原文改得面目全非。

using Maomi.ToMarkdown.Agent;
using Maomi.ToMarkdown.TextSplit;
using Microsoft.Extensions.AI;

IChatClient client = ...; // OpenAI / Ollama / Azure 之类的客户端
string text = ...;

// 流式分片,默认分隔符 "<|CHUNK|>"
IReadOnlyList<TextChunk> chunks = await ModelChunker.SplitWithChatClientAsync(client, text);


如果是 Microsoft Agent Framework 的 Agent,也可以用 SplitWithAgentAsync

using Maomi.ToMarkdown.Agent;
using Microsoft.Agents.AI;

AIAgent agent = ...;

IReadOnlyList<TextChunk> chunks = await ModelChunker.SplitWithAgentAsync(agent, text);


模型以流式方式对话,实现里只收集正文 TextContent,会主动忽略思考/推理类内容(如 TextReasoningContent)以及非文本内容,避免把模型的自言自语混进分块。如果模型没有返回任何分隔符——说明它认为整段是一个整体,或者输出不合格——则会自动回退到 SplitRecursive

它的优势是真正理解内容:能识别章节、段落、代码块的内在关系,切出的块语义最完整。代价也很明显:一次切片就要跑一遍模型,耗时和成本都不是规则分片能比的;而且模型输出有不确定性,它重述过的文本仍要再校验一遍,避免缺字、漏字。所以它适合对语义要求高、语料量又不大,或者离线预处理慢一点也无所谓的场景;批量、线上实时导入则用规则分片更稳。


使用示例:

using Maomi.ToMarkdown.Agent;
using Maomi.ToMarkdown.TextSplit;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;

// LM Studio 本地 OpenAI 兼容端点。
const string BaseUrl = "http://localhost:1234/v1";
const string Model = "qwen/qwen3.5-9b";
const string ApiKey = "lm-studio"; // LM Studio 不校验 key,任意非空值即可,OpenAI SDK 要求提供。

var inputFile = Path.Combine(AppContext.BaseDirectory, "1.2.openai_chat.md");
var text = File.ReadAllText(inputFile);

// 用 OpenAI SDK 指向 LM Studio,再转为 IChatClient。
var clientOptions = new OpenAIClientOptions { Endpoint = new Uri(BaseUrl) };
var openAIClient = new OpenAIClient(new ApiKeyCredential(ApiKey), clientOptions);
IChatClient chatClient = openAIClient.GetChatClient(Model).AsIChatClient();

Console.WriteLine($"正在使用模型 {Model} 对文件进行语义分片:{inputFile}");
Console.WriteLine($"原文长度:{text.Length} 字符");
Console.WriteLine(new string('-', 60));

var options = new ChatOptions { Temperature = 0 };
IReadOnlyList<TextChunk> chunks = await ModelChunker.SplitWithChatClientAsync(chatClient, text, "<|CHUNK|>", options);

Console.WriteLine($"共切出 {chunks.Count} 个分块。");
Console.WriteLine(new string('-', 60));

foreach (var chunk in chunks)
{
    Console.WriteLine($"[{chunk.ChunkIndex}] 行 {chunk.StartLine}-{chunk.EndLine}, 字符 {chunk.StartPosition}-{chunk.EndPosition}");
    Console.WriteLine(chunk.ChunkText);
    Console.WriteLine(new string('-', 40));
}

image-20260825093939433