玩转 Spring AI 第二篇:RAG 落地------Redis 向量库、按空行切分的自定义 Splitter,和 pom 里两个"引而未用"的依赖

💡 系列第一篇把这套 Spring AI 工程的骨架和第一次对话跑通了,这一篇进入最容易"被跑通骗过去"的一章:RAG。前端拖一个 txt 进去,问一句文档里的事,模型真就答出来了------看起来无非是"往向量库里塞了点东西,再取出来拼进提示词"。
但把代码摊开看,有个不太舒服的发现:pom.xml 里引进来的 spring-ai-tika-document-reader 和 spring-ai-advisors-vector-store,代码里一行都没用上。文档读取用的是最朴素的 TextReader,切分器是自己写的正则,检索问答是手写的 {context} / {question} 字符串替换。前端上传框还写死了 accept=".txt"------三件事自洽地凑在一起:能读纯文本、只会切纯文本、也只允许传纯文本。
换句话说,框架给了两条路,这门课走的是手工那条。这一篇就按真实调用链把它拆开:入库和检索各自做了什么、Redis 为什么当时必须装 redis-stack(以及这个前提今天已经不成立了)、嵌入模型在 yml 里"零配置"意味着什么、手写模板埋了哪几处坑,以及到什么程度你应该把它换成官方 Advisor。所有工程侧结论来自 2026-09-29 对课程工程与讲义的一次实读,凭据全部脱敏。
1. 先把链路摊开:两条线、三个接口、零个 Advisor
这套课程的 RAG 不在"框架能力"那一层,而在"手写胶水"那一层。先把地图画清楚,后面每一节从图上取一块展开。
text
入库线 POST /rag/uploadRagDoc
MultipartFile --> TextReader(+customMetadata.fileName) --> CustomTextSplitter(按空行切) --> redisVectorStore.add(list) --> Redis 索引
检索线 POST /rag/search
question --> redisVectorStore.similaritySearch(question) --> 手写 ragPROMPT.replace(...) --> ChatClient.stream --> SSE 推给前端

这两条线除了共用同一个 RedisVectorStore,没有任何共用代码。入库不感知问题长什么样,检索不感知文档从哪来------这本来就是 RAG 该有的解耦,工程上做对了。
把每条线上的"环节"和"官方对应件"摆一起看,才看得出这门课的选择:
| 环节 | 工程里实际写的 | Spring AI 官方提供的 | 差在哪 |
|---|---|---|---|
| 读文档 | new TextReader(resource) |
TikaDocumentReader(多格式解析) |
只吃纯文本,前端也 accept=".txt" |
| 切分 | CustomTextSplitter,正则按空行 |
TokenTextSplitter(有 chunkSize 等参数) |
分片长度无上限,质量由文档排版决定 |
| 入库 | redisVectorStore.add(list) |
VectorStore 抽象 / ETL Writer |
这步没绕开框架,是"真框架" |
| 向量化 | 自动装配的 EmbeddingModel |
任一 embedding 实现 | yml 里零配置,全用默认值 |
| 检索 | similaritySearch(question) 单参 |
SearchRequest.builder().topK().similarityThreshold() |
走库默认:topK=4、阈值 0.0 |
| 拼提示词 | ragPROMPT.replace("{context}", ...) |
QuestionAnswerAdvisor / RetrievalAugmentationAdvisor |
依赖进了 pom,代码没用 |
| 空结果兜底 | 无(会 NPE) | RetrievalAugmentationAdvisor 默认拒绝空上下文 |
边界留给了自己,又没写完 |
一句话理解:向量库负责"找",提示词负责"喂",模型负责"说"。这套课程把"找"交给了框架,把"喂"完全交给了自己。 这一篇后面所有的问题都出在"喂"这一层。
1.1 三个接口的契约
| 接口 | 方法 | 入参 | 返回什么 | 容易误解的点 |
|---|---|---|---|---|
/rag/uploadRagDoc |
POST | multipart file |
LeeResult.ok(...),装的是切分前的文档列表 |
真正入库的是切分后的分片,返回值和副作用不是一回事 |
/rag/doSearch |
GET | question |
LeeResult.ok(Document 列表) |
裸检索,调试很值钱:能直接看到 topK 命中了什么 |
/rag/search |
POST | ChatEntity{message,...} |
响应体里没有答案 | 答案由服务端沿另一条 SSE 长连接按 userId 推给前端 |
java
@RestController
@RequestMapping("rag")
public class RagController {
@PostMapping("uploadRagDoc") // 入库
public LeeResult uploadRagDoc(MultipartFile file) {
var documentList = documentService.loadText(file.getResource(), file.getOriginalFilename());
return LeeResult.ok(documentList);
}
@GetMapping("doSearch") // 裸检索,排查召回质量用这个
public LeeResult doSearch(String question) {
return LeeResult.ok(documentService.doSearch(question));
}
@PostMapping("search") // 检索 + 问答
public void search(@RequestBody ChatEntity chatEntity, HttpServletResponse response) {
List<Document> list = documentService.doSearch(chatEntity.getMessage());
response.setCharacterEncoding("UTF-8");
chatService.doChatRagSearch(chatEntity, list); // 答案不从这里返回,走 SSE
}
}
(摘录时做了行合并与注释删减,方法名、参数、返回语义照原样。)
/rag/search 这个"请求和响应分家"的设计值得单独说一句:HTTP 请求只负责触发,结果从另一条通道回来。好处是流式体验和普通对话复用同一条推流链路;代价是这个接口没有业务返回值,一旦服务端在检索或拼提示词阶段抛异常,前端只会看到"消息永远不来"。第 5 节那个 NPE 就藏在这条路径上。
2. Redis 当向量库:镜像、端口和三个配置项
向量库这层工程里选了 Redis,而且是 redis/redis-stack 镜像------这个选择不是偏好问题,是硬要求。
讲义与代码里的启动方式:
text
docker pull redis/redis-stack:latest
docker run -d --name redis-stack \
-p 9379:6379 \
-e REDIS_ARGS="--requirepass <REDACTED>" \
redis/redis-stack:latest
-p 9379:6379 的意思是:宿主机 9379 → 容器内 6379。所以应用配置里写的是 9379,不是 6379------这个换算关系是第一个坑的源头(见本节末)。
应用侧的配置一共两组,spring.data.redis.* 管"怎么连上",spring.ai.vectorstore.redis.* 管"向量存成什么样":
yaml
spring:
ai:
vectorstore:
redis:
initialize-schema: true # 不配就是 false:索引不会自动建
index-name: lee-vectorstore # 覆盖默认的 spring-ai-index
prefix: 'embedding:' # 与官方默认值相同
data:
redis:
host: 127.0.0.1
port: 9379 # 宿主机映射端口
password: <REDACTED> # 与 REDIS_ARGS 里的 requirepass 对应
三个 vectorstore 配置项各自的作用,对照官方 1.0 文档写的默认值看最清楚:
| 配置项 | 官方默认值 | 作用 | 配错/漏配的表现 |
|---|---|---|---|
initialize-schema |
false |
启动时创建向量索引(维度、字段、距离函数) | 漏了它,索引不存在,写入和检索都会失败。官方文档明确标注这是 breaking change------早期版本是默认建的 |
index-name |
spring-ai-index |
RediSearch 索引名,一个 Redis 实例可以放多个索引 | 换名字等于换一个库:老数据在旧索引里,新代码查新索引,表现为"明明入库了却搜不到" |
prefix |
embedding: |
文档 key 的前缀(embedding:<docId>) |
同一索引下多套数据混放时,靠前缀区分与清理 |
这里补一句 prefix 的实践价值:清库有两种做法,FLUSHALL 是核弹,删索引 + 按前缀 SCAN 清 key 是手术刀。 课程工程里两种都没有,所以"重新导入同一份文档"这件事没有任何清理路径(第 4 节会展开)。
Q1:为什么不能用普通的 redis 镜像?
在课程写作的那个时间点,答案是不能;今天这个答案已经过期了。 拆开讲。
Redis 核心的数据结构里没有向量相似度检索 ,多出来的是 Redis Query Engine(文档里的 Search and Query 模块)。Spring AI 的 Redis 向量库文档在 Prerequisites 里写得很直接:需要 Redis Search and Query 来 "extends the core features of Redis",并给出两种提供方式------Redis Cloud 或 Docker 镜像 redis/redis-stack:latest。也就是说,向量检索在这个方案里不是 Spring AI 实现的,是 Redis 服务端实现的 ,Spring AI 只负责把 Document 序列化进 hash/JSON、把相似度查询翻译成索引命令。所以课程照抄文档拉 redis/redis-stack,逻辑上完全自洽。
问题在于文档滞后于发行策略。redis-stack 仓库 的 README 顶部现在就一句话:
Redis Stack users should upgrade to Redis. New users should use Redis instead of Redis Stack.
而 Redis 主发行版(当前最新 8.10.2)的 README 已经把 "Search and Query Engine: Indexing for hash/JSON documents, supporting vector search , full-text search, geospatial queries" 列成自带能力,另起一条 "Vector Store for GenAI ... retrieval augmented generation (RAG)"。翻译成人话:"必须装 Stack 才有向量检索"这个前提已经不成立了,redis:8 就够了,Stack 只是还没从各家框架的文档里退场。
于是这里出现一个很典型的位置:框架文档教你用一个官方正在劝退的镜像。 我的处理方式是------跟着框架文档走以保证兼容(Spring AI 的 RedisVectorStore 目前仍按 Stack 的形态写 prerequisites),但新项目直接把 redis/redis-stack:latest 换成 redis:8,并把这件事记进选型备注,等框架侧改口径。别把文档当现状,尤其别把"某个镜像的唯一可用路径"这种强断言当长期事实。
顺带一个结论:这个方案没有给 Docker 容器外的网络暴露抽象------RedisVectorStore 换掉之后整套检索代码要重写,因为它依赖的是向量库能力,不是 Redis 的通用 KV 能力。这一点在选型时经常被忽略。
2.1 端口这条坑:讲义写 8379,代码是 9379
| 出处 | 内容 |
|---|---|
讲义 6-2-1 使用Docker安装Redis作为向量库 |
-p 9379:6379 |
讲义 6-3 在项目中配置Redis与嵌入式模型 |
port: 8379 |
代码 application.yml |
port: 9379 |
三处证据里两处是 9379,8379 应当视为讲义笔误。 但这类笔误在新手上杀伤力极大:改配置的人往往只看到最近那篇讲义,不会去翻前面那篇的 docker 命令。而 Redis 启动日志里没有任何提示,因为改的是客户端端口,服务端从来没动过。
排查这类问题的正确顺序不是"改配置重启碰运气",而是先确认服务端实际在监听什么:
text
docker port redis-stack → 9379/tcp -> 0.0.0.0:6379 之类
docker inspect -f '{{...}}' redis-stack → 看 Env 里的 REDIS_ARGS 是否真的带 requirepass
redis-cli -h 127.0.0.1 -p 9379 -a '<REDACTED>' FT._LIST → 能不能列出索引
最后那条是我个人强烈建议固化为习惯的一步:FT._LIST 能同时验证端口、密码、以及索引是否真被 initialize-schema 建出来了,一条命令区分三种失败原因。
2.2 第二种起法会把密码搞丢
讲义第 6 章还有一份 docker-compose.yml,和上面那条 docker run 起的是同一个镜像、同一个端口映射,但------它没有 REDIS_ARGS,也就是起来的 Redis 是无密码的:
yaml
services:
redis:
image: redis/redis-stack:latest
container_name: redis-stack
ports: ["9379:6379"]
volumes: [/Volumes/.../redis-stack/data:/data]
restart: unless-stopped
而 application.yml 里是带 password 的。带密码的客户端去连一个没设 requirepass 的服务端,握手阶段就会被拒(Redis 的通用行为是回一句"服务端没有设置密码却收到了 AUTH",确切文案我没在本工程实测,按你所在 Redis 版本为准)。这个坑和 8379 那个坑的表现很像------都是"连不上 Redis"------但成因完全相反:一个是客户端多给了密码,一个是客户端给错了端口。
注:
-p 9379:6379会把端口绑到所有网卡上。云服务器上要么靠安全组收口,要么写成-p 127.0.0.1:9379:6379。这条是笔者建议,讲义与工程里都没有做。
3. 嵌入模型:pom 里一行依赖,yml 里零配置
这是本篇最"反直觉"的一个事实:整套 RAG 的向量化,配置文件里一个字都没写。
text
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-transformers</artifactId>
</dependency>
(版本由 spring-ai-bom 统一管理,工程里 spring-ai-starter-model-openai 和它并存:对话模型走云端、嵌入模型走本地 。仓库的 .env 里对话端点指向 https://api.deepseek.com(模型 deepseek-chat,key 为 sk-<REDACTED>),另备了一份切到阿里云百炼 compatible-mode 的 .env_qwen。)
真正关键的是:application.yml 里没有任何 spring.ai.embedding.transformer.* 配置项。讲义 6-3 的标题写着"嵌入式模型",正文也只贴了上面那三行依赖。所以:
- 用的是哪个模型、向量多少维------仓库与讲义里没有任何声明;
similaritySearch的 topK、相似度阈值------代码里也没出现,走库默认值;- 这两件事都只能靠"查框架默认值 + 自己打印"来确认,不能靠读工程配置。
官方 1.0 参考文档给出的默认资源是 classpath 下的 onnx/all-MiniLM-L6-v2/tokenizer.json 与 model.onnx,并明确标注 Dimensions 384 (文档页)。这里要把话说准:384 维、sentence-transformers/all-MiniLM-L6-v2 是"官方文档层面的默认值",不是"本工程的证据"------本课程锁的是 BOM 1.0.0,与文档版本不完全对齐。落到自己项目上,别猜,直接打印:
java
// 维度自查:这一行比任何配置文件都可信
log.info("embedding impl={}, dimensions={}",
embeddingModel.getClass().getSimpleName(),
embeddingModel.dimensions());
(dimensions() 的方法名随版本有差异,以你所用版本的 EmbeddingModel 接口为准。这条 API 签名我未逐一核对到 1.0.0。)
3.1 为什么"本地嵌入 + 云端生成"是聪明的组合
| 维度 | 本地 ONNX(本工程) | 云端 Embedding API |
|---|---|---|
| 计费 | 不计 token,CPU 推理 | 按 token 计费,入库时尤其明显 |
| 网络依赖 | 无,可完全离线跑通 | 必须有外网与配额 |
| 维度 | 默认固定 384 | 多数可调(1024/1536/2048...) |
| 语义质量 | 轻量模型,中文表现一般(笔者经验判断,官方文档未述) | 明显更好,尤其长文本与多语 |
| 冷启动 | 模型资源加载(1.0.0 是否首次运行拉取未核实) | 一次网络往返 |
| 换模型代价 | 要重建索引 | 同样要重建索引 |
对教学 demo 来说这个选型非常合理:学员不会因为 embedding 把 key 的额度烧光,也不会因为网络抖动"跑不起来"。真正要注意的是那句"中文表现一般":如果你的知识库是中文、问题也是中文,用默认 384 维英文语料小模型,召回质量会很差------而差到一定程度时,你以为是提示词的问题,其实是向量的问题。 这也是我坚持要先做 /rag/doSearch 验证、再看模型答案的原因。
Q2:嵌入模型能不能随手换?
不能,而且换一次要清一次库。原因有两层:
text
维度层:索引建立时就把 dimension 写死了 → 384 的索引塞不进 1024 的向量 → 必须 DROP INDEX 重建
语义层:即便维度相同,不同模型的向量空间不可比 → 老数据全是噪声,留着比删掉更糟
正确的换模型动作顺序是:导出原始文档 → 删索引 → 改配置 → 以新维度重建 → 全量重新入库。不要试图"新旧模型混跑",那是最容易做出一个"看起来能用、答案越来越离谱"的知识库的方式。这一条属于工程判断,框架不会拦你,只会让你在错误的召回上多花两周。
4. 入库链路:TextReader、CustomTextSplitter,和 add 那一步真正发生的事
入库这条线只有 20 行,但有五处值得细看的地方。
java
@Override
public List<Document> loadText(Resource resource, String fileName) {
TextReader textReader = new TextReader(resource);
textReader.getCustomMetadata().put("fileName", fileName); // 只塞了一个元数据
List<Document> documentList = textReader.get();
// TokenTextSplitter tokenTextSplitter = new TokenTextSplitter(); // 官方切分器:被注释掉了
CustomTextSplitter tokenTextSplitter = new CustomTextSplitter();
List<Document> list = tokenTextSplitter.apply(documentList);
this.redisVectorStore.add(list); // ← 到这一行才真正调用 EmbeddingModel 向量化并写 Redis
return documentList; // ← 返回的是"切分前"的原始文档列表
}
自定义切分器的全部实现:
java
public class CustomTextSplitter extends TextSplitter { // 继承框架的模板方法基类
@Override
protected List<String> splitText(String text) {
return List.of(split(text));
}
public String[] split(String text) {
return text.split("\\s*\\R\\s*\\R\\s*"); // 空行分段,没有任何长度上限
}
}
正则里 \R 是"任意换行序列"(\n、\r\n、\r 都算),两个 \R 之间允许夹空白,整体匹配的就是一个空行 。所以这个切分器的语义是:按空行分段。跨平台这块做得没问题------\R 比硬写 \n\n 健壮,Windows 换行也能切开,这点比很多教程里的 split("\n\n") 强。
问题全在"只此一招":
| # | 事实 | 后果 |
|---|---|---|
| 1 | 构造函数无参数,不用 chunkSize / minChunkSizeChars / minChunkLengthToEmbed / maxNumChunks / keepSeparator |
分片长度完全由文档排版决定,不受控 |
| 2 | 没有空行的文档 → split 返回长度为 1 的数组 |
等于没切分,整篇文档进一个向量 |
| 3 | 空行也出现在 markdown 表格、代码块、列表之间 | 一个语义单元被撕成几片,相邻片段各自孤立 |
| 4 | 没有相邻片段重叠(overlap) | 跨段落的提问必然召回不全 |
| 5 | 返回值是切分前的列表 | 前端看到的"文档数"和索引里的"分片数"对不上 |
第 2 条最坑,因为它的发生条件是沉默的 :一份由 Word 导出、段落只用单换行的 txt,或者一份 Markdown 里被压平的表格,传进去毫无报错,add 也成功,索引也建了,然后 /rag/doSearch 返回的那条 Document.getText() 有几十 KB------提示词直接爆 token,或者被上游截断,模型开始一本正经地漏答。而你在前端看到的现象是"文档传成功了,答案不准"。
我个人的判断是:这份自写切分器在教学语境里是合格的作品,在生产语境里是不可用的。 它存在的意义应该是"让学员看清
TextSplitter只需实现一个splitText",而不该成为默认的落地方案。真要上线,最小改动不是把正则写得更聪明,而是放弃正则路线,改回TokenTextSplitter并把它的构造参数显式写出来------因为长度上限这件事必须由 token 维度来保证,段落边界只是第二优先级。
第 5 条还牵出一个更普遍的问题:入库接口是"副作用型"的 ,return documentList 对调用方几乎没有信息量。如果我是设计者,至少会返回 {原始文档数, 分片数, 索引名, 耗时}------这四个数就是后续所有排查的起点,而它们现在全部丢失在一次 LeeResult.ok(...) 里。
另外这条链路上没有任何删除、去重、幂等逻辑(仓库里没出现 delete 相关调用)。同一份文档传两次,索引里就是两份,检索时 topK 命中的可能是同一段内容的两个副本。配合第 2 节的"没有清理路径",这个知识库只进不出。
5. 检索问答链路:手写 {context} 模板,两处硬伤和一处"引而未用"
这是本篇最有信息量的一节:框架的默认行为、手写的实现、以及两者之间的落差,全都集中在这几行代码上。
java
public List<Document> doSearch(String question) {
return this.redisVectorStore.similaritySearch(question); // 单参重载
}
官方 Vector Stores 文档把单参重载的语义写得很明白:SearchRequest 的实例默认值就是 DEFAULT_TOP_K = 4 与 similarityThreshold = SIMILARITY_THRESHOLD_ACCEPT_ALL(0.0)(文档页)。也就是说这行代码的真实含义是:取回最相近的 4 条,且不做任何相似度过滤。
这直接决定了两件事:
- 正常情况下它几乎不会返回空列表 ------阈值 0.0 意味着"再不相邻也要"。所以问一句库里完全没有的事,你拿到的会是 4 条毫不相关的内容,被原样拼进
{context}。真正兜住"不知道"这句话的,只有提示词最后一句"如果没有查到,请回复:不知道"。把拒答的责任从检索层推给了自然语言层。 - 反过来,"返回空"这件事只发生在库本身为空的时候:服务刚起来还没入库、索引名配错、Redis 被清过。
然后是这个模板,全文照录(换行按语义还原):
java
private static final String ragPROMPT = """
基于上下文的知识库内容回答问题:
【上下文】
{context}
【问题】
{question}
【输出】
如果没有查到,请回复:不知道。如果查到,请回复具体的内容。
不相关的近似内容不必提到。
""";
模板本身写得挺好,尤其"不相关的近似内容不必提到"这句------它正是上一条"阈值 0.0"的必要补丁,说明作者知道会喂进噪声。这是提示词工程层面的成熟,和检索参数层面的失控同时存在,这个组合很真实,很多项目的第一版 RAG 就是这样。
拼装和调用:
java
String context = null;
if (ragContext != null && !ragContext.isEmpty()) {
context = ragContext.stream()
.map(Document::getText)
.collect(Collectors.joining("\n"));
}
// 没有 else 分支
Prompt prompt = new Prompt(ragPROMPT
.replace("{context}", context) // ← context 仍为 null 时这里 NPE
.replace("{question}", question));
// 之后与普通对话一致:.stream().content() -> SSE(add) -> 聚合 -> SSE(finish)
(关键三步的结构还原,非逐行抄录:原代码里 context 的声明与赋值分在相邻几行,赋值条件不覆盖空集合。)
5.1 三个真实问题
问题一:检索结果为空时 NPE,而且工程里没有全局异常处理。
String.replace(CharSequence target, CharSequence replacement) 对 replacement 为 null 是直接抛 NullPointerException 的(JDK 契约)。所以 ragContext 为空列表或为 null 时,context 保持 null,下一行 replace("{context}", context) 当场炸。
结合第 5 节开头那段推理,这个 bug 的触发时机就变得很有指向性:topK=4 + 阈值 0.0 让"检索不到内容"几乎不可能为空,因此这条 NPE 分支在稳态运行下是走不到的;能走到的是冷启动、索引名改动、Redis 被清、或者第 2.2 节那种连不上服务端的情况。 这意味着它是那种"上线三个月后运维清了个 key 就炸"的问题,而不是第一天就能测出来的问题。仓库里也没有任何 @ControllerAdvice / @ExceptionHandler,异常会一直冒到容器,前端 axios 拿到非 200,走不进成功分支------而由于答案本来就不在这个响应体里,用户看到的现象是"点了没反应"。
修法三行,不需要任何框架能力:
java
if (ragContext == null || ragContext.isEmpty()) {
SSEServer.sendMsg(userId, "知识库中未找到相关内容", SSEMsgType.FINISH);
return; // 或者给一个明确的 empty-context 分支
}
问题二:String.replace 不是模板引擎,它扫全文。
两次 replace 是顺序作用在同一个字符串 上的:先替换 {context},再替换 {question}------而第二次替换的作用范围包括刚被插进去的文档内容 。也就是说,只要入库的那份 txt 里含有 {question} 这五个字符,它就会被真实的问题替换掉。听起来像抬杠,但这是一条内容可控的提示词注入面 :知识库文档是外部输入,它可以在自己的正文里出现任何模板变量字面量({context}、{question}),甚至出现"【输出】"这种分隔标记,从而改写你精心设计的提示词结构。
框架层的做法是 PromptTemplate:变量渲染有明确语义,不会把已渲染的内容再当模板扫一遍;QuestionAnswerAdvisor 更是把占位符当成契约,官方文档明确要求自定义模板必须含 query 与 question_answer_context 两个变量。"变量名写成什么"在这两套路线里地位完全不同:一边是随手约定的字符串,一边是框架校验的接口。
问题三:metadata 写进去了,却没读出来。
入库时 getCustomMetadata().put("fileName", fileName),检索时 .map(Document::getText)------fileName 被丢掉了 。结果就是答案里没有任何来源信息,用户不知道这句话出自哪份文档。而同一份工程里,联网检索那条链路反而把来源拼进了上下文([来源] {url}),说明作者知道该怎么做,只是 RAG 这条没做。"引用可追溯"是 RAG 在生产上最有用的一个能力,比多调 10 分召回都值钱。 顺带地,metadata 还有第二个未用上的价值:作为检索过滤条件(按文件名/业务域/权限范围限定召回集),这在多文档知识库里几乎是刚需,而当前实现是"全库盲检"。
5.2 "引而未用":pom 里那两个依赖到底在等什么
| 依赖 / 代码 | 引入位置 | 实际使用 | 现状是否自洽 |
|---|---|---|---|
spring-ai-tika-document-reader(pom 注释"解析文档") |
mcp-client/pom.xml |
未使用 ,读取用的是 TextReader |
与前端 accept=".txt" 自洽:只支持文本,所以只允许传文本 |
spring-ai-advisors-vector-store |
mcp-client/pom.xml |
未使用 ,QuestionAnswerAdvisor 无引用 |
与手写 {context} 模板自洽 |
TokenTextSplitter |
DocumentServiceImpl 里 import 后被注释掉 |
换成 CustomTextSplitter |
自洽,但这是三处里唯一"从框架能力退回手写"的显式动作 |
三处放在一起,可以下一个有把握的判断:这不是忘了删的死依赖,而是"框架给了两条路,这门课选了手工那条"。 而且它选得并不勉强------TextReader + .txt + 自写切分器 + 手写模板,是一条完整、可读、能讲清楚每一行在干什么的教学路径。真正的问题是很多人把它当成了生产方案照抄,而它缺的正是那三处"框架已经帮你做好"的东西。
框架那条路(Advisor)实际是什么形态?官方文档的描述可以直接对上手写版的每一行:QuestionAnswerAdvisor 在问题送到模型前"queries the vector database for documents related to the user question",然后把结果 "appended to the user text"------它就是 similaritySearch + 模板渲染的封装;SearchRequest 显式给出 topK 与 similarityThreshold;更进一步还有一个 RetrievalAugmentationAdvisor(依赖是另一个坐标 spring-ai-rag),把 query 改写、文档检索、上下文增强拆成可插拔阶段,并且------默认不允许上下文为空,为空时直接指示模型不要回答 ,要放开得显式写 .allowEmptyContext(true)。
这一条我认为是全篇最值得抄回家的一句:"检索结果为空时怎么办",框架已经把它当成默认行为实现了,而手写版把它写成了一个 NPE。
Q3:手写模板是不是"低级写法",为什么不直接上 Advisor?
不是低级,是分工不同,而且分界线相当清晰:
| 你的场景 | 建议路线 |
|---|---|
| 教学 / Demo,要让团队看清 RAG 每一步做了什么 | 手写(本课程的选择,我认为它选对了) |
| 需要严格控制喂给模型的每个字节(成本核算、合规审计、私有化模板评审) | 手写 ,但必须换成 PromptTemplate 并补上单测 |
| 检索参数(topK / 阈值 / 元数据过滤)要和对话链路一起演进 | QuestionAnswerAdvisor |
| 需要多路组合:会话记忆 + 检索 + 改写 + rerank,且要可插拔 | RetrievalAugmentationAdvisor (spring-ai-rag) |
| 多人协作、多个业务方共用同一套检索策略 | 必须 Advisor ,否则每个人都会手写一遍 replace,每一遍都漏一遍边界 |
笔者(后端 & 架构)自己的取舍是这样:
text
能讲清楚一次 RAG 的每一行 → 手写(学习阶段,一次性成本)
要让 20 个人写出的 RAG 一致 → Advisor(工程阶段,消灭人祸)
说白了:手写的代价不是那 30 行代码,是那 30 行代码没有默认兜底。 NPE、阈值失控、模板被内容污染,每一个都是"框架本来会帮你挡掉"的。
6. 版本线:工程锁 1.0.0,官方已经走到哪了
这门课的 RAG 代码是 1.0.0 时代的产物,读它之前得先知道版本落差有多大------不然很容易把已经改名的坐标抄进新项目。
本课程 mcp-client/pom.xml 锁的是 spring-ai-bom 1.0.0 (dependencyManagement + import,Boot 是 3.5.0)。Spring AI 官方仓库的 releases(GitHub API,核对于 2026-09-29):
| Tag | 发布日期 | 怎么理解 |
|---|---|---|
v1.0.9 |
2026-06-12 | 1.0.x 维护线仍在发补丁版 |
v1.1.8 |
2026-06-12 | 1.1.x 线同样在维护 |
v2.0.0 |
2026-06-12 | 2.0 GA |
v2.0.1 |
2026-08-21 | 当前 2.0.x 最新补丁 |
v2.1.0-M1 |
2026-09-25 | Milestone,未 GA(M 段的惯例语义,非官方定义) |
也就是说:工程用的 1.0.0 是 1.0 线的第一个正式版,而 1.0 线自己已经修到 1.0.9,主线走到 2.0.1。落差不是"差一个小版本"。
Q4:要不要跟到 2.0.1?
| 你的处境 | 建议 |
|---|---|
| 只是学完这套课、跑通 demo | 不动。锁 1.0.0 就能和讲义逐行对上,升版本换来的收益是零,换来的排查成本是实打实的 |
| 拿它的骨架做团队内部工具 | 升到 1.0.x 最新补丁(同主版本、修 bug),API 面基本不变 |
| 新项目、要长期维护 | 别从这套课程起步,直接用 2.0.1,并把第 5 节那张"手写 vs Advisor"表按 Advisor 路线做默认设计 |
Q5:真要升级,最容易踩的是什么?
我按"文档页面字面差异 + Maven Central 坐标实测"给三条:
| 关注点 | 1.0 线文档 | 2.0.1 线文档 | 提示 |
|---|---|---|---|
| Advisor 坐标 | spring-ai-advisors-vector-store |
spring-ai-vector-store-advisor |
改名是真的,不是排版差异 :老坐标 从 1.0.0-M7 一路发到 1.1.8,最后一版停在 2.0.0-M8 ;新坐标 起始就是 2.0.0-RC1,其后 2.0.0 / 2.0.1 / 2.1.0-M1。两个区间完全互补,切换点就卡在 2.0.0-RC1 |
| 启用 embedding 的方式 | 有 spring.ai.embedding.transformer.* 一组配置 |
文档标注 ...enabled 已移除,改用 spring.ai.model.embedding=transformers |
这就是"yml 零配置"最脆弱的位置:当前靠默认值跑通,默认值本身是会随版本变的 |
| Redis 索引初始化 | initialize-schema 默认 false,且标注为 breaking change |
同 | 1.0 之前是默认建的------网上老教程少写这一行的概率很高 |
Releases 页面在这里比任何二手总结都好用:跨版本升级前,先看目标 tag 的 release notes 里有没有 "breaking change" 字样,再对一次 参考文档的对应版本目录。这门课的工程快照恰好卡在一个信息量很大的位置:所有配置项都是"最新文档里已经改名或已改默认值"的那一批。
7. 如果是我,这条链路怎么改
按"改动成本 / 收益"排的一张表,前三条不需要引入任何新依赖,也不会改变现有接口。
| 优先级 | 现状 | 风险 | 改法 | 成本 |
|---|---|---|---|---|
| P0 | 空检索 → context 为 null → NPE |
冷启动 / 清库 / 索引改名后必现,前端只表现为"没反应" | 空集合提前返回 + 明确话术;同时补一个 @RestControllerAdvice |
半天 |
| P0 | similaritySearch(question) 走默认 topK=4、阈值 0.0 |
噪声必进上下文,"不知道"全靠模型自觉 | 显式 SearchRequest.builder().topK(k).similarityThreshold(t),阈值先给 0.5 起步再调 |
半天 |
| P1 | String.replace 拼模板 |
文档内容可携带模板字面量 → 提示词结构可被污染 | 换 PromptTemplate;模板变量与用户内容严格分离 |
1 天 |
| P1 | CustomTextSplitter 按空行、无长度上限 |
无空行文档=不切分;长段落整段入库 | 改回 TokenTextSplitter 并显式写参数;或空行切完后再按长度二次切 |
1 天 |
| P1 | metadata 只写不读 | 答案无来源、无法按范围过滤 | 检索后把 fileName 一并返回;入库补 docId + 内容 hash |
1~2 天 |
| P2 | 只支持 .txt,Tika 依赖空转 |
用户以为能传 PDF(因为 pom 里有"解析文档"的注释) | 二选一 :真上 TikaDocumentReader 并放开前端 accept;或把 Tika 依赖删掉,别留幻觉 |
上 Tika 约 2 天 |
| P2 | 手写检索问答,Advisor 依赖空转 | 团队多人写 RAG 会写出多份不一致的手写版 | 引入 QuestionAnswerAdvisor,把手写版保留为 /rag/doSearch 这种诊断接口 |
2 天 |
| P2 | 只进不出,无删除/幂等 | 重复上传=重复向量,召回被副本污染 | 按 fileName 先删后插,或提供 /rag/deleteByFile |
1 天 |
如果只能做一件事,我会做 P0 的第二条 :把 topK 和阈值显式写出来。原因是它同时决定了这个知识库"看起来准不准"的上限,而且它是一行代码------在你换切分器、换嵌入模型、换提示词之前,先让自己知道每次到底喂了几条、多相关的东西进模型。 大量"模型答得不行"的抱怨,第一步排查就该停在这里。
最后总结
- 这套课程的 RAG 是一条完整但手写的链路 :
TextReader读文本 →CustomTextSplitter(正则\s*\R\s*\R\s*,按空行切)→redisVectorStore.add(list)→similaritySearch(question)→ 手写{context}/{question}模板 → 云端模型生成。入库与检索两条线只共用向量库,不共用代码。 /rag/search不返回答案 :答案沿 SSE 长连接按userId推。这个设计让流式体验和对话链路复用,代价是接口没有业务返回值,服务端异常表现为"前端永远等不到"。- Redis 必须是 redis-stack :向量检索能力在 Redis Query Engine 侧,不在 Spring AI 侧。
initialize-schema默认是false(官方标注的 breaking change),index-name默认spring-ai-index,prefix默认embedding:------后两个配了覆盖值,第一个必须自己写。 - 端口以 9379 为准 :讲义 6-3 的
8379与它自己的 6-2-1、以及代码里的 yml 都不一致,属笔误;-p 9379:6379要换算清楚。另一处不一致是docker-compose.yml起的 Redis 没有REDIS_ARGS,和带password的客户端对不上。 - 嵌入模型是"零配置" :
spring-ai-starter-model-transformers一行依赖、yml 里一个配置项都没有。384 维与 all-MiniLM-L6-v2 是官方文档默认值,不是本工程的证据;换嵌入模型必须重建索引并全量重灌。 - "引而未用"是这篇最该被带走的事实 :Tika 与向量 Advisor 都进了 pom,代码一个没用,前端只允许
.txt------三者自洽,说明这是选择不是遗漏。框架的 Advisor 路线把similaritySearch+ 模板渲染 + 空上下文策略做成了默认行为,而手写版恰好把空上下文写成了 NPE。 - 什么时候该换 Advisor :当检索参数需要和对话链路一起演进、当多种增强策略要可插拔、当团队里会有第二个人写 RAG 的时候。学习阶段手写一次很值,生产阶段手写第二遍不值。
- 对于后端 / 架构岗,我个人只看这三处:检索参数是不是显式的(而不是靠默认 topK=4)、空结果有没有分支(而不是靠模型自觉说"不知道")、答案能不能溯源(而不是把 metadata 写进去又丢掉)。
下一篇讲这套课程里另一条独立链路:用 SearXNG 做联网实时检索。它会让你看到"手写模板"这套做法复制过去之后,又多了哪两处不一样的坑。
事实说明 :本篇所有类名、方法名、配置项与依赖坐标均来自 2026-09-29 对课程工程与讲义的一次实读 (凭据以
<REDACTED>/sk-<REDACTED>形式脱敏,未复现任何 key、口令、内网地址)。讲义与代码相互矛盾之处 已在正文标注并以代码为准:Redis 端口(讲义 6-3 写8379,6-2-1 与application.yml为9379)、Redis 密码(6-2-2 compose 无REDIS_ARGS,与带 password 的 yml 不一致)。以下五处未核实,不作断言 :① 本工程实际使用的向量维度与模型标识------all-MiniLM-L6-v2与 384 维取自 Spring AI 官方参考文档的默认值说明,仓库与讲义中均无声明;② 1.0.0 下 ONNX 资源是随 jar 分发还是首次运行拉取(文档的cache.directory表述出现在更高版本页面);③initialize-schema校验的确切触发时机(启动期还是首次调用),正文只给"看启动日志"的中性建议;④ Redis 服务端未设requirepass而客户端带 AUTH 的确切报错文案;⑤ 后续讲义是否存在改用 Tika +QuestionAnswerAdvisor的版本,仓库快照里看不到。已核实并据此改写正文的两条 :Advisor 坐标改名(spring-ai-advisors-vector-store末版 2.0.0-M8 与spring-ai-vector-store-advisor起始 2.0.0-RC1,取自 Maven Centralmaven-metadata.xml,2026-09-29);redis/redis-stack已被官方劝退、向量检索并入 Redis 主线(README 原文 "New users should use Redis instead of Redis Stack",及 redis/redis 最新8.10.2README 把 Search and Query Engine / vector search 列为自带能力)。文中"本地嵌入 + 云端生成的选型优劣""默认小模型中文召回一般""NPE 最易在冷启动/清库路径触发""Advisor 与手写的分工线""改造清单与优先级"均为笔者工程判断,非官方表述。Spring AI 各 tag 的发布日期来自核对到 2026-09-29 的 GitHub Releases 数据。
参考资料 & 致谢
1 Spring AI 项目主页 / Spring AI 参考文档首页
3 Spring AI Reference 1.0:Vector Stores(SearchRequest 默认 topK=4、相似度阈值默认 0.0)
5 Spring AI Reference 当前版(2.0.1):同主题页,Advisor 坐标写作 spring-ai-vector-store-advisor
7 Spring AI GitHub Releases(v1.0.9 / v1.1.8 / v2.0.0 / v2.0.1 / v2.1.0-M1,核对于 2026-09-29) / Maven Central 坐标改名依据:spring-ai-advisors-vector-store(末版 2.0.0-M8)与 spring-ai-vector-store-advisor(起始 2.0.0-RC1)
8 Redis:Search and Query(向量检索所需的模块能力) / Redis 官网
9 Docker Hub:redis/redis-stack 镜像 / redis-stack 仓库 README("New users should use Redis instead of Redis Stack") / redis/redis 主发行版 README(最新 8.10.2,Search and Query / vector search 已是自带能力) / Docker 官网
10 Apache Tika 官网(课程 pom 引入但代码未使用的多格式解析能力)
11 Hugging Face:sentence-transformers/all-MiniLM-L6-v2
12 DeepSeek 官网(本工程对话端点的 .env 指向其 OpenAI 兼容 API,key 已脱敏)/ 阿里云百炼控制台(备用 .env_qwen 的 compatible-mode 端点)
13 SearXNG 文档站(下一篇主题,本篇仅预告)
14 Spring Boot 项目主页(工程父 POM:spring-boot-starter-parent 3.5.0 / JDK 21)
感谢课程讲义与工程快照的原作者(按惯例不署名)提供了可逐行核对的实现;也感谢把 GitHub Releases 逐条核对到 2026-09-29 的那次 API 查询,第 6 节的版本表完全来自它。