前言

先讲个场景:你所在的公司有几百份内部文档——人事制度、产品手册、技术规范……员工每天在群里问"年假怎么休"“这个 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项目的新手,写文之时顺便复习了几天前学习的相关知识,如有错误之处,恳请指教。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐