开头在企业内部做知识管理最折腾人的往往不是文档太少而是文档太多、太散、太旧。我参与过的一个项目业务部门积攒了上千份技术手册、操作流程和项目总结统一放到共享盘里结果大家找资料基本靠运气关键词搜出一堆名字相关、内容无关的文件问老同事对方已经调岗问新人对方比我更懵。后来我换了个思路——用Spring Boot搭一个AI知识库把文档向量化之后存进数据库再做检索增强生成RAG让DeepSeek基于企业内部文档回答问题。模型没见过企业私有数据没关系先从文档里把相关内容检索出来拼进上下文再让它生成效果立竿见影。这套方案落地之后原来找资料半小时变成了提问三秒钟。这篇文章会完整分享这套企业级AI知识库从0到1的实操路径架构选型背后的逻辑、Spring Boot工程搭建、文档解析与向量化、RAG检索链路、DeepSeek接口集成、API设计以及我在实测中踩过的大大小小的坑。内容按能照着做的标准来写适合有一定Spring Boot基础的开发者做参考同样的思路也可以迁移到客服问答、内部搜索、智能问答机器人等场景。1. 为什么企业知识库要选RAG架构从传统搜索到大模型增强的演进1.1 企业内部知识管理的三个真实痛点先说传统的做法。绝大多数企业内部知识库的第一版都是共享盘文件夹命名规范好一点的上个关系型数据库再讲究一点的引入Elasticsearch做全文检索。表面上看问题不大实际用起来会发现三个硬伤。第一个痛点是内容堆积速度快人工维护跟不上。生产文档、变更记录、项目复盘每天都在产生。文件夹分类规则一个月前定好三个月后就没有人遵守了。分类一旦混乱检索就成了碰运气。第二个痛点是关键词匹配解决不了语义问题。同样的故障现象一个工程师写的是内存溢出另一个写的是堆外内存持续增长导致服务重启还有一份材料里写的是OOM。用关键词搜索内存溢出后两份文档大概率匹配不到。传统全文索引可以靠分词器缓解一部分问题但同义词、指代、上下文语境这些语义层面的东西靠倒排索引很难解决。第三个痛点是信息找到了还要自己读、自己想。哪怕检索结果足够准确用户拿到的还是一片原始文档要自己翻上下文、自己归纳结论。对于操作手册类的内容还好对于那种分散在十几份文档里的关联信息人要拼出完整答案仍然很费劲。这三个痛点是纯检索系统解决不了的本质上是从给你文档到给你答案的转变。大模型能回答问题但它不了解企业内部的数据企业知识库有数据却不能直接变成模型的常识。两者结合就是RAG——检索增强生成。1.2 RAG架构的核心工作流程RAG的完整链路可以拆成离线索引和在线问答两段。离线索引阶段把企业文档解析成纯文本按照一定策略切成一块一块的文本切片chunk每一块通过Embedding模型转换成向量一串几百维的浮点数连同原始文本一起存进向量数据库。这一步的目的是建立语义索引语义相近的文本在向量空间里的距离也近。在线问答阶段用户提问后先把问题本身也向量化然后用这个向量去向量数据库里做相似度检索找出与问题语义最接近的Top-K个文本切片。接下来把这些切片按固定格式拼接进Prompt连同用户问题一起发给大模型模型基于这些企业文档内容生成回答。因为有真实的文档片段作为依据模型可以在没见过的私有知识上给出有出处、不易编造的答案。这个流程能从架构上同时解决上面三个痛点语义检索替代关键词匹配解决搜不到把答案生成交给大模型解决读不完而每次回答都基于当前检索到的具体文档知识过期了只需要更新索引库不需要重训练模型。1.3 技术选型的取舍逻辑技术上我最终选定的组合是Spring Boot 3.x PostgreSQLpgvector扩展 DeepSeek API 本地Embedding服务。逐个说理由。Spring Boot是我最熟悉、企业环境里也最稳的选择。Java后端生态成熟团队里招人容易部署运维体系监控、日志、打包都是现成的。用Spring Boot做知识库的接入层、业务层和API层能很快融入企业现有的后端架构。向量数据库选了PostgreSQL的pgvector扩展而不是单独的Milvus或Weaviate。理由很现实知识库项目在前期数据量通常是百万条级别以下pgvector配合HNSW索引完全够用而且文档元数据、用户操作记录这些结构化数据也要存用同一个PostgreSQL实例能少维护一套中间件。等数据量真正到了千万级以上再拆出独立向量数据库也不迟。Embedding模型这里有一个非常关键的点DeepSeek开放平台目前提供的是对话补全Chat Completions类接口不直接提供文本向量化Embedding接口。所以向量化环节需要单独部署一个Embedding模型。我这边用的是本地部署的BGE-M3模型通过Ollama启动一个本地Embedding服务接口风格简单、私有化部署、不额外产生调用费用。这一步很多人第一次做容易踩坑以为一个DeepSeek Key就能打通全部链路其实是两套服务在配合工作。最后是DeepSeek负责文本生成。选择它的原因主要是输出质量稳定、API兼容度高、成本在同类模型里属于低档位。实测在代码理解、技术文档归纳、多轮对话这些场景下效果足够支撑企业知识库问答。2. 工程骨架搭建Spring Boot 3 PostgreSQL 本地Embedding服务2.1 依赖清单与工程结构先用Spring Initializr生成一个基础工程Java版本选17构建工具用Maven。依赖方面我一共只加了四个必要的starterspring-boot-starter-web提供Web能力、spring-boot-starter-data-jpa操作元数据表、spring-boot-starter-validation参数校验、postgresql驱动连接PostgreSQL。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId scoperuntime/scope /dependency没有引入Spring AI的封装包原因有两个。一是这套技术栈更新太快封装层抽象变化频繁跟着升级容易被动二是RAG链路的每个环节本质上是几个HTTP调用加SQL查询自己封装反而能更好地控制prompt细节和异常处理。建议先手工把链路跑通之后再评估要不要引入框架。工程结构上我按功能分包com.example.ragknowledge ├── controller/ # REST接口 ├── service/ # 核心业务 │ ├── DocumentParseService.java │ ├── ChunkService.java │ ├── EmbeddingService.java │ ├── VectorStoreService.java │ ├── DeepSeekService.java │ └── RagService.java ├── repository/ # JPA数据访问 ├── entity/ # 实体类 ├── config/ # RestClient与线程池配置 └── dto/ # 请求/响应对象2.2 数据库初始化与向量扩展安装PostgreSQL之后创建一个专用数据库并启用pgvector扩展。注意要先启用扩展再建表否则vector类型不可用。CREATE DATABASE knowledge_db; \c knowledge_db CREATE EXTENSION IF NOT EXISTS vector;库里的表分为两类文档元数据表knowledge_document和文本切片表document_chunk。切片表里的embedding字段类型是vector(1024)维度要和Embedding模型输出维度一致。BGE-M3输出1024维所以这里写1024。如果你的Embedding模型是其他维度必须同步修改否则插入时会报错。CREATE TABLE knowledge_document ( id VARCHAR(64) PRIMARY KEY, title VARCHAR(255) NOT NULL, file_type VARCHAR(20), chunk_count INT DEFAULT 0, upload_time TIMESTAMP DEFAULT now(), status VARCHAR(20) DEFAULT PENDING ); CREATE TABLE document_chunk ( id VARCHAR(64) PRIMARY KEY, document_id VARCHAR(64) NOT NULL REFERENCES knowledge_document(id) ON DELETE CASCADE, chunk_index INT NOT NULL, content TEXT NOT NULL, embedding vector(1024), CONSTRAINT uk_doc_chunk UNIQUE (document_id, chunk_index) ); CREATE INDEX idx_chunk_embedding ON document_chunk USING hnsw (embedding vector_cosine_ops);HNSW索引是pgvector里适合中大型数据量的近似最近邻索引检索速度和精度平衡较好。对刚起步的小数据集甚至不加索引也没问题但加上是更好的习惯后面数据涨了不用返工。2.3 本地Embedding服务的部署与验证本机装好Ollama之后拉取BGE-M3模型ollama pull bge-m3 ollama run bge-m3Ollama会给本地起一个HTTP服务Embedding接口的调用方式如下curl http://localhost:11434/api/embed \ -H Content-Type: application/json \ -d {model: bge-m3, input: 数据库连接池配置}返回的body里有一个embeddings数组每个元素就是一组浮点数组。为了保险我会在配置里单独放一个embedding.base-url方便以后把Ollama部署到独立机器上或者换成别的Embedding服务。2.4 DeepSeek API的接入配置DeepSeek接口的调用地是https://api.deepseek.com/chat/completions请求格式和OpenAI的Chat Completions兼容model字段填deepseek-chat。在application.yml里这样配置spring: datasource: url: jdbc:postgresql://localhost:5432/knowledge_db username: postgres password: ${DB_PASSWORD} jpa: hibernate: ddl-auto: validate deepseek: api-key: ${DEEPSEEK_API_KEY} base-url: https://api.deepseek.com model: deepseek-chat timeout: 60s embedding: base-url: http://localhost:11434 model: bge-m3 dimension: 1024 rag: top-k: 5 similarity-threshold: 0.35API Key和数据库密码不要明文写在配置文件里用环境变量注入。这一点在真实企业环境里是硬性要求哪怕本地开发也要养成习惯。3. 文档接入与向量化改造从PDF和Word到可检索向量3.1 多格式文档解析文档解析是所有后续工作的基础。我这边实际遇到的格式主要是PDF、Word、Markdown、纯文本四类。解析方案没有一个通用银弹我用的组合是这样的纯文本/Markdown直接读取Markdown额外做一次图片引用和代码块的简单清理。PDF用PDFBox基础版本来提取文本遇到扫描版的PDF纯图片需要先接OCR服务这一步最费事。Word用Apache POI的XWPFDocument读取.docx老式.doc文件兼容性一般建议内部统一转成.docx再处理。每个文档解析完之后会得到一个相对干净的纯文本字符串。我在这一步做了一个容易被忽略的操作去掉大量赘余的页眉页脚、目录内容和重复的标题编号。这些文本在后续分块时会造成严重的语义干扰A股最常见的坑就是整页都是页脚信息向量化出来的内容全是重复的页码和公司名。3.2 文本分块chunk size与overlap怎么定分块策略直接决定检索质量。我一开始图省事按照固定字符数500无重叠切分结果提问如何配置数据库连接池时正确答案被切进两个相邻的块里每一块单独看都不完整检索召回了半句话模型给出来的回答自然也是半吊子。经过多轮测试我这边最终用的参数是chunk_size700个字符overlap100个字符。每次切分时当前块的头部会保留上一块末尾的100个字符。这样跨边界的关键信息在每个块里都会出现一次只有一边完整也能被检索到。分块还需要考虑语义边界。纯按字符切分容易把列表截断我采用的方式是优先在标题、段落、列表项边界处切实在没有边界再按字符硬切。核心逻辑可以封装成一个简单的ChunkServiceService public class ChunkService { public ListChunk split(String text, int chunkSize, int overlap) { ListChunk chunks new ArrayList(); // 按标题/段落/列表项边界切分未到chunkSize则继续拼接 // 每次切分保留前一个chunk尾部overlap字符 // 返回带序号的chunk列表 return chunks; } }3.3 向量化服务的代码封装向量化服务的职责很简单接收文本返回float数组。用Spring Boot 3内置的RestClient来调用Ollama接口代码非常简洁Service public class EmbeddingService { private final RestClient restClient; private final EmbeddingProperties props; public EmbeddingService(RestClient.Builder builder, EmbeddingProperties props) { this.restClient builder.baseUrl(props.getBaseUrl()).build(); this.props props; } public float[] embed(String text) { MapString, Object body Map.of( model, props.getModel(), input, text ); MapString, Object resp restClient.post() .uri(/api/embed) .body(body) .retrieve() .body(Map.class); ListListDouble embeddings (ListListDouble) resp.get(embeddings); ListDouble first embeddings.get(0); float[] result new float[first.size()]; for (int i 0; i first.size(); i) { result[i] first.get(i).floatValue(); } return result; } }这里要注意BGE-M3在检索场景下对中文查询通常建议加为这个句子生成表示以用于检索相关文章这类前缀RAG instruction但我们在Embedding接口里对每个chunk做向量化时是否加前缀会直接影响后续检索效果。实测加了指令前缀instruction后文档侧的向量效果更稳定你可以根据自己的检索指标测一版对比一下。4. RAG核心链路编码向量检索、Prompt拼接与DeepSeek调用4.1 向量检索的实现向量检索一般用两种方式实体类映射的JPA方式和JdbcTemplate的原生SQL方式。向量字段与普通关系字段混在一起时JPA映射很别扭所以我直接用JdbcTemplate执行原生SQL检索document_chunk表。核心SQL是根据余弦距离做最近邻查询SELECT id, document_id, chunk_index, content, 1 - (embedding ?) AS similarity FROM document_chunk ORDER BY embedding ? LIMIT ?;是pgvector计算余弦距离的运算符。查询时把用户问题的向量以数组形式传进去。封装成VectorStoreServiceService public class VectorStoreService { private final JdbcTemplate jdbcTemplate; public ListRetrievedChunk search(float[] queryVector, int topK, double minSimilarity) { String sql SELECT id, document_id, chunk_index, content, 1 - (embedding ?) AS similarity FROM document_chunk WHERE 1 - (embedding ?) ? ORDER BY similarity DESC LIMIT ? ; ListRetrievedChunk rows jdbcTemplate.query( sql, new Object[]{queryVector, queryVector, minSimilarity, topK}, (rs, rowNum) - new RetrievedChunk( rs.getString(id), rs.getString(document_id), rs.getInt(chunk_index), rs.getString(content), rs.getDouble(similarity) ) ); return rows; } }这里我加了minSimilarity过滤避免检索出来一堆相似度只有0.1的无关片段这种片段喂给模型只会增加噪音。4.2 Prompt工程与上下文组织检索结果拿到之后怎么拼Prompt是决定回答质量的关键。我试过几种组织方式效果差的典型做法是把所有chunk原样扔进去问题放最后。这样做最常见的问题有两个一是上下文长度会超二是模型分不清哪些片段是本次回答要参考的。经过调优后我的固定模板是你是企业知识库助手。请严格依据以下资料片段回答用户问题。 如果资料中没有答案请直接回答根据现有资料无法回答不要编造。 资料片段 【片段1】 文档标题XXX 内容... 【片段2】 文档标题XXX 内容... 用户问题... 要求回答时先给出结论再引用对应的资料编号说明依据。注意每个片段前面带上了文档标题这非常重要。模型知道回答依据来自哪篇文档在涉及操作规范、故障案例的回答场景下引用来源能显著降低回答的编造成分也让用户可以返回原文复核。4.3 调用DeepSeek生成回答调用DeepSeek服务的封装与Embedding类似但请求体结构不同关键是把消息数组组织好。Chat Completions接口要求参数是messages数组每个元素有rolesystem/user/assistant和content。我把上面拼好的资料片段和用户问题放在user消息里系统级指令单独放一条system消息Service public class DeepSeekService { private final RestClient restClient; private final DeepSeekProperties props; public DeepSeekService(RestClient.Builder builder, DeepSeekProperties props) { this.restClient builder.baseUrl(props.getBaseUrl()).build(); this.props props; } public String chat(String systemPrompt, String userPrompt) { ListMapString, String messages List.of( Map.of(role, system, content, systemPrompt), Map.of(role, user, content, userPrompt) ); MapString, Object body new HashMap(); body.put(model, props.getModel()); body.put(messages, messages); body.put(temperature, 0.3); body.put(stream, false); MapString, Object resp restClient.post() .uri(/chat/completions) .header(Authorization, Bearer props.getApiKey()) .body(body) .retrieve() .body(Map.class); ListMapString, Object choices (ListMapString, Object) resp.get(choices); MapString, Object message (MapString, Object) choices.get(0).get(message); return (String) message.get(content); } }temperature设置为0.3。知识库问答更看重准确性和一致性过高的温度会让模型自由发挥出现过一次一本正经地编了个操作步骤的情况。0.1到0.3是我在生成类问答上的舒适区。RagService把上面三步串起来Service public class RagService { private final EmbeddingService embeddingService; private final VectorStoreService vectorStoreService; private final DeepSeekService deepSeekService; private final RagProperties ragProperties; public RagAnswer answer(String question) { float[] questionVector embeddingService.embed(question); ListRetrievedChunk chunks vectorStoreService.search( questionVector, ragProperties.getTopK(), ragProperties.getSimilarityThreshold() ); if (chunks.isEmpty()) { return RagAnswer.buildEmpty(根据现有资料无法回答); } String context buildContext(chunks); String userPrompt buildUserPrompt(context, question); String answer deepSeekService.chat(SYSTEM_PROMPT, userPrompt); return RagAnswer.build(answer, chunks); } }到这一步最核心的问答闭环已经跑通用户发问题系统检索企业文档DeepSeek基于文档内容生成回答并带出处引用。5. 知识库API设计与异步索引机制5.1 RESTful接口设计知识库对外提供的接口我分成三组文档管理、检索问答、索引管理。文档管理接口是标准RESTful风格POST /api/documents 上传文档Multipart GET /api/documents/{id} 查看文档详情 DELETE /api/documents/{id} 删除文档及其所有切片 GET /api/documents 分页列出文档问答接口是知识库最核心的能力POST /api/rag/ask 提问并获取回答请求体就两个字段{ question: 数据库连接池参数怎么调优 }响应除了answer外还带上命中的资料片段列表方便前端展示引用来源{ answer: 先看连接池的初始大小和最大大小...依据【片段1】【片段3】, references: [ {documentId: doc_001, chunkIndex: 1, content: ...}, {documentId: doc_003, chunkIndex: 3, content: ...} ] }5.2 异步解析与索引一致性文档上传后解析、分块、向量化是典型的重计算任务。如果在HTTP请求里同步执行大文档可能要几十秒接口超时不说并发上传时线程池直接被拖垮。我的方案是接口只负责保存原始文件和创建一条PENDING状态的文档记录然后丢到Async线程池里处理。Async(docIndexExecutor) public void indexDocument(String documentId) { // 1. 从文件存储读取原始文件 // 2. 解析为纯文本 // 3. 分块 // 4. 逐块向量化并写入document_chunk表 // 5. 更新knowledge_document状态为COMPLETED }这里有三个细节要注意一是幂等重入。如果向量化中途进程被重启文档状态还停留在PROCESSING需要支持重新触发任务。我的做法是启动时扫描PROCESSING状态的记录重新执行索引任务。二是顺序写入。分块向量化要严格按chunk_index顺序写入虽然最终查询按向量索引检索但删除和调试时需要知道一块在原文中的位置。三是删除联动。删除文档时先删document_chunk里对应的记录再删元数据记录。我在表设计时加了ON DELETE CASCADEJPA里删除父记录时子记录会被数据库自动清理省掉了人工维护关联的麻烦。5.3 基础安全控制Key鉴权与越权防护在企业环境里知识库往往服务于多个部门直接裸奔一个接口是不行的。第一版我不建议一上来就上Spring Security全家桶而是先用一个轻量级的API Key拦截器。实现很简单定义OncePerRequestFilter校验请求头里的X-API-Key是否在配置的合法Key集合里。成本低、侵入小足够挡住外部流量。更大的安全管控SSO、RBAC可以在这个基础上演进不影响已有接口。另外一个容易被忽视的点是文档级权限。如果企业里A部门的知识不能让B部门搜到那检索SQL就必须带部门/角色过滤条件。建议在document表里预留owner_department字段检索时把当前用户的部门作为过滤条件一并写入SQL。这个功能最好在设计阶段就考虑进去后面补权限字段会涉及大量索引重建代价高不少。6. 实测调优与避坑记录把准确率从60%提到90%的过程6.1 分块参数对检索质量的影响第一版我用的是500字符无重叠分块上线测试集的准确率只有六成左右。几个典型错误场景非常有代表性问题如何刷新缓存检索到的chunk是一段不带方法名和参数说明的代码片段模型只能猜测。问题权限系统有哪些角色相关角色定义刚好被两个chunk各截一半没有一块能作为完整依据。问题服务器重启的影响范围检索到的全是文档中重启二字出现的其他段落。后来把chunk_size调到700、overlap设为100并优先在段落边界切分这三个场景里的检索命中率明显改善。我建议你把分块参数设计成可配置项跑测试集时做几组对比实验再定值而不是拍脑袋选一个。关于chunk_size的上限有一条经验最好让单个chunk在Prompt里的Token数不要超过模型上下文窗口的五分之一。以DeepSeek的上下文窗口来计算700到800个中文字符是比较安全的值再大容易挤占上下文中其他资料片段的空间。6.2 top-k与相似度阈值怎么调top-k代表每次检索取多少个候选片段。k值太小答案分散在多个chunk时召不回k值太大Prompt里塞满低相关片段模型容易迷失重点。我测试的结果是这样的k值准确率表现问题1偏低依赖单块完整度多数问题信息不足3中等简单问题够用跨章节问题仍不够5稳定综合效果最好成本可接受10提升有限上下文冗余增加回答开始出现偏离最终线上固定top-k5。相似度阈值则要看Embedding模型的分数分布。BGE-M3对中文文档的相似度普遍偏高我压到0.35左右能过滤掉明显不相关的片段但又不会误伤有效内容。这个阈值一定要基于你自己的测试数据来标定不同模型、不同语料分布完全不同。6.3 性能与成本控制整个链路最耗时的环节有两个文档索引阶段的向量化和实时问答阶段的DeepSeek调用。向量化是离线的可以用并发线程加速一批文档几十个chunk并发4到8路基本能压到几十秒内。实时问答的首字节延迟主要由DeepSeek接口响应时间决定短问题一般2到5秒这是正常的。成本方面Embedding走本地服务电费忽略不计。DeepSeek的调用成本主要和两个参数相关输入Token数和输出Token数。可以通过两个手段控制一是控制检索得到的chunk总数二是限制生成回答的最大长度max_tokens。企业场景下回答控制在300到500字已经足够不要默认给模型无限发挥空间。6.4 我踩过的几个隐蔽坑第一个坑是pgvector索引失效。我在document_chunk表里创建HNSW索引之后一开始查询还是走全表扫描排查发现是数据量太小优化器觉得全表扫比索引快。数据量上来之后索引才被用上这不是代码问题是优化器策略。第二个坑是上传文件名编码。用MultipartFile拿到文件名后中文名在部分环境下会变成一串百分号编码导致库里存的title是一堆乱码。需要手动做一次URL解码或者干脆用UUID做id、用原文件名做展示字段。第三个坑是**Async方法自调用失效**。Spring的Async是基于代理实现的同一个类内部调用是不经过代理的异步会静默退化成同步。我当时把indexDocument方法放在同一个Service里外部接口一调用就长时间阻塞在这个方法上。解决办法是拆一个独立的异步Service类或者通过注入自身代理来调用。第四个坑与DeepSeek的API响应结构有关。流式响应的结构和非流式完全不同如果一开始用streamtrue解析方式要换成SSE格式。我第一版图省事直接用了streamfalse把逻辑跑通后才考虑流式体验。建议初版先用非流式减少解析复杂度。第五个坑是并发写入索引与查询的脏数据。文档还在解析过程中用户提问就会发生这时候document_chunk只写了一半检索出来的结果是不完整的。解决方法是给文档状态加一个阈值只有处理状态为COMPLETED的文档其chunk才参与检索。SQL里关联一下文档状态条件即可简单且有效。写在最后的一点体会这套知识库我从白板画架构到跑通第一个问答用了大概两天后面为了让准确率从能查到进化到答得稳又花了两周反复调分块参数和阈值。整个过程最深的感受是RAG方案里检索质量远比模型本身重要。模型再聪明检索回来的片段是错的它也只能一本正经地错下去。所以如果让我给后来者排优先级我会说投入最多精力在文档清洗、分块策略和检索调试上这几件事做好了再差的模型都能给出像样的回答。顺便提一个后续可以扩展的方向每一条问答请求都被我打了日志包括问题、命中的文档、模型回答和用户点赞反馈。这批数据攒久了以后可以拿出来做检索的点击率分析、高频问题挖掘甚至定向微调。知识库不是一个静态系统它是越用越值钱的。关于这套系统如果你在实际搭建时遇到我这里没有覆盖到的问题欢迎按照这个思路自己再挖一遍大多数坑都是可以靠检索日志和测试集定位并解决的。