夜雨聆风学习资料网

ARTICLE · 984659

手搓一个 Agent07:从文档问答到生产级智能体

手搓一个 Agent07:从文档问答到生产级智能体

✦ 干货分享 ✦

DocMind 07 | 知识库的灵魂:文档切片与向量化

"

「搜索是全网搜,但我想搜的是我自己的一堆 PDF。」 这一章,给 DocMind 装上一个「私人图书馆」。

"


01一、先接住上一章的话

第 5 章我们学会了让 Agent「搜索」。但那个搜索是「全网搜」——搜的是公开网页、公开文章。可现实中更值钱的资料,偏偏是全网搜不到的:

  • 我公司内部的接口文档
  • 我这一堆 PDF、Word、PPT
  • 某个同事整理的 Markdown 笔记

这些资料,大模型一个都没见过。你问它「咱们公司支付下单的标准流程是什么」,它要么编,要么说不会。因为它「知识截止日在训练时」,压根没看过你公司的东西。

所以这一章,我们给 DocMind 加一个私人知识库。让 Agent 能「翻我自己的资料」来回答问题。


02二、为什么需要 RAG?先搞懂大模型的两个「差评」

大模型回答不了私有数据问题,根子是两个硬伤:

1. 知识有截止日期 模型训练是有时间的。今天的新闻,上个月的新产品,它一概不知。

2. 没见过你的私有数据 这是最关键的。你公司的内部规范、你珍藏的资料,根本不在它的训练集里。它连「你公司存在这个东西」都不知道,更别说回答。

那怎么办?总不能每次问都重训一个模型吧——又贵又慢,根本不可能。

于是有了 RAG(Retrieval-Augmented Generation,检索增强生成)

名字听着高大上,其实就是一句话:

"

先把答案可能藏的资料找出来,塞进问题里,再让模型回答。

"

拆成三步:

  1. 找到
    和问题相关的私有资料片段(检索 Retrieval)
  2. 把这段资料拼接进给模型的 prompt(增强 Augmented)
  3. 模型基于这段资料来生成回答(生成 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 当一块切片?向量化时和什么都能「有点相关」,检索出来什么都能沾边但不精准,等于白检索。
  • 把一句话切成一片?是精准了,但这句话脱离了上下文,模型根本不知道它在说谁、在干什么。

所以切片要「大小合适 + 保留上下文」。常见策略大致三种:

策略
做法
优点
缺点
适用场景
固定大小切
每固定字数切一块,不管语义
简单、快
容易把一句话、一个段落拦腰切断
内容规整、不追求精度的起步 demo
按语义边界切
按段落、标题、换行切
保留完整语义,检索质量高
块大小不均,代码更复杂
结构清晰的正式文档
TokenTextSplitter
按 token 数切 + overlap,跟着模型能力走
和模型上下文最贴合,overlap 防断句
需要理解 token 概念
生产环境最常用

这里掏心窝子讲一个概念: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:

7modelglm-4

8embedding:

9options:

10modelembedding-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 就不再是「会聊天的唠嗑机器」,而是「读过你全部资料的私人顾问」。

相关学习资料

返回首页浏览学习资料