版本:Spring AI 2.0.1
目标:走通「文档进库 → 检索增强生成」,分清经典 QA Advisor 与 Modular RAG,写出可运行的最小 ingest / 问答代码。
大模型训练语料里没有你的私有手册与工单。RAG 把「能检索的私有片段」在回答前塞进 Prompt,让模型基于材料说话,而不是凭空编。
整条链可以拆成两段:
-
离线 ingest:文档 → 切分 → Embedding → 写入向量库
-
在线问答:问题 → Embedding → 相似检索 → 片段进入 Prompt → Chat
Embedding 负责语义距离,VectorStore 负责存与搜,Advisor 负责把搜到的内容接进 ChatClient。
10.1 文档进库:ETL 三件套
DocumentReader → DocumentTransformer(s) → DocumentWriter / VectorStore
Document 带文本、id、metadata;检索命中后还可能带 score。元数据会进入过滤条件,入库前就要定好 tenantId、source、version 等键。
| 输入类型 | 常见 Reader |
|---|---|
| Markdown | MarkdownDocumentReader |
| HTML / 网页 | JsoupDocumentReader |
PagePdfDocumentReader / ParagraphPdfDocumentReader | |
| Office 杂糅 | TikaDocumentReader |
| JSON / 纯文本 | JsonReader / TextReader |
不要把用户任意 URL 直接丢进 Reader(SSRF、本地文件读取风险)。上传先落到受控存储,再由服务端选 Reader。
最小 ingest(Markdown → 切分 → 写入):
Resource resource = new ClassPathResource("docs/handbook.md");
List<Document> docs = new MarkdownDocumentReader(
resource,
MarkdownDocumentReaderConfig.builder()
.withAdditionalMetadata("tenantId", "acme")
.withAdditionalMetadata("source", "handbook")
.build()
).get();
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(800)
.withMinChunkSizeChars(200)
.build();
List<Document> chunks = splitter.apply(docs);
vectorStore.add(chunks);
切分优先用 TokenTextSplitter.builder();中文语料注意把 。?! 等标点纳入切分规则(按所用 splitter 的配置项)。父文档 metadata 会复制到 chunk。
更新语料时先按条件删旧再写入,避免过期片段残留:
vectorStore.delete(
FilterExpressionBuilder.builder()
.eq("source", "handbook")
.build()
);
vectorStore.add(chunks);
各 VectorStore 实现的 delete / filter API 略有差异,语义都是「按条件清旧再灌新」。
10.2 EmbeddingModel
float[] embed(String) / embed(Document)
EmbeddingResponse call(EmbeddingRequest)
int dimensions()
常见来源:OpenAI、Ollama、Mistral、Google、Vertex、Bedrock、PostgresML、Transformers 等。OpenAI 配置示例:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
embedding:
options:
model: text-embedding-3-small
约束:
-
维度必须与向量索引一致;换 Embedding 模型通常等于重建索引
-
入库与查询必须用同一套 Embedding,不要「入库用 A、查询用 B」
-
Chat 与 Embedding 可以来自不同供应商,但同一语料只能共用一套向量空间
-
大批量 ingest 走批处理,并观察限流与重试
手工调用(调试 / 预热):
EmbeddingResponse response = embeddingModel.call(
new EmbeddingRequest(List.of("订单退款规则是什么?"), null));
float[] vector = response.getResult().getOutput();
int dim = embeddingModel.dimensions();
10.3 VectorStore
VectorStore 同时是 Writer 与 Retriever:
add / delete / similaritySearch(SearchRequest)
List<Document> hits = vectorStore.similaritySearch(
SearchRequest.builder()
.query("如何申请退款?")
.topK(5)
.similarityThreshold(0.75)
.filterExpression("tenantId == 'acme'")
.build());
SearchRequest 常见字段:query、topK、similarityThreshold、filterExpression。
进程内可用 SimpleVectorStore 做演示与单测。生产按已有基础设施选:
| 基础设施 | 倾向 |
|---|---|
| 已有 PostgreSQL | PgVector |
| 已有 Redis | Redis VectorStore |
| 本地实验 | Chroma / Qdrant / SimpleVectorStore |
| 托管省运维 | Pinecone / 云厂商向量能力 |
| 图 + 向量同库 | Neo4j |
PgVector 依赖印象:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
个别实现可能没有 dedicated starter,需直接依赖实现模块。语义缓存用的向量库不是业务知识库,不要混成同一个 Store。
多租户硬规则:
写入:服务端注入 tenantId,不信客户端
检索:filter 必须带同一 tenantId
删除:按租户 + 业务键,禁止无条件全表扫
filter 字符串若拼接了用户输入,字段与取值都要白名单,防止过滤条件被篡改。
10.4 两条 RAG 装配路径
QuestionAnswerAdvisor(经典短路径)
检索 → 注入 Prompt → 对话。适合 FAQ、手册问答。
@Bean
ChatClient ragChatClient(ChatModel chatModel, VectorStore vectorStore) {
return ChatClient.builder(chatModel)
.defaultAdvisors(
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(5)
.similarityThreshold(0.7)
.build())
.build())
.build();
}
String answer = ragChatClient.prompt()
.user("退货几日内可申请?")
.advisors(a -> a.param(
QuestionAnswerAdvisor.FILTER_EXPRESSION,
"tenantId == 'acme'"))
.call()
.content();
FILTER_EXPRESSION 的常量名以依赖版本为准;要点是按请求注入租户过滤,不要把租户写死在全局默认里却忘了多租户场景。
RetrievalAugmentationAdvisor(Modular RAG)
把查询改写、多路扩展、检索、拼接、后处理拆成可插拔积木,适合要精细控制流水线的场景。
Advisor modularRag = RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.topK(5)
.similarityThreshold(0.7)
.build())
// 可选:QueryRewriter / QueryExpander / DocumentPostProcessor …
.build();
ChatClient client = ChatClient.builder(chatModel)
.defaultAdvisors(modularRag)
.build();
| QA Advisor | Modular RAG | |
|---|---|---|
| 复杂度 | 低 | 中高 |
| 可插拔改写 / 多路检索 | 弱 | 强 |
| 适合 | FAQ、手册 | 多集合、压缩上下文、可控流水线 |
流式接口通常是先检索再 stream:首 token 前会有一小段静默,可用 status=retrieving 一类事件提示前端。
另有 VectorStoreChatMemoryAdvisor:用向量库做长期对话记忆,和本章业务知识库 RAG 不是同一条产品线——一个记「这个用户说过什么」,一个记「公司手册写了什么」。
10.5 完整最小问答骨架
@Service
public class HandbookQaService {
private final ChatClient chatClient;
public HandbookQaService(ChatModel chatModel, VectorStore vectorStore) {
this.chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().topK(5).build())
.build())
.build();
}
public String ask(String tenantId, String question) {
return chatClient.prompt()
.user(question)
.advisors(a -> a.param(
QuestionAnswerAdvisor.FILTER_EXPRESSION,
"tenantId == '" + sanitize(tenantId) + "'"))
.call()
.content();
}
private String sanitize(String tenantId) {
return tenantId.replaceAll("[^a-zA-Z0-9_-]", "");
}
}
sanitize 只是示范:禁止把原始用户输入直接拼进 filter。生产环境用枚举租户、预编译表达式或参数化 filter API。
10.6 质量与运维
-
空检索率、过期 chunk、错误的 tenant filter,往往比「换更大模型」更影响体感
-
换 Embedding 维度要有重建索引的 runbook
-
打开 VectorStore observation,才能分清慢在检索还是慢在 Chat
-
语义缓存可省钱,错误答案也要有 TTL / 失效手段
-
对比实验:同一问题开关 RAG,记录是否引用到文档 id
10.7 小结
RAG 主链是 ingest → Embedding → VectorStore → Advisor 注入 → Chat。先用 QuestionAnswerAdvisor 跑通带租户过滤的手册问答;流水线要改写、多路检索或压缩上下文时,再上 RetrievalAugmentationAdvisor。向量维度一致、过滤条件可信、语料可更新,这三件事比纠结 Advisor 类名更决定能不能上线。