news 2026/9/26 9:24:53

Spring AI 企业知识库问答实战:RAG 架构、PGVector 与混合检索

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI 企业知识库问答实战:RAG 架构、PGVector 与混合检索

1. 为什么选择 Spring AI 来做企业知识库问答

企业知识库问答这个需求,这两年我接到的咨询特别多。几乎每一家有点规模的公司,内部都堆着成千上万份文档——产品手册、运维手册、合同模板、历史工单、会议纪要,散落在 Confluence、语雀、共享盘、甚至个人电脑里。员工想找一条信息,要么在群里问一圈没人理,要么靠关键词搜索翻半天,最后找到的还可能是三年前的过期版本。这个痛点非常真实,也非常普遍。

传统做法是做一个全文检索系统,把文档丢进 Elasticsearch,靠关键词匹配。但关键词匹配有个致命问题:用户问“服务器磁盘满了怎么处理”,文档里写的是“存储空间不足的应急方案”,字面完全不重叠,搜不出来。用户得自己猜文档里用了什么词,这体验就很差。而基于大语言模型的问答系统,能理解语义,用户怎么问都能找到相关内容,还能直接给出总结好的答案,这就是 RAG(检索增强生成)的价值所在。

那为什么用 Spring AI 而不是 Python 那一套?这是很多 Java 团队最关心的问题。我接触过的企业里,后端主力技术栈绝大多数是 Java 和 Spring Boot,运维体系、监控体系、发布流程都是围绕 Java 建的。如果为了做个知识库问答,单独搭一套 Python 服务,意味着要引入新的语言、新的依赖管理、新的部署方式,运维成本陡增。Spring AI 的出现,让 Java 团队能用自己熟悉的方式把大模型能力接进来,这是它最大的意义。

Spring AI 本质上是一套抽象层,它把不同大模型厂商的 API 差异屏蔽掉,提供统一的ChatClient、EmbeddingClient、VectorStore等接口。你今天用 OpenAI,明天想换成别的模型,改改配置就行,业务代码基本不用动。这个抽象设计对企业来说非常重要,因为大模型这个领域变化太快了,谁也不想被某一家绑死。

这篇文章我会完整讲一遍怎么用 Spring AI 搭一个企业知识库问答系统,包括整体架构怎么设计、文档怎么切块、向量库怎么选、检索怎么做、多轮对话怎么处理,以及我在实际项目里踩过的坑。适合有 Spring Boot 基础、想快速落地一个 RAG 应用的开发者,也适合技术负责人做方案选型参考。

2. 整体架构设计与技术选型思路

2.1 RAG 的核心链路拆解

RAG 这个词听起来玄乎,拆开看其实就两条链路:一条是离线索引链路,一条是在线问答链路。

离线索引链路干的事是:把企业里的各种文档读进来,切成小块,每块转成一个向量(一串浮点数),存到向量数据库里。这个过程是一次性的,或者文档更新时增量做。在线问答链路干的事是:用户提问,把问题也转成向量,去向量库里找最相似的几个文档块,把这些文档块和用户问题一起拼成提示词,发给大模型,让大模型基于这些文档块生成答案。

用生活化的类比:离线索引就像给图书馆的每本书做索引卡片,卡片上不是写关键词,而是写这本书的“语义指纹”。在线问答就是用户来描述他想找什么,你拿他的描述去比对所有卡片的指纹,找出最接近的几本,然后把这几本书的相关段落翻给一个很聪明的助手,让他读完告诉你答案。

这个链路里,每个环节都有讲究。文档切块切多大、向量模型选哪个、检索返回几条、提示词怎么写,都会直接影响最终效果。我见过太多团队,模型选最贵的,向量库选最潮的,但切块策略一塌糊涂,最后效果惨不忍睹。所以下面我会逐个环节讲清楚。

2.2 技术栈选型与理由

先把我推荐的技术栈列出来,再说为什么。

组件选型理由
应用框架Spring Boot 3.x + Spring AIJava 团队零学习成本,生态成熟
大模型OpenAI 兼容接口抽象层统一,可灵活切换
向量模型text-embedding 系列维度适中,中文效果可接受
向量数据库PGVector复用现有 PostgreSQL,运维成本低
文档解析Apache Tika支持格式多,PDF/Word/Excel 通吃
切块策略递归字符切分实现简单,效果稳定

重点说一下 PGVector 这个选择。很多教程一上来就推荐专用的向量数据库,比如 Milvus、Qdrant、Weaviate。这些确实性能强、功能全,但对大多数企业来说,引入一个新的数据库意味着多一套运维体系、多一份备份策略、多一个故障点。而 PGVector 是 PostgreSQL 的一个扩展,你现有的 PostgreSQL 加个扩展就能用,数据和应用数据在同一个库里,事务、备份、监控全都复用。对于文档量在百万级以下的企业知识库,PGVector 的性能完全够用。我实测过,单表几百万个向量,配上合适的索引,查询延迟在几十毫秒级别,体验很好。

大模型这块,Spring AI 支持 OpenAI 兼容的接口。这意味着只要某个模型服务提供了 OpenAI 兼容的 API,你就能接进来。这个设计非常实用,因为企业往往有合规要求,不能直接把数据发给外部服务,需要走内部部署的模型。只要内部模型服务包装成 OpenAI 兼容格式,Spring AI 就能无缝对接。

2.3 项目模块划分

我习惯把这类项目拆成三个模块,职责清晰,方便独立演进。

第一个是文档接入模块,负责从各种来源读取文档,解析成纯文本,做清洗和切块。这个模块的输入是文件路径或文档流,输出是切好的文本块列表。

第二个是向量索引模块,负责把文本块转成向量,写入向量库,同时维护文档元数据(来源、标题、更新时间等)。这个模块要支持增量更新,文档改了要能重新索引。

第三个是问答服务模块,负责接收用户问题,做检索,拼提示词,调大模型,返回答案。这个模块还要处理多轮对话的上下文管理。

这三个模块可以放在一个 Spring Boot 应用里,也可以拆成微服务。我建议初期放一个应用里,用包结构区分就行,等量大了再拆。过早拆微服务只会增加复杂度,没有实际收益。

3. 核心细节解析与实操要点

3.1 文档切块:RAG 效果的第一道分水岭

切块这件事,看起来简单,实际上是最容易翻车的地方。我见过一个团队,把整篇几万字的文档直接转成一个向量,结果检索时要么全中要么全不中,效果极差。也见过切得太碎,一句话一个块,检索出来全是碎片,大模型拼不出完整答案。

切块的核心矛盾是:块太大,向量表达的信息太杂,检索精度下降;块太小,上下文不完整,大模型理解不了。业界比较通用的做法是块大小在 500 到 1000 个字符之间,块之间保留 10% 到 20% 的重叠。重叠的目的是防止一个完整的语义被切断,比如一句话正好跨在两个块的边界上,有重叠就能保证至少有一个块包含完整语义。

Spring AI 提供了TokenTextSplitter和基于字符的切分器。我一般用递归字符切分,它会优先按段落切,段落太长再按句子切,句子还长再按字符切。这样能尽量保证语义完整性。配置大概是这样:

TokenTextSplitter splitter = new TokenTextSplitter( 800, // 目标块大小 100, // 最小块大小 50, // 块间重叠 10000, // 最大块数 true // 保留分隔符 );

这里有个经验:中文和英文的切块策略要区别对待。英文按 token 算比较准,中文一个字可能就是一个 token 甚至更多。如果你的文档中英混杂,建议按字符数切,而不是按 token 数。我一般中文文档用 500 到 800 字符一块,英文文档用 1000 到 1500 字符一块。

还有一个容易被忽略的点:切块前要做文档清洗。PDF 解析出来经常有页眉页脚、页码、乱码,Word 解析出来可能有大量空行和格式符号。这些噪音如果不清理,会污染向量,导致检索不准。我一般会做这几步清洗:去掉连续空行、去掉纯数字行(页码)、去掉重复出现的页眉页脚、统一全角半角标点。

3.2 向量模型选择与维度考量

向量模型决定了检索的天花板。模型不好,后面怎么调都白搭。选向量模型主要看三个指标:语义表达能力、维度、推理速度。

语义表达能力就是它能不能把语义相近的文本映射到相近的向量。这个一般看公开的评测榜单,但榜单只能参考,最好还是用自己的业务数据测一下。维度方面,常见的有 768 维、1024 维、1536 维、3072 维。维度越高,表达能力越强,但存储和计算成本也越高。1536 维是个比较平衡的选择,大多数场景够用。

推理速度也很关键,因为索引阶段要把所有文档块都转一遍。如果文档量大,模型太慢会导致索引时间不可接受。我一般会先拿一小批文档测一下吞吐,估算全量索引时间。

Spring AI 里配置向量模型很简单,通过application.yml指定就行:

spring: ai: openai: api-key: ${OPENAI_API_KEY} embedding: options: model: text-embedding-3-small

这里有个坑要注意:向量模型一旦选定,就不能随便换。因为不同模型生成的向量空间不一样,换了模型,之前索引的所有向量都失效了,必须全量重建。所以选型时要慎重,考虑清楚未来会不会换。如果预见到可能要换,可以在设计上留个字段记录向量模型版本,方便后续做灰度迁移。

3.3 PGVector 的安装与索引配置

PGVector 的安装,在 Linux 上相对简单,Windows 上稍微麻烦一点。核心就是编译扩展、在数据库里执行CREATE EXTENSION vector。装好之后,建表时用vector类型存向量。

建表语句大概长这样:

CREATE TABLE knowledge_chunk ( id BIGSERIAL PRIMARY KEY, content TEXT NOT NULL, embedding vector(1536), doc_id VARCHAR(64), doc_title VARCHAR(255), chunk_index INT, created_at TIMESTAMP DEFAULT NOW() );

索引这块是性能关键。PGVector 支持两种索引:IVFFlat 和 HNSW。IVFFlat 建索引快、占空间小,但查询精度略低;HNSW 查询精度高、速度快,但建索引慢、占空间大。我一般推荐 HNSW,因为查询体验更重要,建索引慢一点可以接受。

CREATE INDEX ON knowledge_chunk USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

这里的m和ef_construction是两个关键参数。m控制每个节点的连接数,越大精度越高但索引越大,16 是个常用值。ef_construction控制建索引时的搜索范围,越大索引质量越高但建得越慢,64 是平衡值。查询时还有个ef_search参数,可以在会话级别调整,值越大召回越高但越慢。

注意:HNSW 索引在数据量很大时建索引会占用大量内存,建议在业务低峰期建,并且监控内存使用。如果内存紧张,可以先建 IVFFlat,等数据稳定后再换 HNSW。

3.4 检索策略:不只是向量相似度

很多人以为 RAG 的检索就是拿问题向量去向量库查 topK,其实远不止。纯向量检索有几个明显问题:对专有名词、型号、编号这类精确匹配不敏感;对否定语义处理不好;topK 固定,可能召回不足或召回过多。

我的做法是混合检索:向量检索加关键词检索,两路结果做融合。关键词检索用 PostgreSQL 自带的全文检索就行,不需要额外引入 Elasticsearch。融合算法用 RRF(Reciprocal Rank Fusion),它对不同来源的分数尺度不敏感,实现简单效果好。

-- 向量检索 SELECT id, content, 1 - (embedding <=> :queryVec) AS score FROM knowledge_chunk ORDER BY embedding <=> :queryVec LIMIT 20; -- 关键词检索 SELECT id, content, ts_rank(to_tsvector(content), plainto_tsquery(:query)) AS score FROM knowledge_chunk WHERE to_tsvector(content) @@ plainto_tsquery(:query) ORDER BY score DESC LIMIT 20;

两路各取 20 条,用 RRF 融合后取前 5 到 8 条送给大模型。这个数量不是拍脑袋定的,太少信息不够,太多会超出上下文窗口且引入噪音。我一般会做个实验,用一批测试问题跑不同 topK,看答案质量,选最优值。

还有一个进阶技巧是重排序。检索回来的文档块,用一个专门的重排序模型再排一遍,把最相关的放前面。这个能显著提升效果,但会增加一次模型调用,延迟上升。如果对延迟不敏感,强烈建议加。

4. 实操过程与核心环节实现

4.1 项目初始化与依赖配置

先建一个 Spring Boot 3.x 项目,Maven 依赖加上 Spring AI 的 starter。注意 Spring AI 的版本要和 Spring Boot 版本匹配,不然会有兼容问题。我写这篇文章时用的是 Spring AI 1.0 系列,对应 Spring Boot 3.3 以上。

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.apache.tika</groupId> <artifactId>tika-core</artifactId> <version>2.9.1</version> </dependency>

配置文件里把数据库和模型相关的都配上:

spring: datasource: url: jdbc:postgresql://localhost:5432/knowledge username: postgres password: ${DB_PASSWORD} ai: openai: api-key: ${OPENAI_API_KEY} base-url: ${OPENAI_BASE_URL} chat: options: model: gpt-4o-mini temperature: 0.2 embedding: options: model: text-embedding-3-small vectorstore: pgvector: index-type: HNSW distance-type: COSINE_DISTANCE dimensions: 1536

temperature设成 0.2 是有意的。知识库问答要的是准确和稳定,不是创意,温度低一点能让答案更聚焦、更少胡编。base-url单独抽出来是为了方便切换模型服务地址,不同环境用不同配置。

4.2 文档解析与切块实现

文档解析我用 Apache Tika,它能自动识别文件类型,PDF、Word、Excel、PPT、HTML 都能处理。核心代码就几行:

public String parseDocument(InputStream inputStream) throws Exception { AutoDetectParser parser = new AutoDetectParser(); BodyContentHandler handler = new BodyContentHandler(-1); Metadata metadata = new Metadata(); parser.parse(inputStream, handler, metadata, new ParseContext()); return handler.toString(); }

BodyContentHandler(-1)里的 -1 表示不限制输出长度,默认是 100KB,文档大了会被截断,这个坑我踩过。

解析完做清洗,然后切块。切块我用 Spring AI 的TokenTextSplitter,但前面说了中文要按字符切,所以我实际用的是自己封装的递归字符切分器,逻辑是按\n\n、\n、。、!、?、;这个优先级递归切,直到每块小于目标大小。

public List<String> split(String text, int chunkSize, int overlap) { List<String> chunks = new ArrayList<>(); int start = 0; while (start < text.length()) { int end = Math.min(start + chunkSize, text.length()); if (end < text.length()) { int lastBreak = findLastBreak(text, start, end); if (lastBreak > start) end = lastBreak; } chunks.add(text.substring(start, end)); start = end - overlap; if (start < 0) start = 0; if (end == text.length()) break; } return chunks; }

findLastBreak就是从 end 往前找最近的断句符号。这个逻辑简单但有效,比直接按固定长度切效果好很多。

4.3 向量写入与增量更新

向量写入用 Spring AI 的VectorStore接口,它封装了写入逻辑:

List<Document> documents = chunks.stream() .map(chunk -> new Document(chunk, Map.of( "docId", docId, "title", title, "chunkIndex", index ))) .toList(); vectorStore.add(documents);

Document的第二个参数是元数据,这个很重要。检索时可以按元数据过滤,比如只搜某个部门的文档,或者只搜某个时间之后的文档。元数据设计要提前想好,后面加字段要重建索引。

增量更新是个难点。文档改了,怎么知道哪些块要更新?我的做法是给每个文档算一个内容哈希,存到文档表里。更新时先比对哈希,一样就跳过,不一样就删掉这个文档的所有旧块,重新切块索引。删除用元数据过滤:

vectorStore.delete("docId == '" + docId + "'");

注意:删除和写入最好放在一个事务里,或者至少保证删除成功后再写入。如果删除失败但写入成功,会出现重复块,检索时同一内容出现多次,影响效果。

4.4 问答链路完整实现

问答链路的入口是一个 REST 接口,接收问题和会话 ID。核心流程是:问题向量化、混合检索、拼提示词、调大模型、返回答案。

public String ask(String question, String sessionId) { // 1. 检索 List<Document> docs = hybridSearch(question, 8); // 2. 拼上下文 String context = docs.stream() .map(Document::getContent) .collect(Collectors.joining("\n\n---\n\n")); // 3. 取历史对话 List<Message> history = sessionStore.getHistory(sessionId); // 4. 拼提示词 String systemPrompt = """ 你是一个企业知识库助手。请严格基于下面提供的资料回答问题。 如果资料中没有相关信息,直接说"根据现有资料无法回答",不要编造。 回答要简洁准确,必要时引用资料原文。 资料: """ + context; // 5. 调用模型 String answer = chatClient.prompt() .system(systemPrompt) .messages(history) .user(question) .call() .content(); // 6. 存历史 sessionStore.append(sessionId, question, answer); return answer; }

提示词里那句“如果资料中没有相关信息,直接说无法回答”非常关键。不加这句,大模型会倾向于用自己训练时的知识来编答案,这在企业场景是灾难。我见过一个案例,用户问某个内部流程,资料里没有,模型编了一套流程出来,用户信以为真去执行,结果出了问题。所以防幻觉的提示词是必须的。

多轮对话的处理,我是把最近几轮问答作为历史消息传给模型。但要注意,历史不能太长,否则会挤占上下文窗口,也会让模型分心。我一般保留最近 3 轮,更早的做摘要或者直接丢弃。另外,检索时最好把当前问题和上一轮问题合并一下再检索,这样能处理“那它呢”这种指代性问题。

5. 常见问题与排查技巧实录

5.1 检索不准的排查思路

检索不准是最常见的问题,表现是答案答非所问,或者明明文档里有却检索不到。排查要按链路一步步来。

先看切块是否合理。把检索到的块打印出来,看内容是否完整、是否包含答案。如果块切得太碎,答案被切散了,就要调大块大小。如果块太大,包含太多无关信息,就要调小。

再看向量模型是否合适。拿几个典型问题,手动算一下问题和正确文档块的相似度,看是否明显高于其他块。如果相似度都差不多,说明模型区分度不够,考虑换模型。

然后看检索数量是否够。topK 太小可能漏掉正确块,调大试试。但也不能无限大,太大引入噪音。我一般从 5 开始试,逐步加到 10、15,看效果拐点。

最后看是否需要混合检索。如果问题里有专有名词、型号、编号,纯向量检索往往不行,加上关键词检索通常能解决。

5.2 大模型答非所问或编造答案

这个问题一般出在提示词上。检查几点:提示词有没有明确要求“基于资料回答”;有没有明确说“不知道就说不知道”;资料和问题的位置是否清晰。

我常用的提示词模板是这样的:

你是一个严谨的知识库助手。请只使用【参考资料】中的信息回答问题。 【参考资料】中没有的内容,一律回答"根据现有资料无法回答"。 不要使用你自己的知识,不要推测,不要编造。 回答时如果引用了资料,请标注来源文档标题。 【参考资料】 {context} 【用户问题】 {question}

这个模板的关键是把“只使用参考资料”和“不知道就说不知道”都写死。实测下来,加了这两句,编造率大幅下降。

还有一个技巧是降低 temperature。温度高模型更“发散”,更容易编。知识库问答场景,温度设 0 到 0.3 之间比较合适。

5.3 性能与成本优化

性能问题主要在两个地方:检索慢和模型调用慢。检索慢一般是索引没建好,检查 HNSW 索引是否生效,ef_search是否设得太大。模型调用慢是网络和模型本身决定的,能优化的就是减少调用次数和 token 数。

成本优化有几个方向。一是缓存,相同问题直接返回缓存答案,不用重新检索和调用模型。二是模型分级,简单问题用小模型,复杂问题用大模型。三是控制上下文长度,检索返回的块数不要太多,提示词不要写太长。

我做过一个统计,一个日活几百人的知识库,如果不做缓存,光模型调用成本一个月就不少。加了缓存后,命中率能到 30% 到 40%,成本直接降三分之一。缓存 key 用问题的向量哈希,相似问题也能命中。

5.4 常见问题速查表

现象可能原因排查方向
检索不到相关内容切块太大/太小、向量模型不合适打印检索结果,调整切块参数
答案编造提示词没约束、温度太高加防幻觉提示词,降温度
答案不完整topK 太小、块被切断调大 topK,增加块重叠
检索慢索引未生效、ef_search 太大检查索引,调小 ef_search
重复内容增量更新时旧块未删干净检查删除逻辑,加事务
专有名词搜不到纯向量检索不敏感加关键词检索做混合
多轮对话答非所问历史太长、指代未处理限制历史轮数,合并问题检索

6. 一些实战中的经验与建议

做企业知识库问答,技术只是一部分,还有不少非技术的坑。我挑几个印象深的说说。

文档质量决定效果上限。我接过一个项目,客户文档全是扫描件 PDF,OCR 出来错字连篇,检索效果怎么调都上不去。后来花了两周做文档治理,把核心文档重新整理成结构化文本,效果立刻好转。所以项目启动前,一定要先评估文档质量,如果太差,先做治理,别急着上系统。

用户预期管理很重要。知识库问答不是万能的,它只能回答文档里有的内容。上线前要跟用户说清楚,不然用户问了个文档里没有的问题,系统说“无法回答”,用户会觉得系统不行。我一般会在界面上加个提示,说明系统的能力边界。

持续迭代是必须的。上线只是开始,后面要根据用户反馈不断调优。我一般会记录用户的提问和系统的回答,定期分析哪些问题答得好、哪些答得差,针对性优化。差的问题往往是切块或检索的问题,找到规律就能批量改进。

权限控制别忽略。企业知识库往往有权限要求,不同部门能看的文档不一样。这个要在检索层做过滤,根据用户身份过滤元数据。别小看这个,我见过因为没做权限控制,普通员工搜到了高管薪酬文档的事故。

最后说个技术选型的心得。Spring AI 这个生态还在快速演进,版本之间 API 可能有变化。我的建议是锁定一个稳定版本,别追新。等社区验证过、文档齐全了再升级。生产环境稳定比新特性重要得多。

这个系统我前后搭过好几套,从最初的纯向量检索,到后来的混合检索加重排序,效果是一点点磨出来的。没有银弹,就是不断测试、调整、再测试。希望这些经验能帮你少走点弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 9:24:38

VSCode 查看 Git 提交历史与逐行记录:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 9:23:27

Windows 11 LTSC 2024 主力机安装与激活全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 9:22:08

MySQL 8 安装全攻略:从下载到配置的完整实战教程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 9:21:07

NVMatrix EBS如何帮数据库节省一半内存?实战调优与踩坑记录

内存涨价的行情大家应该都有感受&#xff0c;DBA群里讨论的最多的不是SQL优化&#xff0c;反而是“这台机器内存还能撑多久”、“新采购的服务器还按64G配吗”。我这两年做过不少数据库性能和容量的优化项目&#xff0c;其中一类很有意思的解法是把目光从内存本身挪开&#xff…

作者头像 李华