目录
一、最终的项目结构
1.1 模块划分
四篇 Demo 下来,代码长成了这样:
rag-knowledge-base/
├── src/main/java/com/rag/
│ ├── RagApplication.java # 启动类
│ ├── config/
│ │ ├── AiConfig.java # AI 配置
│ │ └── VectorStoreConfig.java # 向量存储配置
│ ├── controller/
│ │ ├── RagController.java # RAG API
│ │ └── EvaluationController.java # 评估 API
│ ├── service/
│ │ ├── DocumentService.java # 文档切分
│ │ ├── EmbeddingService.java # 向量化
│ │ ├── VectorStoreService.java # 向量存储与检索
│ │ ├── Bm25Service.java # 关键词检索
│ │ ├── HybridRetrievalService.java # 混合检索 + RRF 融合
│ │ ├── RerankService.java # 精排
│ │ └── RagService.java # RAG 主流程
│ ├── evaluation/
│ │ └── EvaluationService.java # 评估
│ └── model/
│ └── Document.java # 文档实体
├── src/main/resources/
│ ├── application.yml # 配置文件
│ └── test-cases.json # 测试集
└── pom.xml
这个划分值得留意的地方在于职责边界是按「可替换」来切的,而不是按技术分层:
VectorStoreService和Bm25Service各管一路召回,谁都不认识谁。想让混合检索换成别的组合(比如加一路稀疏向量),只动HybridRetrievalService。RerankService对外只有一个「给我一批文档,还你一批排好序的」,Cohere 和 LLM 两种实现可以互换,上层无感。EvaluationService独立在evaluation包下,它只是RagService的一个消费者。评估没有侵入主流程——这一点很重要,意味着关掉评估不影响线上问答。
1.2 主流程代码
RagService.java(最终版)
@Slf4j
@Service
public class RagService {
private final DocumentService documentService;
private final EmbeddingService embeddingService;
private final VectorStoreService vectorStoreService;
private final HybridRetrievalService hybridRetrievalService;
private final RerankService rerankService;
private final ChatModel chatModel;
@Value("${rag.top-k:5}")
private int topK;
/**
* RAG 问答主流程
*
* 完整流程:
* 1. 文档上传:切分 → Embedding → 存储
* 2. 用户提问:Embedding → 混合检索 → Rerank → Prompt组装 → LLM生成
*/
public String ask(String question) {
log.info("========== RAG 流程开始 ==========");
log.info("用户问题: {}", question);
// ========== 第一步:混合检索(粗排) ==========
log.info("步骤 1: 混合检索(BM25 + 向量)...");
List<Document> coarseResults = hybridRetrievalService.search(question, topK * 2);
if (coarseResults.isEmpty()) {
log.warn("未找到相关文档");
return "抱歉,知识库中没有找到相关信息。";
}
log.info("粗排返回 {} 个文档", coarseResults.size());
// ========== 第二步:Rerank(精排) ==========
log.info("步骤 2: Rerank 精排...");
List<Document> fineResults = rerankService.rerank(question, coarseResults);
log.info("精排后保留 {} 个文档", fineResults.size());
// ========== 第三步:组装 Prompt ==========
log.info("步骤 3: 组装 Prompt...");
String prompt = buildPrompt(question, fineResults);
// ========== 第四步:调用 LLM 生成回答 ==========
log.info("步骤 4: 调用 LLM 生成回答...");
String answer = callLlm(prompt);
log.info("========== RAG 流程结束 ==========");
return answer;
}
/**
* 上传文档
*/
public int uploadDocument(String content, String source) {
log.info("上传文档: {}", source);
// 1. 切分
List<Document> chunks = documentService.splitDocument(content, source);
// 2. Embedding
List<String> texts = chunks.stream()
.map(Document::getContent)
.collect(Collectors.toList());
List<float[]> embeddings = embeddingService.embedBatch(texts);
// 3. 设置 Embedding
for (int i = 0; i < chunks.size(); i++) {
chunks.get(i).setEmbedding(embeddings.get(i));
}
// 4. 存储
vectorStoreService.storeBatch(chunks);
log.info("文档上传完成,切分为 {} 个 Chunk", chunks.size());
return chunks.size();
}
/**
* 组装 Prompt
*/
private String buildPrompt(String question, List<Document> documents) {
StringBuilder context = new StringBuilder();
for (int i = 0; i < documents.size(); i++) {
context.append(String.format("[%d] %s\n\n", i + 1, documents.get(i).getContent()));
}
return String.format("""
## 参考资料:
%s
## 用户问题:
%s
## 回答要求:
1. 只基于参考资料回答
2. 引用来源,如"根据[1]..."
3. 如果资料不足,明确说明
""", context, question);
}
/**
* 调用 LLM
*/
private String callLlm(String prompt) {
SystemMessage systemMessage = new SystemMessage(
"你是一个企业知识库问答助手。基于参考资料回答问题,引用来源。");
UserMessage userMessage = new UserMessage(prompt);
ChatResponse response = chatModel.call(
new Prompt(List.of(systemMessage, userMessage)));
return response.getResult().getOutput().getContent();
}
}
对照第二篇那版 RagService,差别集中在一处:ask() 的第一步从「向量检索」变成了「混合检索」,第二步多了「Rerank 精排」。主干的四步骨架没变过,换掉的是中间两个零件。
这正是这个系列想说明的事——RAG 的链路结构是稳定的,所有的优化都是在给固定位置换更好的实现。 理解了这一点,你再看任何 RAG 框架(LangChain、LlamaIndex、Spring AI 的 advisor 链),都能一眼看出它把什么换成了什么。
二、这一路换来的东西
完整的优化曲线是这样的,每一层都解决前一层暴露出来的具体问题:
| 阶段 | 换掉了什么 | 换来了什么 | 付出了什么 |
|---|---|---|---|
| Demo 1 | —— | 链路跑通,能问能答 | 每种失效场景都无解 |
| Demo 2 | 单路召回 → 双路 + RRF | 关键词和语义都不漏 | 两次检索;中文分词要额外装插件 |
| Demo 3 | 粗排直接出结果 → 粗排 + 精排 | 排序质量明显改善 | 一次外部 API 调用,几十到几百毫秒延迟 |
| Demo 4 | 手感判断 → 评估集 | 优化有了方向和护栏 | 维护测试集的持续成本 |
有三件事想强调一下,因为它们很容易在「优化清单」式的叙事里被忽略:
其一,成本是叠加的,不是二选一的。 加了混合检索,每次查询就有两次召回;加了 Rerank,就多一跳外部网络调用。这些代价全都叠在最终的用户等待时间上。「效果更好」从来不是免费的,问题是这笔延迟预算你花不花得起。
其二,每一层的天花板由上一层决定。 精排只能从粗排给的候选里挑,召回漏了的它救不回来;再好的检索也救不回被切分切碎的材料。优化要从链路前端往后推,反过来做通常是在给一个注定无效的地方加钱。
其三,评估是唯一让上面两条可以被观测的东西。 没有它,你无法知道延迟花得值不值,也无法知道瓶颈在哪一层。这也是为什么评估放在最后写、但实际上应该最早搭起来——它不是一个收尾动作,而是前面所有优化的前提。
三、再往前走的方向
前五篇是一套能跑通、能测的完整链路。真要再往上走,有几个方向是绕不开的。
3.1 Query 改写
现在的流程是「用户问什么,就检索什么」。但用户的问题往往是口语化的、指代不明的、或者和文档的表述方式差得很远。用问题去检索,本身就损失了一部分信息。
两条常见的路子:
HyDE(Hypothetical Document Embeddings) —— 先让 LLM 凭空写一个「假设性的答案」,然后用这个答案去检索,而不是用问题。原理是:假设答案即使内容不准,它的表述形态更接近真实文档(都是陈述句、都包含领域术语),因此在向量空间里离真文档更近。代价是每次查询多一次 LLM 调用,而且它的收益高度依赖模型对领域的先验——在冷门领域,假设答案可能编得离题万里,反而把检索带偏。
Multi-Query —— 把一个问题改写成几个不同角度的变体,分别检索后合并去重。它提高的是召回覆盖面,特别适合「一个问题需要多篇文档才能回答」的场景。代价同样是额外的 LLM 调用,以及合并结果时的去重逻辑。
两者都要注意:它们都需要评估集来验证。 多花一次 LLM 调用换来的召回提升,如果只有 1~2 个百分点,可能并不值得那笔延迟和成本。
3.2 GraphRAG
普通 RAG 的检索是「平面」的——它找的是和问题相似的片段。这带来一个结构性盲区:需要跨多个片段、多跳推理才能回答的问题。
比如「A 项目的负责人现在管哪条业务线」——第一跳要找到「A 项目的负责人是张三」,第二跳要找到「张三现在负责 B 业务线」。这两句话在文档里可能相隔很远,语义上和问题也都不够相似,向量检索很可能一条都召不回来。
GraphRAG 的思路是先从文档里抽取实体和关系,构建知识图谱,检索时沿图的结构走多跳。它能处理这类关系型问题,但代价很实在:抽取要额外跑一遍 LLM、图谱要维护、实体消歧(同一个人的不同称呼)是个持续的脏活。在关系型问题不是主要场景时,引入它的复杂度通常不划算。
3.3 多轮对话
现在的 ask() 是无状态的——每一轮都是独立的。接进对话产品里,立刻会遇到指代问题:
用户:入职三年能休多少天年假?
系统:根据 [1],入职满一年可享受 10 天年假。
用户:那五年呢? ← 「那…呢」指代的是什么?
「那五年呢」单独拿去检索,什么都召不回来。常见的处理是指代消解 + 查询改写:把当前问题和对话历史一起交给 LLM,让它输出一个自包含的完整查询(「入职满五年能休多少天年假」),再拿这个去检索。
要注意的是历史不是越长越好。对话历史本身会污染检索——把二十轮闲聊塞进改写 Prompt,改写质量会下降。实践中通常只保留最近几轮,更早的历史压缩成一段摘要。
四、离生产还差什么
这部分源文档里没有,但它可能是整个系列最该看的一节。Demo 能跑通和系统能上线之间,隔着下面这些事。
4.1 文档的增删改
现在只有「上传」这一条路径,而真实知识库是不断变化的:文档会更新、会被删除、会换版本。
- 更新:改一个文件,不能把全库重灌一遍。要有办法定位到这份文档切出的所有 chunk(metadata 里的
source就是干这个的),先删后插。 - 删除:知识库里删了文档,向量库里还留着,模型照样会检索到已经作废的内容并据此回答。这是 RAG 系统里最危险的静默错误之一——答案有出处、格式规范、逻辑自洽,唯一的问题是那份材料早就不算数了。
- 一致性:PostgreSQL 里业务数据和向量数据在同一张表还算好办,一旦拆到独立的向量库,就得处理「业务库删成功、向量库删失败」这类跨系统的一致性问题。
4.2 多租户隔离
企业知识库几乎一定需要隔离——A 部门的文档不能出现在 B 部门的回答里。这是权限问题,不是检索精度问题,出错的后果完全不是一个量级。
实现上通常是在 documents 表加租户字段,检索时强制带上过滤条件(pgvector 支持在相似度查询里叠加 WHERE)。但要注意过滤必须做在检索层,不能做在应用层——靠应用层从检索结果里筛掉的写法,一旦某次查询忘了带条件,就是一次数据泄漏。
4.3 成本与缓存
几个主要的成本项:Embedding(每次入库和每次查询都要算一次)、LLM 生成、Rerank(按调用量计费)。
值得做的优化:
- 查询侧缓存:高频问题(「年假怎么算」这类)的检索结果或最终回答可以直接缓存。命中率在真实场景里往往比预期高得多。
- Embedding 缓存:文档没变就不用重算向量,增量入库时尤其重要。
- 控制 Context 长度:
top-n从 3 加到 10,token 成本线性上涨,而效果不一定提升——这件事同样需要用评估集来验证,而不是凭直觉。
4.4 可观测性
线上出问题时,「用户说答得不对」是唯一的信息来源,这完全不够。至少需要记录:
- 本次查询的改写后查询(如果做了改写)
- 召回的各路结果和分数(BM25 的、向量的、融合后的)
- Rerank 前后的排序变化
- 最终塞进 Prompt 的完整上下文和模型的原始输出
没有这些,你只能猜是哪一层坏了。RAG 的失败模式几乎全是静默的——它不报错,只是答得不对。 可观测性在这里不是「加分项」,而是排障的唯一手段。
4.5 评估的常态化
第四篇建的是离线评估。生产环境还需要在线指标:用户对回答的点赞/点踩、追问率(追问多说明第一次没答好)、以及把线上失败案例自动回流到测试集的机制。
测试集必须随着真实问题一起生长,否则它会越来越偏离用户实际在问什么。
五、常见误区
❌「优化项堆得越多,系统就越好」 每一层优化都自带成本:延迟、钱、以及新的失败模式(比如 Rerank 服务挂了、zhparser 没装)。最该做的优化,是评估集显示的那一项,而不是清单上最酷的那一项。一个只做了混合检索但把召回率做扎实的系统,好过一个样样都沾但每样都半吊子的系统——而且后者更难排障,因为出问题时你分不清是哪一层。
❌「文档更新了,索引会自动同步」 不会,除非你专门写了同步逻辑。索引和文档不一致是 RAG 最典型的线上事故:用户按旧制度办事、拿旧价格报价、引用已经作废的条款。而且它不会报错,检索和生成都正常工作,只是依据是错的。上线前必须把「文档变更 → 索引更新」这条链路当成一个正式功能来做,而不是脚本里的一个手动步骤。
❌「向量库和业务库各自独立就行」 一旦拆成两个系统,就出现了跨库一致性问题:业务库事务回滚了但向量已经写入、文档删了但向量还在、两边分页对不上。能共用一个 PostgreSQL 实例的时候就别拆——这是第一篇选 pgvector 的核心理由之一,它省掉的不只是部署成本,更是一整类一致性问题。
❌「延迟优化只能靠加机器」 RAG 的延迟分布很畸形:一次查询里既有几十毫秒的向量检索,也有几百毫秒到几秒的 LLM 生成,还有一跳外部 Rerank 调用。加机器对 LLM 生成那一段基本无效。先量出各段耗时占比,再决定优化谁——通常最大的那块是 LLM 生成,能优化的手段是流式输出(改善体感而非总耗时)和控制 Context 长度。
❌「评估是项目收尾时做的事」 恰恰相反。评估应该在被优化之前就建好,否则整个优化过程都是盲目的。这个系列把它排在第五篇讲,是因为它要依赖前四篇的完整链路才能跑起来——但如果你要真实落地,顺序应该是先搭一个最小评估集(哪怕只有 20 条),再去动任何优化。
六、自检清单
- 能画出完整链路,并指出每一步对应哪个类
- 说得清
ask()相对第二篇那版具体换掉了哪两个零件 - 能说出每一层优化各自的代价,而不只是收益
- 知道「优化的天花板由链路前一层决定」,并能举出例子
- 知道 HyDE 为什么有效,以及它在什么场景下会失效
- 说得出 GraphRAG 擅长而普通 RAG 做不到的一类问题
- 知道多轮对话里「那…呢」这类查询要经过什么处理才能检索
- 说得清为什么文档删除后索引不同步是最危险的静默错误
- 知道多租户过滤为什么必须做在检索层
附:延伸阅读
相关资料
| 资源 | 说明 |
|---|---|
| Precise Zero-Shot Dense Retrieval without Relevance Labels (HyDE) | HyDE 原始论文,含它在冷门领域失效的分析 |
| From Local to Global: A Graph RAG Approach | 微软 GraphRAG,多跳与全局性问题的解法 |
| Multi-Query 与 RAG-Fusion 实践 | 查询改写与结果融合的常见实现模式 |
| pgvector: Filtering | 相似度查询里叠加 WHERE 的写法与索引交互 |
| RAGAS 文档 | 评估常态化时接入真实指标 |
| langchain4j-aideepin | 一套完整的 Java RAG 实现,可对照本系列的模块划分 |
关联笔记
- RAG 实战(一):RAG 是什么,以及环境怎么搭 —— 系列起点
- RAG 实战(五):给 RAG 做量化评估 —— 第四节「评估常态化」的前提
- 系统扩展:从 0 到 1000 万用户 —— 向量库该不该独立部署,和这篇 4.2、4.4 是同一类取舍
最后更新:2026-09-29