从 Demo 到生产:用 Spring AI Alibaba + Ollama 落地本地 RAG 知识库的工程实战指南
摘要
很多团队第一次做 RAG,往往都从一个“能问能答”的 Demo 开始:本地起一个 Ollama,塞几篇 Markdown 到向量库,写一个接口调用模型,十几分钟就能跑起来。
但真正上线后,问题会立刻出现:
- • 文档一多,切块和索引速度急剧下降
- • 检索命中率不稳定,答案时好时坏
- • 多租户无法隔离,权限边界模糊
- • 并发一上来,Ollama 与向量检索链路同时成为瓶颈
- • 无法定位问题出在切块、召回、重排,还是 Prompt
- • 文章里的 Demo 代码能跑,但离“可维护、可扩展、可审计”还差很远
这篇文章不再停留在“本地跑通”,而是从原理、架构、工程实现、性能治理、生产运维五个层面,完整讲清楚如何基于 Spring AI Alibaba + Ollama 构建一个真正能落地的本地 RAG 知识库系统。
本文会给出:
- • 一套适合企业内部知识问答的生产级 RAG 架构
- • 一条完整的离线索引流水线与在线查询链路
- • 一套可直接改造成项目骨架的 Spring Boot 代码
- • 高并发、可扩展、可观测、可灰度的工程治理方案
- • 一个贴近真实业务的场景化案例
一、为什么很多 RAG Demo 一上线就失效
1.1 Demo 解决的是“可行性”,生产系统解决的是“稳定性”
一个最小化 RAG Demo 通常只有四步:
- 读取文档
- 文档切块
- 向量化后写入向量库
- 查询时 TopK 检索,再把上下文拼进 Prompt
这个路径本身没错,但它默认了很多理想条件:
- • 文档规模小
- • 数据更新慢
- • 查询并发低
- • 不需要权限控制
- • 不要求引用溯源
- • 不要求监控、审计和回放
而生产环境恰恰相反。企业知识库是一个持续变化、权限敏感、查询模式复杂的系统,RAG 本质上不是一个“模型调用问题”,而是一个检索系统 + 生成系统 + 工程系统的复合问题。
1.2 真正的难点不在“接上模型”,而在“控制回答质量”
RAG 最核心的价值不是让模型“更会说”,而是让模型:
- • 只在有证据时回答
- • 尽量基于证据回答
- • 能把证据给出来
- • 没证据时明确拒答
所以一个生产级 RAG 系统的优化重点,通常不是 Prompt 花活,而是下面四件事:
- • 检索召回是否足够准
- • 上下文拼装是否足够稳
- • 文档索引是否持续可更新
- • 整条链路是否可观测、可回放、可治理
二、先把底层原理讲透:RAG 不只是“检索 + 生成”
从架构视角看,RAG 至少包括两条主链路:
- • 离线链路:文档采集 -> 清洗 -> 切块 -> 向量化 -> 建索引
- • 在线链路:问题理解 -> 检索召回 -> 重排过滤 -> Prompt 组装 -> 生成回答 -> 引用返回
这两条链路都要稳定,系统才算稳定。
2.1 文档切块不是越碎越好,也不是越大越好
很多 Demo 直接用固定长度切块,例如 500 或 1000 tokens。它简单,但不一定合理。
切块过大时:
- • 单块包含多个主题,语义噪声增多
- • 检索召回命中后,注入模型的上下文容易跑偏
- • Token 成本和响应时延都更高
切块过小时:
- • 语义不完整,尤其是技术文档中的“定义 + 约束 + 示例”可能被拆开
- • TopK 需要取更大,召回结果碎片化严重
- • 上下文拼接后可读性变差,模型更容易丢失逻辑
工程上更推荐三类策略:
- • 固定窗口切块:适合 FAQ、工单模板、短说明文
- • 递归分块:按标题、段落、句子逐级切分,适合技术文档
- • 结构化分块:保留章节、标题、表格、代码块边界,适合设计文档、接口文档、SOP
一个实用经验是:
- • 正文块长控制在
300~800 tokens - • 重叠区控制在
50~120 tokens - • 为每个 chunk 保留
title、section、source、tenantId、docVersion、chunkNo
这些元数据在检索过滤、引用展示、故障排查时非常重要。
2.2 Embedding 的本质是“把语义映射到可计算空间”
Embedding 模型并不理解业务,只是把文本编码成一个高维向量,使语义相近的文本在向量空间里更接近。
这里有三个常见误区:
- • 误区一:向量维度越高越好
并不是。维度更高通常意味着存储更大、索引更慢、计算更贵,不一定带来稳定收益。 - • 误区二:只要用了 embedding,召回自然就准
也不是。召回质量还和文档切块、领域术语、别名、缩写、数字实体、过滤条件密切相关。 - • 误区三:同一份文档永远不用重建向量
错。文档修改、切块策略变化、embedding 模型升级,都应触发重建或局部重建。
在本地方案中,Ollama 常见搭配是:
- • Chat 模型:
qwen2.5、deepseek-r1、llama3.x - • Embedding 模型:
nomic-embed-text
如果是中文文档占比高的企业场景,建议一定做离线评估,而不是只看单次问答效果。
2.3 检索不是单点能力,而是“召回 + 排序”的组合能力
真正决定 RAG 上限的,往往不是生成模型,而是检索系统。
一个成熟的检索链路一般分三层:
- 粗召回:向量相似度检索,快速找出语义相关块
- 补召回:BM25 或关键词检索,召回包含术语、接口名、错误码、版本号的文本
- 重排序:对候选片段做 rerank,减少“看起来像、其实不对”的噪声块
这也是为什么生产系统通常不会只做纯向量检索,而会采用混合检索。
2.4 生成不是“把检索结果丢给模型”,而是“受约束的答案合成”
生成阶段真正要解决的是三个问题:
- • 给模型多少上下文
- • 用什么格式给上下文
- • 模型何时回答、何时拒答
一个生产可用的 Prompt 通常至少要明确:
- • 角色约束:你是企业知识助手,不是自由聊天机器人
- • 证据约束:只能依据参考资料回答
- • 拒答策略:没有证据就明确拒答
- • 引用格式:回答中引用文档名、章节、片段编号
- • 输出风格:简明、可执行、避免泛化描述
三、从单机 Demo 到生产系统:推荐的整体架构
3.1 分层架构图
┌─────────────────────────────────────────────────────────────────────┐│ 接入层 / API Gateway ││ Web / App / IM Bot / OpenAPI / SSO / 限流 / 鉴权 / 灰度 │└──────────────────────────────┬──────────────────────────────────────┘ │┌──────────────────────────────▼──────────────────────────────────────┐│ RAG Query Orchestrator ││ 会话管理 / Query 重写 / 检索编排 / Prompt 装配 / 引用聚合 / SSE │└───────────────┬──────────────────────────────┬──────────────────────┘ │ │┌───────────────▼───────────────┐ ┌──────────▼───────────────────────┐│ Retrieval Service │ │ Generation Service ││ 向量召回 / BM25 / Rerank / │ │ Ollama Chat / Prompt 模板 / ││ 多租户过滤 / 阈值控制 │ │ 流式输出 / Token 统计 │└───────────────┬───────────────┘ └──────────┬───────────────────────┘ │ │┌───────────────▼──────────────────────────────────────────────────────┐│ 数据与存储层 ││ PgVector / Elasticsearch / Redis / MySQL / MinIO / Kafka │└───────────────┬──────────────────────────────────────────────────────┘ │┌───────────────▼──────────────────────────────────────────────────────┐│ Offline Index Pipeline ││ 文档上传 -> 清洗解析 -> 分块 -> 向量化 -> 建索引 -> 版本切换 │└──────────────────────────────────────────────────────────────────────┘
3.2 为什么我推荐“在线查询链路”和“离线索引链路”分离
因为这两类流量的特征完全不同。
在线查询链路关注:
- • 低时延
- • 可预测延迟
- • 熔断降级
- • SSE 流式体验
离线索引链路关注:
- • 批量处理吞吐
- • 异步削峰
- • 幂等重试
- • 失败补偿
- • 文档版本管理
如果把这两类链路混在一个服务里,通常会出现两个问题:
- • 大批量导入文档时拖慢在线问答
- • 查询高峰时挤压索引任务,导致知识更新延迟
因此,生产环境建议至少拆成三个逻辑模块:
- •
rag-api:对外提供查询接口、上传接口、会话接口 - •
rag-indexer:异步消费文档,做解析、切块、embedding、写索引 - •
rag-admin:知识库管理、重建索引、文档版本切换、运营配置
如果团队规模不大,也可以物理不拆服务,但逻辑上必须拆链路。
四、技术选型建议:为什么是 Spring AI Alibaba + Ollama
4.1 组合定位
这个组合的核心价值不是“功能堆砌”,而是职责边界清晰:
- • Spring Boot:承载 Web、配置、监控、线程池、事务、工程框架
- • Spring AI / Spring AI Alibaba:统一模型调用抽象、Prompt、Advisor、RAG 集成能力
- • Ollama:本地运行 LLM 与 embedding 模型,降低外部依赖和调用成本
- • PgVector / Elasticsearch:负责向量检索与混合检索
- • Redis:缓存热点问题、会话摘要、租户配置
- • Kafka:承接异步文档入库任务
4.2 为什么本地 RAG 适合企业内部知识库
本地部署并不只是为了省钱,它还有几个非常现实的价值:
- • 内部文档不出域,安全边界更清晰
- • 模型响应更可控,不依赖公网抖动
- • 成本结构从“按调用计费”转向“固定资源投入”
- • 方便做定制化管控,例如文档白名单、租户隔离、内容审计
当然,本地方案也有代价:
- • 模型效果更依赖本地硬件
- • 并发能力受推理资源限制更明显
- • 需要自己处理模型热更新、资源调度和容量规划
所以它更适合:
- • 企业内部知识助手
- • 私有部署场景
- • 研发、运维、客服、法务类文档问答
- • 对数据安全和可控性要求较高的组织
五、项目目录设计:先把工程骨架搭对
建议按“查询域”和“索引域”拆分包结构,而不是把所有类都塞进 service:
rag-knowledge/├── pom.xml├── src/main/java/com/example/rag│ ├── RagKnowledgeApplication.java│ ├── config│ │ ├── RagProperties.java│ │ ├── OllamaConfig.java│ │ ├── VectorStoreConfig.java│ │ ├── ExecutorConfig.java│ │ └── ObservationConfig.java│ ├── controller│ │ ├── ChatController.java│ │ └── DocumentController.java│ ├── app│ │ ├── ChatApplicationService.java│ │ └── DocumentIndexApplicationService.java│ ├── domain│ │ ├── model│ │ ├── service│ │ └── repository│ ├── infrastructure│ │ ├── ingestion│ │ ├── retrieval│ │ ├── llm│ │ ├── cache│ │ ├── queue│ │ └── persistence│ └── dto└── src/main/resources ├── application.yml └── prompts/ ├── system.st └── answer.st
这种结构的好处是:
- • 未来加 Elasticsearch、Milvus、OpenSearch,不会牵一发动全身
- • 离线索引与在线问答职责更清晰
- • 后续拆服务时迁移成本更低
六、生产级依赖配置
下面给出一个更贴近生产场景的 Maven 依赖示例。它不追求“最少依赖”,而是追求“具备演进空间”。
<project> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.6</version> <relativePath /> </parent> <groupId>com.example</groupId> <artifactId>rag-knowledge</artifactId> <version>1.0.0</version> <properties> <java.version>21</java.version> <spring-ai.version>1.0.0</spring-ai.version> <spring-ai-alibaba.version>1.1.0.2</spring-ai-alibaba.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-bom</artifactId> <version>${spring-ai-alibaba.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jdbc</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-ollama-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-advisors-vector-store</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter-dashscope</artifactId> </dependency> <dependency> <groupId>org.springframework.kafka</groupId> <artifactId>spring-kafka</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.tika</groupId> <artifactId>tika-core</artifactId> <version>2.9.2</version> </dependency> <dependency> <groupId>org.apache.tika</groupId> <artifactId>tika-parsers-standard-package</artifactId> <version>2.9.2</version> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies></project>
6.1 这套依赖为什么更像生产系统
- •
webflux:适合流式响应和更高并发的 I/O 模式 - •
actuator + micrometer:方便做指标暴露和 Prometheus 接入 - •
pgvector:本地部署简单,适合企业私有化起步 - •
kafka:让索引链路异步化 - •
redis:做热点缓存和会话摘要缓存 - •
tika:统一处理 PDF、Word、TXT、Markdown 等文档解析
如果未来要演进混合检索,可以继续增加 Elasticsearch 或 OpenSearch,而不是推翻原有系统。
七、核心配置:把“可运行”升级为“可治理”
server: port: 8080spring: application: name: rag-knowledge ai: ollama: base-url: http://localhost:11434 init: pull-model-strategy: never chat: options: model: qwen2.5:7b temperature: 0.2 top-p: 0.9 embedding: options: model: nomic-embed-text vectorstore: pgvector: index-type: hnsw dimensions: 768 distance-type: cosine_distance table-name: rag_embedding_store datasource: url: jdbc:postgresql://localhost:5432/rag_knowledge username: rag password: rag123 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 data: redis: host: localhost port: 6379 timeout: 2s kafka: bootstrap-servers: localhost:9092 consumer: group-id: rag-indexer auto-offset-reset: earliestmanagement: endpoints: web: exposure: include: health,info,prometheus,metrics endpoint: health: show-details: alwayslogging: level: com.example.rag: INFO org.springframework.ai: INFOrag: retrieval: top-k: 8 candidate-k: 20 similarity-threshold: 0.72 max-context-chars: 12000 ingestion: chunk-size: 700 overlap-size: 80 batch-size: 32 cache: hot-query-ttl: 10m security: enable-tenant-filter: true
再补一个绑定类,避免魔法值散落:
@Data@ConfigurationProperties(prefix = "rag")public class RagProperties { private Retrieval retrieval = new Retrieval(); private Ingestion ingestion = new Ingestion(); private Cache cache = new Cache(); private Security security = new Security(); @Data public static class Retrieval { private int topK = 8; private int candidateK = 20; private double similarityThreshold = 0.72; private int maxContextChars = 12000; } @Data public static class Ingestion { private int chunkSize = 700; private int overlapSize = 80; private int batchSize = 32; } @Data public static class Cache { private Duration hotQueryTtl = Duration.ofMinutes(10); } @Data public static class Security { private boolean enableTenantFilter = true; }}
八、离线索引链路:生产环境最容易被低估的部分
8.1 为什么文档入库必须异步化
如果用户一上传文件,你就在 HTTP 请求线程里做:
- • 文档解析
- • 文本清洗
- • 分块
- • embedding
- • 数据库存储
- • 向量索引写入
那么一份稍大的 PDF 就可能把接口拖到几十秒,甚至超时。
正确做法是:
- 上传接口只负责接收文件与记录任务
- 把任务投递到 Kafka
IndexerConsumer异步消费- 完成后回写任务状态和文档版本
8.2 文档索引任务模型
public record IndexingTask( String taskId, String tenantId, String knowledgeBaseId, String documentId, String fileName, String objectKey, String version) {}
8.3 文档上传入口
@RestController@RequestMapping("/api/documents")@RequiredArgsConstructorpublic class DocumentController { private final DocumentIndexApplicationService indexApplicationService; @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public Mono<IndexTaskResponse> upload( @RequestPart("file") FilePart filePart, @RequestParam("tenantId") String tenantId, @RequestParam("knowledgeBaseId") String knowledgeBaseId) { return indexApplicationService.submit(filePart, tenantId, knowledgeBaseId); }}
8.4 索引应用服务
@Service@RequiredArgsConstructorpublic class DocumentIndexApplicationService { private final ObjectStorageService objectStorageService; private final IndexTaskRepository indexTaskRepository; private final KafkaTemplate<String, IndexingTask> kafkaTemplate; public Mono<IndexTaskResponse> submit(FilePart filePart, String tenantId, String kbId) { String taskId = UUID.randomUUID().toString(); String documentId = UUID.randomUUID().toString(); String version = String.valueOf(System.currentTimeMillis()); return objectStorageService.save(filePart, tenantId, kbId, documentId) .map(objectKey -> { IndexingTask task = new IndexingTask( taskId, tenantId, kbId, documentId, filePart.filename(), objectKey, version ); indexTaskRepository.savePending(task); kafkaTemplate.send("rag-indexing-task", task.documentId(), task); return new IndexTaskResponse(taskId, documentId, "PENDING"); }); }}
8.5 索引消费者:真正干活的地方
@Slf4j@Service@RequiredArgsConstructorpublic class IndexingTaskConsumer { private final DocumentParser documentParser; private final ChunkingService chunkingService; private final VectorIndexService vectorIndexService; private final IndexTaskRepository indexTaskRepository; @KafkaListener(topics = "rag-indexing-task", concurrency = "3") public void consume(IndexingTask task) { indexTaskRepository.markRunning(task.taskId()); try { ParsedDocument parsed = documentParser.parse(task); List<KnowledgeChunk> chunks = chunkingService.split(parsed, task); vectorIndexService.index(task, chunks); indexTaskRepository.markSuccess(task.taskId(), chunks.size()); } catch (Exception ex) { log.error("index task failed, taskId={}", task.taskId(), ex); indexTaskRepository.markFailed(task.taskId(), ex.getMessage()); } }}
8.6 这里面最关键的不是“消费成功”,而是“幂等”
因为真实生产环境里一定会遇到:
- • 消费重试
- • 服务重启
- • 网络抖动
- • 部分 chunk 写入成功,部分失败
因此向量写入必须支持幂等键,建议 chunk 主键采用:
tenantId + knowledgeBaseId + documentId + version + chunkNo
这样无论重试多少次,都不会把同一个 chunk 重复写成多份脏数据。
九、在线查询链路:让回答“更准”而不是“更会说”
9.1 查询链路推荐分为六步
用户问题 -> Query 规范化 -> 多租户过滤 -> 粗召回(向量 / BM25) -> Rerank 重排 -> Prompt 装配 -> 流式生成 + 引用返回
9.2 查询模型设计
public record ChatQuery( String tenantId, String knowledgeBaseId, String sessionId, String question, boolean stream) {}
9.3 检索结果模型
@Builderpublic record RetrievedChunk( String chunkId, String documentId, String fileName, String sectionTitle, String content, double score, int rank) {}
9.4 检索服务接口
public interface RetrievalService { List<RetrievedChunk> retrieve(ChatQuery query);}
9.5 一个更贴近生产的检索实现
下面这个实现不是“最短代码”,但它体现了生产思路:
- • 先做查询规范化
- • 再做租户过滤
- • 先召回更多候选
- • 然后重排截断
- • 最后再交给生成模型
@Slf4j@Service@RequiredArgsConstructorpublic class HybridRetrievalService implements RetrievalService { private final VectorStore vectorStore; private final KeywordSearchService keywordSearchService; private final RerankService rerankService; private final RagProperties ragProperties; @Override public List<RetrievedChunk> retrieve(ChatQuery query) { String normalizedQuestion = normalize(query.question()); SearchRequest vectorRequest = SearchRequest.builder() .query(normalizedQuestion) .topK(ragProperties.getRetrieval().getCandidateK()) .similarityThreshold(ragProperties.getRetrieval().getSimilarityThreshold()) .filterExpression(buildFilter(query)) .build(); List<Document> vectorCandidates = vectorStore.similaritySearch(vectorRequest); List<RetrievedChunk> keywordCandidates = keywordSearchService.search(query); List<RetrievedChunk> merged = RetrievalMergeSupport.rrfMerge( mapVectorDocuments(vectorCandidates), keywordCandidates ); List<RetrievedChunk> reranked = rerankService.rerank(normalizedQuestion, merged); return reranked.stream() .limit(ragProperties.getRetrieval().getTopK()) .toList(); } private String buildFilter(ChatQuery query) { return "tenantId == '%s' && knowledgeBaseId == '%s'" .formatted(query.tenantId(), query.knowledgeBaseId()); } private String normalize(String question) { return question == null ? "" : question.trim().replaceAll("\\s+", " "); }}
9.6 为什么要先取 candidateK,再做 rerank
因为向量召回 Top5 并不代表最适合生成答案的就是那 5 个块。
实际工程里更常见的做法是:
- • 向量先取 Top20
- • 关键词取 Top20
- • 融合后对 20~40 个候选重排
- • 最终只保留 Top5~Top8 进入 Prompt
这样既保证召回面,也能降低上下文噪声。
十、Prompt 组装:决定回答质量的最后一道闸门
Prompt 不建议硬编码在 Java 字符串里,生产环境最好做成模板文件,便于迭代。
prompts/system.st
你是企业内部知识库助手。你的回答必须严格依据提供的参考资料。规则:1. 如果参考资料中找不到明确证据,直接回答“根据当前知识库资料,无法给出确定结论”。2. 不要编造接口名、配置项、版本号和流程步骤。3. 回答时优先给出结论,再给出依据。4. 回答末尾输出引用来源,格式为:[文件名#章节]
prompts/answer.st
参考资料:{context}用户问题:{question}请输出:1. 简洁结论2. 操作步骤或判断依据3. 引用来源
对应装配代码:
@Component@RequiredArgsConstructorpublic class PromptFactory { private final ResourceLoader resourceLoader; private final RagProperties ragProperties; public Prompt build(ChatQuery query, List<RetrievedChunk> chunks) { String context = chunks.stream() .map(this::formatChunk) .collect(Collectors.joining("\n\n")); String safeContext = context.length() > ragProperties.getRetrieval().getMaxContextChars() ? context.substring(0, ragProperties.getRetrieval().getMaxContextChars()) : context; String systemText = read("classpath:prompts/system.st"); String userText = read("classpath:prompts/answer.st") .replace("{context}", safeContext) .replace("{question}", query.question()); return new Prompt( List.of( new SystemMessage(systemText), new UserMessage(userText) ) ); } private String formatChunk(RetrievedChunk chunk) { return """ [文件名]%s [章节]%s [内容]%s """.formatted(chunk.fileName(), chunk.sectionTitle(), chunk.content()); } private String read(String location) { try (InputStream in = resourceLoader.getResource(location).getInputStream()) { return new String(in.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException ex) { throw new IllegalStateException("failed to load prompt: " + location, ex); } }}
十一、生产级问答服务:不是直接 chatClient.prompt().user(...).call() 就结束
11.1 查询应用服务
@Service@RequiredArgsConstructorpublic class ChatApplicationService { private final RetrievalService retrievalService; private final PromptFactory promptFactory; private final ChatModel chatModel; private final CitationAssembler citationAssembler; private final HotQueryCacheService hotQueryCacheService; public Flux<ChatAnswerChunk> chat(ChatQuery query) { Optional<ChatAnswer> cached = hotQueryCacheService.get(query); if (cached.isPresent()) { return Flux.just(ChatAnswerChunk.fromCached(cached.get())); } List<RetrievedChunk> chunks = retrievalService.retrieve(query); if (chunks.isEmpty()) { return Flux.just(ChatAnswerChunk.completed( "根据当前知识库资料,无法给出确定结论", List.of() )); } Prompt prompt = promptFactory.build(query, chunks); return chatModel.stream(prompt) .map(resp -> ChatAnswerChunk.delta(resp.getResult().getOutput().getText())) .concatWith(Mono.fromSupplier(() -> { List<Citation> citations = citationAssembler.assemble(chunks); return ChatAnswerChunk.completed("", citations); })); }}
11.2 SSE 控制器
@RestController@RequestMapping("/api/chat")@RequiredArgsConstructorpublic class ChatController { private final ChatApplicationService chatApplicationService; @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<ChatAnswerChunk>> stream(@Valid @RequestBody ChatRequest request) { ChatQuery query = new ChatQuery( request.tenantId(), request.knowledgeBaseId(), request.sessionId(), request.question(), true ); return chatApplicationService.chat(query) .map(chunk -> ServerSentEvent.builder(chunk).build()); }}
11.3 请求与响应对象
public record ChatRequest( @NotBlank String tenantId, @NotBlank String knowledgeBaseId, @NotBlank String sessionId, @NotBlank String question) {}@Builderpublic record ChatAnswerChunk( String type, String content, List<Citation> citations) { public static ChatAnswerChunk delta(String text) { return ChatAnswerChunk.builder() .type("delta") .content(text) .citations(List.of()) .build(); } public static ChatAnswerChunk completed(String text, List<Citation> citations) { return ChatAnswerChunk.builder() .type("completed") .content(text) .citations(citations) .build(); } public static ChatAnswerChunk fromCached(ChatAnswer answer) { return ChatAnswerChunk.builder() .type("cached") .content(answer.content()) .citations(answer.citations()) .build(); }}
这套设计的关键点在于:答案文本流式返回,引用在末尾统一返回。这样既兼顾前端体验,也不会在流式过程中频繁打断结构。
十二、高并发与可扩展:真正的生产升级点
这是本文最关键的部分。很多文章会停在“能跑”,但生产系统真正拉开差距的是下面这些治理能力。
12.1 瓶颈通常不在一个点,而在整条链路
RAG 在线查询链路的常见耗时拆分:
- • Query 预处理:5~20ms
- • 检索召回:30~150ms
- • Rerank:20~100ms
- • Prompt 组装:5~10ms
- • Ollama 首 Token:300ms~2s
- • 流式生成完成:1s~8s
所以优化时不要只盯着模型,要做整链路分析。
12.2 线程池隔离:防止慢索引拖死快查询
@Configurationpublic class ExecutorConfig { @Bean("queryExecutor") public ThreadPoolTaskExecutor queryExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(16); executor.setMaxPoolSize(32); executor.setQueueCapacity(200); executor.setThreadNamePrefix("rag-query-"); executor.initialize(); return executor; } @Bean("indexExecutor") public ThreadPoolTaskExecutor indexExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(500); executor.setThreadNamePrefix("rag-index-"); executor.initialize(); return executor; }}
核心原则很简单:
- • 查询线程池与索引线程池隔离
- • 热路径与冷路径隔离
- • 模型调用、检索调用、对象存储调用尽量解耦
12.3 缓存不是可选项,而是延迟治理手段
可以缓存的内容包括:
- • 热点问题最终答案
- • 会话摘要
- • 文档元数据
- • 查询改写结果
- • 高频知识块
一个简单示例:
@Service@RequiredArgsConstructorpublic class HotQueryCacheService { private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; private final RagProperties ragProperties; public Optional<ChatAnswer> get(ChatQuery query) { String key = cacheKey(query); String value = redisTemplate.opsForValue().get(key); if (value == null) { return Optional.empty(); } try { return Optional.of(objectMapper.readValue(value, ChatAnswer.class)); } catch (Exception ex) { return Optional.empty(); } } public void put(ChatQuery query, ChatAnswer answer) { try { redisTemplate.opsForValue().set( cacheKey(query), objectMapper.writeValueAsString(answer), ragProperties.getCache().getHotQueryTtl() ); } catch (Exception ignored) { } } private String cacheKey(ChatQuery query) { return "rag:hot:%s:%s:%s".formatted( query.tenantId(), query.knowledgeBaseId(), DigestUtils.md5DigestAsHex(query.question().getBytes(StandardCharsets.UTF_8)) ); }}
12.4 多实例扩展时,先横向扩 API,再独立扩推理
如果并发开始上涨,推荐的扩容顺序是:
- 先扩
rag-api实例数 - 再把
rag-indexer独立扩容 - 最后单独治理 Ollama 推理资源
原因是:
- • API 层大多是无状态的,最容易横向扩展
- • 索引任务天然可通过 Kafka 分区扩容
- • Ollama 才是最贵、最稀缺的资源,不应盲目复制
12.5 Ollama 的生产治理建议
Ollama 很适合本地和私有化场景,但要注意这些工程现实:
- • 不要让所有模型实例共用一台弱机器
- • Chat 模型与 Embedding 模型最好拆实例
- • 大批量 embedding 时要限流,不要和在线问答抢资源
- • 对外只暴露应用服务,不建议业务侧直接打 Ollama
一个很实用的做法是:
- •
ollama-chat节点专门负责生成 - •
ollama-embedding节点专门负责向量化 - • 应用层根据用途路由到不同节点
12.6 降级策略一定要提前设计
线上系统不可能永远满血工作。至少要准备四级降级:
- 缓存命中优先返回
- 关闭 rerank,仅保留向量召回
- 从流式回答降级为非流式短回答
- 模型不可用时直接返回“知识库检索结果摘要”
最后一级尤其关键。很多系统一旦模型挂了就整体不可用,但其实检索链路还在,完全可以至少给用户返回相关文档片段和来源。
十三、混合检索与可扩展架构:别把自己锁死在单一向量库里
13.1 为什么纯向量检索在企业场景经常不够
因为企业知识库里有大量“语义不强、精确匹配极强”的内容:
- • 错误码
- • 版本号
- • 配置项名称
- • 类名 / 方法名 / 表名
- • 域名 / 路径 / 接口 URI
例如:
"如何处理 error code 4132""gateway.route.timeout.ms 的默认值是多少"
这类问题单靠语义向量并不一定稳定,BM25 往往更准。
13.2 推荐的扩展策略
第一阶段:
- •
PgVector做本地起步 - • 适合中小规模知识库与私有化 PoC
第二阶段:
- • 引入
Elasticsearch/OpenSearch - • 增加 BM25、过滤、聚合与混合检索
第三阶段:
- • 做检索路由
- • FAQ、SOP、代码文档、运维手册使用不同召回策略
也就是说,系统设计从一开始就要允许后续扩成:
- •
VectorRetriever - •
KeywordRetriever - •
HybridRetriever - •
RerankService
而不是把所有逻辑写死在一个 RagService 里。
十四、多租户与权限隔离:这是企业知识库的硬要求
很多本地 RAG Demo 根本没考虑权限问题,但企业内部最危险的事情之一,就是把 A 团队的文档回答给 B 团队。
最少要做三层隔离:
- 数据层隔离:文档、chunk、索引记录都要有
tenantId - 检索层隔离:查询必须自动追加 tenant filter
- 应用层隔离:接口鉴权后,不允许前端自由指定越权 tenantId
如果是更高安全级别场景,还可以继续做:
- • 知识库级别 ACL
- • 文档标签级别访问控制
- • 引用片段脱敏
- • 审计日志留痕
一个工程建议是:
不要相信前端传来的 tenantId,应该由登录态解析后注入上下文。
十五、可观测性:不观测,就无法优化
RAG 系统的故障常常不是“报错”,而是“回答质量下降”。
所以你需要的不是只有异常日志,而是完整的链路指标。
15.1 至少要监控这些指标
- • 问答 QPS
- • 首 Token 延迟
- • 全量响应耗时
- • 检索耗时
- • Rerank 耗时
- • 平均命中文档数
- • 拒答率
- • 缓存命中率
- • 索引任务成功率
- • 向量化平均耗时
- • 每租户调用量
15.2 建议埋点位置
- •
ChatController:接口层总耗时 - •
RetrievalService:召回耗时、候选数、最终命中数 - •
PromptFactory:上下文长度 - •
ChatModel调用处:首 Token 与总耗时 - •
IndexerConsumer:单任务处理耗时、chunk 数
简单示例:
@Component@RequiredArgsConstructorpublic class RetrievalMetrics { private final MeterRegistry meterRegistry; public <T> T record(String tenantId, Supplier<T> supplier) { Timer.Sample sample = Timer.start(meterRegistry); try { return supplier.get(); } finally { sample.stop(Timer.builder("rag.retrieval.latency") .tag("tenant", tenantId) .register(meterRegistry)); } }}
进一步可以接入 OpenTelemetry,把一次问答完整串成 Trace,定位慢点非常直观。
十六、真实案例:企业内部运维知识助手怎么落地
假设我们服务的是一个中型互联网团队,知识库包含:
- • 300+ 篇运维 SOP
- • 120+ 篇架构设计文档
- • 80+ 篇故障复盘
- • 40+ 篇发布流程文档
典型提问包括:
- • “线上发布后 5xx 飙升,第一步排查什么”
- • “网关限流配置在哪个仓库”
- • “K8s Pod 一直 CrashLoopBackOff 怎么处理”
- • “订单服务超时的默认阈值是多少”
16.1 一次完整问答链路
用户提问:
线上发布后 payment-service 5xx 飙升,应该如何排查?
系统处理流程:
- 从登录态拿到
tenantId=payment-team - 在
payment-team知识域内做混合检索 - 命中《发布回滚 SOP》《5xx 故障排查手册》《网关限流配置说明》
- Rerank 后保留最相关 5 个片段
- Prompt 强制模型“先给结论,再给排查步骤,再列引用”
- SSE 流式返回答案,同时附上引用来源
输出效果应类似:
建议优先检查最近一次发布变更、应用实例健康状态和网关限流配置。排查顺序:1. 先确认 payment-service 新版本是否存在实例启动失败或健康检查异常2. 再查看网关是否触发限流或熔断3. 若 5xx 集中在数据库写接口,检查连接池与慢 SQL4. 如发布窗口内指标明显恶化,按 SOP 执行灰度回退引用来源:[发布回滚 SOP#支付服务灰度回退][5xx 故障排查手册#应用侧排查][网关限流配置说明#payment-service]
这样的回答才真正具备生产价值,因为它:
- • 有结论
- • 有顺序
- • 有依据
- • 可追溯
十七、常见线上问题与治理思路
17.1 “明明知识库里有,为什么答不出来”
优先排查四件事:
- • 文档切块是否把关键语义拆散了
- •
topK是否太小 - • 相似度阈值是否过高
- • 是否缺少关键词召回
17.2 “答出来了,但引用不对”
常见原因:
- • 上下文块顺序错乱
- • 重排后未同步更新引用顺序
- • 一个回答融合了多个 chunk,但只展示了第一个来源
17.3 “并发一高,回答就很慢”
重点看:
- • Ollama 是否和 embedding 共用资源
- • 数据库连接池是否过小
- • 检索服务是否串行调用了多个后端
- • 缓存是否没有命中
17.4 “导入文档时把线上问答拖死了”
这通常说明:
- • 索引链路没有异步化
- • 索引线程池和查询线程池没有隔离
- • embedding 资源未拆分
17.5 “升级模型后效果反而变差”
模型升级一定要做 A/B 评估,至少比较:
- • 命中率
- • 拒答率
- • 平均延迟
- • 引用准确率
- • 热点问题人工评分
不要凭少量主观问答直接切换。
十八、压测与容量规划:没有数据就谈不上生产
建议至少准备三类压测:
18.1 在线问答压测
关注:
- • QPS
- • P95 / P99
- • 首 Token 延迟
- • 错误率
18.2 索引吞吐压测
关注:
- • 每分钟处理文档数
- • 每分钟生成 chunk 数
- • embedding 吞吐
- • 索引写入成功率
18.3 混合流量压测
真实场景通常是:
- • 有人在问问题
- • 也有人在导入文档
- • 还有定时任务在重建索引
所以一定要做查询与索引并发混合压测,才能知道隔离是否有效。
一个经验值是:
- • 如果 P99 延迟主要卡在模型生成,先治理推理资源
- • 如果 P99 延迟主要卡在检索与重排,优先治理检索链路
十九、从当前架构继续演进,还能往哪里走
如果你的系统已经完成第一阶段落地,后续可以继续升级:
19.1 Query Rewrite
对用户问题做重写、补全、实体标准化,例如把:
“支付超时怎么办”
重写为:
“payment-service 请求超时时的排查步骤、默认超时配置以及回滚策略”
这对检索质量提升非常明显。
19.2 会话记忆与问题继承
用户第二轮提问通常是:
- • “那回滚后还要看什么?”
- • “这个阈值默认是多少?”
如果不做会话上下文管理,召回质量会明显下降。
19.3 Rerank 模块独立化
未来可以把重排做成独立能力,支持:
- • 规则重排
- • 模型重排
- • 多租户定制权重
19.4 知识库版本切换
当大批文档重建索引时,推荐使用:
- • 新版本索引先构建完成
- • 再原子切换版本指针
避免用户查询期间读到半成品知识库。
19.5 回答质量评估平台
建立一套离线评测集,持续比较:
- • 检索命中率
- • 引用准确率
- • 人工评分
- • 拒答合理性
这会比只靠主观体验稳定得多。
二十、结语:生产级 RAG 的关键,不是“接上模型”,而是“把系统做完整”
很多团队做本地 RAG 时,最容易高估模型本身,低估工程系统。
真正能上线、能长期演进的 RAG,至少要把下面这些问题想清楚:
- • 离线索引和在线查询是否分链路
- • 检索是否支持混合召回和重排
- • 多租户与权限隔离是否严格
- • 索引任务是否异步化、幂等化
- • 线程池、缓存、降级是否齐全
- • 指标、日志、Trace 是否能支撑排障
- • 模型与 embedding 资源是否拆分治理
如果只做 Demo,RAG 看起来是一个“调用模型”的问题。
但一旦进入生产,它其实是一个检索架构、数据架构、服务治理和 AI 工程化共同作用的系统工程。
而 Spring AI Alibaba + Ollama 这套组合的价值,恰恰就在于:
- • 上手成本低
- • Java 生态友好
- • 可本地化部署
- • 容易逐步演进到生产架构
建议的落地路径是:
- 先用单机 Ollama + PgVector 跑通最小闭环
- 再把离线索引链路异步化
- 然后加入混合检索、缓存、监控和租户隔离
- 最后再做容量规划、灰度与质量评估
这样,你做出来的就不再是一个“能演示的 AI Demo”,而是一套真正可上线、可运维、可持续演进的企业级本地 RAG 系统。
学AI大模型的正确顺序,千万不要搞错了
🤔2026年AI风口已来!各行各业的AI渗透肉眼可见,超多公司要么转型做AI相关产品,要么高薪挖AI技术人才,机遇直接摆在眼前!
有往AI方向发展,或者本身有后端编程基础的朋友,直接冲AI大模型应用开发转岗超合适!
就算暂时不打算转岗,了解大模型、RAG、Prompt、Agent这些热门概念,能上手做简单项目,也绝对是求职加分王🔋

📝给大家整理了超全最新的AI大模型应用开发学习清单和资料,手把手帮你快速入门!👇👇
学习路线:
✅大模型基础认知—大模型核心原理、发展历程、主流模型(GPT、文心一言等)特点解析
✅核心技术模块—RAG检索增强生成、Prompt工程实战、Agent智能体开发逻辑
✅开发基础能力—Python进阶、API接口调用、大模型开发框架(LangChain等)实操
✅应用场景开发—智能问答系统、企业知识库、AIGC内容生成工具、行业定制化大模型应用
✅项目落地流程—需求拆解、技术选型、模型调优、测试上线、运维迭代
✅面试求职冲刺—岗位JD解析、简历AI项目包装、高频面试题汇总、模拟面经
以上6大模块,看似清晰好上手,实则每个部分都有扎实的核心内容需要吃透!
我把大模型的学习全流程已经整理📚好了!抓住AI时代风口,轻松解锁职业新可能,希望大家都能把握机遇,实现薪资/职业跃迁~
这份完整版的大模型 AI 学习资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)