ARTICLE DETAIL

资讯详情

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

Spring AI实战:基于RAG构建私有知识库问答系统

Spring AI实战:基于RAG构建私有知识库问答系统 去年年底我们团队接手了一个内部知识库问答的需求几百份产品文档、运维手册、项目复盘散落在各处每次新同学入职光找资料就要折腾一周。当时试过直接把文档往模型上下文里塞又贵又慢还经常答非所问。后来我们决定用检索增强生成RAG来做——把文档切碎、向量化、存起来用户提问时先检索相关片段再让大模型基于这些片段回答。方案定了技术选型上有个现实问题摆在我们面前团队全是Java背景跑Python那一套LangChain FastAPI pgvector先不说学习成本后面的维护和链路排查就够喝一壶的。这时候我注意到Spring AISpring官方出的AI框架思路和LangChain类似但整个编程模型完全贴合Spring的习惯。这篇文章就结合我们的实战过程聊聊怎么用Spring AI把RAG落地让模型真正“掌握”私有的业务知识。1. 先搞清楚RAG到底解决什么问题1.1 大模型的“知道”与“不知道”所有做AI应用的团队都会撞上同一个墙大模型再能说它的知识边界在训练完成那一刻就冻结了。拿GPT系、通义千问、DeepSeek这些模型来说你问它今年公司最新的故障处理流程它要么一本正经地编一个要么直接说不知道。更麻烦的是企业内部的文档、制度、代码注释、历史工单这些内容从来不会出现在公网训练语料里。有一种粗暴的做法是微调把私有知识通过训练融进模型参数。但要积累足够多的成对问答数据、要机器、要时间而且知识一更新就得重新训练对大多数团队来说性价比很低。RAG的思路完全反过来知识不塞进模型而是放在外部让模型在回答前“查资料”。1.2 RAG的标准流水线拆解RAG的全称是Retrieval-Augmented Generation核心就两个动作先检索再生成。知识先经过清洗、分块、向量化后存入向量数据库等用户提问时同一个向量化模型把问题变成向量去库里找回最相似的几个片段最后把“问题 检索到的片段”拼成提示词交给大模型让它基于这些素材做回答。拆开来看主要有五个环节文档加载Document Loading从PDF、Word、Markdown、HTML里抽取文本。文本分块Splitting把长文本切成合适大小的块控制检索粒度和上下文容量。向量化Embedding把文本变成高维向量语义相近的文本向量距离也近。向量存储与检索Vector Store Retrieval存向量、算相似度、取Top-K。增强生成Generation把检索结果注入提示词交给大模型组织最终回答。如果用传统方案比如Java里现拼接各种组件每一步都要自己对接一个第三方库光是文档解析和向量库客户端的版本兼容就能折腾几天。Spring AI的价值不是发明了新的AI理论而是把上面这套流水线抽象成了统一的API让Java开发者能用Spring的方式写AI应用。2. 为什么Java团队选Spring AI而不是Python方案2.1 团队结构和维护成本是硬约束很多团队在选型时会忽略一个关键变量这套系统将来谁来维护。我们的情况很典型后端是纯Java运维体系基于Spring Boot如果为了一个RAG功能引入Python服务就得同时维护两套部署链路、两套日志规范、两套监控体系。单独的RAG服务还好一旦要和企业现有的鉴权、审批流、工单系统打通异构服务的沟通成本会指数级上升。Spring AI最大的好处是它长在Spring生态里配置方式、Bean管理、拦截器、异常处理、Metrics监控全部沿用Spring Boot那套玩法。你不需要为了AI功能重新学一门语言的Web框架原先怎么写Controller现在还怎么写。2.2 Spring AI的模块结构与核心抽象Spring AI把功能按模块拆得很清楚spring-ai-core核心抽象定义Model、ChatClient、EmbeddingModel、VectorStore这些接口。spring-ai-openai对接OpenAI接口协议的模型通义千问等兼容OpenAI协议的国内模型也能用。spring-ai-ollama对接本地Ollama部署的开源模型。spring-ai-pgvector基于PostgreSQLpgvector的向量存储实现。spring-ai-pdf、spring-ai-tika负责PDF、Word等格式的文档解析。这套分层结构和Spring Data的思路几乎一样接口统一实现可插拔。你今天用OpenAI明天想换本地DeepSeek只改依赖和配置业务代码基本不动。2.3 我们选型时的几个关键对比点对比维度Spring AIPython系LangChain/LlamaIndex与Java技术栈的融合度原生Spring Boot配置和编程模型统一需要额外部署Python服务走接口通讯团队学习成本会Spring Boot就能快速上手需要熟悉Python生态和LangChain的Chain机制模型接入支持OpenAI、Ollama、通义等切换成本低也支持多模型但Java调用要自己封装向量存储内置pgvector、Redis、Milvus等实现依赖外部库配置自由度更大生态成熟度仍在快速迭代版本变化较快更成熟社区案例多纠结过、也踩过坑之后我得说一句公道话如果你的团队主力是Java,Spring AI现阶段确实值得押注如果团队本来就是Python背景或者要做很复杂的Agent编排LangChain的灵活性和案例储备仍然有优势。技术选型没有绝对的对错只有适不适合自己的团队结构。3. 关键环节怎么设计才不踩坑3.1 文档接入格式解析比想象中麻烦RAG的第一步是让系统“读得懂”你的文档。企业里最常见的三种格式是Markdown、Word和PDF它们的解析难度完全不一样。Markdown最友好本身是纯文本按标题切分就能得到结构良好的内容。Word文档用docx4j或者Spring AI里的Tika也能处理。最麻烦的是扫描版PDF本质是图片解析出来全是乱码这种情况先得接OCR我们用的方案是PaddleOCR。千万别把扫描件直接丢给解析器结果会让你怀疑人生。Spring AI里提供了一个叫PagePdfDocumentReader的类可以按页读取PDF。但原生它只处理文本型PDF遇到扫描件还是要自己接OCR。我们最后做了一层文档处理管道上传文件 → 判断格式 → 文本型PDF直接提取扫描型走OCR → 统一输出成规范的Markdown → 进入分块环节。3.2 分块策略直接决定检索命中的质量分块是整个RAG链路里最玄学也最影响效果的环节。块太大塞进上下文的内容会超出模型窗口而且一个大片段里混着多个主题向量化后的语义容易被稀释块太小语义不完整检索容易返回碎片化的、缺乏上下文语境的片段模型照着回答就会前言不搭后语。我们实践下来的经验是默认先按500~800字左右分重叠区设为50~150字。重叠区很关键它保证了跨块边界的语义能在两边都保留下来。另外要注意按文档结构分块Markdown里按标题层级切代码文档按代码块切比纯按字符数硬切效果好得多。Spring AI里可以用TokenTextSplitter按token数切也可以自定义分块逻辑。如果你追求更高阶的效果可以试语义分块计算相邻句子之间的向量相似度相似度低就在那里断开但计算成本会高一些。我们目前生产环境还是用固定大小重叠简单可靠。3.3 向量化Embedding模型选型不能随便用中文模型很多人图省事直接调模型API里的embedding接口但中文场景下embedding模型的选择直接决定了检索准不准。我们最初用某个面向英文优化的embedding模型测中文文档检索结果的准确率只能用惨不忍睹来形容。后来换成了针对中文优化的模型BAAI/bge-large-zh同一套检索逻辑命中率明显提升。另外要注意的一点是文档入库时用的embedding模型和线上检索时用的模型必须一致。如果你中途换模型之前库里的向量就全部失效了必须重新跑一遍入库流程。这个坑我们踩过线上问答答非所问排查了半天最后发现是两套embedding模型不统一。3.4 向量库里应该存什么向量库不是只存向量就完事了。生产级的RAG还需要存原始文本内容、文档来源、上传时间、业务标签这些元数据。Spring AI的VectorStore接口在写入时会接收Document对象它本身带了content和metadata两个字段这点设计得比一些Python框架还直观。我们通常会在metadata里放三个东西source文件路径或URL、docType文档分类、department所属业务线。后面做过滤检索、权限控制都会用到。举个例子某个部门的人提问时可以在检索阶段直接过滤掉其它部门的文档既提高检索精准度也做了数据隔离。3.5 检索相似度计算和Top-K怎么定检索环节最关键的是相似度算法和Top-K参数。Spring AI内置支持多种相似度算法常用的有余弦相似度Cosine、欧几里得距离L2、内积Inner Product。文本向量一般默认用余弦相似度它对向量模长不敏感更适合语义相似度场景。Top-K则决定了最终交给模型多少个片段。取少了可能漏掉关键信息取多了会让提示词变得臃肿模型容易被无关片段带偏。我们压测下来的经验是知识库单次回答取4~6个片段每个片段约500字加上问题本身总上下文控制在3000字以内。现在主流模型的上下文窗口动辄几万token但并不是塞得越多回答就越准过多的孤立片段反而会引入噪声。4. 实操用Spring AI搭一个私有知识库问答4.1 工程初始化和依赖引入我们用的是Spring Boot 3.2 Spring AI 0.8.1这个组合。创建工程后先引入核心依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version0.8.1/version typepom/type scopeimport/scope /dependency再引入模型和向量库相关依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-ollama-spring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-pgvector-store-spring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-pdf-document-reader/artifactId /dependency当时选择Ollama是为了把知识库问答部署在内网数据不出域。用Ollama在服务器上跑一个本地模型比如qwen2.5交互方式模拟OpenAI的接口但请求只走内网。这对很多技术团队来说是硬需求企业文档不能随便丢给外部API。4.2 配置向量库连接和模型地址在application.yml里做基础配置。我们用PostgreSQL pgvector插件省去单独维护Milvus等专用向量数据库的运维成本毕竟公司已有的PostgreSQL实例可以直接复用。spring: ai: ollama: base-url: http://localhost:11434 chat: model: qwen2.5:7b embedding: model: bge-m3 vectorstore: pgvector: index-type: HNSW distance-type: COSINE_DISTANCE dimensions: 1024 initialize-schema: true几个容易踩的细节说明一下。distance-type建议用COSINE_DISTANCE跟上文说的余弦相似度对齐dimensions必须和embedding模型输出的维度一致bge-m3输出1024维如果配置成1536会直接报错initialize-schema: true的意思是首次启动时自动创建向量表生产环境建议由DBA统一管理表结构。4.3 文档入库从文件到向量的完整链路先把核心Service写出来。我们需要一个方法接收上传的文档解析、分块、向量化、存入VectorStoreService RequiredArgsConstructor public class KnowledgeService { private final VectorStore vectorStore; private final EmbeddingModel embeddingModel; public void ingestDocument(MultipartFile file, String docType, String department) { // 1. 解析文档文本 String content parseDocument(file); // 2. 文本分块 ListString chunks splitContent(content); // 3. 构建Document对象列表附带元数据 ListDocument documents chunks.stream() .map(chunk - { MapString, Object metadata new HashMap(); metadata.put(source, file.getOriginalFilename()); metadata.put(docType, docType); metadata.put(department, department); return new Document(chunk, metadata); }) .collect(Collectors.toList()); // 4. 向量化并写入向量库 vectorStore.add(documents); } }vectorStore.add()内部其实做了两件事调用EmbeddingModel把每个Document的内容转成向量然后把向量和原始文本、元数据一起写入pgvector表。整个过程对使用者来说是透明的这也是Spring AI框架顺手的地方。分块方法当时做了个简单的重载支持固定字符数和按标题切分两种策略private ListString splitContent(String content) { TokenTextSplitter splitter TokenTextSplitter.builder() .withChunkSize(800) .withChunkOverlap(150) .build(); return splitter.split(content); }这里提个容易忽略的点TokenTextSplitter是按token数来切不是按字符数切。中文一个字有时候对应一个token有时候一个字就占两个token所以800个token大概对应五六百字的中文。如果你希望控制得更精细可以自己实现基于字符数或段落结构的分块器。4.4 问答接口检索增强生成完整实现问答环节的核心是一个接口接收用户问题内部完成向量化、检索、拼装提示词、调用大模型、返回答案。RestController RequiredArgsConstructor public class ChatController { private final VectorStore vectorStore; private final ChatClient chatClient; PostMapping(/api/chat) public String chat(RequestBody ChatRequest request) { // 1. 根据用户问题检索相似片段 ListDocument documents vectorStore.similaritySearch( SearchRequest.builder() .query(request.getQuestion()) .topK(5) .build() ); // 2. 拼装上下文 String context documents.stream() .map(Document::getContent) .collect(Collectors.joining(\n\n---\n\n)); // 3. 构造提示词 PromptTemplate promptTemplate new PromptTemplate( 你是一个企业知识库助手。请基于下面的资料回答问题。 如果资料中没有相关信息请直接说明资料库中未找到相关内容。 引用资料时请标注来源。 资料 {context} 问题{question} ); Message message promptTemplate.createMessage(Map.of( context, context, question, request.getQuestion() )); // 4. 调用大模型生成回答 return chatClient.call(message).getContent(); } }细节都在注释里了。我单独说一下topK(5)这个参数它不是拍脑袋定的。我们压测过topK从1到10的情况结果很有意思topK1时答案经常信息不全topK5时效果最好再往上加到8~10虽然召回了更多片段但模型经常被不相关的内容干扰。建议各团队按自己的文档质量做一次同样的实验不要照抄别人的参数。4.5 进阶元数据过滤和来源溯源上面那版是demo级别的生产环境还得加两样东西检索过滤和来源标注。元数据过滤用起来很简单比如只允许某个部门的员工搜索本部门的文档Filter.Expression expression FilterExpressionBuilder.expression(department :department) .bind(department, request.getDepartment()) .build(); ListDocument documents vectorStore.similaritySearch( SearchRequest.builder() .query(request.getQuestion()) .topK(5) .filterExpression(expression) .build() );来源标注则是在组装提示词时让模型把引用的文档来源也说出来String contextWithSource documents.stream() .map(doc - [来源: doc.getMetadata().get(source) ]\n doc.getContent()) .collect(Collectors.joining(\n\n));这样回答里如果引用了某个手册的内容大模型会顺手带出“来源xx手册.pdf”员工可以自己点开原文核对信任度一下就上去了。我们做用户调研时这是被点赞最多的一个功能。4.6 最终效果与性能表现整套系统部署之后我们对知识库里的200多份文档做了评测。随机提出50个问题人工判定回答准确可用的大概占了80%左右。对于“某个模块的配置项在哪里”、“xx系统的故障恢复步骤是什么”这类定位型问题准确率能到90%以上。但涉及多个文档交叉推理的问题效果明显变差。性能上本地部署的qwen2.5:7b在普通GPU上生成一次回答大概需要3~8秒向量检索部分不到100毫秒。对内部知识库场景来说完全能接受。如果对延迟敏感可以接企业级API模型检索逻辑完全不用改。5. 常见问题与排查技巧实录5.1 检索结果不准先区分“查不到”和“排不对”检索不准是最常见的投诉。排查时要先区分两种情况是向量库里根本没有相关内容还是相关内容没被搜出来。前者去检查文档是否入库成功后者要重点排查embedding模型和分块逻辑。我们遇到过一个非常隐蔽的问题某个文档入库时解析出来的内容是空的因为PDF里有几页是图片。这种问题通过打印日志里的Document内容就能发现。生产环境建议在vectorStore.add()前后都打日志记录文档数、token数、metadata信息定位问题会快很多。5.2 大模型答非所问提示词指令不明确不是模型的错很多人会直接把检索结果拼上去就完事然后怪模型笨。实际上是你没告诉它“不知道的时候要承认”。我们在提示词里加了一段“如果资料中没有相关信息请直接说明资料库中未找到相关内容”幻觉率立刻降了一半以上。另外建议在提示词里约束回答格式比如“先回答问题再补充说明来源”。不约束格式的话模型经常会把资料里的内容原样抄一遍而不是用自己的话组织。5.3 pgvector写入报维度不符pgvector创建表时指定的向量维度必须和embedding模型输出维度一致。bge-m3输出1024维如果你配置的是1536稍微一用就报错。解决方法是看报错信息里的维度数字然后改配置。注意改配置后要把原来的表drop掉重建因为pg的索引对维度是强绑定的。5.4 数据更新了回答还是旧内容RAG有一个天然问题文档重新入库后旧数据不会自动删除。如果你用同一个source标识了不同版本的内容库里会同时存在新旧两个版本。检索时可能命中旧版导致回答过时。解决办法是做覆盖式写入入库前先按source删除旧的Document再写入新的。Spring AI的VectorStore提供了delete方法配合元数据过滤可以很方便地实现。5.5 Ollama本地模型回答质量不稳定本地部署的开源小模型在效果上确实不如大参数API模型这是物理规律没法改变。我们的经验是对回答质量要求高的场景可以做成双模型策略——检索用本地模型先兜底如果用户对回答点了“踩”再调一次企业级API模型重新生成。不过双模型会拉高成本不是所有场景都值得。6. 给准备上手RAG的团队几个建议如果这篇文章对你有帮助最后这三条建议希望能帮你少走弯路。第一先小规模跑通闭环再谈优化。找一个文档量小、问题边界清晰的业务场景比如IT服务台的故障排查手册先把“上传文档→分块→入库→提问→回答”这条链路跑通再逐步扩充知识范围。不要一上来就建一个超大规模的知识库出问题的时候你连根因都找不到。第二评测集比代码更值钱。我们在上线前整理了几十个典型问题按业务模块分类每次改动后都跑一遍回归。没有评测集的RAG项目优化全凭感觉改一个分块参数也不知道是变好了还是变差了。哪怕是手工维护一个文档记录的评测问题列表也比没有强得多。第三给用户一个反馈渠道。我们在问答界面加了“回答对/错”两个按钮用户点击“错”时会记录问题和当时检索到的上下文。这个反馈数据是后续调优最宝贵的素材哪些文档没被检索到、哪些回答是模型在瞎编都藏在里面。RAG并不是一门高不可攀的技术它本质上就是用工程手段把大模型和你的业务知识连接起来。Spring AI把这套流程简化到了Java开发者能轻松驾驭的程度剩下的事情就看你怎么把知识整理好、把评测做扎实了。
返回列表