Guide · 使用教程
Chonkie Python 教程:分块边界、token 预算与元数据校验
固定 Chonkie 1.7.0,以真实 tokenizer 检查原文位置、完整输入预算、稳定 ID 和来源字段;十组本地测试明确入库与检索验证边界。
文档能被切成几块,并不表示已经适合入库。本文使用 Python 3.11、Chonkie 1.7.0 与 tiktoken 0.14.0,检查三件事:片段能否回到对应原文、完整嵌入输入是否超出预算,以及来源与版本信息是否完整。
核对与运行日期:2026-10-20。全部使用合成文本,不调用嵌入模型、LLM 或向量数据库。测试通过只证明这些工程约束,不证明检索召回或回答质量提高。
先确定预算和位置的含义
本例用 RecursiveChunker 按真实 cl100k_base tokenizer 切分正文,再把标题前缀加到每一块,最后复算完整 payload。正文目标为 64 token,总输入预算为 96 token;这是演示设置,不代表任何供应商的通用上限。Recursive Chunker
| 字段或规则 | 本例约定 |
|---|---|
| 源文本 | Source.text,保持原样,不先规范化 Unicode |
| start / end | Python 字符串索引,左闭右开,对应这份源文本 |
| source_version | 源文本 UTF-8 内容的 SHA-256 指纹 |
| strategy_id | 算法、计数器、预算与包版本的指纹 |
| chunk_id | 来源身份、版本、策略、位置及最终 payload 的稳定指纹 |
| token_count | 标题前缀与片段正文拼接后重新计数 |
Python 字符串索引不等于 UTF-8 字节位置、JavaScript UTF-16 索引、PDF 页内坐标或用户感知字符数量。清理、OCR 修订或 Unicode 规范化以后,应保存新的源版本与位置,不能继续把旧 offset 当成原文件坐标。
Chonkie 的 character tokenizer 计数也不是模型 token 数。TokenChunker 的 overlap 与递归规则切分不同,本文主流程不配置重叠,测试另用字符窗口说明计数差异。Token Chunker
1. 固定依赖并准备 tokenizer
在空目录中建立环境。本例固定直接依赖和用到的原生包版本;实际部署还应按平台生成完整锁文件。
python3.11 -m venv .venv
.venv/bin/python -m pip install \
'chonkie==1.7.0' 'chonkie-core==0.10.2' 'tokie==0.1.4' \
'tiktoken==0.14.0'
.venv/bin/python -c 'import tiktoken; tiktoken.get_encoding("cl100k_base")'
最后一条命令预加载 tokenizer 资源,首次可能需要联网。正式离线执行时,要让同一运行账户能访问已准备的缓存;不能因为 Python 包已安装就假设所有模型和词表都已就绪。
2. 实现分块与入库前校验
保存为 chunk_workflow.py。Source 的租户、文档 ID 和标题应来自应用已经验证的摄取记录,不接受模型自由指定。下面只传播和校验这些字段,不实现登录或访问控制;真实写入和查询仍需执行权限策略。
主流程调用原生 Chonkie,再统一检查片段、完整 payload、位置、元数据和 ID。任何检查失败都拒绝返回该批次,不静默截断正文,也不把部分成功的块继续入库。
import hashlib
import importlib.metadata
import json
from dataclasses import dataclass
import tiktoken
from chonkie import RecursiveChunker
@dataclass(frozen=True)
class Source:
tenant_id: str
document_id: str
title: str
text: str
def digest(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def encode_id(parts: list) -> str:
return digest(json.dumps(parts, ensure_ascii=False, separators=(",", ":")))
def count_tokens(text: str) -> int:
# Tokenizer assets must be cached before offline execution.
enc = tiktoken.get_encoding("cl100k_base")
return len(enc.encode(text, disallowed_special=()))
def prefix(source: Source) -> str:
return f"Document: {source.title}\n\n"
def strategy_id(chunk_size: int, max_input_tokens: int) -> str:
return encode_id([
"recursive", "cl100k_base", chunk_size, max_input_tokens,
importlib.metadata.version("chonkie"),
importlib.metadata.version("chonkie-core"),
importlib.metadata.version("tokie"),
importlib.metadata.version("tiktoken"),
])
def row_id(source: Source, row: dict) -> str:
return encode_id([
source.tenant_id, source.document_id, row["source_version"],
row["strategy_id"], row["start"], row["end"], row["embedding_text"],
])
def audit(source: Source, rows: list[dict], chunk_size: int,
max_input_tokens: int) -> None:
version = digest(source.text)
expected_strategy = strategy_id(chunk_size, max_input_tokens)
cursor, seen = 0, set()
for row in rows:
if (row["tenant_id"], row["document_id"], row["source_version"],
row["strategy_id"]) != (
source.tenant_id, source.document_id, version, expected_strategy):
raise ValueError("Source metadata mismatch")
start, end = row["start"], row["end"]
if type(start) is not int or type(end) is not int:
raise ValueError("Offsets must be integers")
if not (cursor <= start < end <= len(source.text)):
raise ValueError("Invalid or overlapping source offsets")
if source.text[cursor:start].strip():
raise ValueError("Source content missing before chunk")
if source.text[start:end] != row["text"]:
raise ValueError("Chunk does not match source span")
payload = prefix(source) + row["text"]
actual = count_tokens(payload)
if row["embedding_text"] != payload or row["token_count"] != actual:
raise ValueError("Embedding payload or token count mismatch")
if actual > max_input_tokens:
raise ValueError("Embedding payload exceeds configured budget")
if row["chunk_id"] != row_id(source, row) or row["chunk_id"] in seen:
raise ValueError("Invalid or duplicate chunk ID")
seen.add(row["chunk_id"])
cursor = end
if source.text[cursor:].strip():
raise ValueError("Source content missing after last chunk")
def prepare(source: Source, chunk_size: int = 64,
max_input_tokens: int = 96) -> list[dict]:
if not all(isinstance(v, str) and v.strip()
for v in [source.tenant_id, source.document_id, source.title]):
raise ValueError("Trusted source identifiers and title are required")
if not isinstance(source.text, str):
raise ValueError("Source text must be a string")
if type(chunk_size) is not int or type(max_input_tokens) is not int:
raise ValueError("Budgets must be integers")
if not (0 < chunk_size < max_input_tokens):
raise ValueError("Leave room for the embedding prefix")
if count_tokens(prefix(source)) >= max_input_tokens:
raise ValueError("Title prefix exhausts the embedding budget")
if not source.text.strip():
return []
chunker = RecursiveChunker(
tokenizer=tiktoken.get_encoding("cl100k_base"),
chunk_size=chunk_size,
min_characters_per_chunk=1,
)
version = digest(source.text)
strategy = strategy_id(chunk_size, max_input_tokens)
rows = []
for chunk in chunker.chunk(source.text):
payload = prefix(source) + chunk.text
row = {
"tenant_id": source.tenant_id, "document_id": source.document_id,
"source_version": version, "strategy_id": strategy,
"start": chunk.start_index, "end": chunk.end_index,
"text": chunk.text, "embedding_text": payload,
"token_count": count_tokens(payload),
}
# Chonkie's generated chunk ID is not used as an idempotency key.
row["chunk_id"] = row_id(source, row)
rows.append(row)
# No partial batch is returned when any check fails.
audit(source, rows, chunk_size, max_input_tokens)
return rows
if __name__ == "__main__":
source = Source("demo-tenant", "handbook-1", "示例手册",
("中文与 English 同行。保留 emoji 🦛 与 e\u0301。\n\n" * 8))
rows = prepare(source)
print(json.dumps({"chunks": len(rows),
"max_payload_tokens": max(r["token_count"] for r in rows),
"budget": 96}, ensure_ascii=False))
这里没有使用 Chonkie 自动生成的 Chunk.id 作为幂等键,而是显式生成自己的稳定 ID。相同文档、版本、策略和位置重复运行得到相同键;不同租户、正文、标题或策略改变时,键也改变。它支持幂等写入的准备工作,数据库的 upsert、旧版本替换与事务仍要另外实现。
源文本不改变时,相同内容在不同位置仍属于不同片段。只用片段文字的哈希,会把重复段落或不同权限来源错误地合并。SHA-256 指纹也不是加密或匿名化,不能用它替代数据权限。
校验允许切分结果之间略去纯空白,但不允许遗漏非空白内容。由于主流程不使用重叠,它还拒绝重叠位置;若改为 overlap 策略,需要单独设计覆盖与重复量检查,而不是直接复用这个假设。
3. 用真实组件测试边界
保存为 test_chunk_workflow.py。测试使用真实 RecursiveChunker、TokenChunker 和 tokenizer,不用预设切分结果替代库调用。篡改样例模拟进入导出阶段之前的坏数据,确保这些记录被拒绝。
import copy
import unittest
from dataclasses import replace
from chonkie import TokenChunker
from chunk_workflow import Source, audit, count_tokens, prepare
class ChunkWorkflowTests(unittest.TestCase):
def setUp(self):
self.source = Source("tenant-a", "doc-1", "合成手册",
("重复段落。English text. 🦛 e\u0301\n\n" * 20))
def test_native_unicode_spans_and_payload_budget(self):
rows = prepare(self.source)
self.assertGreater(len(rows), 1)
for row in rows:
self.assertEqual(self.source.text[row["start"]:row["end"]], row["text"])
self.assertEqual(count_tokens(row["embedding_text"]), row["token_count"])
self.assertLessEqual(row["token_count"], 96)
def test_ids_repeat_stably_and_identical_passages_remain_distinct(self):
a, b = prepare(self.source), prepare(self.source)
self.assertEqual([r["chunk_id"] for r in a], [r["chunk_id"] for r in b])
self.assertEqual(len(a), len({r["chunk_id"] for r in a}))
self.assertLess(len({r["text"] for r in a}), len(a))
def test_text_title_and_strategy_changes_invalidate_ids(self):
original = prepare(self.source)[0]["chunk_id"]
for source in [replace(self.source, text=self.source.text + "更新"),
replace(self.source, title="新标题")]:
self.assertNotEqual(prepare(source)[0]["chunk_id"], original)
self.assertNotEqual(prepare(self.source, chunk_size=48)[0]["chunk_id"], original)
def test_same_text_in_two_tenants_has_distinct_identity(self):
a, b = prepare(self.source), prepare(replace(self.source, tenant_id="tenant-b"))
self.assertTrue(set(r["chunk_id"] for r in a).isdisjoint(r["chunk_id"] for r in b))
self.assertTrue(all(r["tenant_id"] == "tenant-a" for r in a))
def test_oversized_title_is_rejected(self):
with self.assertRaises(ValueError):
prepare(replace(self.source, title="很长标题" * 100))
def test_tampered_text_offset_and_missing_chunk_are_rejected(self):
original = prepare(self.source)
cases = []
for field, value in [("text", "伪造内容"), ("start", 1)]:
rows = copy.deepcopy(original); rows[0][field] = value; cases.append(rows)
cases.append(original[1:])
for rows in cases:
with self.subTest(kind="corrupt source span"):
with self.assertRaises(ValueError): audit(self.source, rows, 64, 96)
def test_missing_and_foreign_metadata_are_rejected(self):
for field in ["tenant_id", "document_id", "source_version", "strategy_id"]:
for remove in [True, False]:
rows = prepare(self.source)
if remove: del rows[0][field]
else: rows[0][field] = "foreign"
with self.subTest(field=field, remove=remove):
with self.assertRaises((ValueError, KeyError)):
audit(self.source, rows, 64, 96)
def test_payload_count_and_budget_rechecked_before_export(self):
rows = prepare(self.source)
rows[0]["token_count"] = 0
with self.assertRaises(ValueError): audit(self.source, rows, 64, 96)
with self.assertRaises(ValueError): prepare(self.source, 64, 65)
def test_character_overlap_is_not_model_token_budget(self):
text = "🦛" * 30
chunks = TokenChunker(tokenizer="character", chunk_size=12, chunk_overlap=3).chunk(text)
for c in chunks:
self.assertEqual(text[c.start_index:c.end_index], c.text)
self.assertGreater(count_tokens(c.text), c.token_count)
self.assertEqual(chunks[1].start_index, 9)
def test_blank_input_and_invalid_settings(self):
self.assertEqual(prepare(replace(self.source, text=" \n\t")), [])
for kwargs in [{"chunk_size": 0}, {"chunk_size": True},
{"chunk_size": 100, "max_input_tokens": 96}]:
with self.assertRaises(ValueError): prepare(self.source, **kwargs)
with self.assertRaises(ValueError): prepare(replace(self.source, tenant_id=""))
if __name__ == "__main__":
unittest.main(verbosity=2)
4. 运行并检查结果
.venv/bin/python -m unittest -v test_chunk_workflow
.venv/bin/python chunk_workflow.py
本次实际测试结果:
Ran 10 tests
OK
十组覆盖多语言原文位置与完整预算、重复运行及重复段落 ID、源文本和策略变化、不同租户的身份区分、过长标题、被改动的文本与位置、缺失或错误元数据、重新计算预算、字符窗口和模型 token 的区别,以及空文本与非法配置。
最终复测期间阻断 socket 外连;没有调用付费模型或真实向量数据库。chunk_workflow.py 的输出仅是本地分块数量、最大 payload token 数和演示预算,不是检索准确率或性能排名。
5. 入库前还要补齐什么
- 对齐实际模型。 本例固定 cl100k_base,不自动匹配任意 embedding 服务。实际模型如果使用其他 tokenizer,应更换计数器,记录身份与版本,并为服务端模板及 special tokens 留预算。
- 检查最终字符串。 标题、章节、上下文和 metadata 拼接完成之后再计数。若 payload 超预算,应明确缩小正文目标或调整前缀后重新生成,不能仅把计数字段改小。
- 绑定可信来源。 写入服务与查询服务都要执行权限规则。tenant_id 字段存在或两个租户 ID 不碰撞,不等于已经测试了访问隔离。
- 实现版本替换。 新版本写完后如何撤下旧版本、如何重试和恢复,需要数据库事务或发布流程支持。稳定键不会自行删除过期记录。
- 评估业务效果。 在独立问题集上检查召回、引用覆盖、答案质量、延迟和费用,才能判断新分块策略是否适合业务。
方法与费用边界
Chonkie 还有语义、神经或生成式分块等路线,具体模型资源与联网行为取决于所选组件。本文仅验证规则与 token 驱动的本地流程,不能据此声称所有方法都离线或无模型用量。选择分块器、Pipeline
Chonkie 软件采用 MIT,软件许可费 $0;真实环境中的 OCR、模型、嵌入、数据库、主机和维护费用另计。许可证
常见问题
- 本例会调用模型 API 吗?
- 不会。它使用规则分块与本地 tokenizer,首次资源准备可能联网,最终十组测试阻断外连,没有模型和向量数据库请求。
- start 和 end 表示 PDF 坐标吗?
- 不是。它们是对应 Source.text 版本的 Python 字符串索引,左闭右开,不等于字节、UTF-16 或 PDF 页内坐标。
- 为什么要计算标题加正文?
- 模型实际收到的是完整 payload,标题和上下文也占预算;仅检查正文 chunk_size 可能漏掉超限。
- 稳定 chunk ID 是否已经实现幂等写入?
- 没有。稳定键是准备条件,数据库 upsert、事务、重试和旧版本替换仍需单独实现。
- 租户字段通过校验能证明没有越权吗?
- 不能。测试只检查来源字段和身份键,不实现登录和访问控制,真实写入和查询都要执行权限策略。
- 十组测试通过能证明 RAG 效果提高吗?
- 不能。它们验证边界、计数、标识与坏数据拒绝,真实召回、引用、回答质量、延迟和费用需要独立业务评估。