指出RecursiveCharacterTextSplitter会在代码 fences 中间切断,导致markdown渲染出错和模型理解受损;修复方案是让代码块和表格只能在行/列边界分割,实测检索质量提升明显。
大多数 RAG 教程里都有这样一段分块代码:
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
)
chunks = splitter.split_documents(docs)
它在纯文本上运行没问题。拿去跑文档站点,它会悄悄做出四个破坏性操作,没有一个会抛出错误,而它们全都会在之后表现为"助手对我们 API 的回答含糊不清"。
我花了不少时间构建了一套面向文档的 ingestion pipeline,大部分工作都集中在四个地方。没什么花哨的,但每一样对检索质量的提升都比换 embedding 模型来得多。
1,000 字符的窗口会不断落在代码块中间。会出现一个 chunk 以这样的内容结尾:
```python
def authenticate(client_id, client_secret):
resp = requests.post(TOKEN_URL, data={
下一个 chunk 则以剩余的 dict 和一个没有开围栏的闭围栏开始。
这里会出现两类问题。明显的那一个是两个 chunk 都不包含可运行的示例,所以模型检索到半个函数,然后对另一半进行虚构。更隐蔽的那一个是半开的围栏会污染下游所有内容——每个 markdown 渲染器,以及大多数模型,都会把 chunk 剩余部分当作代码处理。你精心写的关于 token 过期时间的说明文,在模型看来现在身处一个 Python 块里面。
修复方法是:代码块和表格只能在行和行的边界上切分,如果确实发生了切分,围栏必须用其语言标签重新打开,表格标题必须重复。而且 overlap 绝对不能 bisect 一个围栏——这个 bug 就藏在 overlap 窗口里,因为 chunk 本身看起来没问题,只有相邻的那个被破坏了。
这是以最低修复代价换取最高检索质量提升的一条。
滑动窗口给你的 chunk 是这样读的:
You must include the state parameter and verify it on return. Tokens expire after 3600 seconds.
把它做 embedding 然后问"OAuth token 有多久有效?",它可能回来也可能不回来,因为文本里没有任何地方写明 OAuth、authentication 或者这是哪个产品。那些本可以匹配上的词在页面开头四百字符处的一个 <h2> 里。
文档是一棵树,chunk 应该继承它的路径。docs pipeline 中的每个 chunk 都应该以标题路径开头:
Authentication > Authorization flows > OAuth 2.0
### OAuth 2.0
You must include the `state` parameter and verify it on return...
这样 chunk 就能够独立回答问题了。它自带上下文,embedding 时会接近用户实际提问的位置,而且当你在 UI 里展示来源时,你有了一条可以显示而不是裸 URL 的面包屑。
同时在另一个字段(rawText)里保留不带前缀的版本,因为当你把检索到的 chunk 喂给模型用于生成时,你通常想要干净的正文,而不是把面包屑在 prompt 里重复十遍。
RecursiveCharacterTextSplitter 计算的是字符。你的 embedding 模型有 token 限制。两者之间的比率因内容差异而变化极大:
所以一个 1,000 字符的 chunk 在纯文本里大约是 250 个 token,但在代码里可能是 500 个 token。如果你是用字符计数来对照模型限制设定窗口的,你的代码 chunk 在 embedding 步骤就会被静默截断,而 embedding 步骤的截断是不可见的——没有错误,没有警告,只有一个代表 chunk 前 60% 内容的向量。
用实际的 tokenizer(tiktoken / gpt-tokenizer,取决于你的模型是 cl100k_base 或 o200k_base)来计数真实的 BPE token。这只是一个依赖项,但它消除了一整类"为什么 API 参考页面的检索效果更差"的调查工作。
文档站点是重复制造机。版本化文档(/v2/、/v3/、/latest/)、语言变体、打印视图、以及框架生成的索引页,意味着对一个 500 页文档站点的朴素爬取可能产生 1,800 个 chunk,而其中 600 个就够了。
基于 hash 的精确去重能捕获字节级完全相同的页面,最多帮你去掉一半。剩下的大部分需要近似去重检测——带分带查找的 SimHash 在这里既便宜又有效。
但这里有个陷阱。朴素的近似去重检测会愉快地合并掉:
Install on Linux — Run ./configure && make && make install.
Install on Windows — Run ./configure && make && make install.
正文完全一样,但对"在 Windows 上怎么安装?"的回答却完全不同。所以相似度匹配必须限定在标题范围内——只有标题路径也匹配的两个 chunk 才是去重的候选对象。
不写爬虫就搞定以上四条
我把这件事打包成了一个 Apify actor——Docs-to-RAG Pipeline Builder——因为每个项目我都在重建同样的东西,而爬取这一半比看起来要烦人得多。
{
"startUrls": [{ "url": "https://docs.example.com" }],
"maxCrawlPages": 500
}
它返回的 chunk 已经遵守了以上所有四条规则。每个 chunk 看起来像这样:
{
"id": "9f2c1a77b0e34d15",
"url": "https://docs.example.com/api/authentication",
"anchorUrl": "https://docs.example.com/api/authentication#oauth-2-0",
"pageTitle": "Authentication",
"breadcrumb": ["Authentication", "Authorization flows"],
"heading": "OAuth 2.0",
"headingLevel": 3,
"chunkIndex": 2,
"chunkCount": 5,
"text": "Authentication > Authorization flows > OAuth 2.0\n\n### OAuth 2.0\n\n...",
"rawText": "### OAuth 2.0\n\n...",
"tokenCount": 612,
"contentHash": "1b0f...",
"generator": "mkdocs-material",
"extractor": "profile:mkdocs-material",
"crawledAt": "2026-08-24T10:14:22.104Z"
}
注意 anchorUrl。将引用深度链接到确切标题而不是页面顶部,只改两行代码,但能让支持机器人感觉起来可信得多,而且几乎没人这么做。
即使你自己构建也值得偷过来的两个设计决策
分两轮爬取,不是一轮。用 Chromium 渲染每个页面既慢又贵;完全不渲染则会在每个客户端渲染的文档站上出问题。所以:先走普通 HTTP 抓取,给提取的内容打分,只把那些没通过关卡的页面——JS shells、内容异常单薄的页面——升级到浏览器渲染。renderingMode: "auto" 加上 escalateBelowWords: 60 可以实现这个。在典型的文档站上,大多数页面根本不会触达浏览器。
让提取策略互相竞争而不是选一个。文档站点由生成器构建,而生成器有已知的 DOM 形状。所以运行多个提取器——如果用户给定了就有一个明确的主内容选择器、一个匹配检测到的生成器的 profile(MkDocs、Docusaurus、Sphinx、Starlight、VitePress 等)、一个通用的 readability 风格提取器、以及一个以 <body> 为兜底的链接密度启发式——对所有输出打分,取胜者。单硬编码选择器是一个等待文档站点升级主题那天爆炸的静默故障;它不会报错,只是开始把导航侧边栏当作内容返回。
运行报告告诉你每个提取器在哪些页面上赢了,这就是你发现选择器过期的方式。
不花 API 钱的 Embeddings
embeddingProvider 有三个设置,其中两个不花一分钱:
{
"startUrls": [{ "url": "https://docs.example.com" }],
"embeddingProvider": "cloudflare-worker",
"workerUrl": "https://docs-to-rag-worker.you.workers.dev",
"workerApiKey": "<API_TOKEN>"
}
你把一个小 Worker 部署到自己 Cloudflare 账号,它在 Workers AI 上运行 @cf/baai/bge-m3(1024 dims)。免费层大约覆盖每天九百万 token——一个以 800 token 分块的 500 页站点只用了单日额度的约 0.5%。把 embeddingProvider: "local" 的话,它在设备上运行 Xenova/all-MiniLM-L6-v2(384 dims),模型下载后不再有网络调用。
两者在困难检索任务上明显都比前沿 embedding 模型差。两者对"回答关于我们文档的问题"来说都完全够用,而免费改变了你在改变分块策略后是否愿意重新 embedding——你会的,而且会有好几次。
没人要求但所有人最后都在用的产物
除了写入 key-value store 的数据集,它还输出:
chunks.jsonl — 可用于 embedding 的记录
corpus.md — 整个站点作为一个可读的 markdown 文件
llms.txt 和 llms-full.txt — llms.txt 站点索引格式
run-report.json — 提取器胜出情况、升级情况、失败情况
corpus.md 是我用的最多但预期最少的一个。当检索给出一个错误答案时,能够在一个文件里 grep 整个语料库,用五秒钟就能知道是内容在爬取时缺失了,还是存在但没被检索到。这两种失败在聊天 UI 里看起来一样,但修复方法完全不同。
在你的语料库上测量,因为我没法在你的上测量
没有在你的数据上测量的检索质量声明没多大价值,我的也一样。好消息是,这个特定的 A/B 测试运行起来很便宜,用本地 embedding 的话完全免费。
用同一份语料库构建两套:
基线 — 用 RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200) 对原始 HTML 转换的文本进行分块
结构感知 — 标题递归分块,开启面包屑前缀,开启去重
然后写 20-30 个你已知答案的问题。这是人们跳过但唯一重要的部分。从你实际的支持工单或团队的 Slack 里拿——真实的人问过的问题,而不是你在看文档时自己想出来的问题,那些问题会偏向文档已经使用的措辞。
比较三个数字:
Chunk 数量。去重差值在版本化文档站上通常是第一个跳出来的数据点。在覆盖率相等的情况下更少的 chunk 在 embedding 成本和检索噪声上都是直接胜利。
Hit rate @5 — 对每个问题,包含答案的 chunk 是否在前五个里?这是预测你的助手用起来感觉好不好的数字。
完整示例率。在检索到的包含代码的 chunk 中,有多少包含一个完整可运行的块而不是碎片?这是滑动窗口输得最惨的一个,值得单独计数,因为仅靠 hit rate 会掩盖它。
如果结构感知分块在你的语料库上没有胜过基线,这是一个真实有用的发现——它可能意味着你的文档比你想象的更扁平,你的努力应该放在 retriever 或重排上。在这个承诺之前先测量。
我会怎么反驳自己
结构感知分块不是免费的。它比八行 LangChain 代码复杂得多,而对于不是结构化的语料——支持工单导出、访谈记录、扫描合同 PDF——这些都没用,因为没有什么标题可以感知。滑动窗口在那里确实是正确的默认选择。
这个主张比"滑动窗口是坏的"要窄得多:文档是你将索引的最具结构化的语料之一,而在分块步骤丢掉那种结构是 RAG pipeline 处理文档时最常见的自摆乌龙。如果你索引的是别的东西,忽略大部分内容,去调优你的 retriever。
这个 actor 是 Docs-to-RAG Pipeline Builder(MIT 许可,按事件付费,用单个 startUrls 条目即可运行)。如果你更想自己构建,文章顶部的四条规则是我会优先实现的——按这个顺序。