第12篇:《AI的幻觉差点让我背锅:于是我给它开了一场"开卷考试"》

承上:上一篇我们把安全、成本、监控全部搞定,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 简单直接,零配置 无结构信息
PDF 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 重写 protectedgetLastPunctuationIndex 方法。普通场景下,上面三种方式完全够用。

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协作完成

相关推荐
腻害兔2 小时前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:IM 即时通讯模块,一个被低估的「全功能聊天系统」
java·前端·vue.js·产品经理·ai编程
小徐_23332 小时前
寓言故事一则:狗猛酒酸
前端·ai编程
ServBay3 小时前
AI 时代的供应商锁定风险,开发者和企业如何保持主动权
aigc·ai编程
会思想的苇草i3 小时前
oMLX 部署本地大模型
大模型·ai编程·开发·本地部署·omlx
Miracleeee3 小时前
当 Skill 放大一万倍,它就变成了 Memory——从渐进式披露到动态上下文的工程演进
aigc·openai
妙码生花4 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(三十九):前端上传组件实现
javascript·vue.js·ai编程
DigitalOcean4 小时前
Kimi K3 现已上线 DigitalOcean 无服务器推理
llm·agent·ai编程
小四的小六5 小时前
WebView到AI产品,最值得掌握的3个核心能力——从自己产品里总结的能力地图
前端·ai编程·webview
windliang6 小时前
Claude Code 源码分析(一):从 claude 命令到 Agent 主循环
javascript·面试·ai编程