Skip to content

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. 之后截断,产生碎片。

这个正则表达式处理常见的边缘情况:

php
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 标志处理中文、日文等使用全角标点的语言。

php
$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 限制,然后开始新的块。

php
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 列表——我们退回到逐字符分割:

php
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 个块,单独哪个块都无法很好地检索到。

重叠通过复制相邻块的一部分来解决这个问题:

php
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 管道中如何连接:

php
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–512FAQ、简短回答
1024–1536均衡均衡通用文档
2048–4096长篇分析

更小的块更好地匹配特定查询(更高精确度),但可能丢失周围上下文。更大的块保留上下文但稀释了 embedding——一个包含五个主题的 4000 token 块不会像一个关于单一主题的 1000 token 块那样强烈地匹配任何单个主题。

1536 token ≈ 1,100 个英文单词 ≈ 2–3 段落。这通常能捕获一个完整的想法或章节,而不混入无关内容。

PHP 中的 Token 计数

此实现使用 rajentrivedi/tokenizer-x 包进行 token 计数:

bash
composer require rajentrivedi/tokenizer-x
php
use 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 感知分块归结为三个核心思路:

  1. 首先按句子分割 —— 一个能处理小数、版本号和多语言标点的正则表达式提供自然边界。
  2. 用真实 token 计数贪心打包 —— 不要近似。在组合字符串上计算 token,因为分词不是可加的。
  3. 优雅地兜底 —— 当文本没有句子边界时,字符级分割保证永不超过 token 限制。

重叠步骤是可选的,但推荐用于检索管道。20% 重叠是一个好的起点——如果发现上下文边界遗漏就增加,如果存储成本重要就减少。

此实现有意不做的一件事:语义分块(使用 embedding 检测主题边界)。该方法可以提升 15–25% 的检索效果,但计算成本高 3–5 倍,且增加显著的复杂性。先从句子感知的 token 分块开始——它能很好地处理大多数情况,而且是确定性的、快速的、易于调试的。只有当检索质量确实需要时,才升级到语义分块。