RAG 检索增强:Spring AI 文档管线、向量检索、ACL 与取消
前面的 Spring 课程把容器、事务、MVC 与 Web 客户端拆开讲清;这一页把 Spring 生态里另一类应用搬上来:让模型基于自己的知识库回答问题的检索增强生成(RAG)。本页回答四个工程问题——文档怎样从文件变成可检索的块;向量检索在真实 Elasticsearch 上怎样执行、过滤权限;检索结果怎样进入提示词并生成可核查的引用;流式输出被取消时,哪些工作停了、哪些停不下来。
本页是 U82(#119)的 D-AI/P08 权威正文,以 Spring AI 2.0.2 为贯通实现,LangChain4j 1.22.0 只做有源码依据的 API 对照。可运行结论来自 labs/spring-ai-rag 两个模块的 27 个测试:rag-lab 在真实 Elasticsearch 8.19.23 上执行文档处理、向量检索、权限过滤、引用与取消;es9-boundary 用真实请求固定"官方 Store 的 9.x 客户端连 8.x 服务端"的媒体类型边界。聊天模型与嵌入模型是本地确定性替身:本实验验证的是编排、检索与过滤行为,不是回答质量;真实模型的引用忠实度、幻觉、延迟与成本归 U84,在拿到外部凭据前如实标受阻。
一条 RAG 请求的生命周期
RAG 的输入是一个用户问题与当前用户身份(租户、权限),输出是"基于检索到的材料"的回答加引用。中间要经过六步:
| 阶段 | 做什么 | 本页对应实现 | 失败会怎样 |
|---|---|---|---|
| 文档处理 | 读取文件、按结构切块、补元数据 | MarkdownDocumentReader + TokenTextSplitter | 块过大截断上下文,块过小丢语义 |
| 嵌入 | 把文本变成向量 | EmbeddingModel(替身/真实模型) | 维度不一致时写入失败 |
| 索引 | 写入向量库并建索引 | VectorStore.add(ES dense_vector) | mapping 不匹配整批写入失败 |
| 检索 | 按相似度与元数据过滤取 topK | similaritySearch + 过滤表达式 | 过滤缺失就是越权泄露 |
| 重排与增强 | 去重、截断、拼装上下文 | QuestionAnswerAdvisor / RetrievalAugmentationAdvisor | 上下文超限或引入噪声 |
| 生成与流式 | 调模型、流式返回、可取消 | ChatClient.stream() | 取消只停下游,已检索内容不回滚 |
一句话主线:检索结果决定模型能看到什么,过滤表达式决定用户能看到什么;两者都是代码,不是提示词里的礼貌请求。
版本、工程与观察入口
固定 Spring AI 2.0.2(Maven Central 当前 GA,查询 2026-10-27;官方 Getting Started 声明 "Spring AI 2.0.x supports Spring Boot 4.0.x and 4.1.x")、Spring Boot 4.0.9、Spring Framework 7.0.10、LangChain4j 1.22.0。Elasticsearch 服务端是本机 DBngin 的 8.19.23(单节点,xpack security 关闭;不启动、停止或修改其配置)。
Elasticsearch 客户端有一个必须讲清的边界。Spring AI 官方 spring-ai-elasticsearch-store 2.0.2 的 POM 编译依赖 elasticsearch-java 9.4.8,Spring Boot 4.0.9 BOM 管理 9.2.9;客户端 9.x 的 rest5 传输默认发送 compatible-with=9 的媒体类型,8.x 服务端直接拒绝:
co.elastic.clients.elasticsearch._types.ElasticsearchException: [es/info] failed:
[media_type_header_exception] Invalid media-type value on headers [Content-Type, Accept]这个失败由 es9-boundary 模块的真实请求固定(U82 es9VsEs8=PASS failure=media_type_header_exception),同一服务端接受普通 HTTP(U82 es8PlainJson=PASS status=200),证明问题在客户端声明的版本头而非服务不可用。因此 rag-lab 按"服务端同线"原则显式钉 elasticsearch-java 8.19.23,并用官方 8.19.x 低层 RestClient 实现同一 VectorStore 契约;这不是否定官方实现,而是"客户端跨线不受支持"这一事实的运行证据。服务端升到 9.x 时换回官方 Store 并重跑,属于新锁定动作。
公开入口在仓库根目录:
python3 labs/spring-ai-rag/run.py入口执行两个模块的 clean test、核对六份 Surefire 报告(合计 27)、校验 27 条 U82 观察行、确认本实验自建的 u82-* 索引全部清理,并把环境、依赖版本、源文件 SHA-256 写入 labs/spring-ai-rag/evidence/u82-2026-10-27.json。模型替身不访问网络,Elasticsearch 只写本实验索引;不触碰用户索引、不启动或停止 DBngin 服务。
本页框架原码均为固定 tag 的连续片段(spring-projects/spring-ai v2.0.2,langchain4j/langchain4j 1.22.0),元数据含整文件 SHA-256 与行段,由 scripts/check_source_excerpts.py 在线复核。源码、配置、编译产物与实跑观察是四种证据,不能互相替代。
文档处理:读取、切分与元数据
Document 模型与元数据
Spring AI 的 Document 是"文本 + 元数据 + 可选媒体 + 可选分数"的统一载体:嵌入、检索、过滤、引用全部围绕它。元数据不是装饰——过滤表达式只能引用元数据键,引用编号也只能从元数据里的来源字段生成。本实验的每个块以 tenant、acl、doc、title、category 五个键进入索引(U82 docReader=PASS blocks=11 ... metadata=[acl, category, doc, tenant, title])。
Markdown 读取与标题分块
MarkdownDocumentReader 按 Markdown 结构切块:每个标题触发新块,标题文本进入元数据(category=header_N、title),正文段落归并到当前块。MarkdownDocumentReaderConfig 控制横线是否强制分块、代码块与引用块是否采集。本实验关闭代码块采集、开启横线分块,两份合成知识库(公开 FAQ 5 块、内部手册 6 块)得到 11 块,category 实测只有 header_1 与 header_2(U82 docMetadata=PASS categories=[header_1, header_2])。
读到这里要注意一个常见误解:reader 的分块依据是文档结构,不是 token 数。一份没有标题的长文档会被 reader 读成一个大块,随后才由切分器按 token 处理——两层各管一件事。
TokenTextSplitter 的默认值与切分时机
固定源码 TokenTextSplitter.java,176–186 行:
// Only apply punctuation-based truncation if we have more tokens than the
// chunk size
// This prevents unnecessary splitting of small texts
if (tokens.size() > chunkSize) {
// Find the last period or punctuation mark in the chunk
int lastPunctuation = getLastPunctuationIndex(chunkText);
if (lastPunctuation != -1 && lastPunctuation > this.minChunkSizeChars) {
// Truncate the chunk text at the punctuation mark
chunkText = chunkText.substring(0, lastPunctuation + 1);
}默认值都要能背出来:chunkSize=800(token)、minChunkSizeChars=350、minChunkLengthToEmbed=5、maxNumChunks=10000、keepSeparator=true、标点表 ['.', '?', '!', '\n']、编码 CL100K_BASE。两个行为容易踩坑:
- 小块不再被强行切分这一点从 1.1.1 起生效,2.0 延续(1.0–1.1.0 会无条件按标点截断,小块也受影响)。本实验用
chunkSize=20切"退款政策很短。"得到 1 块;切一段 40 余字的中文按。得到 3 块(U82 splitter=PASS shortBlocks=1 longBlocks=3)。 - 默认标点表没有中文句号。中文语料应在 builder 里替换为
。?!;,否则长中文段落只能在 800 token 处硬切,块边界落在句子中间。
切分还会复制元数据到每个子块(U82 splitMetadata=PASS chunks=4),这是 ACL 能随块进入索引的前提;反过来,如果 ACL 只在入库前的整篇文档上设置、切分后才补,就会出现"部分块无标签"的漏洞。
元数据与 ACL 的继承
本实验的权限模型是"文档级 ACL 标签 + 检索时过滤":块按内容分为 public/internal/confidential 三档,检索器按当前用户可见档位拼过滤表达式。标签由入库管线统一写入(教学实现按关键词分级,真实系统应由源系统打标并审计——这是正文后文"越权与失效反例"要展开的弱点)。
向量索引与真实检索
EmbeddingModel 契约与替身边界
EmbeddingModel 的契约很小:embed(String)、embed(Document)、批量 call(EmbeddingRequest)、dimensions()。实验用确定性替身:把文本切成中文字符 bigram 与英文词,做带符号特征哈希到 256 维再 L2 归一化。它的作用域必须说清——能让检索、过滤、topK、引用、取消在真实 ES 上可验证;不能让"语义相似"或"回答质量"可验证。替身的相似度只反映词面重叠,真实嵌入模型的效果评测归 U84。
维度是硬约束:dense_vector 的 dims 在建索引时固定,写入向量维度不符会被 ES 拒绝。本实验在 mapping 中真实读到 dims: 256 与 cosine(U82 esKnn=PASS server=8.19.23 hits=3 topScore=0.405)。
VectorStore SPI 与 8.x 自建实现
VectorStore 契约只有四件事:add、delete(List)、delete(Filter.Expression)、similaritySearch(SearchRequest),另有 getNativeClient()(类型擦除,运行时无法校验)与观测/批处理 builder 钩子。官方的 ES 实现与本实验自建实现的差别集中在一处:阈值在哪里过滤。本实验自建实现把阈值放在客户端按归一化余弦分数过滤(与 SimpleVectorStore 语义一致),官方实现把阈值下推为 kNN 的 similarity 参数。对于"阈值只淘汰尾部低分块"的常见情形,两者结果一致;但排名靠前的块不过阈值时,服务端下推会在 numCandidates 内继续下探,客户端过滤做不到同样的下探——本实验没有做两条路径的等价性验证,这是教学实现与官方实现的已知差异,不是等价替换。
SimpleVectorStore 的语义
SimpleVectorStore 是理解阈值语义的最好参照。固定源码 SimpleVectorStore.java,152–160 行:
return this.store.values()
.stream()
.filter(document -> doFilterPredicate(request.getFilterExpression()).test(document))
.map(content -> content
.toDocument(EmbeddingMath.cosineSimilarity(userQueryEmbedding, content.getEmbedding())))
.filter(document -> document.getScore() != null && document.getScore() >= request.getSimilarityThreshold())
.sorted(Comparator.comparing(Document::getScore).reversed())
.limit(request.getTopK())
.toList();顺序是"先过滤元数据 → 再算余弦 → 再按阈值筛 → 排序 → 截断 topK"。这个顺序有两个后果:过滤在相似度计算之前(省算力),阈值是命中下限而非质量保证(0.0 表示全部接受),topK 在阈值之后截断(所以阈值高时可能返回少于 topK 条)。官方在类注释里明确标注它"not designed for production use",只适合测试与演示。
Elasticsearch dense_vector 与 kNN
官方 ES Store 的检索是 kNN:固定源码 ElasticsearchVectorStore.java,248–269 行:
public List<Document> doSimilaritySearch(SearchRequest searchRequest) {
Assert.notNull(searchRequest, "The search request must not be null.");
try {
float threshold = (float) searchRequest.getSimilarityThreshold();
// reverting l2_norm distance to its original value
if (this.options.getSimilarity().equals(SimilarityFunction.l2_norm)) {
threshold = 1 - threshold;
}
final float finalThreshold = threshold;
float[] vectors = this.embeddingModel.embed(searchRequest.getQuery());
SearchResponse<ObjectNode> res = this.elasticsearchClient.search(sr -> sr.index(this.options.getIndexName())
.knn(knn -> knn.queryVector(EmbeddingUtils.toList(vectors))
.similarity(finalThreshold)
.k(searchRequest.getTopK())
.field(this.options.getEmbeddingFieldName())
.numCandidates((int) (1.5 * searchRequest.getTopK()))
.filter(fl -> fl
.queryString(qs -> qs.query(getElasticsearchQueryString(searchRequest.getFilterExpression())))))
.size(searchRequest.getTopK()), ObjectNode.class);
return res.hits().hits().stream().map(this::toDocument).toList();kNN 候选数与过滤下推
四个要点:
numCandidates = 1.5 × topK:ES 先按近似最近邻取候选,再精排取 k。候选数太小会漏掉真邻居,太大增加延迟——这是召回率与延迟的旋钮,不是越大越好。- 过滤放在 kNN 的
filter里(下推),越权文档在候选阶段就被排除,而不是取回后再丢弃。 - 相似度
cosine建索引;返回分数是 ES 的(1 + cos)/2,官方再换算回2s − 1的余弦区间,并同时写入distance = 1 − 分数。 initialize-schema默认为false(相比旧版本是破坏性变更):索引不存在且未开启初始化时,afterPropertiesSet直接抛Index not found。官方 Store 需显式initializeSchema(true)才会建索引;本实验的自建 8.x 实现在构造时直接创建等价的 mapping,并把metadata.acl、metadata.tenant等字段建模为keyword——如果交给动态映射,字符串会变成text加分词,acl == 'public'的过滤语义就不再是精确匹配。
topK、阈值与候选数
三个参数各管一件事:topK 管"最多给模型几块",阈值管"多不相关就不要",上下文的 token 预算同时约束两者。实验的真实观察:同一问题 topK=4 返回 4 块、topK=1 返回 1 块;把阈值设到高于最高分(0.411)后返回 0 块而不是放宽命中(U82 topKThreshold=PASS topK4=4 topK1=1 aboveTop=0 highest=0.411)。后面这一点是拒答路径的基础:检索为空时,增强器应该明确告诉模型"没有材料",而不是让它自由发挥。
过滤、ACL 与越权反例
可移植过滤 DSL
Spring AI 的过滤是可移植 DSL:字符串形式 tenant == 'tenant-a' && (acl == 'public' || acl == 'internal'),或 FilterExpressionBuilder 的 eq/and/in 构造等价表达式树。ES 侧由官方转换器翻译成 query_string,固定源码 ElasticsearchAiSearchFilterExpressionConverter.java,101–106 行:
@Override
public void doKey(Key key, StringBuilder context) {
var fieldPath = "metadata." + key.key().trim();
emitLuceneString(fieldPath, context);
context.append(':');
}关键结构是 metadata.<key>: 前缀——过滤字段固定落在元数据命名空间。这意味着两件事:文档必须以元数据形式携带权限标签;索引必须把该字段建为精确匹配类型(本实验显式 keyword),否则 query_string 会走进分词匹配,acl == 'public' 可能命中 not-public。
检索期 ACL 与租户隔离
RetrievalAugmentationAdvisor 的检索器支持动态过滤,固定源码 VectorStoreDocumentRetriever.java,110–121 行:
private Filter.Expression computeRequestFilterExpression(Query query) {
var contextFilterExpression = query.context().get(FILTER_EXPRESSION);
if (contextFilterExpression != null) {
if (contextFilterExpression instanceof Filter.Expression) {
return (Filter.Expression) contextFilterExpression;
}
else if (StringUtils.hasText(contextFilterExpression.toString())) {
return new FilterExpressionTextParser().parse(contextFilterExpression.toString());
}
}
return this.filterExpression.get();
}请求级过滤优先于检索器默认过滤:这是把"当前用户可见什么"从静态配置变成每请求计算的接入点。真实的授权系统应在这里注入由身份服务算出的表达式(或干脆在检索前做文档级鉴权),而不是让上层业务代码记得传参。本实验用 KnowledgeBase.tenantAclFilter(tenant, level) 演示:租户相等 + ACL 三档之一。
实验观察(真实 ES,阈值 0,topK 10):
internal用户查"员工薪酬信息谁能看到":命中 10 条,没有任何 confidential 块;confidential用户同一问题能命中薪酬块(U82 aclFilter=PASS internalHits=10 confidentialHits=10)。public用户查审批流程:只命中 5 条 public 块(U82 aclPublicOnly=PASS hits=5)。tenant-b用户查退款政策:0 条;tenant-a用户 5 条(U82 tenantIsolation=PASS crossTenantHits=0 ownHits=5)。
过滤是"检索期约束",它把"当前用户可见什么"收敛到检索器/advisor 的单一注入点,降低各调用方漏传过滤的概率(漏传时框架不会替你补),不防以下情况:标签本身打错(薪酬段没被识别)、元数据被写入路径篡改、或者调用方绕过检索器直接 similaritySearch 不带过滤。所以正文的结论要保守:ACL 过滤是必要不充分条件,授权必须另有唯一的强制点。
越权与失效反例
三个反例分别处于不同证据等级:反例 2 有实跑测试;反例 1 由过滤命中数与提示词检查间接支撑(没有单独跑"完全不带过滤"的检索);反例 3 是机制推演,本实验索引始终显式 keyword,未复现动态映射。
- 没有过滤 = 全量泄露。同一问题、同一 topK,不带过滤时薪酬块会进入 topK 结果,并随
QuestionAnswerAdvisor进入提示词。实验用public与internal两条都带过滤的路径验证了提示词里"有没有机密内容"(U82 aclLeak=publishBlocked),"无过滤会泄露"由同一份语料上 confidential 块的命中能力间接支撑,本实验未单独复现无过滤检索。 - 过滤写错但没报错。把
acl == 'public'写成acl = 'public'必须在解析期抛异常;本实验断言非法表达式与括号不配对都快速失败(U82 aclMalformed=FAIL-FAST parserRejects=true)。如果框架选择"解析失败就退化为无过滤",这就是一个静默越权洞。 - 动态映射把 keyword 变 text。不显式建模元数据字段时,
acl会被动态映射为text并分词;acl == 'internal'可能匹配到包含该词的其他字段内容。这类问题在单元测试里看不见,必须在真实索引 mapping 上核对。
按过滤删除与快速失败
delete(Filter.Expression) 与查询共用同一套表达式方言,用途是文档版本切换、租户下线与错误标签清理。实验按 acl == 'internal' 删除后剩 6 块(5 条 public + 1 条 confidential),未匹配的块保留(U82 deleteByFilter=PASS remaining=6)。两个运维注意:ES 的 delete-by-query 是"按查询删除",大索引上代价高且不可回滚;删除与重建之间要有版本化索引(别名切换)而不是原地删改。
检索重排、引用与上下文预算
QuestionAnswerAdvisor 的提示词与上下文
QuestionAnswerAdvisor 是最短路径的 RAG:以用户消息为查询做一次相似检索,把文档拼进内置模板。固定源码 QuestionAnswerAdvisor.java,61–73 行是默认模板,109–121 行是检索与拼装:
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
{query}
Context information is below, surrounded by ---------------------
---------------------
{question_answer_context}
---------------------
Given the context and provided history information and not prior knowledge,
reply to the user comment. If the answer is not in the context, inform
the user that you can't answer the question.
"""); public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
// 1. Search for similar documents in the vector store.
var searchRequestBuilder = SearchRequest.from(this.searchRequest)
.query(Objects.requireNonNullElse(chatClientRequest.prompt().getUserMessage().getText(), ""));
var filterExpr = doGetFilterExpression(chatClientRequest.context());
if (filterExpr != null) {
searchRequestBuilder.filterExpression(filterExpr);
}
var searchRequestToUse = searchRequestBuilder.build();
List<Document> documents = this.vectorStore.similaritySearch(searchRequestToUse);三个工程含义:
- 检索发生在 advisor 的
before阶段,也就是模型调用之前;检索文档同时进入响应上下文(qa_retrieved_documents),这是引用与审计的数据来源。 - 过滤器有请求级覆盖(
qa_filter_expression),与上一节检索器的动态过滤同构;两条路径都支持"按用户算表达式"。 - 模板里检索内容与问题分离:文档放在分隔线区间内,模板明确要求"只依据上下文,不知道就说不知道"。这是提示层缓解注入的削弱措施,不是安全边界——文档里的恶意指令仍可能影响模型,真正的工具越权防线归 U83。
RetrievalAugmentationAdvisor 的模块化组装
RetrievalAugmentationAdvisor 是 Modular RAG:检索器(可动态过滤)、文档合并器、重排器、查询增强器(含空上下文模板)分开组装。本实验的组合是:VectorStoreDocumentRetriever(阈值 0.2、topK 6、动态 ACL 过滤)→ ScoreAndDiversityPostProcessor(按分数去重、每篇最多 2 块、总 4 块)→ ContextualQueryAugmenter(不允许空上下文)。真实观察:internal 用户查薪酬,检索 2 块、每篇最多 1 块、引用 2 条(U82 ragAdvisor=PASS retrieved=2 maxPerDocument=1 citations=2)。
两条 advisor 的选择不是风格问题:单轮问答用 QuestionAnswerAdvisor 足够;需要查询改写、多查询扩展、重排或空上下文策略时用 RetrievalAugmentationAdvisor,代价是多一层组装与更多可配置点。
重排与上下文预算
重排发生在检索之后、拼装提示词之前,因此它能改变送进模型的上下文而不改变检索召回。本实验的重排是确定性的:按分数降序 + 每篇文档配额。它能解决的问题是同一文档多个块挤占 topK(真实观察里 QuestionAnswerAdvisor 取 4 块,来自同一手册的块占了 3 个名额),不能解决"语义上不相关但词面重叠"的伪命中——那需要 cross-encoder 之类的重排模型,本实验未运行,如实标注。
上下文预算是成本与正确性的交点:每块都要付 token 费用,块太多会挤掉真正相关的材料,甚至触发上下文超限。可计算的约束是"块数 × 平均块 token ≤ 模型上下文预算 − 提示与回答预留",本实验的 maxTotal=4 与 chunkSize=800 就是这组约束的教学取值,不是生产最优。
引用来自检索结果
引用必须来自检索到的文档元数据,而不是模型自称的来源。本实验的引用在 RagAssistant.citations 中由检索列表生成:[kb/platform-handbook.md#1] 这样的格式含来源文件与序号(U82 qaAdvisor=PASS retrieved=4 citations=[[kb/platform-handbook.md#1], ...])。这条规则的意义在于可核查性:用户点开引用应该能落在具体文档,模型文本里出现的"根据文档"不算证据。
引用忠实度(模型是否真的按引用内容回答)无法由本实验验证——它需要真实模型与人工/自动评测,归 U84。
空上下文与拒答
检索为空时的默认行为在 ContextualQueryAugmenter:固定源码 ContextualQueryAugmenter.java,72–77 行:
private static final PromptTemplate DEFAULT_EMPTY_CONTEXT_PROMPT_TEMPLATE = new PromptTemplate("""
The user query is outside your knowledge base.
Politely inform the user that you can't answer it.
""");
private static final boolean DEFAULT_ALLOW_EMPTY_CONTEXT = false;allowEmptyContext=false(默认)时,检索为空会把用户查询整个替换成"知识库外,礼貌拒答"的模板,模型看不到原始问题。本实验验证了越界问题走拒答模板(U82 emptyContext=PASS prompt=refusal-template)。但要诚实:这是提示约束,模型仍可能拒绝执行模板;把"拒答率"写进验收需要真实模型评测(U84)。allowEmptyContext=true 则是另一种产品策略:让模型用自己的先验回答并注明无来源——适合客服 FAQ,不适合合规场景。
流式输出与取消
惰性执行与 Flux 契约
ChatClient.stream().content() 返回 Flux<String>。两个契约点:call()/stream() 本身不触发模型,终点方法(content()、chatResponse()、collectList())订阅时才执行;流式只支持响应式栈,命令式应用也要引入 WebFlux。实验的完整流得到 11 个元素(10 个 token + 结束标记,U82 streamComplete=PASS tokens=11)。
ChatModel 的默认 stream 直接抛"不支持流式",固定源码 ChatModel.java,64–66 行:
default Flux<ChatResponse> stream(Prompt prompt) {
throw new UnsupportedOperationException("streaming is not supported");
}这意味着"支持流式"是模型实现的能力,不是接口的保证;自建客户端要知道自己走的是哪条路径。
take、dispose 与 timeout 的取消语义
Reactor 的取消是协作式、向下游发起、向上游传播的:订阅者取消后,上游收到 cancel 信号,生产者停止发射。实验用带间隔的脚本模型验证了三种取消:
| 取消方式 | 观察 |
|---|---|
take(3) | 只消费 3 个 token,上游 doOnCancel 计数 1,总共只发射 3 个 token(U82 streamCancel=PASS consumed=3 emitted=3 cancelled=1) |
Disposable.dispose() | dispose 后不再收到新 token,上游取消计数 1(U82 dispose=PASS received=3 cancelled=1 emitted=3) |
Flux.timeout | 首包到达后上游停顿超过窗口,抛 TimeoutException,订阅终止(U82 streamTimeout=PASS error=TimeoutException) |
有一个容易误判的语义:Flux.timeout 是元素间超时,每收到一个元素就重置计时。timeout(Duration) 同时对首个元素与后续元素计时,首包不来同样会超时;fallback 版本用于超时后的降级取值,不是首次超时的替代。本实验用"首包很快、后续卡住"的形态观察到了已接收部分输出后的超时。
取消的边界同样重要:取消只停止调用方等待与后续 token,不回滚已经完成的工作。RAG 的检索在流开始前已经执行,取消不会撤销检索,也不会撤消费用;真实模型侧是否停止计费、HTTP 流是否优雅关闭,取决于 provider 与客户端,本实验未接真实模型,这部分归 U84。实验验证了取消不会触发重复检索或重复模型调用(U82 ragStreamOnce=PASS invocations=1)。
维护状态与 LangChain4j 对照
版本与支持边界
Spring AI 的版本选择不是"越新越好":
| 版本线 | 状态(2026-10-27) | 支持声明 | 本仓库的用法 |
|---|---|---|---|
| 2.0.2 | 当前 GA | 官方声明支持 Boot 4.0.x / 4.1.x | 本页主实现,与 Boot 4.0.9 同组合 |
| 1.1.8 | 上一稳定线 | 文档声明支持 Boot 3.4.x / 3.5.x | 不用于 Boot 4.0.9;升级要整线迁移并复测 |
| 2.1.0-RC1 | 预览 | — | 不纳入教学基线(不默认 RC) |
同时要区分"框架版本"与"客户端版本":Spring AI 2.0.2 的 ES Store 依赖 elasticsearch-java 9.x,而服务端是 8.19.23 时客户端跨线不可用——版本锁定的正确单位是组合(框架 + 客户端 + 服务端),不是单个 jar。Elasticsearch 客户端的 compatible-with 机制问的是"按哪个大版本的 API 语义处理请求",8.x 服务端不接受声明为 9 的请求头;这不是 bug,是官方兼容策略。
LangChain4j 的对应抽象
LangChain4j 1.22.0 是与 Spring AI 平行的 Java AI 框架(最低 JDK 17;其 Spring Boot 集成按 Boot 3.5+/4.0+ 分成两套 starter)。它有对应但不同名、不同形的抽象:
| 概念 | Spring AI 2.0.2 | LangChain4j 1.22.0 |
|---|---|---|
| 文档片段 | Document(文本 + 元数据 + 分数) | TextSegment + Metadata |
| 向量库 | VectorStore | EmbeddingStore<TextSegment> |
| 过滤器 | 字符串 DSL / Filter.Expression 树 | Filter 对象树 + metadataKey(...) builder |
| 检索器 | VectorStoreDocumentRetriever(默认 topK 4) | EmbeddingStoreContentRetriever(默认 maxResults 3) |
| 增强器 | Advisor | RetrievalAugmentor |
两组默认值都写在源码里,可直接核对:Spring AI 的 SearchRequest.DEFAULT_TOP_K = 4;LangChain4j 的 DEFAULT_MAX_RESULTS = (query) -> 3,固定源码 EmbeddingStoreContentRetriever.java,70–71 行:
public static final Function<Query, Integer> DEFAULT_MAX_RESULTS = (query) -> 3;
public static final Function<Query, Double> DEFAULT_MIN_SCORE = (query) -> 0.0;LangChain4j 的过滤器是对象树,MetadataFilterBuilder.metadataKey("acl").isEqualTo("public") 可再用 and/or 组合;FilterParser 另有 SQL 风格字符串,但与 Spring AI 的表达式语法不是同一套。
不假称同一抽象
同一语料、同一嵌入配方下,两个框架的最近邻一致:同一问题 top1 命中同一块,Spring AI 分数 0.405、LangChain4j 0.7024(U82 lc4jParity=PASS sameTop=true)。分数不同不代表语义不同:LangChain4j 的 RelevanceScore 把余弦映射到 [0,1],(0.405+1)/2 = 0.7025 正是同一余弦的另一种归一化。同样,同一 ACL 过滤语义下两框架命中数一致(10 条,U82 lc4jFilter=PASS springHits=10 lc4jHits=10)。
这个对照能给出的结论只有一句:抽象可以对应,行为不能假设等价。默认参数、分数区间、过滤语法、空上下文策略、流式取消的传播路径各不相同,迁移时必须逐项复测。本对照使用 LangChain4j 官方 InMemoryEmbeddingStore;其 Elasticsearch 模块仍是 1.22.0-beta32 且依赖 elasticsearch-java 9.3.1,与本机 8.19.23 服务端同样跨线,未运行并如实标注。
观测、失败与恢复
失败分类与恢复
本页覆盖的失败按层分类,恢复路径各不相同:
| 失败 | 现象 | 根因 | 恢复 |
|---|---|---|---|
| ES 不可达 | 连接异常,BeforeAll 失败 | 服务未启动/端口错误 | 启动服务或覆盖 U82_ES_URI;应用应降级为"知识库暂不可用"而不是无来源回答 |
| 索引不存在 | Index not found | initialize-schema=false 且未预建 | 显式初始化或由运维建索引;生产用版本化索引 + 别名 |
| 9.x 客户端 × 8.x 服务端 | media_type_header_exception | 客户端声明 compatible-with=9 | 客户端与服务端同线;升级服务端或降客户端 |
| 非法过滤表达式 | 解析异常 | 语法错误/括号不配对 | 快速失败并记安全事件;禁止退化为无过滤 |
| 检索为空 | 空列表 | 阈值过高/无相关内容/过滤过严 | 拒答模板或放宽阈值(需产品决策);不得静默改用模型先验 |
| 流式卡住 | 下游长时间无 token | 上游阻塞/网络挂起 | timeout + 取消 + 重试策略;已计费 token 不可回收 |
恢复责任要分清:索引与客户端版本是部署责任,过滤表达式是应用责任,拒答策略是产品与合规责任。
观测钩子与未运行项
Spring AI 的向量库实现继承 AbstractObservationVectorStore,检索与写入会产出 Micrometer observation(维度、集合名、相似度度量);ChatClient 的调用与 advisor 也有观测钩子。本实验只在源码层面定位这些钩子,Micrometer/Prometheus 实际导出、指标口径与告警规则未运行——这部分需要完整观测栈,本票不宣称。
可观测的替代最小集:检索命中数与分数分布(每次请求记录 topK 实际返回数、最高分)、过滤表达式原文与命中量、空上下文比例、取消率与超时率。这些计数不需要额外基础设施,能先回答"检索是不是在工作"。
实验读法
python3 labs/spring-ai-rag/run.pyrag-lab五个测试类:文档管线(4)、ES 检索与 ACL(7)、RAG 编排(4)、流式取消(5)、LangChain4j 对照(4)。es9-boundary一个测试类(3):官方 9.x 传输边界与反向对照。- 证据在运行证据:退出码、每份报告计数、27 条观察行、依赖版本、源文件哈希。
- 索引
u82-*由测试与入口双重清理;运行前如发现遗留索引,先清理再跑。
测试里的断言优先看"送进模型的提示词"与"响应上下文里的检索文档",而不是只看回答字符串——模型是替身,回答文本没有信息量,编排与上下文才有。
闭卷验收
本页 35 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。