ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring AI 向量检索实战:从 Embedding 到 RAG 完整链路

Spring AI 向量检索实战:从 Embedding 到 RAG 完整链路 Spring 生态里的 AI 搜索扩展其实没你想的那么玄乎。先说结论这一篇聚焦在“检索”这条链路上也就是怎么用向量数据库把 RAGRetrieval-Augmented Generation检索增强生成的地基打牢。这篇我不会去堆概念而是把 Spring AI 里和 Embedding、向量存储、相似度检索相关的核心代码和踩坑点拉出来一条条讲清楚适合那些已经跑通了 Spring Boot 基础项目、准备正经做知识库问答或者私有数据检索的 Java 工程师。你如果正在纠结“选哪个向量库”“Embedding 模型怎么接”“检索结果不相关该怎么办”那这篇正好是你的菜。1. 从普通搜索到向量搜索为什么要多此一举1.1 关键词搜索的天花板我之前维护过一个文档检索系统用户在里面搜“怎么改密码”结果返回的全是包含“密码”和“修改”两个词的管理员操作日志真正需要的“找回密码流程”反而排到第五页。这不是个例而是关键词搜索的天然缺陷它只能做字面匹配理解不了同义表达、抽象概念和上下文语义。比如你问“上个月销售额为什么下滑”传统搜索引擎会把“下滑”“销售额”“上个月”拆开去倒排索引里找返回一堆包含这些词的报表名和邮件但“下降”“营收”“Q2财务数据”这些语义相近却字面不同的内容就全部漏掉了。问题的根源在于关键词搜索把文本当作“词的集合”来处理完全丢失了词与词之间的关联和整体语义。向量搜索换了个思路既然文本本身没法直接计算相关性那就把它转换成一串数字——也就是向量——然后在向量空间里计算距离。距离近的语义就相近。这样一来“销售额下滑”和“营收下降”虽然没有一个字重合在向量空间里却会靠得很近检索结果自然就更贴合用户意图。1.2 RAG 其实是在给大模型配一个“外挂记忆”大语言模型训练完就“冻结”了它知道的是某个时间点之前的公开语料不知道你的业务数据、最新文档和内部知识库。你要让它回答这类问题只有两条路要么微调模型把知识塞进参数里代价高昂且每次更新都要重新训练要么用 RAG把检索到的外部资料作为上下文拼到提示词里让模型基于这些资料来回答。我见过不少团队一开始迷信微调花了几个星期准备数据集、做指令微调最后效果还不如一个简单的 RAG 原型。原因很简单知识是动态的今天更新的产品文档明天就得能被检索到微调的冷启动周期完全跟不上而 RAG 每次回答前都实时检索最新内容天然就支持知识迭代。RAG 的完整链路是先对文档进行切分每个片段经过 Embedding 模型变成向量存进向量数据库用户提问时把问题也变成向量去向量库中找出最相似的几个片段最后把这些片段和问题一起交给大模型让它生成回答。这一篇的核心就是把这条链路的前半段——“召回”——讲透。1.3 Spring AI 在这条链路上到底扮演什么角色Spring AI 这个项目在 Java 生态里的定位一句话总结就是把 AI 相关的操作抽象成了一套 Spring 风格的 API。你用EmbeddingModel接口对接 OpenAI、Azure、Ollama、百炼等各种模型服务商用VectorStore接口对接 Redis、Milvus、PgVector、Chroma 等不同的向量数据库再配合AiClient调用大模型补全整个 RAG 链路就能用统一的方式串联起来。这项工作的意义在于过去 Java 团队做一个 RAG 功能要分别研究各个向量库 SDK 的差异、各家模型服务商 API 的差异再把它们拼接到 Spring Boot 里现在 Spring AI 把这些都收拢了你换个向量库只需要改配置和依赖业务代码不用动。这背后受益的是整个 Java 服务端生态——毕竟工具链统一了团队的维护成本才能降下来。2. 技术选型Embedding 模型和向量库的选择困境2.1 Embedding 模型怎么选本地还是远程Embedding 模型负责把文本变成向量这个选择直接决定了上层的检索质量。我用过几种路径各有各的适用场景给你拆细一点。第一类是云端 API比如 OpenAI 的text-embedding-3-small、阿里云百炼的text-embedding-v3。优点显而易见效果稳定、维护零成本、API 调用简单向量维度通常也较大比如 1536 维或 1024 维语义表达能力更强。缺点则是每次入库和检索都要依赖网络存在数据出域的问题。对于企业内部文档这种敏感数据如果合规不允许外发走云端 API 就得慎重。另外虽然单次调用价格很低但全量文档入库时的调用量巨大积少成多也是一笔开销。第二类是本地模型典型代表是通过 Ollama 跑的nomic-embed-text、all-MiniLM-L6-v2以及 Java 生态里可以直接内嵌的 ONNX 模型。本地方案的最大优势是离线可用、数据不出内网响应延迟低。缺点是效果上限往往低于云端大模型尤其是对于中文长文本、专业术语密集的场景本地小模型容易受限于词表覆盖度。此外你要额外处理模型文件的部署和依赖比如 Spring AI 里接入 ONNX 模型还要手动下载对应的 tokenizer 文件。我个人的建议是数据敏感或需要离线运行选本地小模型起步数据允许上云、对检索效果要求高优先用远程 API。至于模型本身的命名差异不用太纠结。核心要看三个指标支持的语言中文场景必须确认中文效果、向量维度决定存储占用和后续索引参数、最大输入 token 数决定你切分的文档块最多能有多长。2.2 向量数据库全景对比不是越火越好选向量数据库的标准我总结下来就三条一是和当前技术栈的集成成本二是数据规模和数据变更频率三是团队有多少精力愿意花在运维上。基于这个原则我对目前常见的方案做个对比方案部署难度扩展性适合场景备注Spring AI SimpleVectorStore无仅限单机内存/文件原型验证、测试环境开箱即用重启丢数据或用文件持久化PgVector低中等PostgreSQL 已有的团队直接复用关系库事务和向量检索统一管理Redis低中等已有 Redis 缓存层的场景内存型检索快数据量大则成本偏高Milvus中高强大规模生产环境独立集群支持分布式和丰富索引类型Chroma低中等Python/本地工具链友好本地嵌入式和 Java 集成一般我需要专门提醒一点Spring AI 官方文档和示例里大量使用 SimpleVectorStore这是一个存储在内存里的简易实现你可以在application.yml里配置它的持久化文件路径但它本质上不带任何高级索引和分布式能力。很多人拿它做了一版 Demo 后直接上生产结果数据量一上来检索变慢、内存吃紧这就是选型没有前置思考的结果。如果你预期数据量在百万级向量以下、对高可用要求不极端PgVector 或者 Redis 是性价比最高的入场方案真要上 Milvus就做好独立运维的心理准备。另外是“向量的存储和检索要不要交给同一个组件”的问题。很多人一听向量库就默认必须引入专门的数据库但实际上如果你的业务已经有 PostgreSQL 或者 Redis直接在上面启用向量插件或模块是更务实的路径少一个组件就少一个故障点这个我在第三节会具体讲配置。2.3 Spring AI 版本和依赖选择写作这篇文章时 Spring AI 已经迭代到了 1.0.0 GA 版本Maven 依赖坐标也发生了不少变化。常见的是模块spring-ai-starter-model-openai或spring-ai-starter-model-ollama加上spring-ai-starter-vector-store-pgvector或redis、milvus。每个 Starter 独立承载对应的模型和向量库集成互相之间解耦。这里我必须给一个非常重要的建议务必使用 BOM 统一版本管理不要各个包分别写版本号。Spring AI 的版本演进非常快API 调整也很频繁如果各自锁死版本一旦升级就会遇到各种NoSuchMethodError或者类找不到的问题。一个可能让你掉坑的细节是Spring AI 的EmbeddingModel接口在 0.8.x 到 1.0.x 之间有不少调整网上流传的很多老代码段直接粘贴到新版本会编译不过。建议你打开本地 Maven 仓库里的源码或者 IDE 中的反编译类以当前版本实际 API 为准。如果公司里没有一个统一的 AI 网关平台这一步迟早要面对。3. 动手搭建用 Spring Boot 完成一次完整的 RAG 检索3.1 工程初始化和基础配置我先用一个 Spring Boot 3.x 项目演示JDK 用 17 以上。假设我们要做的是一个“企业内部 FAQ 知识库检索”数据源是若干 Markdown 文档目标是用户提问后返回最相关的文档片段。创建项目时我把依赖大致分为三块Web 组件spring-boot-starter-webAI 模型接入spring-ai-starter-model-openai如果你的模型走的是 OpenAI 兼容 API比如阿里云百炼、DeepSeek 的兼容模式也可以用这一个 Starter 通过自定义 base-url 来指过去向量存储这里先演示SimpleVectorStore最后给出 PgVector 的切换配置然后在application.yml里做类似下面的配置spring: ai: openai: api-key: ${OPENAI_API_KEY} base-url: ${OPENAI_BASE_URL:https://api.openai.com} embedding: options: model: text-embedding-3-small注意base-url这个属性很多国内团队用的是兼容网关而不是 OpenAI 原始地址这个配置能让你省掉大量硬编码。另外如果你只有补全模型的密钥没有单独的 Embedding 模型权限那你还是要先确认一下账号的模型访问权限再继续否则启动没问题、一调 Embedding 就报 401。3.2 文档加载与切分切片策略决定了检索上限RAG 里有一个常见的误区以为选了好模型、好向量库就万事大吉实际上检索效果的上限在文档切分这一步就已经悄悄定死了。先看文档加载。Spring AI 提供了PagePdfDocumentReader、TextDocumentReader、MarkdownDocumentReader等类用法基本一致Bean public DocumentReader documentReader() { return new MarkdownDocumentReader(classpath:docs/faq.md); }这里有个容易被忽略的细节classpath:路径在打成 jar 包后读取没问题但在开发环境调试时会因为工作目录不同出现找不到文件的报错。稳妥的做法是放在外部文件系统里或者用 Spring 的ResourceLoader按运行环境动态解析。再来看切分。默认的TokenTextSplitter以 token 数为基础做切分通常我会显式指定两个参数chunkSize和overlap。我给个参考值Bean public TextSplitter textSplitter() { return new TokenTextSplitter(500, 100); }这两个值的含义是每 500 个 token 切一块相邻两块之间保留 100 个 token 的重叠。为什么要重叠因为一个连贯的语义单元很可能恰好被拦腰截断在两块之间如果不做重叠检索时这块缺前因、那块缺后果模型拿到上下文就不完整。我实测下来500 到 800 的块大小配合 10% 到 20% 的重叠率在绝大多数企业文档上都能取得不错的效果。但切片不能完全照搬默认值要考虑文档结构。如果文档本身有清晰的小节标题和列表项建议在切分前用正则或 Markdown 解析把结构保留下来把每个章节作为独立的 Document 载体保留标题作为元数据。这样检索出某个片段时你还能同时返回它的章节来源和原文链接用户在界面上看到的不再是一段无头无尾的文本。3.3 向量化与入库注意异步和幂等加载完文档、切好分片之后接下来就是 Embedding 入库。核心代码如下Service public class DataIngestionService { private final EmbeddingModel embeddingModel; private final VectorStore vectorStore; private final DocumentReader documentReader; private final TextSplitter textSplitter; public DataIngestionService(EmbeddingModel embeddingModel, VectorStore vectorStore, DocumentReader documentReader, TextSplitter textSplitter) { this.embeddingModel embeddingModel; this.vectorStore vectorStore; this.documentReader documentReader; this.textSplitter textSplitter; } PostConstruct public void ingest() { ListDocument documents documentReader.get(); ListDocument splitDocuments textSplitter.apply(documents); vectorStore.add(splitDocuments); } }vectorStore.add()这一行实际上是同步调用了 Embedding 模型逐条生成向量如果文档数量大这个操作会非常耗时。我在一个 2000 片的文档集上实测调用远程 Embedding API 单线程跑大概需要十几分钟。所以生产环境建议把入库流程放到独立的异步任务或者批处理框架中不要放在PostConstruct里阻塞主应用启动否则发布时冷启动时间会让人怀疑服务是不是挂了。另外一个关键点是幂等性。vectorStore.add()如果因为网络抖动导致部分失败重试时会把已成功的文档再插一遍结果就是同一篇内容产生多个重复向量检索时会出现大量“同一个意思”的结果占据前排位置。解决方案有两个一是入库前根据文档唯一 ID 做去重查询二是入库后对元数据中的 source 字段建立索引重跑任务前先清理该 source 下已有数据。具体的写法要根据你选的向量库来定但思路是通用的。3.4 检索流程从 Query 到相似内容检索端是用户请求的入口一般做成一个 Rest API。完整的 RAG 检索流程分为两步第一步把用户 Query 转成向量去向量库召回最相似的 TopK 文档片段第二步把召回内容和用户问题一起交给大模型生成回答。先看召回部分Service public class SearchService { private final VectorStore vectorStore; private final EmbeddingModel embeddingModel; public ListDocument search(String query, int topK) { SearchRequest request SearchRequest.builder() .query(query) .topK(topK) .similarityThreshold(0.7) .build(); return vectorStore.similaritySearch(request); } }topK控制召回数量similarityThreshold控制相似度下限。这里的 0.7 只是一个示例值具体阈值和你选择的 Embedding 模型、向量距离算法强相关不能盲目套用。比如text-embedding-3-small的相似度普遍偏高相关结果经常在 0.75 到 0.9 之间而某些本地小模型分数普遍偏低可能相关结果只有 0.55。所以我建议先跑一批真实查询观察分数分布再定阈值。然后是生成回答。Spring AI 1.0 里的写法是调用ChatClientService public class RagService { private final VectorStore vectorStore; private final ChatClient chatClient; public String answer(String question) { ListDocument docs vectorStore.similaritySearch( SearchRequest.builder() .query(question) .topK(4) .build()); String context docs.stream() .map(Document::getContent) .collect(Collectors.joining(\n\n---\n\n)); String prompt You are a helpful assistant. Answer the question based only on the provided context. If the context does not contain enough information, say I dont know. Context: %s Question: %s .formatted(context, question); return chatClient.prompt().user(prompt).call().content(); } }我对topK的取值也有些心得不要贪多。召回的片段越多上下文越长大模型越容易被无关信息“带偏”回答反而发散。我通常控制在 3 到 5 个片段同时把 Prompt 里明确写出“只依据上下文回答信息不足时直接承认不知道”能显著降低胡说八道的概率。3.5 切到生产级向量库以 PgVector 为例你如果觉得 SimpleVectorStore 不过瘾我可以给一个 PgVector 的最小切换方案。依赖加上dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-vector-store-pgvector/artifactId /dependency然后在配置里指定数据库连接和向量表结构初始化spring: datasource: url: jdbc:postgresql://localhost:5432/ragdb username: postgres password: postgres ai: vectorstore: pgvector: index-type: HNSW distance-type: COSINE_DISTANCE initialize-schema: trueinitialize-schema: true会让 Spring AI 帮你自动建向量表对快速验证很有用。生产环境建议改成 false由 DBA 统一管理表结构变更。HNSW 是近似最近邻索引检索速度快但构建索引时比较吃内存和 CPU数据量小的时候也可以用 IVFFlat构建快但召回精度略逊。至于距离类型文本向量检索我用的是余弦距离COSINE_DISTANCE它对向量的模长不敏感更适合衡量语义方向的一致性。至于 Milvus 的接入稍微复杂一点需要额外配置宿主和端口信息但代码层面的VectorStore接口是不变的这一点正是 Spring AI 抽象带来的最大收益。你前期用 SimpleVectorStore 写好的业务代码换库时基本只需要动 POM 和配置。4. 检索效果不佳问题排查与调参路径4.1 召回结果不相关的通用排查顺序RAG 上线后最常见的反馈就是“它答非所问”很多人第一反应是模型不行实际上多数问题出在召回链路。我一般按下面的顺序排查先看召回列表本身——把 Search API 的返回结果打到日志里看看检索出来的文档片段和用户问题有没有语义关联。如果片段本身就不相关那是 Embedding、切分或向量库的问题如果片段相关但回答不相关那才是大模型或 Prompt 的问题。这一步能帮你迅速缩小排查范围。如果确认是召回环节不相关进一步看两件事第一Query 本身是否含有多义或抽象表述比如“怎么提效”这种问题单独拿出来就缺乏上下文第二文档切分是否把关键定义和说明拆散了。有一个非常实用的小技巧把召回的片段连起来读一遍如果读起来前言不搭后语那八成是切片策略需要优化。4.2 相似度阈值和 TopK 的调参误区我见过团队把similarityThreshold设成 0.9结果用户提问稍一变化就召回不到任何内容也见过团队把 TopK 设成 20结果模型被一大段上下文淹没回答质量明显下降。这两个参数需要配合着调。基本方法是先不做阈值过滤用 TopK 召回 10 到 20 条结果人工标注其中哪些是相关的记下相关结果的最低分数。然后以这个分数为基线适当放宽 0.05 左右作为阈值。TopK 不是越大越好因为在上下文窗口有限的情况下更多片段意味着更多噪声。我的习惯是回答具体事实类问题TopK 取 3 到 5回答综述类问题TopK 可以放宽到 6 到 8。这背后其实是在考量大模型“概括能力”和“被噪声干扰的概率”之间的平衡。4.3 中文场景的额外坑中文文档检索是我踩过最多坑的地方。第一很多 Embedding 模型的中文分词和语义理解能力差异很大同一个模型在英文评测集上表现不错在中文上却可能一塌糊涂。选模型前务必用你自己的文档样本做小规模评测不要直接看榜单。第二中文文本如果没有标准的空格分词切分器按 token 切分后的块边界往往是半句话甚至一个词的中间。我目前的做法是优先按段落和句子边界切分而不是纯按 token 数量硬切工具上可以用自定义的TextSplitter实现。第三英文缩写和中文术语混排时检索效果尤其不稳定比如“RAG”和“检索增强生成”在某些模型里可能距离较远如果你发现类似问题可以考虑在文档里首次出现术语时同时给出中英文全称。4.4 常见问题速查表现象可能原因排查/解决检索结果全是不相关内容切片过长/过短、Embedding 模型选错检查切片内容语义完整性换用更大模型测试召回为空相似度阈值过高查看 TopK 结果原始分数降低阈值回答偏题但片段相关Prompt 中上下文使用方式不当做消融实验只给单个片段测试回答效果数据入库很慢同步调用 Embedding 模型改为异步批量分批执行重复文档污染结果入库没有幂等控制依据文档 ID 或 source 做去重和清理本地模型中文效果差模型词表/训练语料中文占比低切换专门的中文 Embedding 模型或云端 API更新文档后检索没变化向量库没有清理旧数据按 source 删除旧向量再重新入库维度不一致报错入库和检索使用了不同 Embedding 模型确认所有向量来自同一个模型配置5. RAG 效果的量化评估不能只靠感觉5.1 检索层指标与生成层指标分开看很多团队做完 RAG 只靠“我试了几个问题感觉还行”来验收这是不科学的。我建议至少要从两个层面分别衡量检索层和生成层。检索层最核心的指标是 RecallK 和 PrecisionK。RecallK 衡量的是对于一组已知相关的文档你的检索在前 K 个结果里召回了多少比例PrecisionK 衡量的是前 K 个结果里有多少是真的相关的。这两个指标能直接反映召回链路质量而且可以自动化评测——准备一组标注好的“问题-相关文档”对跑一遍检索脚本统计比值就行。生成层的指标相对更难自动化。常用的有 Faithfulness即回答是否忠实于给定的上下文不让模型胡编Answer Relevance即回答是否切中问题Context Relevance即召回上下文本身和问题的相关程度。社区里已经有 RAGAS 等开源评测框架可以跑一部分自动化评测但核心标注工作还是离不开人工。我的建议是先手工标注 50 到 100 条评测集再逐步引入自动化工具辅助回归。5.2 建立你自己的“黄金数据集”我个人的经验是效果调优的第一步永远是先建一个属于自己的评测集而不是急着换模型换参数。可以从线上日志里捞 100 条真实用户问题人工为每道题标注出应召回的文档 ID 和期望答案要点。之后你每次调整切片粒度、换 Embedding 模型、调相似度阈值都用这个评测集跑一遍对比指标变化。这样才能避免“这个问题修好了那个问题又变差了”的盲人摸象式调参。5.3 从召回质量反推业务效果最后说一点容易被忽略的RAG 系统的成败不仅取决于技术指标还取决于业务定义。同样是“知识库问答”客服场景对召回准确率要求极高因为答错直接影响用户体验而内部文档检索场景则更关注查全率因为用户可以自行判断多条结果是否可用。我在项目启动时都会先和业务方对清楚“什么是好”再定指标和阈值否则埋头调参半个月发现大家预期的根本不是同一个东西。检索链路是整个 RAG 的地基地基不牢后面模型能力再强也白搭。这一篇把向量化、切分、入库、检索、调参这条路走了一遍用的都是 Spring AI 体系内的标准姿势。下一篇我会把重心放到生成侧Prompt 怎么设计、上下文怎么压缩、怎么结合重排序Rerank进一步提升输出质量以及如何用 Spring AI 的 Advisor 机制把增强逻辑优雅地嵌入到调用链里。先把自己的评测集准备好下一篇的调优才有地方落地。
返回列表