☰
Spring Boot+DeepSeek构建企业级AI知识库:RAG实践与代码解析
2026/10/22 21:32:45 网站建设 项目流程

最近几个月我一直在折腾一件事:把企业内部散落的各类文档变成AI能直接回答问题的知识库。前后试过好几条技术路线,最后稳定跑通、敢拿给业务部门用的,就是 Spring Boot + DeepSeek 这套组合。这篇不是概念科普,是拿着代码走了一遍的实战复盘,从RAG链路设计、API封装到向量检索和线上问题排查,全部揉在一块讲。适合Java技术栈的同学,哪怕之前没接触过大模型开发,跟着走也能把一个最小可用的企业级AI知识库搭起来。

企业里做知识库问答,最大的痛点不是“没有大模型”,而是“AI不知道你的私有文档写了什么”。大模型训练时见不到你们的制度文件、产品手册、项目复盘。把私有文档切碎、转成向量、存进向量库,提问时先检索再让大模型基于检索结果回答,这就是RAG(检索增强生成)的核心思路。而Spring Boot负责把这条链路串成工程化的服务,DeepSeek负责最后一步的推理生成。

1. 企业级AI知识库的整体设计思路

1.1 为什么是Spring Boot + DeepSeek这套组合

先回答一个很多人纠结的问题:知识库AI应用,为什么不用Python那一套FastAPI + LangChain,非要Spring Boot?

如果你是个人玩、写脚本跑实验,LangChain确实方便,几十行就能把检索问答跑通。但企业落地不一样,身边Java后端团队占了大多数,知识库通常要嵌进现有的OA、ERP、工单系统里,要接权限体系,要做审计日志,要用现有的监控和发布流程。单独为了一个知识库再引入一套Python微服务,意味着多一套运维、多一类排障、多一个跨团队协作负担,很多小团队根本扛不住。

Spring Boot直接复用Java体系,一个jar包部署,和现有系统天然一个技术栈,数据源、事务、权限、日志都能复用。DeepSeek这边,一方面中文效果扎实,另一方面API价格亲民,还兼容主流OpenAI格式的调用方式,Java直接HTTP调用毫无压力。两者加在一起,是一条投入产出比很高的路线。

1.2 RAG核心链路与知识库的完整流程

一个企业级AI知识库,本质上就是一条标准化的RAG管道。整个流程我建议画成八个环节,写代码和排障都按这个思路来:

  1. 文档上传:用户通过接口上传PDF、Word、Markdown、TXT等文件。
  2. 格式解析:把二进制文件中的文本抽取出来,这一步是最大的坑,后面详细讲。
  3. 文本清洗:去掉页眉页脚、空行、乱码字符、多余的换行。
  4. 文档分块:把长文本切成长度合适的片段,每片独立建立索引。
  5. 向量化:切分好的文本片段通过Embedding模型转成向量,也就是一组浮点数数组。
  6. 向量入库:把向量和原文、元数据一起存进支持向量检索的数据库。
  7. 语义检索:用户提问时,把问题同样转成向量,在库里做相似度搜索,找出最相关的几个片段。
  8. 增强问答:把检索到的片段拼进Prompt,连同用户问题一起发给大模型,让模型基于资料回答。

这八个环节环环相扣,任何一个环节出错,最终答案都不对。比如文档解析烂了,后面检索再准也没用;分块策略不对,信息被拦腰切断,模型上下文就不完整。

1.3 落地时的模块划分与工程目录规划

别一上来就搞微服务,知识库这个场景,一个Spring Boot单体应用完全能扛住前期的全部需求。我建议按职责分包,清晰好维护:

com.example.kb ├── controller # 接收HTTP请求,处理上传和问答 ├── service # 业务逻辑,文档处理、问答编排 ├── service.embedding # Embedding适配层,可切换不同模型服务 ├── client # 大模型HTTP客户端封装 ├── repository # 数据访问层 ├── entity # 数据库实体 └── config # 配置类与Bean定义

业务上建议拆成三大服务:文档处理服务负责解析、清洗、分块;向量服务负责Embedding调用和向量检索;问答服务负责组装Prompt、调用大模型、返回结果。三个服务之间通过接口调用,互不干扰,后期扩展多知识库、权限隔离都方便。

2. 环境准备与技术选型:把地基夯实

2.1 依赖与技术版本清单

我实际使用的版本组合是JDK 17 + Spring Boot 3.2.x + PostgreSQL 15 + pgvector插件。选pgvector的原因是它对PostgreSQL用户最友好,不用额外部署一套向量数据库,线上运维成本低,数据还有事务保证。

Maven关键依赖如下,可直接抄作业:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.4</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.apache.tika</groupId> <artifactId>tika-core</artifactId> <version>2.9.0</version> </dependency> <dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.31</version> </dependency> </dependencies>

Tika负责从各种格式里抽取文本,PDFBox用来细化处理PDF。如果只需要解析PDF、Word,这两个就够用;后期如果涉及OCR识别扫描件,再单独接OCR组件。

2.2 DeepSeek密钥与模型参数配置

去DeepSeek开放平台注册账号,创建API Key后,把关键配置集中放在application.yml里:

deepseek: api-key: sk-xxxxxxxxxxxxxxxx base-url: https://api.deepseek.com chat-model: deepseek-chat temperature: 0.2 max-tokens: 2048 timeout-seconds: 60

这里有两个参数要特别说。temperature控制随机性,知识库场景要求答案稳定、忠于原文,我建议调到0.1~0.3,别学聊天机器人那样追求发散。max-tokens控制单次回复的最大长度,知识库问答通常2000左右就够,太长会推高延迟和成本。

2.3 Embedding向量化方案的插拔设计

这是很多人会踩坑的地方。大模型对话和向量化是两码事,对话用DeepSeek没问题,向量化则需要Embedding模型。DeepSeek平台如果已经开放了对应的Embedding能力,可以直接用HTTP调用;如果团队想完全内网化,也可以用本地部署的开源Embedding模型,比如BGE-M3、含all-MiniLM系列在内的各类轻量模型。

我建议在工程里抽象一个EmbeddingService接口:

public interface EmbeddingService { List<Float> embed(String text); int dimension(); }

远程方案和本地方案各写一个实现类,配置项切换即可。这样做的好处是,将来无论换成哪个Embedding供应商,或者自己部署模型,都不需要改业务代码。我在多个项目里一直用这个模式,省了很多重构功夫。

3. 核心代码实现:DeepSeek接入与问答链路

3.1 用RestClient封装DeepSeek客户端

Spring Boot 3.x中,RestClient是官方推荐的HTTP调用方式,比老RestTemplate优雅,比WebClient轻量。先注册一个Bean:

@Configuration public class DeepSeekClientConfig { @Bean public RestClient deepSeekRestClient( @Value("${deepseek.api-key}") String apiKey, @Value("${deepseek.base-url}") String baseUrl, @Value("${deepseek.timeout-seconds}") int timeoutSeconds) { return RestClient.builder() .baseUrl(baseUrl) .defaultHeader("Authorization", "Bearer " + apiKey) .defaultHeader("Content-Type", "application/json") .requestFactory(getRequestFactory(timeoutSeconds)) .build(); } private ClientHttpRequestFactory getRequestFactory(int timeoutSeconds) { var factory = new JdkClientHttpRequestFactory(); factory.setReadTimeout(Duration.ofSeconds(timeoutSeconds)); factory.setConnectTimeout(Duration.ofSeconds(10)); return factory; } }

注意超时的时间设计。DeepSeek的deepseek-chat模型在长上下文下,响应可能要十几秒到几十秒,读超时设置太短会频繁报错。我一般设60秒,连接超时10秒。这个区分很重要,连接慢和响应慢是两个问题。

定义请求和响应的数据模型,用Java record非常合适:

public record ChatMessage(String role, String content) {} public record ChatRequest( String model, List<ChatMessage> messages, double temperature, int max_tokens, boolean stream ) {} public record ChatResponse( String id, List<Choice> choices, Usage usage ) { public record Choice(int index, Message message) { public record Message(String role, String content) {} } public record Usage(int prompt_tokens, int completion_tokens, int total_tokens) {} }

再封装一个专门的大模型服务方法:

@Service public class DeepSeekService { private final RestClient restClient; private final DeepSeekProperties properties; public DeepSeekService(RestClient deepSeekRestClient, DeepSeekProperties properties) { this.restClient = deepSeekRestClient; this.properties = properties; } public String chat(String systemPrompt, String userPrompt) { List<ChatMessage> messages = List.of( new ChatMessage("system", systemPrompt), new ChatMessage("user", userPrompt) ); ChatRequest request = new ChatRequest( properties.getChatModel(), messages, properties.getTemperature(), properties.getMaxTokens(), false ); ChatResponse response = restClient.post() .uri("/chat/completions") .body(request) .retrieve() .body(ChatResponse.class); if (response == null || response.choices().isEmpty()) { throw new RuntimeException("DeepSeek返回结果为空"); } return response.choices().get(0).message().content(); } }

代码很短,但有几个细节值得说。第一,把API Key加在请求头里,千万别写进日志或传到页面。第二,模型名从配置读,不要写死,方便后续切换deepseek-reasoner这种推理模型。第三,响应为空的情况要显式抛异常,靠上层统一处理。

3.2 流式SSE对接:让输出像打字机一样逐字返回

同步接口虽然简单,但大模型生成几十秒,用户看着转圈很容易以为系统挂了。企业级体验建议上流式。Spring Boot里用SseEmitter实现,思路如下:

@GetMapping(value = "/api/kb/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(@RequestParam String question) { SseEmitter emitter = new SseEmitter(0L); // 使用独立线程池,避免阻塞Tomcat线程 executor.execute(() -> { try { // 构造请求,stream=true // 用RestClient获取ResponseBody // 逐行读取SSE事件,解析delta.content // 每次都执行 emitter.send(data) } catch (Exception e) { emitter.completeWithError(e); } finally { emitter.complete(); } }); return emitter; }

流式解析的核心是每一行数据格式长这样:

data: {"choices": [{"delta": {"content": "你好"}}]} data: [DONE]

逐行读,把data:后面的JSON解析出来,提取delta里的content增量,不断推给前端。前端用EventSource或fetch的ReadableStream接收,体验就很自然。

3.3 知识库问答接口与Prompt模板设计

问答接口是整个系统对外暴露的核心。先看完整流程代码:

@Service public class KnowledgeQaService { private final DeepSeekService deepSeekService; private final VectorSearchService vectorSearchService; private final EmbeddingService embeddingService; public String ask(String question, int topK) { // 1. 问题向量化 List<Float> questionVector = embeddingService.embed(question); // 2. 从向量库检索相关片段 List<RetrievedChunk> chunks = vectorSearchService.search(questionVector, topK); // 3. 组装Prompt String systemPrompt = "你是一名企业知识库智能助手,请严格根据提供的资料回答。"; String userPrompt = buildPromptWithContext(question, chunks); // 4. 调用DeepSeek return deepSeekService.chat(systemPrompt, userPrompt); } private String buildPromptWithContext(String question, List<RetrievedChunk> chunks) { StringBuilder sb = new StringBuilder(); sb.append("资料如下:\n"); for (int i = 0; i < chunks.size(); i++) { sb.append("[").append(i + 1).append("] ") .append(chunks.get(i).content()).append("\n\n"); } sb.append("用户问题:").append(question).append("\n"); sb.append("请优先引用资料原文回答,如果资料中没有答案,请明确说明未找到相关信息。"); return sb.toString(); } }

Prompt模板是整个问答效果的关键变量。我经历过的教训是:不给模型约束,它就会自由发挥。所以系统提示词里必须明确“只依据资料回答”“找不到就承认找不到”。

3.4 一次完整问答的请求与响应

调用链路梳理一下。用户请求POST /api/kb/ask,参数是“年假制度最长连续休几天”,系统内部会发生这些事:

  1. 问题被转成向量。
  2. 向量库里找出相似度最高的3到5个文档片段。
  3. DeepSeek收到带上下文的Prompt。
  4. 模型返回:“根据《员工休假管理制度》第二条,连续休假最长不得超过15天。”

最终返回给用户的JSON结构我习惯这样设计:

{ "code": 0, "data": { "answer": "根据《员工休假管理制度》第二条,连续休假最长不得超过15天。", "references": [ {"source": "员工休假管理制度.pdf", "chunk": "第二条 连续休假最长不得超过15天..."} ] } }

把命中的原文片段作为references返给前端,用户能自己核对答案来源。这个设计在企业场景非常受认可,业务方看到答案有出处,信任感完全不一样。

4. 知识库构建核心:文档解析、分块与向量检索

4.1 文档解析与清洗:最容易翻车的环节

文档解析是整个知识库最容易出问题、又最不被重视的环节。很多项目上线后回答质量差,查到最后是解析阶段已经乱七八糟了。常见的坑包括:PDF里表格解析成分裂的文本、Word里的分页符残留、扫描件没有OCR层导致抽出来是空白。

我用Tika作为统一解析入口,对不同文件类型走对应解析器:

@Component public class DocumentParser { private final Tika tika = new Tika(); public String parse(byte[] fileBytes, String fileName) throws Exception { // 根据扩展名交给Tika处理 return tika.parseToString(new ByteArrayInputStream(fileBytes)); } }

Tika解析出来的文本通常很脏。我建议清洗环节做这几件事:去掉连续空行、去掉页眉页脚特征文本、把多个空白字符压缩成一个、统一换行符。清洗不是写正则磨半天,而是先抽几份真实样本看看再定规则,不同来源的文档脏法不一样。

4.2 分块策略:切得好不好直接决定检索效果

分块的目标是让每个片段尽可能表达一个完整语义单元。切得太长,向量化时语义被稀释;切得太短,上下文又不够完整。我用的基础策略是“块大小800字符、重叠200字符”,语言文本大概在两三百个token内,既保留上下文重叠又避免向量维度被无关内容污染。

核心思路的伪代码:

public List<String> splitText(String text, int chunkSize, int overlap) { // 优先按段落分,段落内再按句子切 // 每往前进一个chunkSize - overlap的步长 // 如果遇到标题或Markdown二级标题,强制在这里断块 }

加上按标题切分很重要。企业文档大多有标题结构,在标题处切开能让每个块更像一个完整知识点,后续检索命中率更高。如果文档没有明确标题,就按段落和句子边界切,至少别把一句话拦腰截断。

4.3 pgvector建表与相似度检索SQL

PostgreSQL启用pgvector插件后,建表语句很直白:

CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE doc_chunk ( id BIGSERIAL PRIMARY KEY, doc_id BIGINT NOT NULL, chunk_index INT NOT NULL, content TEXT NOT NULL, meta JSONB, embedding vector(1024) ); CREATE INDEX ON doc_chunk USING hnsw (embedding vector_cosine_ops);

这里的1024要和你的Embedding模型输出维度严格一致。BGE-M3输出1024维,其他模型可能是768维、1536维,依据实际模型改。

检索时,通过余弦距离找最近邻:

SELECT id, doc_id, content, 1 - (embedding <=> CAST(:queryVec AS vector)) AS similarity FROM doc_chunk WHERE 1 - (embedding <=> CAST(:queryVec AS vector)) > 0.6 ORDER BY embedding <=> CAST(:queryVec AS vector) LIMIT :topK;

<=>是余弦距离运算符,1 - 距离就是余弦相似度。加一个0.6的相似度阈值,避免检索出一堆毫不相关的内容还硬塞给大模型。阈值具体多少要看你用的Embedding模型,我的经验是先用0.5~0.7之间,再根据测试结果调。

4.4 检索质量优化:先调Threshold还是先调TopK

检索质量从两个维度入手:相关性过滤和数量控制。TopK决定传给模型的片段数量,太多会让模型抓不住重点,太少又信息不足。我的建议是TopK取4到6,相似度阈值从0.5起步逐步上调。

实际操作中,我常用一个技巧:把TopK提高到10先不分太多,然后按阈值过滤。这样既保证召回率,又避免低质片段混进上下文。等效果稳定了,再把参数固化到配置中心。后续有条件可以加Rerank重排模型,把粗排Top50精排成Top5,效果还会再往上走一截。

5. 常见问题与排查方法实录

5.1 模型回答完全没用到知识库内容

这个现象很典型:你问“报销流程是什么”,它答了一段通用财务知识,和你们公司制度完全对不上。

排查顺序固定三条:第一,看检索环节有没有返回片段,查日志里向量搜索的结果,如果为空,问题出在Embedding或入库,而不是大模型。第二,看片段相似度分数高不高,如果最高也就0.3、0.4,说明检索没召回相关文档,要调整分块策略或阈值。第三,看Prompt拼接是否正确,有些代码bug导致片段根本没拼进去,模型当然只能自由发挥。

我在写这套系统时,几乎每个问题都能归到这三类里。建议上线前做一个包含20~30个真实问题的测试集,每个问题都记录检索到的片段和相似度,排查效率高很多。

5.2 接口超时与线程阻塞

知识库问答的回应时间主要消耗在向量检索和大模型生成上,两个都是网络IO。如果直接用Tomcat线程同步调用,高并发下线程池很快被打满,其他正常接口也会一起卡住。

解决思路是把问答接口的调用放到单独线程池,或者用CompletableFuture异步编排。再配合上面说的流式SSE,网络IO的等待时间不占用业务线程,用户体验和系统稳定性都能提升。我还习惯把Embedding调用和Chat调用的超时分开配置,避免一个超时影响另一个连接池。

5.3 向量维度不一致导致查询报错

更换Embedding模型后,最经典的报错就是“different dimensions”。旧数据是1024维,新模型输出768维,PostgreSQL直接拒绝比较。解决方法是换模型时,一定要先重建向量数据,或者写一个数据迁移任务,分批重新Embedding全量文档。

这个坑特别隐蔽,因为测试时只导入了少量新数据,完全没发现旧向量还在库底。我的习惯是在meta字段里记录每个块的embedding模型名称和版本,查询时排除其他模型的向量。别嫌麻烦,这个标记能省下太多排障时间。

5.4 企业落地绕不开的权限过滤

企业知识库和公开搜索引擎最大的区别在于权限。不是所有员工都能看所有文档,比如财务制度可能只对管理层开放。

实现权限过滤的最简做法,是在文档元数据里打上可见范围标签,例如部门、角色、密级。检索SQL中带上权限条件:

WHERE (meta->>'dept') = :dept OR (meta->>'isPublic') = 'true' ORDER BY embedding <=> :queryVec LIMIT :topK;

这样检索结果天然就是当前用户能看的范围,从源头杜绝了越权内容进入Prompt。如果团队短期做不了细粒度权限,至少也要做到按文档目录级别隔离。

5.5 成本控制与并发限流

DeepSeek虽然便宜,但知识库的Embedding调用量通常比想象中大。一份10万字的文档,切分后可能产生200个块,就意味着200次Embedding调用。全量入库时数据量大,一定要批量处理,别一条条同步请求。

线上服务也要做限流和缓存。对高频问题可以做缓存,同一个问题短时间内重复问直接返回缓存结果;对大模型的并发调用限制在合理水位,防止人为刷接口打爆预算。把每次问答的token消耗记进日志,每月统计成本分布,心里才有底。

6. 最后再分享一点个人体会

回到开头那句话,企业级AI知识库真正常踩的坑不在大模型,而在数据侧。文本抽得干净、分块分得合理、埋点埋得充足,后面再配个还行的模型就很好用。JDK里先把最小闭环跑通,再逐步加权限、多知识库、审计这些重机制。我用这套Spring Boot + DeepSeek的组合,两周就交付了第一个可用版本,之后基本都是数据侧迭代。如果你正打算在Java体系里落地知识库,照着这条链路走下去,会比想象中顺利得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询