PHP 中面向 RAG 管道的 Token 感知文本分块
简介
你正在用 PHP 构建 RAG 管道。用户上传了一份 40 页的 PDF,你需要将文本分成适合 embedding 模型 token 限制的块。朴素的方法——每 N 个字符切割一次——会在句子中间截断,破坏上下文,产生检索质量低下的分块。
字符数不能直接映射到 token 数。句子 "The price is $3.14 per unit" 有 28 个字符但只有 9 个 token。像 https://example.com/api/v2/users?page=1&limit=50 这样的 URL 有 49 个字符但超过 20 个 token。按字符分割意味着要么浪费 token 预算,要么超出限制。
你需要的是一个以 token 为单位的分块器——在句子边界处分割,严格遵守每个分块的 token 限制,并能处理单个句子超过限制的边缘情况。在本文中,我们将构建这样一个工具。
三层策略
我们的分块器使用分层分割方法:
输入文本
│
▼
┌───────────────────────┐
│ 第一层:句子 │ 基于标点 + 空格
│ (基于正则) │ 在句子边界处分割
└──────────┬────────────┘
▼
┌───────────────────────┐
│ 第二层:Token 打包 │ 将句子打包到
│ (贪心装箱) │ 不超过 token 限制的块中
└──────────┬────────────┘
▼
┌───────────────────────┐
│ 第三层:兜底 │ 对超过限制的句子
│ (字符级分割) │ 按字符分割
└───────────────────────┘每一层处理不同的粒度。大部分文本通过第一层和第二层处理。第三层仅在极端情况下激活——base64 数据块、压缩的 JSON 块或没有句子边界的长 URL 段落。
第一层:句子感知分割
按句子分割听起来简单,直到你遇到 "价格是3.14元。下一项。" ——在 . 后面加空格的朴素分割会在 3. 之后截断,产生碎片。
这个正则表达式处理常见的边缘情况:
const SPLIT_SENTENCE_REGEX =
'/(?<!\b[0-9]\.)(?<![0-9])(?<=[.!?。?!])(?!\d)\s+/u';分解:
| 片段 | 用途 |
|---|---|
(?<!\b[0-9]\.) | 不在小数如 3. 后分割 |
(?<![0-9]) | 前面是数字时不分割 |
(?<=[.!?。?!]) | 在句末标点后分割 |
(?!\d) | 后面是数字时不分割(如 v2.0 是...) |
\s+ | 消耗句子之间的空格 |
/u | 支持 CJK 标点(。?!)的 Unicode 模式 |
后行断言防止在小数、版本号和缩写处错误分割,Unicode 标志处理中文、日文等使用全角标点的语言。
$sentences = preg_split(
self::SPLIT_SENTENCE_REGEX,
$text,
-1,
PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
);TIP
PREG_SPLIT_NO_EMPTY 标志过滤掉 preg_split 有时在边界处产生的空字符串。没有它,你需要在打包循环中手动过滤空值。
第二层:贪心 Token 计数打包
提取句子后,我们用贪心算法打包成块:将句子添加到当前块,直到下一个句子会超过 token 限制,然后开始新的块。
public static function splitTextIntoTokenChunks(
string $text,
int $token_limit_per_chunk
): array {
$chunks = [];
$current_chunk = '';
$sentences = preg_split(
self::SPLIT_SENTENCE_REGEX,
$text,
-1,
PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
);
if (empty($sentences)) {
if (TokenizerX::count($text) <= $token_limit_per_chunk) {
return $text ? [$text] : [];
}
return self::splitLongWord($text, $token_limit_per_chunk);
}
foreach ($sentences as $sentence) {
$sentence = trim($sentence);
if (empty($sentence)) {
continue;
}
$sentence_token_count = TokenizerX::count($sentence);
// 第三层触发:句子本身超过限制
if ($sentence_token_count > $token_limit_per_chunk) {
if ($current_chunk) {
$chunks[] = $current_chunk;
}
$sub_chunks = self::splitLongWord(
$sentence,
$token_limit_per_chunk
);
$chunks = array_merge($chunks, $sub_chunks);
$current_chunk = '';
continue;
}
// 尝试将句子添加到当前块
$test_chunk = $current_chunk
? $current_chunk . ' ' . $sentence
: $sentence;
$test_chunk_tokens = TokenizerX::count($test_chunk);
if ($test_chunk_tokens <= $token_limit_per_chunk) {
$current_chunk = $test_chunk;
} else {
// 放不下——完成当前块,开始新的
if ($current_chunk) {
$chunks[] = $current_chunk;
}
$current_chunk = $sentence;
}
}
if ($current_chunk) {
$chunks[] = $current_chunk;
}
return $chunks;
}关键细节是 $test_chunk 方法——我们对组合后的字符串(当前块 + 空格 + 新句子)计算 token,而不是单独计算句子。这很重要,因为分词器不会产生可加的计数:tokens("A B") ≠ tokens("A") + tokens("B")。句子之间的空格可能与相邻字符合并为一个 token,或者词的边界可能发生变化。通过计算组合字符串,我们得到真实的 token 成本。
WARNING
不要试图维护一个 $current_chunk_tokens 计数器然后加上 $sentence_token_count。Token 计数在拼接时不可加。始终重新计算组合字符串。
第三层:字符级兜底
当单个句子超过 token 限制时——比如压缩的 JSON 块、base64 字符串或长 URL 列表——我们退回到逐字符分割:
private static function splitLongWord(
string $word,
int $token_limit
): array {
$chunks = [];
$current_chunk = '';
$current_chunk_tokens = 0;
for ($i = 0; $i < mb_strlen($word); $i++) {
$char = mb_substr($word, $i, 1);
$char_token_count = TokenizerX::count($char);
if ($current_chunk_tokens + $char_token_count <= $token_limit) {
$current_chunk .= $char;
$current_chunk_tokens += $char_token_count;
} else {
$chunks[] = $current_chunk;
$current_chunk = $char;
$current_chunk_tokens = $char_token_count;
}
}
if ($current_chunk) {
$chunks[] = $current_chunk;
}
return $chunks;
}此方法使用 mb_substr 确保 Unicode 安全——一个占 3 字节 1 个 token 的中文字符不会被分割成无效的字节序列。逐字符 token 计数在这里是可接受的近似:对于单个字符,分词器的输出是确定的,所以累加计数有效(不同于句子拼接)。
TIP
这个兜底机制有意设计得保守。它牺牲可读性(在词中间分割)来保证正确性(永不超过 token 限制)。实际上,它很少被触发——大多数自然语言文本在任何合理的 token 限制内都有句子边界。
添加重叠以提升检索效果
突然结束的分块可能丢失跨越边界的上下文。如果用户问"电子产品的退货政策是什么?"而答案从第 3 个块末尾开始,延续到第 4 个块,单独哪个块都无法很好地检索到。
重叠通过复制相邻块的一部分来解决这个问题:
public static function overlapChunks(
array $chunks,
float $overlap_percentage = 0.2
): array {
$overlapped_chunks = [];
foreach ($chunks as $index => $chunk) {
// 添加前一个块的尾部
if (isset($chunks[$index - 1])) {
$previous = $chunks[$index - 1];
$overlap_chars = (int) (strlen($previous) * $overlap_percentage);
$chunk = substr($previous, -$overlap_chars) . $chunk;
}
// 添加下一个块的头部
if (isset($chunks[$index + 1])) {
$next = $chunks[$index + 1];
$overlap_chars = (int) (strlen($next) * $overlap_percentage);
$chunk .= substr($next, 0, $overlap_chars);
}
$overlapped_chunks[] = $chunk;
}
return $overlapped_chunks;
}使用 20% 重叠,一个 1536 token 的块大约从前一个块获取 300 个 token,从下一个块获取 300 个 token。这创建了冗余,以约 40% 更多存储的代价提升了检索召回率。
块 1: [==========]
块 2: [==========] ← 与 1 和 3 重叠
块 3: [==========]WARNING
重叠发生在字符级别,而不是 token 级别。这是有意的权衡——基于字符的重叠快得多,对检索来说产生"足够好"的结果。如果你需要精确的 token 级重叠,需要重新分词和裁剪,这会增加相当大的复杂性。
整合:文档解析管道
以下是这些组件在处理上传文档的真实 RAG 管道中如何连接:
use App\Utils\TextUtil;
class DocumentParser
{
const TOKENS_PER_CHUNK = 1536;
public function parse(string $text, string $source): array
{
// 步骤 1:分割为 token 受限的块
$chunks = TextUtil::splitTextIntoTokenChunks(
text: $text,
token_limit_per_chunk: self::TOKENS_PER_CHUNK,
);
// 步骤 2:添加重叠以提升检索效果
$chunks = TextUtil::overlapChunks($chunks, 0.2);
// 步骤 3:准备进行 embedding
return array_map(fn (string $chunk, int $i) => [
'content' => $chunk,
'source' => $source,
'chunk_index' => $i,
'token_count' => TokenizerX::count($chunk),
], $chunks, array_keys($chunks));
}
}TOKENS_PER_CHUNK = 1536 值的选择是为了在常见 embedding 模型限制(大多数接受最多 8192 个 token)内有足够余量,同时粒度足够细以实现精确检索。更小的块(512–1024 token)提高精确度;更大的块(2048–4096)提高上下文。1536 是实用的折中方案。
为什么选择 1536 Token?
分块大小直接影响检索质量:
| 块大小 | 精确度 | 上下文 | 最适合 |
|---|---|---|---|
| 256–512 | 高 | 低 | FAQ、简短回答 |
| 1024–1536 | 均衡 | 均衡 | 通用文档 |
| 2048–4096 | 低 | 高 | 长篇分析 |
更小的块更好地匹配特定查询(更高精确度),但可能丢失周围上下文。更大的块保留上下文但稀释了 embedding——一个包含五个主题的 4000 token 块不会像一个关于单一主题的 1000 token 块那样强烈地匹配任何单个主题。
1536 token ≈ 1,100 个英文单词 ≈ 2–3 段落。这通常能捕获一个完整的想法或章节,而不混入无关内容。
PHP 中的 Token 计数
此实现使用 rajentrivedi/tokenizer-x 包进行 token 计数:
composer require rajentrivedi/tokenizer-xuse Rajentrivedi\TokenizerX\TokenizerX;
// 计算字符串中的 token 数
$count = TokenizerX::count("你好,世界!"); // 5
// 使用特定编码计算
$count = TokenizerX::count("你好,世界!", "cl100k_base");默认编码(cl100k_base)与 GPT-4 和大多数现代 embedding 模型兼容。如果你使用的是不同的模型系列,请检查它使用的分词器并相应配置。
TIP
如果不需要精确的 token 计数,快速近似值是 ceil(mb_strlen($text) / 4) ——英文文本平均大约每 4 个字符一个 token。这对中日韩文本(每个字符通常是 1–2 个 token)或代码(token 不太可预测)不适用,但对快速估算和测试用例很有用。
总结
PHP 中面向 RAG 的 token 感知分块归结为三个核心思路:
- 首先按句子分割 —— 一个能处理小数、版本号和多语言标点的正则表达式提供自然边界。
- 用真实 token 计数贪心打包 —— 不要近似。在组合字符串上计算 token,因为分词不是可加的。
- 优雅地兜底 —— 当文本没有句子边界时,字符级分割保证永不超过 token 限制。
重叠步骤是可选的,但推荐用于检索管道。20% 重叠是一个好的起点——如果发现上下文边界遗漏就增加,如果存储成本重要就减少。
此实现有意不做的一件事:语义分块(使用 embedding 检测主题边界)。该方法可以提升 15–25% 的检索效果,但计算成本高 3–5 倍,且增加显著的复杂性。先从句子感知的 token 分块开始——它能很好地处理大多数情况,而且是确定性的、快速的、易于调试的。只有当检索质量确实需要时,才升级到语义分块。
