摘要

很多团队第一次做 RAG,往往都从一个“能问能答”的 Demo 开始:本地起一个 Ollama,塞几篇 Markdown 到向量库,写一个接口调用模型,十几分钟就能跑起来。

但真正上线后,问题会立刻出现:

  • • 文档一多,切块和索引速度急剧下降
  • • 检索命中率不稳定,答案时好时坏
  • • 多租户无法隔离,权限边界模糊
  • • 并发一上来,Ollama 与向量检索链路同时成为瓶颈
  • • 无法定位问题出在切块、召回、重排,还是 Prompt
  • • 文章里的 Demo 代码能跑,但离“可维护、可扩展、可审计”还差很远

这篇文章不再停留在“本地跑通”,而是从原理、架构、工程实现、性能治理、生产运维五个层面,完整讲清楚如何基于 Spring AI Alibaba + Ollama 构建一个真正能落地的本地 RAG 知识库系统。

本文会给出:

  • • 一套适合企业内部知识问答的生产级 RAG 架构
  • • 一条完整的离线索引流水线与在线查询链路
  • • 一套可直接改造成项目骨架的 Spring Boot 代码
  • • 高并发、可扩展、可观测、可灰度的工程治理方案
  • • 一个贴近真实业务的场景化案例

一、为什么很多 RAG Demo 一上线就失效

1.1 Demo 解决的是“可行性”,生产系统解决的是“稳定性”

一个最小化 RAG Demo 通常只有四步:

  1. 读取文档
  2. 文档切块
  3. 向量化后写入向量库
  4. 查询时 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.5deepseek-r1llama3.x
  • • Embedding 模型:nomic-embed-text

如果是中文文档占比高的企业场景,建议一定做离线评估,而不是只看单次问答效果。

2.3 检索不是单点能力,而是“召回 + 排序”的组合能力

真正决定 RAG 上限的,往往不是生成模型,而是检索系统。

一个成熟的检索链路一般分三层:

  1. 粗召回:向量相似度检索,快速找出语义相关块
  2. 补召回:BM25 或关键词检索,召回包含术语、接口名、错误码、版本号的文本
  3. 重排序:对候选片段做 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 就可能把接口拖到几十秒,甚至超时。

正确做法是:

  1. 上传接口只负责接收文件与记录任务
  2. 把任务投递到 Kafka
  3. IndexerConsumer 异步消费
  4. 完成后回写任务状态和文档版本

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,再独立扩推理

如果并发开始上涨,推荐的扩容顺序是:

  1. 先扩 rag-api 实例数
  2. 再把 rag-indexer 独立扩容
  3. 最后单独治理 Ollama 推理资源

原因是:

  • • API 层大多是无状态的,最容易横向扩展
  • • 索引任务天然可通过 Kafka 分区扩容
  • • Ollama 才是最贵、最稀缺的资源,不应盲目复制

12.5 Ollama 的生产治理建议

Ollama 很适合本地和私有化场景,但要注意这些工程现实:

  • • 不要让所有模型实例共用一台弱机器
  • • Chat 模型与 Embedding 模型最好拆实例
  • • 大批量 embedding 时要限流,不要和在线问答抢资源
  • • 对外只暴露应用服务,不建议业务侧直接打 Ollama

一个很实用的做法是:

  • ollama-chat 节点专门负责生成
  • ollama-embedding 节点专门负责向量化
  • • 应用层根据用途路由到不同节点

12.6 降级策略一定要提前设计

线上系统不可能永远满血工作。至少要准备四级降级:

  1. 缓存命中优先返回
  2. 关闭 rerank,仅保留向量召回
  3. 从流式回答降级为非流式短回答
  4. 模型不可用时直接返回“知识库检索结果摘要”

最后一级尤其关键。很多系统一旦模型挂了就整体不可用,但其实检索链路还在,完全可以至少给用户返回相关文档片段和来源。


十三、混合检索与可扩展架构:别把自己锁死在单一向量库里

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 团队。

最少要做三层隔离:

  1. 数据层隔离:文档、chunk、索引记录都要有 tenantId
  2. 检索层隔离:查询必须自动追加 tenant filter
  3. 应用层隔离:接口鉴权后,不允许前端自由指定越权 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 飙升,应该如何排查?

系统处理流程:

  1. 从登录态拿到 tenantId=payment-team
  2. payment-team 知识域内做混合检索
  3. 命中《发布回滚 SOP》《5xx 故障排查手册》《网关限流配置说明》
  4. Rerank 后保留最相关 5 个片段
  5. Prompt 强制模型“先给结论,再给排查步骤,再列引用”
  6. 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 生态友好
  • • 可本地化部署
  • • 容易逐步演进到生产架构

建议的落地路径是:

  1. 先用单机 Ollama + PgVector 跑通最小闭环
  2. 再把离线索引链路异步化
  3. 然后加入混合检索、缓存、监控和租户隔离
  4. 最后再做容量规划、灰度与质量评估

这样,你做出来的就不再是一个“能演示的 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%免费

在这里插入图片描述

Logo

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

更多推荐