
📰 科技要闻
• 智谱创始人唐杰发内部信谈「GLM时刻」之后:市值较半年前上市初期涨了10倍,跻身万亿港元俱乐部
• OpenAI推出ChatGPT智能体,长鑫科技公布承销团阵容,国产存储第一股即将落地
• 半导体设备全球景气周期持续确认,PCB钻针量价齐升趋势明确
起因:向量库调好了,检索还是不准
上一篇把 Milvus 从安装到调优讲了个遍,索引类型、分片策略、一致性级别全部到位,我以为这一关过了。结果上线第一周产品经理就来找我:「为什么问『接口超时怎么排查』,返回的内容全是产品需求文档里带『超时』两个字的段落,一点上下文都没有?」
我当时就纳闷了,向量数据库调得再好,召回的东西本身就是残缺的,那查全率、查准率全都是空谈。跑去看那批 Chunk,才发现问题:我们最早图省事,直接把 PDF 转成纯文本,然后按 500 字符切一刀,完全不管句子、段落、表格的边界。一张接口超时排查的流程图被切成了三段,中间那段单独摘出来根本读不懂,向量化之后语义自然是错的。
这玩意儿坑了我整整一周去重做。说实话,我干 Android 这么多年,处理过各种奇葩的布局适配问题,但第一次意识到「解析文档」和「切分文档」这两步,才是 RAG 系统里最脏、最累、但也最决定成败的活儿。这篇就把这坑趟平的过程记下来。
我的判断:Chunking 比向量库选型更重要
先说个可能有争议的观点:如果你的知识库检索质量不行,先别急着换 Embedding 模型或者调向量库的索引参数,八成是 Chunk 切得烂。这是我踩坑之后的真实体会——垫在最底层的数据质量,决定了后面所有环节的天花板。你用再贵的 Embedding 模型,把一句话硬切成两半塞进两个 Chunk,语义已经断了,模型再强也补不回来。
这也是为什么我把这篇放在 Milvus 之后而不是之前——很多教程会先讲「怎么把文本转成向量存进库」,把切分环节一笔带过,用一行 text_splitter.split_text() 糊弄过去。但生产环境里,文档格式五花八门,PDF 里带扫描件、Word 里嵌套表格、网页里全是导航栏噪音,这一步的工程量往往比选向量库大得多。
文档解析工具链选型
三个主流方案对比
解析这一步我横向测了三个工具:PyMuPDF(原来叫 fitz)、Unstructured、LlamaParse。三者定位完全不同,别指望一个工具通吃所有格式。
工具 | 类型 | 优势 | 短板 |
|---|---|---|---|
PyMuPDF | 本地库 | 速度极快,免费,坐标精确 | 表格/多栏需自己写规则 |
Unstructured | 本地/API | 支持格式多,自带元素分类 | 复杂布局准确率一般 |
LlamaParse | 云端API | 表格/多栏解析质量最好 | 收费,数据要出网 |
我的选择是分场景用:结构简单的纯文本 PDF(比如技术规范、日志导出),直接上 PyMuPDF,一台普通服务器一分钟能啃几百页,成本几乎为零。遇到财报、带复杂表格的产品文档,我会切到 LlamaParse——它底层用视觉模型理解页面布局,能把表格还原成 Markdown 表格结构,这一点 PyMuPDF 做不到,纯粹是几何坐标提取,不懂语义。Unstructured 我用来处理网页和 Word,它的 partition() 接口会自动把内容分类成 Title/NarrativeText/Table/ListItem,这个分类信息后面做 Chunking 时非常好用。
别信「一个库解决所有格式」的宣传。我见过团队全用 Unstructured 处理扫描版 PDF,结果 OCR 准确率不到 60%,最后还是老老实实上了专门的 OCR 服务做前置处理。
PDF 解析的四个真实难点
PDF 是这里面最麻烦的格式,没有之一。它本质上是「打印指令的集合」,不是结构化文档,同一个视觉效果可以用完全不同的底层结构实现,这就是为什么解析 PDF 永远比看起来难。
表格提取:PDF 里的表格没有语义标签,只是若干条线段+文字坐标。PyMuPDF 1.23 之后内置了 page.find_tables(),靠检测线框和对齐关系推断表格结构,简单表格准确率还不错,但合并单元格、无边框表格基本抓瞎,这类场景我会退回给 LlamaParse。
图片 OCR:架构图、截图里的文字信息,直接丢了很可惜。我的做法是解析时把图片单独抽出来,跑一遍 OCR(用的是 PaddleOCR,中文识别效果比 Tesseract 好不少),OCR 结果作为图片的「替代文本」拼进 Chunk,同时保留图片原始路径,方便后续多模态检索升级。
多栏布局:学术论文、法规文件常见双栏排版。如果只按坐标从左到右扫描,会把左栏第一段和右栏第一段拼到一起,语义直接乱了。PyMuPDF 的 get_text("dict") 能拿到每个文本块的坐标框,得自己写一段逻辑按 x 坐标聚类分栏,再按栏内 y 坐标排序——这段代码我写了差不多一天才调对,边界情况一堆。
扫描件:本质是图片,没有可提取的文本层。这种必须先过 OCR 引擎,我踩过的坑是:直接把整页扔给 OCR,识别出来的文字顺序经常是乱的(先识别右上角标题再识别正文),必须先做版面分析(Layout Analysis)切出区块,再对每个区块单独 OCR,顺序才靠得住。
Chunking 策略的四代演进
解析完文档只是拿到了干净的结构化内容,怎么切成 Chunk 才是真正的技术活。我把自己用过的方案按复杂度排了个序,越往后效果越好,但成本也越高,没有免费的午餐。
固定长度切分
↓ 按字符数硬切,忽略语义边界
递归分割
↓ 按段落/句子优先级递归切
语义分块
↓ 用Embedding相似度找断点
Agentic Chunking → 让LLM理解全文后主动划分主题边界,成本最高
固定长度切分是最省事的方案,也是最容易把语义切烂的方案,我文章开头踩的坑就是它。它唯一的优点是快,Chunk 大小可预测,适合做 MVP 验证管线通不通。
递归分割是目前工程上最常用的「够用」方案,LangChain 的 RecursiveCharacterTextSplitter 就是这个思路:先尝试按 \n\n(段落)切,切出来还超长就按 \n(换行)切,还超长再按句号、空格切,一层层退化。逻辑不复杂,但比无脑硬切好太多,因为它至少尊重了文档的自然结构。
from langchain.text_splitter import
RecursiveCharacterTextSplittersplitter = (
RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=80,
separators=[
"\n\n",
"\n",
"。",
",",
" ",
],
)
)
chunks = splitter.split_text(
doc_text
)注意我把中文句号「。」和逗号「,」也放进了 separators——这是个容易被忽视的细节,LangChain 默认的分隔符列表是给英文设计的,直接拿来处理中文文档,切分效果会明显打折。
语义分块更进一步,思路是逐句计算 Embedding,在相邻句子的向量相似度出现「断崖式下降」的地方切一刀,这个断点大概率就是话题转换的边界。这其实就像你在听一个人讲话,他讲得好好的忽然顿了一下换个话题,你能感觉到那个停顿——只不过程序把这种「顿了一下」的语气量化成了余弦相似度的数值。
import numpy as npdef semantic_split(
sentences, embeddings,
threshold=0.75
):
# 计算相邻句子的余弦相似度
sims = []
for i in range(
len(embeddings) - 1
):
a, b = (
embeddings[i],
embeddings[i+1],
)
sim = np.dot(a, b) / (
np.linalg.norm(a) *
np.linalg.norm(b)
)
sims.append(sim)# 相似度低于阈值处即为断点
breakpoints = [
i for i, s in
enumerate(sims)
if s < threshold
]
return _merge_by_breakpoints(
sentences, breakpoints
)这个方案效果确实更好,我在一批技术博客上做过对比,语义分块切出来的 Chunk 主题一致性明显更高。但代价也很实在:每篇文档要先按句子拆分再逐句跑 Embedding,计算量比递归分割高一个量级,而且 threshold 这个超参数极其依赖数据分布,换一批文档往往要重新调。
Agentic Chunking 是目前最激进的方案:把整篇文档喂给 LLM,让它像人一样理解全文逻辑后主动标出主题边界,甚至能识别出「这一段其实是对前面某个概念的补充说明,应该跟前面合并」这种深层结构。效果我认可,但成本高到离谱——一篇万字文档跑一次 GPT-4 级别模型做切分,钱花的比后续查询阶段还多,目前我只在极高价值、低频更新的文档(比如公司核心制度文件)上用过。
我的真实结论:90% 的场景,递归分割 + 精心调过的 separators 就够用了。语义分块留给检索质量要求极高的场景,Agentic Chunking 目前性价比太低,别被论文里的效果图忽悠着直接上生产。
Chunk 大小与重叠度:拿数据说话
这两个参数网上众说纷纭,谁都说自己那套是「最佳实践」。我拿公司内部 300 篇技术文档、200 条真实用户问题做了个简单实验,用命中率(Top-5 召回里是否包含标注正确答案)作为指标,结果供参考。
Chunk大小 | 重叠度 | Top-5命中率 | 备注 |
|---|---|---|---|
200字 | 0 | 61% | 上下文太碎,答案常被截断 |
500字 | 80字 | 78% | 综合最优,我们最终生产配置 |
800字 | 100字 | 74% | 语义稀释,无关信息拉低相似度 |
500字 | 0 | 70% | 无重叠时边界信息容易丢失 |
这组数据印证了我的直觉:Chunk 太小,一个知识点被拆得七零八落,检索到了也是断章取义;Chunk 太大,一个 Chunk 里塞进多个话题,向量表示会被「稀释」,真正相关的那句话的语义信号被其他无关内容盖住了。这就像包寿司——米饭包得太薄,片后一夕容易散架,鱼胚都夹不住;包得太大,一口塞不下,味道也会被乱七八糟的馅料盖掉。Chunk 大小就是在找那个「一口刚好」的分寸。重叠度的作用是防止关键信息刚好落在切分边界上被腰斩,但重叠度也不是越大越好,同样的内容存两份,既浪费存储,检索时还可能把重复内容都塞进上下文挤占宝贵的 Token 预算。
500字 + 80字重叠是我们这批中文技术文档上的最优解,但这个数字不是万能公式——如果你的文档是短问答型的 FAQ,Chunk 可以更小;如果是法律合同这种上下文强关联的文本,可能需要更大的 Chunk 甚至整条款作为一个 Chunk。别抄我的数字,自己拿真实数据跑一遍实验,这才是靠谱的做法。
元数据:Chunk 不能是孤儿
切完 Chunk 只存文本和向量,是我见过最常见的偷懒行为,后果是检索出来的内容没法溯源,用户问「这个结论出自哪份文档」你答不上来,产品可信度直接崩掉。我现在的做法是每个 Chunk 强制带一份结构化元数据,随内容一起存进向量库的 payload 字段。
@dataclass
class ChunkMeta:
chunk_id: str
# 来源与定位信息
doc_id: str
source_url: str
page_num: int
# 结构信息,用于展示面包屑
heading_path: list[str]
# 例: ["第三章","3.2 排障流程"]
# 时间信息,用于过滤过期内容
updated_at: str
# 前后关联,用于Parent-Child
parent_chunk_id: str | None
prev_chunk_id: str | None
next_chunk_id: str | Noneheading_path 是我觉得最容易被忽视但价值最高的字段。它记录了这个 Chunk 在文档目录树里的完整路径,来自 Unstructured 或者 LlamaParse 解析出来的标题层级。有了它,回答用户问题时可以在引用旁边展示「出自《XX手册》第三章 3.2 排障流程」,用户对答案的信任度会明显提升——这跟我们 Android 里做面包屑导航是一个道理,用户永远想知道「我在哪」。
updated_at 这个字段上线前我们没加,后来吃了个哑巴亏:一份已经废弃的旧版部署文档因为语义匹配度高,一直排在检索结果前列,用户按着旧文档操作出了生产事故。加上时间字段之后,配合检索阶段的时间衰减权重,才把这类「过期但相似度高」的内容压下去。
Parent-Child Chunking:小块检索,大块喂给模型
前面那组实验数据其实暴露了一个根本性的矛盾:小 Chunk 检索精准但上下文不够,大 Chunk 上下文够但检索精度差。这两个目标天然对立,鱼和熊掌不可兼得——直到我了解到 Parent-Child 这个方案,才算真正解决了这个矛盾,而不是在两者之间找妥协。可以把它想象成图书馆的索引卡片和整本书的关系:你靠卡片上几个关键词快速定位到书在哪一区、哪一页,但真正要读懂内容,还是得把整本书翻开,从那一章完整地读下去。小块就是那张索引卡片,大块才是完整的那一章。
核心思路很简单:切两套 Chunk,一套小(比如 200 字,用于向量检索,语义纯粹、精准打靠),一套大(比如 1500 字,作为父块,包含完整上下文)。向量库里只存小块的向量,但小块的 payload 里带着父块 ID。检索时先用小块找出最相关的候选,再回查它们各自的父块,把父块内容整个塞给 LLM 做最终生成。
用户query向量检索
↓
命中Child块 → 200字小块,语义精准,只用来做匹配
↓ 按parent_chunk_id回查
取出Parent块 → 1500字大块,完整语境,喂给LLM生成
LlamaIndex 的 HierarchicalNodeParser 已经内置了这个能力,自己实现也不复杂,核心就是维护好 Child 到 Parent 的映射关系。
def build_parent_child(doc_text):
parents = split_text(
doc_text, chunk_size=1500
)
result = []
for p_idx, parent in enumerate(
parents
):
p_id = f"parent_{p_idx}"
children = split_text(
parent, chunk_size=200
)
for c_idx, child in
enumerate(children):
result.append({
"id":
f"{p_id}_"
f"c{c_idx}",
"text": child,
"parent_chunk_id":
p_id,
})
# parent单独存一份,供回查
store_parent(p_id, parent)
return result上线之后效果很直接:Top-5 命中率从原来单一 500 字方案的 78% 提到了 85%,更关键的是生成答案的完整度肉眼可见地提升了,因为模型拿到的是完整语境而不是一个 200 字的碎片。代价是存储和检索链路多了一次回查,我们在 Milvus 之外单独用了一个轻量 KV 存储(Redis)存 Parent 文本,用 Child 命中的 parent_chunk_id 直接查,延迟增加在 5ms 以内,完全可以接受。
有个坑要提一下:Parent 块的边界最好跟 heading_path 对齐,也就是尽量让一个 Parent 块落在同一个小节里,不要跨章节。否则回查出来的大块会把两个不相关主题的内容拼在一起,模型生成时反而容易被干扰信息带偏。
写在最后
这篇写完我最大的感触是:Chunking 这件事没有一招鲜吃遍天的标准答案,它高度依赖你的文档类型、用户提问模式、甚至下游 LLM 的上下文窗口大小。我见过太多团队把精力全砸在 Embedding 模型选型和向量库调优上,却在切分这一步随手用了默认参数——这就像 Android 开发里,大家都在纠结用 Compose 还是 View 系统,却没人检查过底层数据源给的字段是不是本来就是脏的。地基不稳,上层再花哨也扛不住。
下一篇我打算接着往上走一层,聊聊 Embedding 模型选型——OpenAI 的通用能力、BGE 的中文效果、M3E 的私有化部署成本,到底该怎么权衡,尤其是当你的文档里中英文混排、还夹杂大量代码片段时,选错模型会有多离谱。这也是我们知识库项目踩过的另一个大坑,下次接着聊。