ARTICLE · 984659
手搓一个 Agent07:从文档问答到生产级智能体
✦ 干货分享 ✦
DocMind 07 | 知识库的灵魂:文档切片与向量化
◆
"
「搜索是全网搜,但我想搜的是我自己的一堆 PDF。」 这一章,给 DocMind 装上一个「私人图书馆」。
"
01一、先接住上一章的话
第 5 章我们学会了让 Agent「搜索」。但那个搜索是「全网搜」——搜的是公开网页、公开文章。可现实中更值钱的资料,偏偏是全网搜不到的:
我公司内部的接口文档 我这一堆 PDF、Word、PPT 某个同事整理的 Markdown 笔记
这些资料,大模型一个都没见过。你问它「咱们公司支付下单的标准流程是什么」,它要么编,要么说不会。因为它「知识截止日在训练时」,压根没看过你公司的东西。
所以这一章,我们给 DocMind 加一个私人知识库。让 Agent 能「翻我自己的资料」来回答问题。
02二、为什么需要 RAG?先搞懂大模型的两个「差评」
大模型回答不了私有数据问题,根子是两个硬伤:
1. 知识有截止日期 模型训练是有时间的。今天的新闻,上个月的新产品,它一概不知。
2. 没见过你的私有数据 这是最关键的。你公司的内部规范、你珍藏的资料,根本不在它的训练集里。它连「你公司存在这个东西」都不知道,更别说回答。
那怎么办?总不能每次问都重训一个模型吧——又贵又慢,根本不可能。
于是有了 RAG(Retrieval-Augmented Generation,检索增强生成)。
名字听着高大上,其实就是一句话:
"
先把答案可能藏的资料找出来,塞进问题里,再让模型回答。
"
拆成三步:
- 找到
和问题相关的私有资料片段(检索 Retrieval) 把这段资料拼接进给模型的 prompt(增强 Augmented) 模型基于这段资料来生成回答(生成 Generation)
对比一下第 5 章的全网搜索:那是「在外面搜」;这里 RAG 是「在我自己的资料库里搜」。
03三、一份 PDF 是怎么进知识库的?(文档处理流水线)
你上传一份 PDF,后台大致走这么一条流水线:
1上传的 PDF
2 │
3 ▼
4[读入] —— 把 PDF 的字节变成一页一页的文字
5 │
6 ▼
7[切片] —— 把超长的文档切成一段段小块的「片段 chunk」
8 │
9 ▼
10[向量化] —— 把每段文字变成一堆数字(向量)
11 │
12 ▼
13[存库] —— 数字和原文一起存进向量库
这条流水线在 Spring AI 里有个专有名字,叫 ETL——别被吓到,就是三个英文词的缩写:
- E(Extract,抽取)
:读 PDF,得到文字 - T(Transform,转换)
:切片的切块 - L(Load,加载)
:向量化 + 存库
接下来的几节,我们一节一节拆开讲。这一章重点讲 T(切片) 和 向量化 这两步,存库到第 15 章会展开进阶玩法。
04四、Spring AI 的「Document 家族」
在 Spring AI 里,「一篇文章/一段话」统一叫 Document(文档)。整个流水线都是围绕它转的。
第一步:读入(读 PDF)
Spring AI 给 PDF 提供了现成的读取器,像水管一样,直接把文件插进去,吐出 Document 列表:
- PdfDocumentReader
:整份 PDF 读成一坨大文档 - PagePdfDocumentReader
:按页读,一页一个 Document(后面讲坑时你会发现它很关键)
还有 TextDocumentReader(读纯文本)、MarkdownDocumentReader(读 Markdown)等等,套路一样。
第二步:切片
读进来是一整份可能几十页的文档,直接扔给模型可不行——模型上下文装不下,检索也不准。所以得切片。Spring AI 里做切片的叫 DocumentTransformer(文档转换器),最常用的实现是 TokenTextSplitter。
切 Token,Splitter,官方取的这个名字就叫「切片的搬运工」。它能按 token 数把长文档切成固定大小的块,还支持一个 key 参数——overlap(重叠)。
先来个完整的代码,读 PDF 再切片:
1@Component
2publicclassPdfSplitter {
3
4// 从 resources 目录读一份 PDF,按页切 + 按 token 切片
5public List<Document> split(InputStream pdfStream, String docName) {
6
7// 1. 读入:按页读 PDF,一页一个 Document
8 PagePdfDocumentReader reader = new PagePdfDocumentReader(pdfStream);
9
10// 2. 读取到的全部文档
11 List<Document> documents = reader.get();
12
13// 3. 切片:每个 chunk 最多 300 token
14 TokenTextSplitter splitter = new TokenTextSplitter(
15300, // chunk 大小(目标 token 数)
1650, // minChunkSize 最小块
1730, // overlap 相邻块重叠 token 数
1850, // maxTokensPerChunk
19true); // keepSeparator
20
21return splitter.apply(documents);
22 }
23}
你看,读和切就这两三行,抽象的特别好。换一种文档,只是换一个 XxxReader,切片的代码完全不用动。
05五、切片策略:为什么 cut 多长是个学问
切片长度不是随便定的。切太大,检索不准;切太小,丢上下文。
举两个极端例子你就懂了:
把整份 200 页 PDF 当一块切片?向量化时和什么都能「有点相关」,检索出来什么都能沾边但不精准,等于白检索。 把一句话切成一片?是精准了,但这句话脱离了上下文,模型根本不知道它在说谁、在干什么。
所以切片要「大小合适 + 保留上下文」。常见策略大致三种:
这里掏心窝子讲一个概念:overlap(重叠)到底干嘛的?
假设一段话是「小明……」,固定切 300 token 一截。切点正好落在现金的使用方法是……最关键的句子中间,把一句完整的话劈成两半,前半句在上一块,后半句在下一块。模型单独看任何一块,都读不懂这句。
overlap 就是让相邻两块之间多留出 30 个 token 的重复尾巴。这样即使某句话在块边界附近被切了,下一块的开头也带着这句话的尾巴,上下文不丢。
"
经验值:overlap 一般是 chunk 大小的 10%~20%,或者直接设 100~200 tokens。够用就好,不用死抠。
"
06六、本章大坑:DeepSeek 不提供 embedding 接口!
一切顺利……直到撞上一堵墙。这是本章最大的坑,提前给你打预防针。
我们知道,向量化要用到 Embedding 模型(把文字变成数字的那个)。你可能会想:我们系统都用 DeepSeek 当对话模型了,Embedding 也顺手用 DeepSeek 呗?
不行。DeepSeek 根本没有提供 embedding 接口。
这不是 bug,也不是少配置,是人家官方就没做这个能力。DeepSeek 专注做对话推理,embedding 这块不提供。
怎么办?两个现实的选择:
选择 A:智谱 embedding API - 国内厂商,有免费额度,提供与 OpenAI 兼容的接口 - Spring AI 有官方 starter,配几行就完事 - 适合:做线上产品,图省事、要稳定
选择 B:本地 bge-small(开源模型) - 开源、免费、离线也能跑,数据不出本地 - 但要自己下模型文件、部署推理服务,麻烦一点 - 适合:对数据隐私要求高、不想依赖外部 API
结论一句话给到:
"
DeepSeek 负责「说人话」(对话),Embedding 单独用智谱或本地 bge。各司其职。
"
这听起来像是要配两套东西,反而更麻烦?别慌,这正是 Spring AI 厉害的地方。
Spring AI 的 EmbeddingModel 抽象
Spring AI 把「把文字变成向量」这件小事抽象成了一个接口,叫 EmbeddingModel。不管你是智谱、是本地 bge,还是 OpenAI,换来换去,你业务代码一行都不用改——只换一个 starter + 一行配置。
换 embedding 提供方,就像换一个螺丝,而不是换一整台机器。
比如用智谱(注意:spring-ai-starter 官方不一定叫这名字,以你引入的版本实际 artifactId 为准):
1<dependency>
2<groupId>com.zhipu.ai</groupId>
3<artifactId>spring-ai-starter-model-zhipuai</artifactId>
4</dependency>
1spring:
2ai:
3zhipuai:
4api-key: ${ZHIPU_API_KEY}
5chat:
6options:
7model: glm-4
8embedding:
9options:
10model: embedding-2# 智谱的 embedding 模型
只要引入了这个 starter,Spring 容器里就会自动多出一个 EmbeddingModel 的 bean,我们后面的代码直接 @Autowired 注入就能用,底层是谁根本不 care。
"
这为你埋下一颗种子——「换组件」这个思路,第 15 章我们会大讲特讲。
"
系统里沿用 DeepSeek 当对话模型,加上智谱或 bge 当 embedding 模型,两个一起用,各管各的,没毛病。
07七、向量库:先用最轻的 SimpleVectorStore
切片和向量化都讲了,该「存库」了。向量库很多(Milvus、pgvector……),但那个复杂,先不碰。
这一章用 Spring AI 内置的 Simplestore——SimpleVectorStore。
它的特点: - 进程内的,不需要额外装数据库,零依赖 - 数据默认存在内存里,重启就没了(demo 足够) - 原理和真向量库一模一样,先把原理讲透,以后换就行
原理一句话:
"
每个 Document 切片,用 EmbeddingModel 变成一堆数字(向量),存进一个列表。查询的时候,把问题也变成向量,和库里所有切片算「谁跟问题最接近」(相似度),把最接近的几个捞出来。
"
看代码,存的时候不外乎「embed + add」两步:
1@Component
2publicclassVectorStoreService {
3
4// Spring 自动注入 embedding 模型和向量库
5privatefinal EmbeddingModel embeddingModel;
6privatefinal SimpleVectorStore vectorStore;
7
8publicVectorStoreService(EmbeddingModel embeddingModel) {
9this.embeddingModel = embeddingModel;
10this.vectorStore = new SimpleVectorStore(embeddingModel);
11 }
12
13// 把切片存进向量库
14publicvoidsave(List<Document> chunks) {
15// 关键两步:
16// 1. EmbeddingModel.embed() 给每块生成向量
17// 2. SimpleVectorStore.add() 插入向量库
18 vectorStore.add(chunks);
19 }
20}
你没看错,存库就这两步,被 Spring 封装得像喝水一样。
如果想知道向量长什么样、到底是几维,可以从 EmbeddingModel 里拿:
1// 把一个句子变成向量,打印维度
2float[] embedding = embeddingModel.embed("DocMind 的知识库很好用");
3System.out.println("向量维度:" + embedding.length); // 比如 1024 / 768
08八、本章产出:把流水线跑起来
到这,我们把「读 PDF → 切片 → 向量化 → 存库」整条流水逻辑串起来了。写一个入口方法,把「上传 PDF」这个动作变成后台一行行日志:
1publicvoidingestPdf(InputStream pdfStream, String docName) {
2
3// 1. 读 + 切片
4 List<Document> chunks = pdfSplitter.split(pdfStream, docName);
5
6// 2. 逐片打印信息,方便观察
7 chunks.forEach(chunk -> {
8 String text = chunk.getContent();
9// 打印:这是第几片、前 30 个字、以及向量维度
10 System.out.printf("切片:%d | 向量维度:%d | 内容:%s...%n",
11 chunks.indexOf(chunk),
12 embeddingModel.embed(text).length,
13 text.substring(0, Math.min(30, text.length())));
14 });
15
16// 3. 全部存进向量库
17 vectorStore.save(chunks);
18
19 System.out.println("完成:共切了 " + chunks.size() + " 片,已入库。");
20}
上传一个 PDF,后台会打印类似:
1切片:0 | 向量维度:768 | 内容:DocMind 是面向 Java 开发者……
2切片:1 | 向量维度:768 | 内容:……的私有知识库解决方案……
3…
4完成:共切了 12 片,已入库。
注意,我们还没做「检索」——也就是「用户问问题,从库里捞答案」,那是下一章的事。这一章,我们把「资料吃得进去」做成了,资料已经躺进知识库了,可以利索地说:知识库的地基,打好了。
09本章踩坑(三连问:现象 → 根因 → 解法)
坑 1:DeepSeek 没有 embedding 接口(重磅)
- 现象
:想用 DeepSeek 生成向量,发现调用报 404 / 没有这个接口。 - 根因
:DeepSeek 官方不做 embedding,只做对话推理。 - 解法
:不是缺点,是「换组件」的好歇脚石。结论:DeepSeek 做对话,Embedding 单独用智谱或本地 bge。正好趁这个机会体会 Spring AI 的 EmbeddingModel 抽象——换 embedding 提供方,业务代码一行不改(这也呼应了第 15 章要讲的「换组件」大戏)。
坑 2:切片 overlap 到底设多少
- 现象
:overlap 设 0,检索经常漏掉关键句,因为句子被拦腰切断。 - 根因
:切点无脑落在句子中间。 - 解法
:overlap 设 chunk 的 10%~20%,OR 直接给 100~200 tokens。让它兜住块边界的上下文。
坑 3:PDF 中文解析乱码 / 空白
- 现象
:读 PDF,中文变成乱码,或某些页读出来是空白。 - 根因
:PDF 编码复杂,有的用扫描图(图片)而非真正的文字,PdfDocumentReader 这类轻解析读不出来。 - 解法
:一般文字型 PDF 用 PagePdfDocumentReader(基于 PDFBox)就 OK;遇到复杂表格、扫描件就得上 OCR(光学字符识别,推荐 Apache Tika)。记得:扫描版 PDF 本质是图片,必须 OCR 才能出字。
坑 4:向量维度不能混用
- 现象
:换了个 embedding 模型,检索结果全乱套。 - 根因
:不同模型的向量「长度」和「含义」都不同,比如 768 维和 1024 维,根本没法比较。 - 解法
:换 embedding 模型,等于换了整套向量「语言」,已存的向量必须全量重新向量化一遍,不能新旧混着用。
10下一章预告
这一章,DocMind 终于能「吃进」你的一堆 PDF,把资料变成一个个可检索的向量,存进知识库。
但它现在还只会「存」,不会「找」。下次你问它「我们的支付标准流程是什么」,它照样一脸懵——因为还没人教它怎么把资料捞出来用。
下一章,我们就给 Agent 装上一个检索工具:把「从知识库里捞相关片段」变成一个 Agent 能调用的工具。
"
第 8 章:检索即工具——把 RAG 变成 Agent 的一个工具。
"
到那时,DocMind 就不再是「会聊天的唠嗑机器」,而是「读过你全部资料的私人顾问」。