从零到一:手写一个RAG系统(简易架构设计)
前言
先讲个场景:你所在的公司有几百份内部文档——人事制度、产品手册、技术规范……员工每天在群里问"年假怎么休"“这个 Bug 怎么处理”,HR 和技术支持疲于奔命。
这时候你想到:能不能让 AI 来回答?
直接问 ChatGPT 肯定不行,它不知道你们公司的制度。于是你接触到了 RAG(Retrieval-Augmented Generation) ——让 AI 先查知识库,再回答问题。它长这样:
用户提问 → 去知识库搜相关文档 → 把文档+问题一起给大模型 → 大模型基于文档回答
作者认为RAG就是锁住 AI 的嘴,只允许它用官方资料说话,避免错误。
听起来简单对吧?只用把ai限制查询自己的知识库就好了啊,但真正落地到场景,你会发现一堆问题:
- 文档格式五花八门(PDF/Word/Markdown…),怎么入库?
- 用户说话太随意(“那个啥,上次说的那个事儿”),怎么听懂?
- 知识库几十万条数据,怎么能又快又准地找到相关的?
- 大模型胡说八道怎么办?
本文就以一个真实项目为蓝本,带你看看一个 RAG 系统到底长什么样。篇幅较长,结合作者个人理解与思考,适合新手读者阅读(因为作者也是学后端后学习的这个项目的)。
一、整体架构:5大核心板块

(不得不说ChatGPT生成图像的能力真是恐怖如斯)
如图,这是整个系统架构的一个主要板块的展示每个板块都有专门的设计,接下来逐个讲。
二、文档入库流水线:怎么进去的?
假设你现在有几百份文档需要导入系统,格式各不相同:
- 公司制度.pdf → PDF 格式
- 产品手册.docx → Word 格式
- 技术规范.md → Markdown 格式
直接把这些文件扔给大模型?不行。大模型读不了 PDF,也处理不了超长文本,所以我们需要一条 加工流水线
原始文档 → 获取(Fetch) → 解析(Parse) → 分块(Chunk) → 向量化(Embedding) → 入库(Index)(分块后可增强)
2.1 获取器(Fetcher)
文档来源各不相同,设计上用 策略模式 统一抽象:
public interface DocumentFetcher {
FetchResult fetch(DocumentSource source);
}
// 从本地上传
public class LocalFileFetcher implements DocumentFetcher { ... }
// 从 HTTP 链接拉取
public class HttpUrlFetcher implements DocumentFetcher { ... }
// 从飞书文档拉取
public class FeishuFetcher implements DocumentFetcher { ... }
想新增一个来源?写个类实现 DocumentFetcher 接口就行,流水线不用改。
2.2 解析器(Parser)
解析也同理——PDF 用 TikaDocumentParser ,Markdown 用 MarkdownDocumentParser ,系统根据文件类型自动选择:
public class DocumentParserSelector {
public DocumentParser select(String fileName) {
if (fileName.endsWith(".md")) return new MarkdownDocumentParser();
if (fileName.endsWith(".pdf")) return new TikaDocumentParser();
// ...
}
}
2.3 分块器(Chunker)
大模型有上下文长度限制(比如 4K/8K/128K tokens),不能把整篇文档都扔进去。所以要把文档切成 语义完整的块 。
简要介绍两种常用策略:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 固定长度分块 | 按固定字数切,有重叠 | 通用场景 |
| 结构感知分块 | 按章节/段落/标题切 | Markdown、HTML 等有结构的文档 |
public interface ChunkingStrategy {
List<TextChunk> chunk(String text, ChunkingOptions options);
}
public class FixedSizeTextChunker implements ChunkingStrategy {
public List<TextChunk> chunk(String text, ChunkingOptions options) {
// 按 500 字一段,重叠 50 字
}
}
public class StructureAwareTextChunker implements ChunkingStrategy {
public List<TextChunk> chunk(String text, ChunkingOptions options) {
// 按 ## 标题划分段落
}
}
2.4 向量化与入库
每个文本块需要被转换成 向量 (一串浮点数),然后存入向量数据库(如 Milvus、pgvector)。
// 文本 → 向量(调用 Embedding 模型)
float[] vector = embeddingService.embed(chunk.getText());
// 向量 + 文本 + 元数据 → 入库
vectorStoreService.insert(collectionName, chunk.getId(), vector, chunk.getText(),
metadata);
向量化后的效果大概是这样的:
"公司年假制度:入职满一年享有 5 天年假"
↓ Embedding
[0.123, -0.456, 0.789, ..., 0.321] ← 768 维向量
将来用户问"年假几天",问题也会被转成向量,然后在库里找"最相似"的向量——这就是 向量检索 。
2.5 节点编排设计
这整条流水线如果写死代码,以后想加一个"给文档自动打标签"的步骤,就得改一大片。
所以设计上用 管道过滤器模式 + DAG 图调度 :
public class IngestionEngine {
public void execute(PipelineDefinition pipeline, IngestionContext context) {
// 1. 按 DAG 拓扑排序
List<NodeConfig> sortedNodes = topoSort(pipeline.getNodes(), pipeline.
getEdges());
// 2. 依次执行每个节点
for (NodeConfig node : sortedNodes) {
if (conditionEvaluator.shouldExecute(node, context)) {
IngestionNode handler = findHandler(node.getNodeType());
handler.execute(context, node.getSettings());
}
}
}
}
每个节点只做一件事,互不依赖,可插拔、可复用。
三、问答主流程:从用户提问到生成答案
文档入库之后,知识就准备好了。现在来看 用户提问 时发生了什么。
核心流程在 StreamChatPipeline.execute() 里,一共 7 步:
public void execute(StreamChatContext ctx) {
loadMemory(ctx); // 1. 加载对话历史
rewriteQuery(ctx); // 2. 重写用户问题
resolveIntents(ctx); // 3. 识别用户意图
if (handleGuidance(ctx)) return; // 4. 需要澄清?反问用户
if (handleSystemOnly(ctx)) return; // 5. 纯系统回答?直接回复
RetrievalContext result = retrieve(ctx); // 6. 多通道检索
if (handleEmptyRetrieval(ctx, result)) return; // 没搜到?告知用户
streamRagResponse(ctx, result); // 7. 组装 Prompt,调用 LLM
}
下面重点讲几个最容易忽视但又最关键的环节。
四、问题重写与意图识别:让系统"听懂人话"
这是 RAG 系统最容易翻车的地方。用户的问题往往是这样的:
- “上次说的那个调薪方案还能改吗?” → "上次"指什么?"调薪方案"是哪个文档?
- “华为和苹果的营收对比怎么样?” → 这是两个问题,不是一个
4.1 问题重写(Query Rewrite)
解决办法: 在检索之前,先让大模型把问题"翻译"一遍 。
public class MultiQuestionRewriteService implements QueryRewriteService {
public RewriteResult rewrite(String question, List<ChatMessage> history) {
// 用 LLM 做三件事:
// 1. 补充上下文指代:"它"→"华为"
// 2. 补全省略:"年假几天"→"公司年假制度规定员工可休几天"
// 3. 拆分复合问题:"A和B对比"→["A的业绩","B的业绩"]
return llmService.call(rewritePrompt(question, history));
}
}
效果示例:
用户问: 重写后:
"它去年营收怎么样?" → "华为2023年营收是多少?"
"年假怎么休?" → "公司年假制度规定:年假天数、休假条件、申请流程"
"华为和苹果对比" → ["华为2023年营收和利润", "苹果2023年营收和利润"]
4.2 意图识别(Intent Resolution)
重写之后,还要判断 用户想干什么 。系统维护了一棵 意图树 :
全部业务
├── 人事管理
│ ├── 考勤
│ ├── 薪酬福利
│ └── 年假制度 ← "年假怎么休" 匹配到这里
├── 财务管理
└── 技术支持
├── 产品A
└── 产品B
public class IntentResolver {
public List<SubQuestionIntent> resolve(RewriteResult rewriteResult) {
// 将子问题逐个匹配到意图树节点
for (String subQuestion : rewriteResult.getSubQuestions()) {
List<IntentNode> matchedNodes = intentClassifier.classify(subQuestion);
// 取评分最高的节点,关联到对应的知识库集合
subIntents.add(new SubQuestionIntent(subQuestion, matchedNodes));
}
return subIntents;
}
}
4.3 歧义引导:不确定就反问
如果一个问题可以匹配到多个意图(比如"绩效"可以是"人事绩效"也可以是"业务绩效"),系统不会硬猜,而是 反问用户 :
public class IntentGuidanceService {
public GuidanceDecision detectAmbiguity(String question,
List<SubQuestionIntent> intents) {
if (有多个高评分意图且分数接近) {
return GuidanceDecision.prompt("请问您问的是员工绩效评估还是销售绩效?");
}
return GuidanceDecision.noPrompt(); // 不需要引导
}
}
这个小设计很重要——与其给一个错误的答案,不如让用户澄清。
五、多通道检索引擎:又全又准的秘密
这是整个项目中技术含量最高的部分。
5.1 为什么需要多通道?
单路检索总有缺陷:
检索方式 优点 缺点 向量检索(全局) 召回全,什么都能找到 噪声多,不够精准 关键词检索 精确匹配 同义词不行,漏召回 意图定向检索 精准,只在目标范围搜 意图识别错了就全错
所以设计思路是: 两个通道一起跑,取各自的优势 。
5.2 通道抽象
public interface SearchChannel {
SearchChannelResult search(SearchContext context);
SearchChannelType getChannelType();
}
// 通道A:全局向量检索——所有知识库都搜一遍
@Component
public class VectorGlobalSearchChannel implements SearchChannel { ... }
// 通道B:意图定向检索——只在意图匹配到的知识库里搜
@Component
public class IntentDirectedSearchChannel implements SearchChannel { ... }
5.3 并行执行
两个通道互不依赖,用 CompletableFuture 并行跑:
@Component
public class MultiChannelRetrievalEngine implements RetrievalEngine {
private final List<SearchChannel> channels;
private final List<SearchResultPostProcessor> postProcessors;
public RetrievalResult search(RetrieveRequest request) {
// 1. 所有通道并行执行
List<CompletableFuture<SearchChannelResult>> futures = channels.stream()
.map(channel -> CompletableFuture.supplyAsync(
() -> channel.search(request.toSearchContext())
))
.collect(Collectors.toList());
// 2. 等待所有通道完成,合并结果
List<SearchChannelResult> results = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
List<RetrievedChunk> merged = mergeResults(results);
// 3. 后处理链:先去重,再重排序
for (SearchResultPostProcessor processor : postProcessors) {
merged = processor.process(merged, request);
}
return new RetrievalResult(merged);
}
}
5.4 后处理链(责任链模式)
合并结果后,还要经过两道精加工:
合并结果 → ① 去重 → ② 重排序 → 最终结果
第一步:去重
两条通道可能搜到同一份文档,去掉重复的:
@Component
public class DeduplicationPostProcessor implements SearchResultPostProcessor {
public List<RetrievedChunk> process(List<RetrievedChunk> results, ...) {
// 按文档 ID 去重,保留评分高的那个
return results.stream()
.collect(Collectors.toMap(
chunk -> chunk.getDocumentId(),
chunk -> chunk,
(a, b) -> a.getScore() > b.getScore() ? a : b
))
.values().stream()
.sorted(...)
.collect(Collectors.toList());
}
}
第二步:重排序
向量检索用的是"双编码器"——快但不够精细。重排序用的是"交叉编码器"——慢但精准。
@Component
public class RerankPostProcessor implements SearchResultPostProcessor {
public List<RetrievedChunk> process(List<RetrievedChunk> results, ...) {
// 对 Top-K 结果用交叉编码器重新打分排序
return rerankService.rerank(results, query);
}
}
效果: 重排序后,最相关的那条结果通常能排到第一位。
六、Prompt 组装:同样材料,不同效果
搜到相关文档之后,怎么"喂"给大模型?如果只是简单拼接:
"请回答:" + question + ",参考材料:" + docs
效果往往不好。好的做法是 结构化组装 :
public class RAGPromptService {
public List<ChatMessage> buildStructuredMessages(
PromptContext context,
List<ChatMessage> history,
String question) {
List<ChatMessage> messages = new ArrayList<>();
// 1. System Prompt:设定角色和规则
messages.add(ChatMessage.system("""
你是一个专业的业务助手。请根据提供的材料回答问题。
如果材料中没有相关信息,请直接说"未找到相关信息",不要编造。
材料:
{context.formatKbResults()}
{context.hasMcp() ? "工具调用结果:\n" + context.formatMcpResults() :
""}
"""));
// 2. 对话历史(最近的 N 条)
if (CollectionUtil.isNotEmpty(history)) {
messages.addAll(history);
}
// 3. 用户当前问题
messages.add(ChatMessage.user(question));
return messages;
}
}
关键点:
- 明确告诉大模型"只基于材料回答" → 减少幻觉
- 材料中每个段落标注来源编号 → 回答可追溯
- 空检索时告知大模型"说不知道" → 避免编造
七、对话记忆:让上下文连贯
多轮对话中,用户可能说"刚才那个问题再说详细点"——系统需要记得"刚才"指的是什么。
设计上采用 分层记忆 :
public class DefaultConversationMemoryService implements ConversationMemoryService
{
private final ConversationMemoryStore store;
private final ConversationMemorySummaryService summaryService;
public List<ChatMessage> loadAndAppend(String conversationId,
String userId,
ChatMessage newMessage) {
// 1. 加载最近的 N 条消息
List<ChatMessage> recent = store.loadRecent(conversationId, 20);
// 2. 如果历史太长,加载摘要
if (历史长度 > 阈值) {
String summary = summaryService.getSummary(conversationId);
// 把摘要压缩成一条 system 消息
recent.add(0, ChatMessage.system("历史对话摘要:" + summary));
}
// 3. 追加当前消息
store.append(conversationId, newMessage);
return recent;
}
}
这样既保留了上下文信息,又不会因为对话太长而超出大模型的 Token 限制。
八、MCP 工具调用:不只是问答
如果用户问"帮我查一下上个月的销售数据"——这不能从知识库回答,需要 调用业务系统 。
项目集成了 MCP(Model Context Protocol) 来实现工具调用:
用户:"查一下工单 TKT-2024-001 的状态"
│
▼
意图识别 → 匹配到 "工单查询" 工具
│
▼
LLM 自动提取参数 → { ticketId: "TKT-2024-001" }
│
▼
调用 TicketMCPExecutor → 返回工单状态
│
▼
将结果拼入 Prompt → LLM 组织回答
这种设计让系统不再局限于"查文档",还能"调系统",扩展性大大提升。
九、系统启动入口
最后看一下项目是怎么启动的:
@SpringBootApplication
@MapperScan("com.nageoffer.ai.ragent.**.dao.mapper")
public class RagentApplication {
public static void main(String[] args) {
SpringApplication.run(RagentApplication.class, args);
}
}
就是一个标准的 Spring Boot 应用,启动后所有组件通过依赖注入自动装配。
从配置文件中可以配置各种参数:
# application.yaml
rag:
retrieval:
top-k: 10
rerank:
enabled: true
memory:
window-size: 20
summary-threshold: 10
llm:
providers:
- name: bailian
api-key: xxx
- name: siliconflow
api-key: xxx
十、总结
本文从一个真实的 RAG 项目出发,讲了RAG 系统的核心设计,即开头的五大板块,但是真正实际去做这样一个项目时还需要考虑封装ai infra层,对外开放一个LLMService的接口,内部要去实现ai模型的选型,路由,熔断和故障转移等等,总之就大概这么多,作者也是刚接触ai项目的新手,写文之时顺便复习了几天前学习的相关知识,如有错误之处,恳请指教。
更多推荐

所有评论(0)