RAG 实战(六):把散落的零件拼成完整项目

目录


一、最终的项目结构

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 实现,可对照本系列的模块划分

关联笔记


最后更新:2026-09-29