一、为什么很多 RAG Demo 一上线就失效
这几年,RAG 已经成了企业知识问答、智能客服、内部助手、运维 Copilot、研发知识库的标准能力。
但很多团队第一次做 RAG,路径都很像:
- 本地启动
Ollama
- 选一个 Embedding 模型
- 读几篇 Markdown 或 PDF
- 切块后写入向量库
- 查询时做一次相似度召回,再拼 Prompt 调模型
10 分钟就能跑通,效果甚至还不错。
问题是,这样的系统只能证明“RAG 可以做”,不能证明“RAG 可以上线”。
一旦进入真实业务环境,问题会迅速暴露:
- 文档规模从几十篇变成几万篇后,索引耗时和成本激增
- 问题语义稍微复杂一点,向量召回就开始漂移
- 并发上来后,Ollama 推理排队,接口 RT 飙升
- 知识库一更新,老索引与新索引混在一起,结果不可控
- 多租户和权限隔离没设计,回答发生串库
- 答错了也无法定位,到底是切块错、召回错、重排错,还是生成错
根本原因在于:RAG 从来不是一个“调用模型”的问题,而是一个“检索系统 + 生成系统 + 工程系统”的复合架构问题。
所以,真正可落地的 RAG,必须同时解决四件事:
- 检索质量足够稳定
- 在线链路具备低延迟和高并发能力
- 离线索引链路可增量、可扩容、可回滚
- 全链路可观测、可治理、可演进
本文就以 Spring AI Alibaba + Ollama + 本地向量库 为主线,带你把一个 Java RAG Demo 升级为可落地的生产系统。
二、先把原理讲透:RAG 到底解决什么问题
2.1 LLM 为什么需要 RAG
大模型本身有三个天然缺陷:
- 参数知识静态,无法实时反映企业最新文档
- 训练语料是公共知识,不理解你公司的私有制度、接口、SOP、工单
- 遇到事实性问题时,容易“合理胡说”
RAG 的本质,就是在生成前为模型补充一层“外部可检索记忆”。
它并不要求模型记住一切,而是把问题拆成两个阶段:
- 先检索:从知识库中找出与问题最相关的上下文
- 再生成:让模型基于检索结果组织答案
所以,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 恰好反过来。
因此,更可靠的方式是:
- 向量召回 TopK
- 关键词召回 TopK
- 合并候选集
- 用重排模型按 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
这里刻意把 app、domain、infrastructure 分开,是为了避免后面所有逻辑都堆进 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 工作线程会被长时间占用
- 峰值上传会把在线查询服务拖垮
因此推荐模式是:
- 上传接口只负责接收文档与落盘
- 写一条索引任务到队列
- 后台消费者异步执行
- 任务状态可查询
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,一般先排查什么?
系统处理:
- Query Rewrite 改写为
Kubernetes Pod Pending 排查步骤 节点资源 调度失败 taint toleration pvc
- 向量检索召回运维手册和历史事故复盘
- 关键词检索命中 “Pending”“调度失败”“Insufficient CPU”
- 重排后保留 5 个最相关 chunk
- Prompt 生成回答,并附上来源
最终答案不再是泛泛而谈,而是能明确输出:
- 先看
kubectl describe pod
- 检查事件中的
FailedScheduling
- 检查节点 CPU / 内存是否不足
- 检查 taint / toleration 是否匹配
- 检查 PVC 是否处于 Pending
这就是“基于企业知识证据回答”,而不是通用模型的百科式输出。
十八、高并发设计:本地 Ollama 怎么扛住更多请求
这是很多文章最容易轻描淡写的部分,但恰恰是上线成败的关键。
18.1 瓶颈在哪里
RAG 在线请求的主要耗时通常在三段:
- 检索耗时
- Prompt 构造与上下文压缩耗时
- 模型推理耗时
其中最不稳定的往往是第 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 模型,不建议直接覆盖老索引。
更稳妥的做法是:
- 新模型构建一套新索引
- 用同一批评测问题离线评估
- 小流量灰度
- 验证后切换
二十二、可观测性建设:没有观测就没有调优
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 团队,想低成本构建本地知识库,我建议按这个顺序推进:
- 用
Spring AI Alibaba + Ollama + PgVector 跑通最小链路
- 把文档入库、在线问答拆成两条独立链路
- 为 chunk 补齐租户、文档、版本、权限元数据
- 从单向量召回升级到混合召回 + 重排
- 增加缓存、限流、线程池隔离和超时控制
- 补齐可观测性、引用溯源和任务状态跟踪
- 再考虑替换更强模型或更大规模向量库
这条路径的好处是:
- 成本可控
- 学习曲线平滑
- 先把工程地基打牢
- 后续演进不会推倒重来
二十八、结语
RAG 真正难的,从来不是“能不能把模型接起来”,而是:
- 能不能在知识规模变大后依然检索稳定
- 能不能在并发上来后依然低延迟可用
- 能不能在多租户和权限场景下依然安全
- 能不能在答案错误时迅速定位是哪一层出了问题
对 Java 开发者而言,Spring AI Alibaba + Ollama 是一条非常务实的起步路线:
- Spring 生态足够熟悉
- 本地模型足够低成本
- VectorStore 抽象足够灵活
- 架构边界设计好之后,后续升级到云模型、Elasticsearch、Milvus 也不困难
如果说 Demo 证明的是“RAG 能做”,
那么一套具备离线入库、混合召回、重排、权限隔离、并发治理、可观测性和版本发布能力的系统,证明的才是:
RAG 真的能在企业里稳定落地。
如果你觉得独自摸索这条生产落地路径耗时太久,不妨来 云栈社区 看看,这里有众多一线 Java 开发者分享关于 Spring、RAG 以及各种中间件的最前沿实践。
附:本文方案的最小生产化 Checklist
- 文档解析与标准化
- 语义切块与 overlap
- 向量入库与元数据补全
- 多租户与权限过滤
- 查询重写
- 混合检索
- 轻量重排
- Prompt 约束与引用溯源
- 异步索引任务
- 线程池隔离
- 缓存与限流预留
- 指标、日志与审计
- 索引版本化
- 可扩展架构边界
如果你的文章、代码和系统设计已经覆盖了这份清单,那么它就不再只是一个 RAG Demo,而是一套有工程含金量的知识库系统。