承上:上一篇我们把安全、成本、监控全部搞定,AI应用终于拿到了"生产许可证"。但上周发生了一件事,让我意识到还差最后一块拼图------用户问"公司年假怎么请",AI一本正经地回答"员工每年享有15天带薪年假,可任意时间申请"。而实际上,我们公司只有5天。
1. 翻车现场:AI的"幻觉"从哪来?
1.1. 一个让我后背发凉的真实场景

看起来有模有样对吧?全是编的。
真实政策是:5天年假,不能累积,必须提前一周申请。
这就是大模型的幻觉(Hallucination) ------对于它没见过的东西,它会自信地编造一个听起来合理的答案。
1.2. 幻觉产生的原因
markdown
AI的知识来源:
├── 训练数据(截止到某个时间点)
│ └── 互联网公开信息 → 不知道你们公司的内部政策
│
└── 当前对话上下文
└── 用户刚输入的这句话 → 信息完全不够
AI就像一个博览群书但从没进过你公司的实习生。 你问它"公司年假几天",它只能根据"一般公司都是10-15天"来编。
2. 解决方案:给AI开一场"开卷考试"
2.1. 核心思想
既然AI不知道你的私有知识,那就先把相关知识塞给它,让它看着回答。
闭卷考试(现状):
用户提问 → AI凭记忆回答 → 大概率编造
开卷考试(RAG):
用户提问 → 先查资料 → 把资料和问题一起给AI → AI看着资料回答
2.2. 这就是RAG
RAG(Retrieval-Augmented Generation,检索增强生成) ,三个步骤:
markdown
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 读文档 │ → │ 查资料 │ → │ 写回答 │
│ (ETL) │ │(Retrieve) │ │(Generate) │
└──────────┘ └──────────┘ └──────────┘
1. 读文档:把公司的PDF、Word、Markdown拆成小片段,存起来
2. 查资料:用户提问时,搜索最相关的小片段
3. 写回答:把搜索到的片段和用户问题一起发给AI
用后端老鸟的类比:
| 步骤 | 类比 |
|---|---|
| 读文档 | 建索引(CREATE INDEX) |
| 查资料 | 全文检索(LIKE '%关键词%',但更智能) |
| 写回答 | 把查到的记录拼进Prompt,发给AI |
3. 初体验
写文章之前,我以"面试"为例搭建了一个小型知识库,同学们先了解一下,方便去了解RAG、向量知识库。





4. 第一步:读文档------从PDF开始
现实中的企业文档,90%都是PDF。我们先从最难的PDF开始,TXT只是开胃菜。
4.1. 依赖准备
xml
<!-- PDF解析:Apache PdfBox -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
Spring AI的 spring-ai-pdf-document-reader 底层用的是 Apache PdfBox,一个纯Java的PDF解析库,不需要装任何系统依赖。
4.2. 准备测试PDF
创建一个简单的PDF文件 src/main/resources/docs/test.pdf
4.3. 最简读取
java
package com.oldbird.ai.chapter12.reader;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.List;
@Component
public class PdfDocumentReader {
public List<Document> readPdf(String path) throws IOException {
// PagePdfDocumentReader:按页读取PDF
PagePdfDocumentReader reader = new PagePdfDocumentReader(
new ClassPathResource(path)
);
return reader.get();
}
// 快速验证
public static void main(String[] args) throws IOException {
PdfDocumentReader pdfReader = new PdfDocumentReader();
List<Document> docs = pdfReader.readPdf("docs/test.pdf");
System.out.println("总页数:" + docs.size());
for (int i = 0; i < docs.size(); i++) {
System.out.println("\n=== 第" + (i + 1) + "页 ===");
System.out.println(docs.get(i).getContent());
}
}
}
4.4. 运行结果
markdown
总页数:4
=== 第1页 ===
2026/7/28 11:17 第1篇:《Java老鸟的第一行AI代码:我让Spring AI帮我写了首诗》 · 语雀
第1篇: 《Java老鸟的第一行AI代码:我让Spring
AI帮我写了首诗》
1. 选型小帖士:Spring AI vs Spring AI Alibaba
2. 环境要求
2.1. 版本要求
2.2. 获取API Key
2.3. 创建项目
3. 第一行AI代码
3.1. 最简写法
3.2. 运行结果
4. 加一点工程化:分离Controller和Service
5. 代码解读:ChatClient到底干了什么?
6. 本篇避坑指南
6.1.1. 坑1:base-url 路径写错
6.1.2. 坑2:API Key直接写进yml
6.1.3. 坑3:模型名称不对
7. 本篇小结
起:作为一个写了十年CRUD的Java老鸟,我从来没想过,让机器写诗会比写一个查询接口还简
单。
1. 选型小帖士:Spring AI vs Spring AI Alibaba
开始写代码之前,先解决一个让我纠结了好久的问题。
Maven仓库里搜"spring-ai",会出来两个看起来差不多的东西------Spring AI 和 spring-ai-
alibaba。该用哪个?
后来搞明白了,它们的关系很像 JDBC 和 MySQL 驱动:
● Spring AI 是标准接口层。它定义了一套统一的API------ChatClient、Function Calling、
MCP、RAG------不管底层用的是OpenAI、通义千问还是Ollama本地模型,写的代码都一样。
● Spring AI Alibaba 是阿里云基于这套标准的增强实现,深度集成了阿里云百炼平台,额外提
供了Graph工作流引擎等独有功能。
其实选型很简单:
你的情况 推荐选择
刚开始学,想跑通第一段对话 Spring AI
https://www.yuque.com/shiyunxi/nerysi/fbzgg74wbphyn0wk/pdf#print 1/4
=== 第2页 ===
...
4.5. PdfBox的局限
| 场景 | 效果 | 原因 |
|---|---|---|
| 纯文字PDF | ✅ 完美 | 文字直接提取 |
| 扫描版PDF | ❌ 乱码/空白 | 图片需要OCR |
| 复杂表格 | ⚠️ 格式混乱 | 单元格关系丢失 |
| 双栏排版 | ⚠️ 阅读顺序错乱 | 缺少布局分析 |
| 加密PDF | ❌ 读取失败 | 需要解密 |
5. 升级:多格式读取
5.1. 策略模式封装
真实场景中,用户可能上传各种格式的文件。我们需要一个统一的入口:
java
package com.yunxi.ai.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Component
public class DocumentReaderDispatcher {
public List<Document> read(String path) throws IOException {
String lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".pdf")) {
return readPdf(path);
} else if (lowerPath.endsWith(".txt")) {
return readText(path);
} else {
throw new IllegalArgumentException("不支持的文件格式:" + path);
}
}
private List<Document> readPdf(String path) throws IOException {
PagePdfDocumentReader reader = new PagePdfDocumentReader(
new ClassPathResource(path)
);
return reader.get();
}
private List<Document> readText(String path) throws IOException {
TextReader reader = new TextReader(new ClassPathResource(path));
return reader.get();
}
}
5.2. 各格式Reader对比
| 格式 | Reader类 | 优势 | 劣势 |
|---|---|---|---|
| TXT | TextReader |
简单直接,零配置 | 无结构信息 |
PagePdfDocumentReader |
按页读取,保留页码 | 表格、扫描版处理弱 | |
| Word(.docx) | 需要Tika | 保留格式、表格 | 需要额外依赖 |
6. 终极方案:Apache Tika
6.1. 什么是Tika?
Apache Tika 是一个全能文档解析器,支持1000+种文件格式,包括:
- 办公文档:PDF、Word(.docx/.doc)、Excel(.xlsx)、PPT
- 标记语言:HTML、XML、Markdown
- 压缩包:ZIP、TAR(自动解压)
- 图片元数据:JPEG、PNG(提取文字需要OCR配合)
- 邮件:.eml、.msg
一句话:一个Tika,所有格式通吃。
6.2. 添加依赖
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
6.3. 一行代码读取任意格式
java
package com.yunxi.ai.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.List;
@Component
public class TikaUniversalReader {
/**
* 一行代码,读取任意格式
* 支持:PDF、Word、Excel、PPT、HTML、XML、TXT...
*/
public List<Document> readAny(String path) throws IOException {
TikaDocumentReader reader = new TikaDocumentReader(
new ClassPathResource(path)
);
return reader.get();
}
// 测试:同一个方法读取三种不同格式
public static void main(String[] args) throws IOException {
TikaUniversalReader reader = new TikaUniversalReader();
System.out.println("=== PDF ===");
List<Document> pdf = reader.readAny("docs/test.pdf");
System.out.println("页数/段数:" + pdf.size());
System.out.println("第一段:" + pdf.get(0).getText().substring(0, 100));
System.out.println("\n=== Word ===");
List<Document> word = reader.readAny("docs/test.docx");
System.out.println("段数:" + word.size());
System.out.println("第一段:" + word.get(0).getText().substring(0, 100));
System.out.println("\n=== Markdown ===");
List<Document> md = reader.readAny("docs/test.md");
System.out.println("段数:" + md.size());
System.out.println("第一段:" + md.get(0).getText().substring(0, 100));
}
}
6.4. Tika vs 专用Reader
| 对比维度 | 专用Reader | TikaDocumentReader |
|---|---|---|
| 格式支持 | 每种格式一个Reader | 1000+格式一个Reader |
| 配置灵活度 | 高(各Reader有自己的配置) | 低(统一配置) |
| 依赖大小 | 小(按需引入) | 大(tika-core + parsers ~100MB) |
| 解析质量 | 各有优劣 | 综合最好 |
| 适用场景 | 格式确定、需要精细控制 | 用户上传、格式不确定 |
7. 重头戏:文档分割
文档读取只是"拆包装",文档分割才是RAG质量的分水岭。切得好不好,直接决定后面检索的准确率。
7.1. 为什么需要分割?
假设有一本100页的员工手册。用户问"年假几天"。
方案A:整本发给AI
- Token消耗:约50000 Token
- 成本:每次查询约¥0.2
- 效果:AI在5万字中找一句话,容易遗漏或搞混
方案B:切成小块,只发相关块
- Token消耗:约500 Token
- 成本:每次查询约¥0.002
- 效果:AI看到的全是相关内容,回答精准
7.2. Spring AI 2.0 唯一推荐:TokenTextSplitter
写到这个时候,我也很纠结,Spring AI属于新知识,目前更新迭代太快了,我在刚开始搭建知识库的时候用的是Spring AI 1.2.1,自己还实现了多种分片方式的代码...
版本声明 :本系列基于 Spring AI 2.0.0 。2.0版本在ETL Pipeline的API上有重大调整,核心分割器统一使用TokenTextSplitter,底层基于jtokkit库实现,与OpenAI系列模型的Token统计规则完全对齐。如果你看到其他教程里的DocumentTransformer接口或各种XXXSplitter类名,请确认版本号------1.x和2.0的API不可混用。
官方文档:docs.spring.io/spring-ai/r...
在2.0版本中,TokenTextSplitter 是官方推荐的标准方案,覆盖90%以上的文档分割场景。它底层基于 jtokkit 库,与OpenAI系列模型的Token统计规则完全对齐,不会出现切片超出模型上下文窗口的问题。
核心特性(开箱即用,无需二次开发):
| 特性 | 说明 |
|---|---|
| 智能断句 | 默认在 .``?``!``\n处断句,优先保证语义完整 |
| Token精确计数 | 基于CL100K_BASE编码,和模型统计规则一致 |
| 短文本保护 | 小于chunkSize的文本不会被强制切分 |
| 元数据保留 | 原始文档的所有元数据自动复制到子切片 |
| 性能优化 | 标点列表控制在20个字符以内即可最优速度 |
| 可扩展 | 可重写 getLastPunctuationIndex实现自定义断点逻辑 |
默认配置的合理值:
| 参数 | 默认值 | 说明 |
|---|---|---|
chunkSize |
800 | 单块目标Token数 |
minChunkSizeChars |
350 | 单块最小字符数 |
minChunkLengthToEmbed |
5 | 小于5的片段不保留 |
maxNumChunks |
10000 | 单份文档最大切片数 |
keepSeparator |
true | 保留换行等分隔符 |
encodingType |
CL100K_BASE | OpenAI兼容编码 |
适用场景:PDF、Word、TXT等普通文档,无需额外调整参数。
7.2.1. 方式一:默认配置(90%场景首选)
java
package com.oldbird.ai.chapter12.splitter;
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class CommonChunkingService {
public List<Document> splitDocuments(List<Document> rawDocuments) {
// 直接使用官方默认配置,开箱即用
TokenTextSplitter splitter = TokenTextSplitter.builder().build();
return splitter.apply(rawDocuments);
}
}
默认配置的合理值:
| 参数 | 默认值 | 说明 |
|---|---|---|
chunkSize |
800 | 单块目标Token数 |
minChunkSizeChars |
350 | 单块最小字符数 |
minChunkLengthToEmbed |
5 | 小于5的片段不保留 |
maxNumChunks |
10000 | 单份文档最大切片数 |
keepSeparator |
true | 保留换行等分隔符 |
encodingType |
CL100K_BASE | OpenAI兼容编码 |
适用场景:PDF、Word、TXT等普通文档,无需额外调整参数。
7.2.2. 方式二:自定义参数精细化切分
如果文档是专业长文,需要更精准地控制切片大小来适配特定的Embedding模型:
scss
@Component
public class CustomizedChunkingService {
public List<Document> splitCustomized(List<Document> documents) {
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withEncodingType(EncodingType.CL100K_BASE) // GPT-4o的分词编码器
.withChunkSize(512) // 适配主流Embedding模型的输入上限
.withMinChunkSizeChars(200) // 避免生成过碎的无效小片段
.withMinChunkLengthToEmbed(10) // 长度小于10的片段不保留
.withMaxNumChunks(2000) // 限制总切片数量
.withKeepSeparator(true) // 保留换行符,维持段落格式
.build();
return splitter.apply(documents);
}
}
自定义参数指南:
| 参数 | 建议值 | 调参思路 |
|---|---|---|
chunkSize |
300-800 | Embedding模型上限多少就设多少 |
minChunkSizeChars |
chunkSize的40%-60% | 太小会产生碎片,太大失去分割意义 |
minChunkLengthToEmbed |
5-20 | 过短的块没有语义价值 |
maxNumChunks |
按文档大小估算 | 100页PDF约2000块足够 |
7.2.3. 方式三:中文文档优化切分
原生的默认标点符号仅适配英文场景(. ? ! \n)。针对中文文档,需要自定义中文专属的断句标点:
java
@Component
public class ChineseChunkingService {
public List<Document> splitChineseDocument(List<Document> documents) {
// 传入中文专属的句号、问号、感叹号、分号作为切分断点
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(800)
.withMinChunkSizeChars(350)
.withPunctuationMarks(List.of('。', '?', '!', ';'))
.build();
return splitter.apply(documents);
}
}
中英文混合文档可以同时传入两种标点:
less
.withPunctuationMarks(List.of('。', '?', '!', ';', '.', '?', '!'))
7.2.4. 官方内置能力总结
你完全不需要自己开发的东西:
| 你可能想自己做的 | Spring AI 2.0 已经内置 |
|---|---|
| 按段落/换行符切分 | 默认断点已包含 \n |
| 按标点符号断句 | 内置 .``?``!断点,中文可自定义 |
| Token精确计数 | jtokkit库,与OpenAI完全对齐 |
| 短文本保护 | 小于chunkSize自动保留完整内容 |
| 元数据复制 | 自动复制到所有子切片 |
| 性能优化 | 标点列表≤20字符即可最优速度 |
只有极特殊的业务场景 (比如按特定业务规则断句),才需要继承 TokenTextSplitter 重写 protected 的 getLastPunctuationIndex 方法。普通场景下,上面三种方式完全够用。
7.3. 完整ETL Pipeline
代码就是这简单!
kotlin
package com.yunxi.ai.controller;
import com.yunxi.ai.entity.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.core.io.Resource;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@RestController
@RequestMapping("/rag")
@Slf4j
public class RagController {
/**
* 上传文件
*/
@RequestMapping("/upload")
public Result uploadFile(@RequestParam("file") MultipartFile file, @RequestParam("chunkSize") Integer chunkSize) {
if (file == null){
return Result.failed(500, "文件为空");
}
try {
Resource resource = file.getResource();
TikaDocumentReader tikaDocumentReader = new TikaDocumentReader(resource);
List<Document> read = tikaDocumentReader.read();
TokenTextSplitter splitter = TokenTextSplitter.builder().withChunkSize(chunkSize).build();
List<Document> apply = splitter.apply(read);
return Result.successed(apply);
} catch (Exception e){
log.info("文件读取异常", e);
}
return Result.successed();
}
}
分片效果展示(以分片大小500、2000为例):



8. 本篇小结
这一篇我们完成了RAG的第一步------ETL Pipeline:
文档读取:
| 方式 | 适用场景 |
|---|---|
PagePdfDocumentReader |
PDF按页读取 |
TextReader |
纯文本文件 |
MarkdownDocumentReader |
Markdown,保留标题结构 |
TikaDocumentReader |
万能兜底,1000+格式通吃 |
文档分割------唯一推荐 TokenTextSplitter:
| 配置方式 | 适用场景 |
|---|---|
builder().build() |
默认配置,90%场景直接可用 |
withChunkSize(512)等自定义参数 |
适配特定Embedding模型 |
withPunctuationMarks(中文标点) |
中文文档优化切分 |
核心心法:
diff
Spring AI 2.0 的 TokenTextSplitter 已经内置了:
- 智能断句(默认在标点/换行处切分)
- Token精确计数(jtokkit库,与OpenAI对齐)
- 短文本保护(小于chunkSize不强制切分)
- 元数据自动复制
你不需要自己写正则、判断符号、处理边界。
只需要根据文档语言配置好 withPunctuationMarks 即可。
一个方法搞定所有格式:
arduino
// 不管PDF、Word、TXT、Markdown
// 读取 → 统一用 TikaDocumentReader
// 分割 → 统一用 TokenTextSplitter
// 切分逻辑完全通用,不针对文件类型做二次开发
现在文档已经变成一堆高质量的小碎片了,但它们还只是文本。AI不认识文本,只认识数字。
下一篇,我们要把这些文本碎片变成向量,存入向量数据库,让AI真正能"理解"你的私有知识。
本文与DeepSeek协作完成