找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5433

积分

0

好友

747

主题
发表于 1 小时前 | 查看: 2| 回复: 0

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

这几年,RAG 已经成了企业知识问答、智能客服、内部助手、运维 Copilot、研发知识库的标准能力。
但很多团队第一次做 RAG,路径都很像:

  1. 本地启动 Ollama
  2. 选一个 Embedding 模型
  3. 读几篇 Markdown 或 PDF
  4. 切块后写入向量库
  5. 查询时做一次相似度召回,再拼 Prompt 调模型

10 分钟就能跑通,效果甚至还不错。

问题是,这样的系统只能证明“RAG 可以做”,不能证明“RAG 可以上线”。

一旦进入真实业务环境,问题会迅速暴露:

  • 文档规模从几十篇变成几万篇后,索引耗时和成本激增
  • 问题语义稍微复杂一点,向量召回就开始漂移
  • 并发上来后,Ollama 推理排队,接口 RT 飙升
  • 知识库一更新,老索引与新索引混在一起,结果不可控
  • 多租户和权限隔离没设计,回答发生串库
  • 答错了也无法定位,到底是切块错、召回错、重排错,还是生成错

根本原因在于:RAG 从来不是一个“调用模型”的问题,而是一个“检索系统 + 生成系统 + 工程系统”的复合架构问题。

所以,真正可落地的 RAG,必须同时解决四件事:

  • 检索质量足够稳定
  • 在线链路具备低延迟和高并发能力
  • 离线索引链路可增量、可扩容、可回滚
  • 全链路可观测、可治理、可演进

本文就以 Spring AI Alibaba + Ollama + 本地向量库 为主线,带你把一个 Java RAG Demo 升级为可落地的生产系统。

二、先把原理讲透:RAG 到底解决什么问题

2.1 LLM 为什么需要 RAG

大模型本身有三个天然缺陷:

  • 参数知识静态,无法实时反映企业最新文档
  • 训练语料是公共知识,不理解你公司的私有制度、接口、SOP、工单
  • 遇到事实性问题时,容易“合理胡说”

RAG 的本质,就是在生成前为模型补充一层“外部可检索记忆”。

它并不要求模型记住一切,而是把问题拆成两个阶段:

  1. 先检索:从知识库中找出与问题最相关的上下文
  2. 再生成:让模型基于检索结果组织答案

所以,RAG 的上限不只取决于模型,也取决于检索质量。

2.2 RAG 的两条主链路

从系统架构看,RAG 至少包含两条链路。

离线链路:知识入库

文档采集
  -> 文档解析
  -> 清洗标准化
  -> 智能切块
  -> 向量化
  -> 索引构建
  -> 写入向量库 / 倒排索引 / 元数据库

这是知识“写路径”。

它决定:

  • 文档是否能正确抽取
  • 切块是否保留语义完整性
  • 元数据是否支持过滤、溯源、权限控制
  • 索引是否支持增量更新和版本切换

在线链路:问题回答

用户问题
  -> 问题预处理
  -> Query Rewrite / 扩展
  -> 向量检索 / 关键词检索 / 混合召回
  -> 重排
  -> 上下文组装
  -> Prompt 构建
  -> LLM 生成
  -> 引用溯源
  -> 返回答案

这是知识“读路径”。

它决定:

  • 能否召回正确片段
  • 能否在高并发下稳定返回
  • 模型是否基于真实证据回答
  • 是否支持追踪答案来自哪篇文档哪一段

很多 Demo 只写了最后一步“调模型”,却忽略了前面一长串真正决定质量和稳定性的工程环节。

三、生产级 RAG 的核心设计原则

如果你准备让 RAG 真正进入业务系统,我建议遵循下面 8 条设计原则。

3.1 检索优先于生成

在多数企业问答场景里,回答错误的第一原因不是模型弱,而是召回上下文不对
没有正确上下文,再强的模型也只能在错误素材上“流畅发挥”。

3.2 离线与在线彻底解耦

索引构建是重 CPU / 重 I/O / 重内存的任务,必须异步化;
在线查询必须尽量轻量化,只做必要步骤。

3.3 向量检索不是全部

只做向量召回,很容易丢关键词、编号、错误码、接口名、SQL 字段名。
所以生产系统通常采用:

  • 向量召回:解决语义相似
  • BM25 / 关键词召回:解决精确匹配
  • 重排:解决最终排序稳定性

3.4 元数据必须一等公民

每个 chunk 至少要带上这些信息:

  • tenantId
  • knowledgeBaseId
  • documentId
  • documentVersion
  • chunkId
  • sourceType
  • sourceName
  • pageNo 或章节号
  • permissionTags
  • createdAt

没有元数据,后续的权限隔离、灰度发布、版本切换、审计追踪都会很痛苦。

3.5 索引要支持增量、回滚、双写切换

生产环境不要动不动全量重建。
必须支持:

  • 增量索引
  • 文档删除同步
  • 版本化索引
  • 新索引预热后再切流

3.6 并发治理要前置设计

本地 Ollama 很适合低成本试点,但推理资源始终是稀缺资源。
如果没有队列、限流、缓存、舱壁隔离,峰值请求一到,系统会整体排队。

3.7 不要让模型越权

RAG 并不天然安全。
如果召回条件没带权限过滤,模型就可能基于本不该看到的内容回答。

3.8 可观测性不是上线后再补

RAG 的复杂点在于:错了很难排查。
所以系统从第一天起就要记录:

  • 查询重写结果
  • 召回候选集合
  • 重排前后顺序
  • 最终 Prompt 长度
  • 模型耗时
  • 命中文档来源

四、技术栈选型:为什么是 Spring AI Alibaba + Ollama

4.1 这套组合适合谁

这套方案最适合以下团队:

  • 主要技术栈是 Java / Spring Boot
  • 想先在本地或内网做低成本试点
  • 不希望一开始就重度绑定商业模型平台
  • 希望先把工程链路打通,再决定是否升级到云端模型或分布式向量数据库

4.2 Spring AI Alibaba 的价值

Spring AI Alibaba 的价值不只是“接模型”,而是让 Java 团队用熟悉的 Spring 编程模型落地 AI 系统:

  • 统一的聊天模型与 Embedding 接口
  • VectorStore 抽象,方便替换向量库
  • Spring Boot 自动配置,减少样板代码
  • 与阿里云生态衔接顺滑,后续可演进到云上
  • 适合和 Nacos、Sentinel、RocketMQ、Redis、Micrometer 一起构建完整工程方案

4.3 为什么用 Ollama

Ollama 非常适合本地试点和内网落地:

  • 模型下载和运行门槛低
  • 本地部署,无外部 API 成本
  • 适合敏感知识数据不出网场景
  • 支持 Embedding 与聊天模型统一管理

但也要清醒认识它的边界:

  • 单机资源有限
  • 并发能力受 CPU / GPU 资源影响明显
  • 模型吞吐远弱于专业托管推理平台

所以,本文的定位不是“本地模型万能”,而是:让你先以最低成本把系统工程做对,再按业务规模平滑演进。

五、从 Demo 到生产:推荐的整体架构

5.1 单体 Demo 架构

最初期可以是一个单体应用:

Spring Boot
  ├─ 文档上传接口
  ├─ 文档索引服务
  ├─ 检索问答接口
  ├─ PgVector
  └─ Ollama

优点是快,缺点是所有链路耦合在一起。

5.2 生产推荐架构

当你准备上线时,建议至少拆成下面 5 个角色:

                +----------------------+
                |     API Gateway      |
                +----------+-----------+
                           |
          +----------------+----------------+
          |                                 |
 +--------v---------+           +---------v---------+
 | Query Service    |           | Ingestion Service |
 | 在线查询服务      |           | 离线入库服务       |
 +--------+---------+           +---------+---------+
          |                                 |
 +--------v---------+           +---------v---------+
 | Retrieval Layer  |           | Index Pipeline    |
 | 召回/重排层       |           | 解析/切块/向量化   |
 +--------+---------+           +---------+---------+
          |                                 |
 +--------v---------+           +---------v---------+
 | Vector Store     |           | MQ / Task Queue   |
 | PgVector / ES    |           | 异步任务调度       |
 +--------+---------+           +-------------------+
          |
 +--------v---------+
 | LLM Gateway      |
 | Ollama / Cloud   |
 +------------------+

5.3 职责划分

Query Service

负责低延迟在线请求:

  • 会话上下文处理
  • 查询重写
  • 混合召回
  • 重排
  • Prompt 组装
  • 模型调用
  • 引用与审计日志

Ingestion Service

负责重任务:

  • 文档解析
  • OCR
  • 切块
  • Embedding
  • 批量写入索引
  • 增量更新
  • 版本发布

LLM Gateway

作为模型调用隔离层:

  • 统一接 Ollama
  • 限流与超时
  • 熔断与降级
  • 统计各模型耗时和成功率
  • 后续从本地模型平滑切云端模型

这样拆的目的不是“为了微服务而微服务”,而是让高并发读链路重计算写链路互不影响。

六、RAG 质量提升的关键:切块、召回、重排

6.1 切块不是按字符截断,而是语义建模

切块决定了知识颗粒度。

切得太粗:

  • chunk 内主题太多
  • 召回噪声高
  • 上下文利用率低

切得太细:

  • 定义和例子被拆散
  • 模型拿不到完整证据
  • 引用片段支离破碎

生产实践建议:

  • FAQ / 短说明:300 ~ 500 tokens
  • 技术方案 / 设计文档:500 ~ 800 tokens
  • 代码说明 / API 文档:按章节或接口粒度切块,避免打断结构

同时建议保留 10% ~ 20% overlap,防止关键信息恰好落在边界。

6.2 只做向量检索为什么不够

用户提问往往有两类信息混在一起:

  • 语义信息:比如“怎么处理索引构建时的吞吐瓶颈”
  • 精确信息:比如“ERR_40321”“tenant_id”“/api/v1/order/create”

向量检索擅长第一类,不擅长第二类。
BM25 恰好反过来。

因此,更可靠的方式是:

  1. 向量召回 TopK
  2. 关键词召回 TopK
  3. 合并候选集
  4. 用重排模型按 query-document 相关性重新排序

这也是为什么生产级系统普遍使用“混合检索 + 重排”。

6.3 重排是稳定性的关键一跳

向量召回解决“召回出来”,但不能保证排序最优。
重排模型的作用,是对候选文档做更细粒度的相关性判断。

没有重排时常见现象:

  • 前几条语义相关但不够聚焦
  • 正确片段在第 7 条、第 9 条,没被送进 Prompt
  • 最终答案看起来“有点像,但不够准”

如果暂时没有独立 rerank 模型,也建议先做一个轻量规则重排:

  • 标题命中加权
  • 最近版本加权
  • 精确关键词命中加权
  • 权限完全匹配优先

七、项目目录建议:从一开始就按生产结构组织

rag-knowledge-system
├── pom.xml
├── docker-compose.yml
├── src/main/java/com/example/rag
│   ├── RagApplication.java
│   ├── config
│   │   ├── AiConfig.java
│   │   ├── ExecutorConfig.java
│   │   ├── ObservationConfig.java
│   │   └── VectorStoreConfig.java
│   ├── controller
│   │   ├── ChatController.java
│   │   └── DocumentController.java
│   ├── domain
│   │   ├── model
│   │   ├── service
│   │   └── repository
│   ├── app
│   │   ├── ChatApplicationService.java
│   │   └── IngestionApplicationService.java
│   ├── infrastructure
│   │   ├── llm
│   │   ├── retrieval
│   │   ├── storage
│   │   ├── queue
│   │   └── observability
│   └── support
└── src/main/resources
    ├── application.yml
    └── db/migration

这里刻意把 appdomaininfrastructure 分开,是为了避免后面所有逻辑都堆进 service 包里。

八、环境准备:本地最小可运行基础设施

下面以 PostgreSQL + PgVector + Ollama + Redis 为例。
Redis 在这里不是必选,但上线后非常建议引入,用于缓存、限流、会话、任务状态等场景。

8.1 Docker Compose

version: "3.9"

services:
  postgres:
    image: pgvector/pgvector:pg16
    container_name: rag-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: rag_db
      POSTGRES_USER: rag
      POSTGRES_PASSWORD: rag123
    ports:
      - "5432:5432"
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U rag -d rag_db"]
      interval: 5s
      timeout: 5s
      retries: 20

  redis:
    image: redis:7.2
    container_name: rag-redis
    restart: unless-stopped
    ports:
      - "6379:6379"

  ollama:
    image: ollama/ollama:latest
    container_name: rag-ollama
    restart: unless-stopped
    ports:
      - "11434:11434"
    volumes:
      - ./data/ollama:/root/.ollama
    environment:
      OLLAMA_HOST: 0.0.0.0
      OLLAMA_KEEP_ALIVE: 24h

8.2 拉取模型

docker exec -it rag-ollama ollama pull nomic-embed-text
docker exec -it rag-ollama ollama pull qwen2.5:7b
docker exec -it rag-ollama ollama list

建议:

  • Embedding 模型优先选择稳定、通用、上下文窗口较大的模型
  • 生成模型优先选 7B ~ 8B 量级,先平衡效果和本地资源

九、依赖与配置:先把基础设施接对

9.1 Maven 依赖

下面给出一套生产思维下的依赖组合,版本号请以你项目当前基线为准。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</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.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</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-pgvector-store-spring-boot-starter</artifactId>
    </dependency>

    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.retry</groupId>
        <artifactId>spring-retry</artifactId>
    </dependency>

    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>

    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-core</artifactId>
    </dependency>
</dependencies>

9.2 application.yml

server:
  port: 8080
  tomcat:
    threads:
      max: 200
      min-spare: 20
    accept-count: 1000
    connection-timeout: 3000ms

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/rag_db
    username: rag
    password: rag123

  data:
    redis:
      host: localhost
      port: 6379
      timeout: 1000ms

  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        model: qwen2.5:7b
        options:
          temperature: 0.2
      embedding:
        model: nomic-embed-text

    vectorstore:
      pgvector:
        initialize-schema: true
        schema-validation: true
        dimensions: 768
        index-type: hnsw
        distance-type: cosine_distance

app:
  rag:
    retrieval:
      top-k: 8
      keyword-top-k: 8
      final-top-k: 5
      min-score: 0.6
    indexing:
      batch-size: 32
      chunk-size: 700
      chunk-overlap: 100
    llm:
      timeout-ms: 15000
      max-context-chars: 12000
    tenant:
      enabled: true

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics

说明:

  • temperature 在知识问答场景建议较低,减少发散
  • chunk-size 不应写死为一个行业默认值,应通过真实语料压测调优
  • top-k 不要贪大,召回太多会增加噪声和 Prompt 体积

十、数据库与索引设计:不要只存向量

10.1 建议的元数据表

向量库负责“相似度检索”,但业务管理通常还需要关系型元数据表。

create table kb_document (
    id varchar(64) primary key,
    tenant_id varchar(64) not null,
    knowledge_base_id varchar(64) not null,
    document_name varchar(255) not null,
    document_type varchar(32) not null,
    content_hash varchar(128) not null,
    version_no integer not null,
    status varchar(32) not null,
    source_uri varchar(512),
    created_at timestamp not null,
    updated_at timestamp not null
);

create index idx_kb_document_tenant_kb
on kb_document(tenant_id, knowledge_base_id);

10.2 chunk 元数据建议

即使向量内容由 VectorStore 托管,也要保证写入时带完整 metadata:

{
  "tenantId": "t-001",
  "knowledgeBaseId": "kb-devops",
  "documentId": "doc-1001",
  "documentVersion": 3,
  "chunkId": "doc-1001#chunk-08",
  "sourceName": "k8s-faq.pdf",
  "sourceType": "pdf",
  "pageNo": 12,
  "title": "Pod Pending 排查步骤",
  "permissionTags": "devops,ops-admin"
}

这些字段决定你后续能否做:

  • 多租户隔离
  • 灰度索引切换
  • 按知识库过滤
  • 按部门权限过滤
  • 回答引用与审计

十一、生产级代码一:切块与文档标准化

11.1 切块配置

package com.example.rag.config;

import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfig {

    @Bean
    public TokenTextSplitter tokenTextSplitter(
            @Value("${app.rag.indexing.chunk-size}") int chunkSize,
            @Value("${app.rag.indexing.chunk-overlap}") int chunkOverlap) {
        return new TokenTextSplitter(
                chunkSize,
                200,
                20,
                chunkOverlap,
                true
        );
    }
}

这里的关键点不是“用了哪个类”,而是:

  • chunk size 参数化,便于压测调优
  • overlap 显式配置,避免知识断裂
  • 不把切块策略写死在业务代码里

11.2 文档标准化器

真实文档很脏,常见问题包括:

  • PDF 换行断裂
  • 多个空格与空白页
  • 页眉页脚反复出现
  • 乱码和控制字符
  • Markdown 表格、代码块被拆坏

所以正式入库前,要先做标准化。

package com.example.rag.infrastructure.ingestion;

import java.util.regex.Pattern;

public class DocumentNormalizer {

    private static final Pattern MULTI_BLANK = Pattern.compile("[ \\t]{2,}");
    private static final Pattern MULTI_NEWLINE = Pattern.compile("\\n{3,}");
    private static final Pattern CONTROL_CHAR = Pattern.compile("[\\p{Cntrl}&&[^\n\t]]");

    public String normalize(String raw) {
        if (raw == null || raw.isBlank()) {
            return "";
        }
        String text = CONTROL_CHAR.matcher(raw).replaceAll("");
        text = text.replace("\r\n", "\n");
        text = MULTI_BLANK.matcher(text).replaceAll(" ");
        text = MULTI_NEWLINE.matcher(text).replaceAll("\n\n");
        return text.trim();
    }
}

这一步看起来很普通,但它直接影响向量质量和后续召回效果。

十二、生产级代码二:异步文档入库链路

12.1 为什么入库必须异步

文档解析、切块、向量化、批量写入,都是重任务。
如果用户上传文档时同步处理:

  • 接口 RT 会非常难看
  • Tomcat 工作线程会被长时间占用
  • 峰值上传会把在线查询服务拖垮

因此推荐模式是:

  1. 上传接口只负责接收文档与落盘
  2. 写一条索引任务到队列
  3. 后台消费者异步执行
  4. 任务状态可查询

12.2 线程池隔离

package com.example.rag.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

@EnableAsync
@Configuration
public class ExecutorConfig {

    @Bean("indexExecutor")
    public Executor indexExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("rag-index-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Bean("queryExecutor")
    public Executor queryExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(16);
        executor.setMaxPoolSize(32);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("rag-query-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        executor.initialize();
        return executor;
    }
}

核心思路:

  • 索引线程池与查询线程池分离
  • 索引任务允许适度排队
  • 查询线程池失败要尽快暴露,不能无限堆积

12.3 入库服务

package com.example.rag.app;

import com.example.rag.infrastructure.ingestion.DocumentNormalizer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@Service
public class IngestionApplicationService {

    private static final Logger log = LoggerFactory.getLogger(IngestionApplicationService.class);

    private final VectorStore vectorStore;
    private final TokenTextSplitter splitter;
    private final DocumentNormalizer normalizer = new DocumentNormalizer();

    public IngestionApplicationService(VectorStore vectorStore, TokenTextSplitter splitter) {
        this.vectorStore = vectorStore;
        this.splitter = splitter;
    }

    @Async("indexExecutor")
    public void indexPlainText(String tenantId,
                               String knowledgeBaseId,
                               String documentId,
                               int version,
                               String sourceName,
                               String rawContent) {

        long start = System.currentTimeMillis();
        String normalized = normalizer.normalize(rawContent);

        Document root = new Document(
                normalized,
                Map.of(
                        "tenantId", tenantId,
                        "knowledgeBaseId", knowledgeBaseId,
                        "documentId", documentId,
                        "documentVersion", version,
                        "sourceName", sourceName,
                        "createdAt", Instant.now().toString()
                )
        );

        List<Document> chunks = splitter.apply(List.of(root));
        List<Document> enriched = new ArrayList<>(chunks.size());

        for (int i = 0; i < chunks.size(); i++) {
            Document chunk = chunks.get(i);
            chunk.getMetadata().put("chunkId", documentId + "#chunk-" + i);
            chunk.getMetadata().put("traceId", UUID.randomUUID().toString());
            enriched.add(chunk);
        }

        vectorStore.add(enriched);

        log.info("index finished, tenantId={}, kbId={}, documentId={}, chunks={}, cost={}ms",
                tenantId, knowledgeBaseId, documentId, enriched.size(),
                System.currentTimeMillis() - start);
    }
}

这个版本已经比 Demo 更接近生产:

  • 显式写入多租户和版本元数据
  • 入库任务异步化
  • 每个 chunk 有可追踪 ID
  • 标准化在向量化之前完成

12.4 再往前走一步:任务队列化

如果你的文档上传量更大,建议把 @Async 升级为 MQ 任务模型:

  • 上传服务:只写数据库状态和 MQ 消息
  • 索引服务:独立消费文档任务
  • 失败重试:由 MQ 或任务表驱动
  • 任务幂等:通过 documentId + version 控制

本地试点可以先用 @Async,生产高吞吐建议上 MQ。

十三、生产级代码三:混合检索与权限过滤

13.1 为什么查询服务不能直接调用 VectorStore 就结束

因为真实查询需要的不只是“相似内容”,还包括:

  • 多租户过滤
  • 知识库过滤
  • 权限过滤
  • 最低分阈值
  • 混合召回
  • 结果去重
  • 结果重排

所以建议把检索逻辑封装为独立的 RetrievalService

13.2 检索结果模型

package com.example.rag.domain.model;

public record RetrievalHit(
        String documentId,
        String chunkId,
        String content,
        double score,
        String sourceName,
        Integer pageNo
) {
}

13.3 检索服务

package com.example.rag.infrastructure.retrieval;

import com.example.rag.domain.model.RetrievalHit;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@Service
public class RetrievalService {

    private final VectorStore vectorStore;

    public RetrievalService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public List<RetrievalHit> search(String tenantId,
                                     String knowledgeBaseId,
                                     String permissionTag,
                                     String query,
                                     int topK,
                                     double minScore) {

        String filter = "tenantId == '%s' && knowledgeBaseId == '%s'"
                .formatted(tenantId, knowledgeBaseId);

        List<Document> vectorHits = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(query)
                        .topK(topK)
                        .similarityThreshold(minScore)
                        .filterExpression(filter)
                        .build()
        );

        Map<String, RetrievalHit> dedup = new LinkedHashMap<>();
        for (Document doc : vectorHits) {
            if (!hasPermission(doc, permissionTag)) {
                continue;
            }
            String chunkId = String.valueOf(doc.getMetadata().get("chunkId"));
            dedup.put(chunkId, toHit(doc));
        }

        return dedup.values().stream()
                .sorted(Comparator.comparingDouble(RetrievalHit::score).reversed())
                .toList();
    }

    private boolean hasPermission(Document document, String permissionTag) {
        Object tags = document.getMetadata().get("permissionTags");
        if (tags == null || permissionTag == null || permissionTag.isBlank()) {
            return true;
        }
        return tags.toString().contains(permissionTag);
    }

    private RetrievalHit toHit(Document document) {
        Map<String, Object> metadata = document.getMetadata();
        return new RetrievalHit(
                String.valueOf(metadata.get("documentId")),
                String.valueOf(metadata.get("chunkId")),
                document.getText(),
                extractScore(metadata),
                String.valueOf(metadata.getOrDefault("sourceName", "unknown")),
                metadata.get("pageNo") == null ? null : Integer.parseInt(metadata.get("pageNo").toString())
        );
    }

    private double extractScore(Map<String, Object> metadata) {
        Object score = metadata.get("distance");
        if (score == null) {
            return 0.0D;
        }
        return 1 - Double.parseDouble(score.toString());
    }
}

这里有两个生产关键点:

  • 过滤条件前置到检索阶段,而不是检索后再“假装过滤”
  • 权限标签也是召回的一部分,而不是生成后的结果修补

13.4 混合检索建议

如果你准备进一步提高效果,建议增加一个关键词召回通道:

1. 向量检索拿 8 条
2. BM25 检索拿 8 条
3. 合并去重得到候选 12~16 条
4. 进入重排
5. 取最终 Top 5 进入 Prompt

这一步在技术文档、错误码、接口名、配置项名称场景中提升非常明显。

一个更接近生产的实现方式,是再包一层“混合召回协调器”:

package com.example.rag.infrastructure.retrieval;

import com.example.rag.domain.model.RetrievalHit;
import org.springframework.stereotype.Component;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@Component
public class HybridRetrievalCoordinator {

    private final RetrievalService vectorRetrievalService;
    private final KeywordRetrievalService keywordRetrievalService;

    public HybridRetrievalCoordinator(RetrievalService vectorRetrievalService,
                                      KeywordRetrievalService keywordRetrievalService) {
        this.vectorRetrievalService = vectorRetrievalService;
        this.keywordRetrievalService = keywordRetrievalService;
    }

    public List<RetrievalHit> search(String tenantId,
                                     String knowledgeBaseId,
                                     String permissionTag,
                                     String query) {
        List<RetrievalHit> vectorHits = vectorRetrievalService.search(
                tenantId, knowledgeBaseId, permissionTag, query, 8, 0.6
        );
        List<RetrievalHit> keywordHits = keywordRetrievalService.search(
                tenantId, knowledgeBaseId, permissionTag, query, 8
        );

        Map<String, RetrievalHit> merged = new LinkedHashMap<>();
        vectorHits.forEach(hit -> merged.put(hit.chunkId(), hit));
        keywordHits.forEach(hit -> merged.putIfAbsent(hit.chunkId(), hit));

        return merged.values().stream().limit(16).toList();
    }
}

这里的 KeywordRetrievalService 可以先接 PostgreSQL 全文索引、Elasticsearch,或者后续再独立扩展。
重点不是底层一定用哪一个,而是让“向量通道”和“关键词通道”在架构上可组合。

十四、生产级代码四:查询重写、重排与 Prompt 组装

14.1 查询重写为什么重要

用户真实输入经常不是“适合检索的 query”:

  • “这个报错怎么回事”
  • “为什么最近总是 Pending”
  • “数据库又炸了咋办”

这样的 query 语义模糊,召回效果通常不稳定。
所以在检索前,建议先做一次轻量 Query Rewrite:

  • 补全上下文
  • 消歧
  • 提取关键词
  • 生成更适合检索的表达

14.2 Query Rewrite 示例

package com.example.rag.infrastructure.llm;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;

@Component
public class QueryRewriteService {

    private final ChatClient chatClient;

    public QueryRewriteService(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    public String rewrite(String originalQuestion) {
        String prompt = """
                你是企业知识库检索优化器。
                请将用户问题改写为更适合检索的短句。
                要求:
                1. 保留原意
                2. 补充核心关键词
                3. 不要编造事实
                4. 只输出改写后的检索语句

                用户问题:
                %s
                """.formatted(originalQuestion);

        return chatClient.prompt()
                .user(prompt)
                .call()
                .content();
    }
}

注意,这里 Query Rewrite 用的是小 Prompt,不是让模型“自由发挥”,而是严格约束输出。

14.3 轻量重排器

如果暂时没有专用 rerank 模型,可以先做规则重排。

package com.example.rag.infrastructure.retrieval;

import com.example.rag.domain.model.RetrievalHit;
import org.springframework.stereotype.Component;

import java.util.Comparator;
import java.util.List;

@Component
public class RuleBasedReranker {

    public List<RetrievalHit> rerank(String query, List<RetrievalHit> hits, int finalTopK) {
        return hits.stream()
                .map(hit -> new ScoredHit(hit, score(query, hit)))
                .sorted(Comparator.comparingDouble(ScoredHit::score).reversed())
                .limit(finalTopK)
                .map(ScoredHit::hit)
                .toList();
    }

    private double score(String query, RetrievalHit hit) {
        double base = hit.score();
        String content = hit.content().toLowerCase();
        String[] keywords = query.toLowerCase().split("\\s+");
        int matched = 0;
        for (String keyword : keywords) {
            if (!keyword.isBlank() && content.contains(keyword)) {
                matched++;
            }
        }
        return base + matched * 0.05;
    }

    private record ScoredHit(RetrievalHit hit, double score) {
    }
}

它不如专用 rerank 模型强,但比“原样按相似度排序”稳定很多。

14.4 Prompt 组装器

package com.example.rag.infrastructure.llm;

import com.example.rag.domain.model.RetrievalHit;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.stream.Collectors;

@Component
public class PromptBuilder {

    public String build(String question, List<RetrievalHit> hits) {
        String context = hits.stream()
                .map(hit -> """
                        [来源: %s%s]
                        %s
                        """.formatted(
                        hit.sourceName(),
                        hit.pageNo() == null ? "" : ", 第" + hit.pageNo() + "页",
                        hit.content()))
                .collect(Collectors.joining("\n\n"));

        return """
                你是企业内部知识助手。
                请严格基于提供的知识片段回答问题。

                回答要求:
                1. 如果知识片段不足以回答,就明确说“知识库中未找到充分依据”
                2. 不要编造文档中不存在的信息
                3. 尽量给出结构化回答
                4. 回答最后附上引用来源

                用户问题:
                %s

                知识片段:
                %s
                """.formatted(question, context);
    }
}

Prompt 的关键不是写得多花哨,而是:

  • 明确约束“只能基于资料回答”
  • 给模型一个“无法回答时的正确退路”
  • 保留引用信息,方便最终输出溯源

十五、生产级代码五:完整问答服务

package com.example.rag.app;

import com.example.rag.domain.model.RetrievalHit;
import com.example.rag.infrastructure.llm.PromptBuilder;
import com.example.rag.infrastructure.llm.QueryRewriteService;
import com.example.rag.infrastructure.retrieval.RetrievalService;
import com.example.rag.infrastructure.retrieval.RuleBasedReranker;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ChatApplicationService {

    private final QueryRewriteService queryRewriteService;
    private final RetrievalService retrievalService;
    private final RuleBasedReranker reranker;
    private final PromptBuilder promptBuilder;
    private final ChatClient chatClient;

    public ChatApplicationService(QueryRewriteService queryRewriteService,
                                  RetrievalService retrievalService,
                                  RuleBasedReranker reranker,
                                  PromptBuilder promptBuilder,
                                  ChatClient.Builder chatClientBuilder) {
        this.queryRewriteService = queryRewriteService;
        this.retrievalService = retrievalService;
        this.reranker = reranker;
        this.promptBuilder = promptBuilder;
        this.chatClient = chatClientBuilder.build();
    }

    public RagAnswer ask(String tenantId,
                         String knowledgeBaseId,
                         String permissionTag,
                         String question) {

        String rewritten = queryRewriteService.rewrite(question);
        List<RetrievalHit> recalled = retrievalService.search(
                tenantId, knowledgeBaseId, permissionTag, rewritten, 8, 0.6
        );

        List<RetrievalHit> topHits = reranker.rerank(rewritten, recalled, 5);
        String prompt = promptBuilder.build(question, topHits);

        String answer = chatClient.prompt()
                .user(prompt)
                .call()
                .content();

        return new RagAnswer(question, rewritten, answer, topHits);
    }

    public record RagAnswer(
            String question,
            String rewrittenQuestion,
            String answer,
            List<RetrievalHit> citations
    ) {
    }
}

这个结构已经形成了一个清晰的在线链路:

原始问题
  -> 查询重写
  -> 召回
  -> 重排
  -> Prompt 组装
  -> LLM 生成
  -> 引用返回

相较于把所有逻辑写进一个 Controller,这样的拆分更利于调优和观测。

十六、接口层设计:别只返回 answer

很多 Demo 接口只返回一个字符串:

{
  "answer": "......"
}

这远远不够。

生产接口建议返回:

  • 最终答案
  • 是否命中知识
  • 引用来源
  • 召回文档数
  • 请求耗时
  • traceId

16.1 ChatController 示例

package com.example.rag.controller;

import com.example.rag.app.ChatApplicationService;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;

@RestController
@RequestMapping("/api/rag")
public class ChatController {

    private final ChatApplicationService chatApplicationService;

    public ChatController(ChatApplicationService chatApplicationService) {
        this.chatApplicationService = chatApplicationService;
    }

    @PostMapping("/ask")
    public ChatResponse ask(@RequestHeader("X-Tenant-Id") String tenantId,
                            @RequestBody ChatRequest request) {
        long start = System.currentTimeMillis();
        String traceId = UUID.randomUUID().toString();

        ChatApplicationService.RagAnswer result = chatApplicationService.ask(
                tenantId,
                request.knowledgeBaseId(),
                request.permissionTag(),
                request.question()
        );

        return new ChatResponse(
                traceId,
                result.answer(),
                !result.citations().isEmpty(),
                result.citations(),
                System.currentTimeMillis() - start
        );
    }

    public record ChatRequest(
            @NotBlank String knowledgeBaseId,
            String permissionTag,
            @NotBlank String question
    ) {
    }

    public record ChatResponse(
            String traceId,
            String answer,
            boolean grounded,
            Object citations,
            long costMs
    ) {
    }
}

这样的响应对前端、测试、审计、问题定位都更友好。

十七、实际案例:企业运维知识库问答场景

为了避免代码看起来“像框架示例而不像业务系统”,我们来看一个真实感更强的场景。

17.1 场景设定

某公司要做内部运维知识助手,知识来源包括:

  • Kubernetes 故障处理手册
  • 数据库故障 SOP
  • 线上事故复盘
  • 中间件参数模板
  • 常见报警 FAQ

典型问题:

  • “Pod 一直 Pending,先查什么”
  • “订单库主从延迟突然升高的排查步骤”
  • “Redis 连接数打满后应该先看哪些指标”

17.2 为什么这个场景适合 RAG

因为它同时满足:

  • 知识更新频繁
  • 文档量大
  • 强依赖企业私有知识
  • 答案必须能溯源
  • 有明确权限边界

17.3 示例问答链路

用户问题:

生产环境 Pod 一直 Pending,一般先排查什么?

系统处理:

  1. Query Rewrite 改写为
    Kubernetes Pod Pending 排查步骤 节点资源 调度失败 taint toleration pvc
  2. 向量检索召回运维手册和历史事故复盘
  3. 关键词检索命中 “Pending”“调度失败”“Insufficient CPU”
  4. 重排后保留 5 个最相关 chunk
  5. Prompt 生成回答,并附上来源

最终答案不再是泛泛而谈,而是能明确输出:

  • 先看 kubectl describe pod
  • 检查事件中的 FailedScheduling
  • 检查节点 CPU / 内存是否不足
  • 检查 taint / toleration 是否匹配
  • 检查 PVC 是否处于 Pending

这就是“基于企业知识证据回答”,而不是通用模型的百科式输出。

十八、高并发设计:本地 Ollama 怎么扛住更多请求

这是很多文章最容易轻描淡写的部分,但恰恰是上线成败的关键。

18.1 瓶颈在哪里

RAG 在线请求的主要耗时通常在三段:

  1. 检索耗时
  2. Prompt 构造与上下文压缩耗时
  3. 模型推理耗时

其中最不稳定的往往是第 3 段。

本地 Ollama 的典型问题:

  • 推理是稀缺资源
  • 模型切换有额外开销
  • 大 Prompt 会显著拉长生成时间
  • 并发一高,排队不可避免

18.2 四种必须做的治理手段

1. 限流

对问答接口做 QPS 控制,避免瞬时洪峰直接打满推理资源。

2. 缓存

对高频、重复问题做结果缓存。
企业内部问答往往存在明显热点。

3. 舱壁隔离

不同业务租户、不同模型、不同接口使用不同线程池或不同资源池,防止互相拖垮。

4. 降级

模型不可用时,至少要能返回:

  • 纯检索结果摘要
  • 推荐阅读文档列表
  • 稍后重试提示

18.3 缓存示例

package com.example.rag.infrastructure.cache;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.Optional;

@Component
public class AnswerCache {

    private final StringRedisTemplate redisTemplate;

    public AnswerCache(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Optional<String> get(String key) {
        return Optional.ofNullable(redisTemplate.opsForValue().get(key));
    }

    public void put(String key, String value) {
        redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(10));
    }
}

缓存键建议包含:

  • tenantId
  • knowledgeBaseId
  • normalizedQuestion
  • indexVersion

否则索引一更新,你会把旧答案继续返回给用户。

18.4 模型调用隔离

可以把模型调用封装成独立网关:

package com.example.rag.infrastructure.llm;

import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;

@Component
public class LlmGateway {

    private static final Logger log = LoggerFactory.getLogger(LlmGateway.class);

    private final ChatClient chatClient;
    private final MeterRegistry meterRegistry;

    public LlmGateway(ChatClient.Builder builder, MeterRegistry meterRegistry) {
        this.chatClient = builder.build();
        this.meterRegistry = meterRegistry;
    }

    public String generate(String prompt) {
        long start = System.currentTimeMillis();
        try {
            String result = chatClient.prompt().user(prompt).call().content();
            meterRegistry.timer("rag.llm.success").record(System.currentTimeMillis() - start,
                    java.util.concurrent.TimeUnit.MILLISECONDS);
            return result;
        } catch (Exception e) {
            meterRegistry.counter("rag.llm.error").increment();
            log.error("llm call failed", e);
            throw e;
        }
    }
}

进一步可以叠加:

  • Resilience4j 超时
  • 重试
  • 熔断
  • 并发隔离

十九、可扩展设计:从单机本地到分布式演进路线

很多人担心:“一开始用 Ollama 和 PgVector,以后是不是要全部推倒重来?”

如果边界设计得对,不需要。

19.1 演进路线建议

阶段 1:本地验证

  • 单体 Spring Boot
  • Ollama
  • PgVector
  • 本地文件存储

目标:验证知识质量、交互链路、基础效果。

阶段 2:部门级上线

  • Query / Ingestion 拆分
  • Redis 缓存
  • MQ 异步入库
  • Prometheus + Grafana
  • 多租户与权限过滤

目标:稳定承接真实部门流量。

阶段 3:企业级扩容

  • 向量库升级到 Elasticsearch 或 Milvus
  • 模型网关支持本地与云端双路切换
  • 多副本部署
  • 网关限流
  • 索引版本管理与灰度发布

目标:规模化支撑多业务线。

19.2 保持可替换性的关键抽象

真正决定你能否平滑演进的,不是底层用的什么库,而是有没有提前抽象这些接口:

  • DocumentParser
  • EmbeddingGateway
  • RetrievalService
  • Reranker
  • LlmGateway
  • AnswerCache

只要这些边界清晰,你从:

  • Ollama -> 云端模型
  • PgVector -> Elasticsearch / Milvus
  • @Async -> RocketMQ / Kafka

都不需要重写业务主链路。

二十、多租户与权限隔离:知识库系统的底线

很多文章会把这块一笔带过,但真正进入企业环境,这是绝对不能缺的。

20.1 为什么 RAG 特别容易串库

因为模型本身并不知道什么内容该回答给谁。
如果检索时没有带租户和权限过滤,模型就会把任何召回到的内容组织成答案。

这意味着:

  • 不是“模型泄露数据”
  • 而是“你的检索层把不该给它的内容送进去了”

20.2 必须执行的隔离策略

检索前过滤

在向量召回阶段就加上:

  • tenantId
  • knowledgeBaseId
  • permissionTags
  • documentVersion

引用审计

回答结果必须带来源,便于审计“这段答案到底来自哪里”。

Prompt 最小暴露

不要把候选集全量塞给模型,只送最终 TopN。

后台管理权限

上传、重建、发布、删除索引,都应有严格权限控制。

二十一、索引更新策略:别每次都全量重建

21.1 推荐的版本化索引思路

对每个知识库维护 index_version

  • 老版本继续服务线上查询
  • 新版本在后台构建
  • 构建完成后切换活动版本
  • 旧版本保留短期回滚窗口

这样做的价值非常大:

  • 构建期间不影响线上查询
  • 出问题可快速回滚
  • 支持灰度验证新切块策略或新 Embedding 模型

21.2 文档增量更新

建议用文档内容哈希做幂等判断:

  • hash 不变:跳过
  • hash 变化:新增版本
  • 文档删除:打 tombstone 或删除旧 chunk

21.3 双写与切换

如果你准备替换 Embedding 模型,不建议直接覆盖老索引。
更稳妥的做法是:

  1. 新模型构建一套新索引
  2. 用同一批评测问题离线评估
  3. 小流量灰度
  4. 验证后切换

二十二、可观测性建设:没有观测就没有调优

RAG 的最大工程难点之一,是出了问题很难第一时间知道错在哪一层。

所以建议最少打通下面几类指标。

22.1 关键指标

检索层

  • 检索 RT
  • 向量召回数
  • 关键词召回数
  • 重排后保留数
  • 最终命中率

模型层

  • LLM RT
  • 成功率
  • 超时率
  • 平均 Prompt 长度
  • 平均输出长度

业务层

  • 问答总 QPS
  • 缓存命中率
  • 未命中知识比例
  • 用户反馈满意度

22.2 关键日志

每次问答建议记录:

  • traceId
  • tenantId
  • 原始问题
  • 改写问题
  • 召回 chunkId 列表
  • 最终引用 sourceName 列表
  • LLM 耗时
  • 总耗时

22.3 一个经常被忽视的指标:Grounded Rate

也就是“最终答案是否基于检索证据”。
你可以简单定义:

  • 有有效引用来源
  • 答案不是兜底拒答

这会比单纯看 QPS 更能反映知识库真实质量。

二十三、性能压测与调优建议

23.1 压测前先拆指标

不要只测 /ask 接口总 RT。
要分开测:

  • 检索耗时
  • 重排耗时
  • LLM 耗时
  • 缓存命中与未命中

否则压测结果没有调优价值。

23.2 典型调优项

检索层

  • 调整 chunk size 和 overlap
  • 调整 topK
  • 调整向量索引参数
  • 降低无效 metadata 过滤复杂度

Prompt 层

  • 控制最终上下文长度
  • 压缩冗余 chunk
  • 合并相邻片段

模型层

  • 缩短输出长度
  • 降低 temperature
  • 统一模型,减少模型切换
  • 热加载常用模型

缓存层

  • 对热点问题做 5~10 分钟短缓存
  • 对查询重写结果做二级缓存

23.3 经验结论

在多数企业问答场景里:

  • 先把召回质量做对,比盲目升级模型更有效
  • 先控制 Prompt 长度,比一味拉高 topK 更有效
  • 对热点问题做缓存,收益通常非常可观

二十四、部署方案:从本地容器到生产集群

24.1 单机 Docker 部署

适合:

  • 个人验证
  • 小团队试点
  • 离线内网原型

优点:

  • 成本低
  • 部署快
  • 易复现

缺点:

  • 没有高可用
  • 容量和吞吐受单机资源限制

24.2 Kubernetes 部署建议

如果你进入生产环境,推荐拆成至少三类工作负载:

  • query-service:高优先级,弹性扩容
  • ingestion-service:后台异步任务,可限速
  • ollama-gateway 或推理服务:独立资源池

部署重点:

  • Query Service 与 Ingestion Service 分开 HPA
  • 对模型服务单独做资源配额
  • readiness / liveness 探针要区分
  • 配置 Prometheus 采集

24.3 一个常见误区

不要把:

  • 上传解析
  • 索引构建
  • 在线问答
  • 模型推理

全部塞进同一个 Pod,再指望它横向扩容解决一切。
这样做最后只会把资源争抢放大。

二十五、从本地 RAG 升级到企业级 RAG 的完整演进清单

如果你想判断自己的系统现在处于哪个阶段,可以对照下面这张表。

维度 Demo 版 生产版
文档入库 同步处理 异步队列化
切块策略 固定长度 语义切块 + overlap
检索方式 单向量召回 混合召回 + 重排
元数据 很少 完整租户/版本/权限
权限控制 基本没有 检索前过滤
模型调用 直接调用 网关隔离 + 超时/熔断
并发治理 几乎没有 限流 + 缓存 + 线程池隔离
索引更新 全量重建 增量 + 版本切换
可观测性 仅看日志 指标 + Trace + 检索快照
部署形态 单机 多服务分层部署

如果你的系统已经完成右侧大部分能力,那它才真正具备了生产落地基础。

二十六、一个更务实的认知:RAG 不是一次性交付,而是持续优化系统

很多团队在做 RAG 时,总想一次性把答案准确率拉满。
但工程现实是:

  • 首次上线先解决“有用”
  • 第二阶段解决“稳定”
  • 第三阶段解决“规模”
  • 第四阶段解决“精度持续提升”

真正成熟的 RAG 不是一个静态项目,而是一套持续迭代机制:

  • 持续补充高质量知识源
  • 持续优化切块策略
  • 持续评估召回与重排效果
  • 持续分析用户未命中问题
  • 持续治理模型成本和延迟

这也是为什么从架构第一天起,就应该把 RAG 当成“检索平台”而不是“Prompt 脚本”。

二十七、最终完整落地建议

如果你是 Java 团队,想低成本构建本地知识库,我建议按这个顺序推进:

  1. Spring AI Alibaba + Ollama + PgVector 跑通最小链路
  2. 把文档入库、在线问答拆成两条独立链路
  3. 为 chunk 补齐租户、文档、版本、权限元数据
  4. 从单向量召回升级到混合召回 + 重排
  5. 增加缓存、限流、线程池隔离和超时控制
  6. 补齐可观测性、引用溯源和任务状态跟踪
  7. 再考虑替换更强模型或更大规模向量库

这条路径的好处是:

  • 成本可控
  • 学习曲线平滑
  • 先把工程地基打牢
  • 后续演进不会推倒重来

二十八、结语

RAG 真正难的,从来不是“能不能把模型接起来”,而是:

  • 能不能在知识规模变大后依然检索稳定
  • 能不能在并发上来后依然低延迟可用
  • 能不能在多租户和权限场景下依然安全
  • 能不能在答案错误时迅速定位是哪一层出了问题

对 Java 开发者而言,Spring AI Alibaba + Ollama 是一条非常务实的起步路线:

  • Spring 生态足够熟悉
  • 本地模型足够低成本
  • VectorStore 抽象足够灵活
  • 架构边界设计好之后,后续升级到云模型、Elasticsearch、Milvus 也不困难

如果说 Demo 证明的是“RAG 能做”,
那么一套具备离线入库、混合召回、重排、权限隔离、并发治理、可观测性和版本发布能力的系统,证明的才是:

RAG 真的能在企业里稳定落地。

如果你觉得独自摸索这条生产落地路径耗时太久,不妨来 云栈社区 看看,这里有众多一线 Java 开发者分享关于 Spring、RAG 以及各种中间件的最前沿实践。

附:本文方案的最小生产化 Checklist

  • 文档解析与标准化
  • 语义切块与 overlap
  • 向量入库与元数据补全
  • 多租户与权限过滤
  • 查询重写
  • 混合检索
  • 轻量重排
  • Prompt 约束与引用溯源
  • 异步索引任务
  • 线程池隔离
  • 缓存与限流预留
  • 指标、日志与审计
  • 索引版本化
  • 可扩展架构边界

如果你的文章、代码和系统设计已经覆盖了这份清单,那么它就不再只是一个 RAG Demo,而是一套有工程含金量的知识库系统。




上一篇:AI记忆助理赛道:情绪价值与长期记忆的两难,值得All in吗?
下一篇:Spring Boot 集成 Neo4j 图谱高并发实战:架构设计与代码落地全解析
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-5-10 07:11 , Processed in 0.647454 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表