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

玩转 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 Central maven-metadata.xml,2026-09-29);redis/redis-stack 已被官方劝退、向量检索并入 Redis 主线(README 原文 "New users should use Redis instead of Redis Stack",及 redis/redis 最新 8.10.2 README 把 Search and Query Engine / vector search 列为自带能力)。文中"本地嵌入 + 云端生成的选型优劣""默认小模型中文召回一般""NPE 最易在冷启动/清库路径触发""Advisor 与手写的分工线""改造清单与优先级"均为笔者工程判断,非官方表述。Spring AI 各 tag 的发布日期来自核对到 2026-09-29 的 GitHub Releases 数据。

参考资料 & 致谢

1 Spring AI 项目主页 / Spring AI 参考文档首页

2 Spring AI Reference 1.0:Redis Vector Store(Prerequisites 需 Redis Stack、三个配置项默认值、initialize-schema 的 breaking change)

3 Spring AI Reference 1.0:Vector Stores(SearchRequest 默认 topK=4、相似度阈值默认 0.0)

4 Spring AI Reference 1.0:RAG / Advisors(QuestionAnswerAdvisor 坐标与 query/question_answer_context 占位符、RetrievalAugmentationAdvisor 默认拒绝空上下文)

5 Spring AI Reference 当前版(2.0.1):同主题页,Advisor 坐标写作 spring-ai-vector-store-advisor

6 Spring AI Reference:Transformers(ONNX)Embedding Model,默认 all-MiniLM-L6-v2 / 384 维 / spring.ai.embedding.transformer.*

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 节的版本表完全来自它。

相关推荐
今年下半年4 小时前
AI智能体项目实战记录(05):RAG项目实战之向量知识库milvus部署
人工智能·llm·milvus·rag
for_ever_love__5 小时前
检索工具化封装:把 Elasticsearch、SQL、Confluence 包装成 Agent 的统一工具
agent·工具·封装·调用·rag·检索·做成
for_ever_love__5 小时前
Agentic RAG 实战:让 Agent 自己决定怎么检索、检索几次
人工智能·后端·agent·控制·自适应·rag·检索策略
救救孩子把6 小时前
AI面试八股文-12_11-多模态大模型面试题
人工智能·面试·职场和发展·多模态·clip·rag·llave
Light607 小时前
企业自建 AI 智能体,成本差 8 倍还是 46 倍?
java·人工智能·算法·知识图谱·rag·ai agent 上下文·token 成本
Qiang__zi1 天前
windows10+RAGFlow(docker)+Ollama(qwen3:8b, bge-m3)
rag
hey you~2 天前
2026年大模型语音机器人知识库导入与调优全指南:从切分到重排序的7个关键步骤
人工智能·机器人·语音识别·向量检索·rag·知识库导入·知识库调优
东莞市云毅网络有限公司2 天前
监测问句清单去重:停用词表、数字归一与贪心聚类的参数取法
python·数据清洗·rag·企业知识库·文档解析
古法老登瞎折腾2 天前
大数据智能运维实战(二):准备一个可复现的运维实验环境
java·大数据·运维·spring ai·mcp