第 10 章 · Embedding、VectorStore 与 RAG

0 阅读5分钟

版本:Spring AI 2.0.1

目标:走通「文档进库 → 检索增强生成」,分清经典 QA Advisor 与 Modular RAG,写出可运行的最小 ingest / 问答代码。

大模型训练语料里没有你的私有手册与工单。RAG 把「能检索的私有片段」在回答前塞进 Prompt,让模型基于材料说话,而不是凭空编。

整条链可以拆成两段:

  1. 离线 ingest:文档 → 切分 → Embedding → 写入向量库

  2. 在线问答:问题 → Embedding → 相似检索 → 片段进入 Prompt → Chat

Embedding 负责语义距离,VectorStore 负责存与搜,Advisor 负责把搜到的内容接进 ChatClient


10.1 文档进库:ETL 三件套

DocumentReader → DocumentTransformer(s) → DocumentWriter / VectorStore

Document 带文本、id、metadata;检索命中后还可能带 score。元数据会进入过滤条件,入库前就要定好 tenantIdsourceversion 等键。

输入类型常见 Reader
MarkdownMarkdownDocumentReader
HTML / 网页JsoupDocumentReader
PDFPagePdfDocumentReader / 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 做演示与单测。生产按已有基础设施选:

基础设施倾向
已有 PostgreSQLPgVector
已有 RedisRedis 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 AdvisorModular 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 类名更决定能不能上线。