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

1 阅读12分钟

承上:上一篇我们把安全、成本、监控全部搞定,AI应用终于拿到了"生产许可证"。但上周发生了一件事,让我意识到还差最后一块拼图——用户问"公司年假怎么请",AI一本正经地回答"员工每年享有15天带薪年假,可任意时间申请"。而实际上,我们公司只有5天。

1. 翻车现场:AI的"幻觉"从哪来?

1.1. 一个让我后背发凉的真实场景

看起来有模有样对吧?全是编的。

真实政策是:5天年假,不能累积,必须提前一周申请。

这就是大模型的幻觉(Hallucination) ——对于它没见过的东西,它会自信地编造一个听起来合理的答案。

1.2. 幻觉产生的原因

AI的知识来源:
├── 训练数据(截止到某个时间点)
│   └── 互联网公开信息 → 不知道你们公司的内部政策
│
└── 当前对话上下文
    └── 用户刚输入的这句话 → 信息完全不够

AI就像一个博览群书但从没进过你公司的实习生。 你问它"公司年假几天",它只能根据"一般公司都是10-15天"来编。

2. 解决方案:给AI开一场"开卷考试"

2.1. 核心思想

既然AI不知道你的私有知识,那就先把相关知识塞给它,让它看着回答。

闭卷考试(现状):
用户提问 → AI凭记忆回答 → 大概率编造

开卷考试(RAG):
用户提问 → 先查资料 → 把资料和问题一起给AI → AI看着资料回答

2.2. 这就是RAG

RAG(Retrieval-Augmented Generation,检索增强生成) ,三个步骤:

┌──────────┐      ┌──────────┐      ┌──────────┐
│  读文档   │  →  │  查资料   │  →  │  写回答   │
│  (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. 依赖准备

<!-- 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. 最简读取

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. 运行结果

总页数: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. 策略模式封装

真实场景中,用户可能上传各种格式的文件。我们需要一个统一的入口:

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类优势劣势
TXTTextReader简单直接,零配置无结构信息
PDFPagePdfDocumentReader按页读取,保留页码表格、扫描版处理弱
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. 添加依赖

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>

6.3. 一行代码读取任意格式

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

对比维度专用ReaderTikaDocumentReader
格式支持每种格式一个Reader1000+格式一个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实现自定义断点逻辑

默认配置的合理值

参数默认值说明
chunkSize800单块目标Token数
minChunkSizeChars350单块最小字符数
minChunkLengthToEmbed5小于5的片段不保留
maxNumChunks10000单份文档最大切片数
keepSeparatortrue保留换行等分隔符
encodingTypeCL100K_BASEOpenAI兼容编码

适用场景:PDF、Word、TXT等普通文档,无需额外调整参数。

7.2.1. 方式一:默认配置(90%场景首选)

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);
    }
}

默认配置的合理值

参数默认值说明
chunkSize800单块目标Token数
minChunkSizeChars350单块最小字符数
minChunkLengthToEmbed5小于5的片段不保留
maxNumChunks10000单份文档最大切片数
keepSeparatortrue保留换行等分隔符
encodingTypeCL100K_BASEOpenAI兼容编码

适用场景:PDF、Word、TXT等普通文档,无需额外调整参数。

7.2.2. 方式二:自定义参数精细化切分

如果文档是专业长文,需要更精准地控制切片大小来适配特定的Embedding模型:

@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);
    }
}

自定义参数指南

参数建议值调参思路
chunkSize300-800Embedding模型上限多少就设多少
minChunkSizeCharschunkSize的40%-60%太小会产生碎片,太大失去分割意义
minChunkLengthToEmbed5-20过短的块没有语义价值
maxNumChunks按文档大小估算100页PDF约2000块足够

7.2.3. 方式三:中文文档优化切分

原生的默认标点符号仅适配英文场景(. ? ! \n)。针对中文文档,需要自定义中文专属的断句标点:

@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);
    }
}

中英文混合文档可以同时传入两种标点:

.withPunctuationMarks(List.of('。', '?', '!', ';', '.', '?', '!'))

7.2.4. 官方内置能力总结

你完全不需要自己开发的东西

你可能想自己做的Spring AI 2.0 已经内置
按段落/换行符切分默认断点已包含 \n
按标点符号断句内置 .``?``!断点,中文可自定义
Token精确计数jtokkit库,与OpenAI完全对齐
短文本保护小于chunkSize自动保留完整内容
元数据复制自动复制到所有子切片
性能优化标点列表≤20字符即可最优速度

只有极特殊的业务场景(比如按特定业务规则断句),才需要继承 TokenTextSplitter 重写 protectedgetLastPunctuationIndex 方法。普通场景下,上面三种方式完全够用。

7.3. 完整ETL Pipeline

代码就是这简单!

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:

文档读取

方式适用场景
PagePdfDocumentReaderPDF按页读取
TextReader纯文本文件
MarkdownDocumentReaderMarkdown,保留标题结构
TikaDocumentReader万能兜底,1000+格式通吃

文档分割——唯一推荐 TokenTextSplitter

配置方式适用场景
builder().build()默认配置,90%场景直接可用
withChunkSize(512)等自定义参数适配特定Embedding模型
withPunctuationMarks(中文标点)中文文档优化切分

核心心法

Spring AI 2.0 的 TokenTextSplitter 已经内置了:
- 智能断句(默认在标点/换行处切分)
- Token精确计数(jtokkit库,与OpenAI对齐)
- 短文本保护(小于chunkSize不强制切分)
- 元数据自动复制

你不需要自己写正则、判断符号、处理边界。
只需要根据文档语言配置好 withPunctuationMarks 即可。

一个方法搞定所有格式

// 不管PDF、Word、TXT、Markdown
// 读取 → 统一用 TikaDocumentReader
// 分割 → 统一用 TokenTextSplitter
// 切分逻辑完全通用,不针对文件类型做二次开发

现在文档已经变成一堆高质量的小碎片了,但它们还只是文本。AI不认识文本,只认识数字。

下一篇,我们要把这些文本碎片变成向量,存入向量数据库,让AI真正能“理解”你的私有知识。


本文与DeepSeek协作完成