15-RAG系统完全指南

7 阅读16分钟

通过检索增强生成(RAG)技术,让大语言模型能够访问和理解你的私有数据,构建真正属于自己的智能知识助手。本文将深入讲解RAG的核心原理、完整实现流程以及性能优化策略。

时间:45分钟 | 难度:⭐⭐⭐⭐ | Week 3 Day 15


📋 学习目标

  • 理解RAG的工作原理和应用场景
  • 掌握文档加载、分割、向量化的完整流程
  • 学会使用EmbeddingStore存储和检索文档
  • 构建端到端的RAG应用系统
  • 掌握RAG系统的性能优化技巧
  • 了解RAG的局限性和最佳实践

🚀 快速入门:什么是RAG?

RAG = Retrieval + Augmented + Generation

核心问题: LLM虽然强大,但它不知道你的私有数据!

  • 你的公司文档、产品手册、内部知识库
  • 最新的新闻、实时数据
  • 用户的个人信息、历史记录

RAG解决方案: 在生成答案前,先检索相关文档,然后将文档内容作为上下文提供给LLM

RAG工作流程

┌─────────┐      ┌─────────┐      ┌─────────┐      ┌─────────┐      ┌─────────┐      ┌─────────┐      ┌─────────┐
│用户查询 │ ───> │ 向量化  │ ───> │相似搜索 │ ───> │获取文档 │ ───> │构建上下文│ ───> │LLM生成  │ ───> │  答案   │
└─────────┘      └─────────┘      └─────────┘      └─────────┘      └─────────┘      └─────────┘      └─────────┘

简单示例

// 传统LLM(无法回答公司内部问题)
String answer = chatModel.generate("我们公司的年假政策是什么?");
// 结果:抱歉,我不知道贵公司的具体政策...

// RAG方案(可以准确回答)
// 1. 先从公司文档库中检索相关内容
// 2. 将检索到的文档作为上下文
// 3. LLM基于真实文档生成答案
String answer = ragAssistant.answer("我们公司的年假政策是什么?");
// 结果:根据公司人事手册,员工入职满一年后享有10天带薪年假...

1️⃣ RAG架构总览

RAG系统分为两个阶段:索引阶段查询阶段

索引阶段(Indexing Phase)- 离线处理

┌──────────────┐
│   原始文档    │  PDF, Word, TXT, HTML...
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  文档加载器   │  DocumentLoader — 读取文件内容
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  文档分割器   │  DocumentSplitter — 切成小块
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  向量化模型   │  EmbeddingModel — 文本 → 向量
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  向量数据库   │  EmbeddingStore — 存储向量
└──────────────┘

查询阶段(Query Phase)- 在线处理

┌──────────────┐
│   用户提问    │  "公司年假政策是什么?"
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  向量化查询   │  EmbeddingModel — 问题 → 向量
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  相似度搜索   │  EmbeddingStore.findRelevant() — 找最相关的文档块
└──────┬───────┘
       │ Top-K 个相关文档块
       ▼
┌──────────────┐
│  构建 Prompt  │  把文档块 + 用户问题组合成 Prompt
└──────┬───────┘
       │
       ▼
┌──────────────┐
│   LLM 生成   │  ChatModel — 基于文档内容回答
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  返回答案     │  "根据公司手册,入职满一年后享有10天年假..."
└──────────────┘

两个阶段的关系

索引阶段(一次性 / 定期更新):
  文档 → 分割 → 向量化 → 存入数据库
  ════════════════════════════════════
  离线处理,耗时但只做一次

查询阶段(每次用户提问):
  问题 → 向量化 → 搜索 → 构建Prompt → LLM回答
  ════════════════════════════════════════════
  在线处理,要求低延迟

2️⃣ 文档加载与分割

文档加载器(DocumentLoader)

/**
 * LangChain4J 支持多种文档格式
 */
@Service
public class DocumentLoaderExample {

    // 加载单个文本文件
    public Document loadTextFile() {
        return FileSystemDocumentLoader.loadDocument(
            Paths.get("docs/company-handbook.txt"));
    }

    // 加载 PDF(需要 langchain4j-document-parser-apache-pdfbox 依赖)
    public Document loadPdf() {
        return FileSystemDocumentLoader.loadDocument(
            Paths.get("docs/product-manual.pdf"),
            new ApachePdfBoxDocumentParser());
    }

    // 批量加载整个目录
    public List<Document> loadDirectory() {
        return FileSystemDocumentLoader.loadDocuments(
            Paths.get("docs/knowledge-base/"));
    }

    // 带元数据的加载(方便后续过滤检索)
    public Document loadWithMetadata() {
        Document doc = FileSystemDocumentLoader.loadDocument(
            Paths.get("docs/hr-policy.txt"));
        doc.metadata().put("department", "人事部");
        doc.metadata().put("version", "2026-v1");
        doc.metadata().put("category", "policy");
        return doc;
    }
}

文档分割器(DocumentSplitter)

为什么需要分割?

问题:一篇5000字的文档,直接发给LLM?
  1. Token限制:可能超过上下文窗口
  2. 检索精度:整篇文档中只有一段和问题相关
  3. 成本浪费:大量不相关的内容也消耗Token

解决:把文档切成200-500字的小块(Chunk),只检索最相关的几块
/**
 * LangChain4J 提供多种分割策略
 */
@Service
public class DocumentSplitterExample {

    // 策略1:按字符数分割(最简单)
    public List<TextSegment> splitByCharacter(Document doc) {
        DocumentSplitter splitter = DocumentSplitters.recursive(
            300,    // 每块最大300字符
            30      // 块之间重叠30字符(防止上下文断裂)
        );
        return splitter.split(doc);
    }

    // 策略2:按段落分割(保持语义完整性)
    public List<TextSegment> splitByParagraph(Document doc) {
        DocumentSplitter splitter = DocumentSplitters.recursive(
            500,   // 每块最大500字符
            50,    // 重叠50字符
            new OpenAiTokenizer()  // 使用Token计数而非字符计数
        );
        return splitter.split(doc);
    }
}

分割策略对比

┌────────────────┬──────────────┬──────────────┬──────────────┐
│ 分割策略        │ 优点          │ 缺点          │ 适用场景       │
├────────────────┼──────────────┼──────────────┼──────────────┤
│ 固定字符数      │ 简单快速       │ 可能切断句子    │ 结构化文档     │
│ 递归分割        │ 保持语义边界    │ 块大小不均匀    │ 通用(推荐)   │
│ 按段落分割      │ 语义完整       │ 块可能过大      │ 文章、博客     │
│ 按Token数      │ 精确控制成本    │ 需要Tokenizer  │ 成本敏感场景   │
└────────────────┴──────────────┴──────────────┴──────────────┘

重叠(Overlap)的作用

无重叠:
  [块1: "员工入职满一年后享有"] [块2: "10天带薪年假,满三年"]
  → 如果用户问"年假多少天",块1和块2单独都不完整

有重叠(30字符):
  [块1: "员工入职满一年后享有10天带薪年假"]
  [块2: "享有10天带薪年假,满三年后增至15天"]
  → 重叠区域保证关键信息不会丢失

3️⃣ 向量化与存储

Embedding:文本 → 向量

/**
 * Embedding 将文本转换为高维向量(数字数组)
 * 语义相似的文本 → 向量距离近
 * 语义不同的文本 → 向量距离远
 */
@Configuration
public class EmbeddingConfig {

    @Bean
    public EmbeddingModel embeddingModel() {
        // OpenAI 的 text-embedding-3-small 模型
        return OpenAiEmbeddingModel.builder()
            .apiKey(System.getenv("OPENAI_API_KEY"))
            .modelName("text-embedding-3-small")  // 1536维向量
            .build();
    }
}
向量化的直觉理解:

"公司年假政策" → [0.12, -0.34, 0.56, ..., 0.78]  (1536个数字)
"员工休假规定" → [0.11, -0.33, 0.55, ..., 0.77]  (语义相似 → 向量接近)
"Java编程入门" → [-0.45, 0.67, -0.12, ..., 0.34]  (语义不同 → 向量远离)

相似度 = 余弦相似度(向量A, 向量B)
  "年假政策" vs "休假规定" = 0.95(非常相似)
  "年假政策" vs "Java编程" = 0.12(完全不相关)

EmbeddingStore:向量数据库

/**
 * 将文档向量存入向量数据库
 */
@Service
public class VectorStoreService {

    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore<TextSegment> embeddingStore;

    // ===== 方案1:内存存储(开发调试用)=====
    @Bean
    public EmbeddingStore<TextSegment> inMemoryStore() {
        return new InMemoryEmbeddingStore<>();
    }

    // ===== 索引阶段:文档入库 =====
    public void indexDocuments(List<Document> documents) {
        // Step 1: 分割文档
        DocumentSplitter splitter = DocumentSplitters.recursive(300, 30);

        for (Document doc : documents) {
            List<TextSegment> segments = splitter.split(doc);

            // Step 2: 向量化每个文档块
            List<Embedding> embeddings = embeddingModel.embedAll(
                segments.stream().map(TextSegment::text).toList()
            ).content();

            // Step 3: 存入向量数据库
            embeddingStore.addAll(embeddings, segments);
        }

        log.info("索引完成,共处理 {} 个文档", documents.size());
    }

    // ===== 查询阶段:相似搜索 =====
    public List<TextSegment> search(String query, int maxResults) {
        // Step 1: 将查询向量化
        Embedding queryEmbedding = embeddingModel.embed(query).content();

        // Step 2: 在向量数据库中搜索最相似的文档块
        List<EmbeddingMatch<TextSegment>> matches =
            embeddingStore.findRelevant(queryEmbedding, maxResults);

        // Step 3: 返回匹配的文档块
        return matches.stream()
            .filter(match -> match.score() > 0.7)  // 过滤低质量匹配
            .map(EmbeddingMatch::embedded)
            .toList();
    }
}

完整的索引流程图

原始文档                 分割后的块              向量化结果              向量数据库
┌────────────────┐
│ 公司员工手册     │    ┌─────────────┐    ┌──────────────┐    ┌──────────────┐
│                │    │ 块1: 年假政策 │ →  │ [0.12, -0.34]│ →  │ 向量1 + 块1  │
│ 第一章:考勤制度 │ →  │ 块2: 考勤规定 │ →  │ [0.45, 0.67] │ →  │ 向量2 + 块2  │
│ 第二章:年假政策 │    │ 块3: 加班政策 │ →  │ [-0.23, 0.11]│ →  │ 向量3 + 块3  │
│ 第三章:加班政策 │    └─────────────┘    └──────────────┘    └──────────────┘
└────────────────┘

4️⃣ RAG 完整实现:用 AiServices 构建

方式一:使用 ContentRetriever(推荐)

/**
 * LangChain4J 最优雅的 RAG 实现方式
 * 使用 AiServices + ContentRetriever,框架自动处理检索和注入
 */

// Step 1: 定义 RAG 助手接口
public interface KnowledgeAssistant {

    @SystemMessage("""
        你是公司知识库助手。
        根据提供的文档内容回答问题。
        如果文档中没有相关信息,请明确说明"根据现有文档无法回答此问题"。
        回答时引用信息来源。
        """)
    String answer(@UserMessage String question);
}

// Step 2: 配置和构建
@Configuration
public class RagConfig {

    @Bean
    public KnowledgeAssistant knowledgeAssistant(
            ChatLanguageModel chatModel,
            EmbeddingModel embeddingModel,
            EmbeddingStore<TextSegment> embeddingStore) {

        // 创建内容检索器
        ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
            .embeddingStore(embeddingStore)
            .embeddingModel(embeddingModel)
            .maxResults(5)           // 最多检索5个相关文档块
            .minScore(0.7)           // 最低相似度阈值
            .build();

        // 使用 AiServices 构建 RAG 助手
        return AiServices.builder(KnowledgeAssistant.class)
            .chatLanguageModel(chatModel)
            .contentRetriever(contentRetriever)  // ← 关键:注入检索器
            .build();
    }
}

// Step 3: 使用
@Service
public class KnowledgeService {

    @Autowired
    private KnowledgeAssistant assistant;

    public String askQuestion(String question) {
        return assistant.answer(question);
    }
}

AiServices + ContentRetriever 的内部流程

用户调用 assistant.answer("年假多少天?")
  │
  ▼
AiServices 的 InvocationHandler 拦截调用
  │
  ├─ 1. 调用 ContentRetriever.retrieve("年假多少天?")
  │      ├─ 向量化查询
  │      ├─ 在 EmbeddingStore 中搜索
  │      └─ 返回 Top-5 相关文档块
  │
  ├─ 2. 将检索到的文档块注入到 Prompt 中
  │      ┌────────────────────────────────────────┐
  │      │ System: 你是公司知识库助手...              │
  │      │                                        │
  │      │ 以下是相关文档内容:                       │
  │      │ [文档1] 员工入职满一年后享有10天带薪年假... │
  │      │ [文档2] 满三年后年假增至15天...            │
  │      │                                        │
  │      │ User: 年假多少天?                       │
  │      └────────────────────────────────────────┘
  │
  └─ 3. 调用 LLM 生成答案
         └─ "根据公司手册,入职满一年享有10天年假,满三年增至15天。"

💡 关键理解:ContentRetriever 让 RAG 变成了"透明"的 — 你只需要定义接口、配置检索器,框架自动完成"检索→注入→生成"的全过程。和 Agent 的 Tool 调用不同,RAG 的检索是每次都执行的,不需要 LLM 决策。

方式二:手动实现 RAG Pipeline

/**
 * 手动实现 RAG — 完全掌控每一步
 * 适合需要自定义检索逻辑或中间处理的场景
 */
@Service
public class ManualRagPipeline {

    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore<TextSegment> embeddingStore;
    private final ChatLanguageModel chatModel;

    public String query(String userQuestion) {

        // ===== Step 1: 向量化用户查询 =====
        Embedding queryEmbedding = embeddingModel.embed(userQuestion).content();

        // ===== Step 2: 在向量数据库中检索 =====
        List<EmbeddingMatch<TextSegment>> matches =
            embeddingStore.findRelevant(queryEmbedding, 5);

        // ===== Step 3: 过滤低质量结果 =====
        List<String> relevantDocs = matches.stream()
            .filter(m -> m.score() > 0.7)
            .map(m -> m.embedded().text())
            .toList();

        if (relevantDocs.isEmpty()) {
            return "抱歉,在知识库中未找到相关信息。";
        }

        // ===== Step 4: 构建增强 Prompt =====
        String context = String.join("\n\n---\n\n", relevantDocs);
        String augmentedPrompt = """
            请根据以下参考文档回答用户的问题。
            如果文档中没有相关信息,请说明无法回答。

            ## 参考文档:
            %s

            ## 用户问题:
            %s

            ## 要求:
            - 只根据提供的文档内容回答
            - 如果文档信息不足,明确说明
            - 引用具体的文档内容作为依据
            """.formatted(context, userQuestion);

        // ===== Step 5: 调用 LLM 生成答案 =====
        return chatModel.generate(augmentedPrompt);
    }
}

方式三:EasyRAG — 最少代码(快速原型)

/**
 * LangChain4J 的 Easy RAG — 5行代码搞定
 * 适合快速验证和原型开发
 */
@Service
public class EasyRagExample {

    public void quickStart() {
        // 1. 加载文档
        List<Document> documents = FileSystemDocumentLoader.loadDocuments(
            Paths.get("docs/knowledge-base/"));

        // 2. 创建内存向量库并索引
        InMemoryEmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>();
        EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
            .documentSplitter(DocumentSplitters.recursive(300, 30))
            .embeddingModel(new AllMiniLmL6V2EmbeddingModel()) // 本地模型,不需API
            .embeddingStore(store)
            .build();
        ingestor.ingest(documents);

        // 3. 创建 RAG 助手
        ChatLanguageModel model = OpenAiChatModel.builder()
            .apiKey(System.getenv("OPENAI_API_KEY"))
            .modelName("gpt-4o-mini")
            .build();

        ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
            .embeddingStore(store)
            .embeddingModel(new AllMiniLmL6V2EmbeddingModel())
            .maxResults(3)
            .build();

        interface SimpleAssistant {
            String answer(String question);
        }

        SimpleAssistant assistant = AiServices.builder(SimpleAssistant.class)
            .chatLanguageModel(model)
            .contentRetriever(retriever)
            .build();

        // 4. 直接使用
        String answer = assistant.answer("公司的年假政策是什么?");
        System.out.println(answer);
    }
}

5️⃣ 进阶 RAG 技巧

查询转换(Query Transformation)

用户的原始查询往往不够精确,通过转换查询可以提高检索质量:

/**
 * 查询转换:让检索更精确
 */
@Service
public class QueryTransformationService {

    private final ChatLanguageModel model;

    // 技巧1:查询扩展 — 一个问题变多个
    public List<String> expandQuery(String originalQuery) {
        String expanded = model.generate("""
            请将以下问题改写为3个不同的表述方式,覆盖更多检索角度:
            原始问题:%s
            输出格式:每行一个问题
            """.formatted(originalQuery));

        return expanded.lines().filter(l -> !l.isBlank()).toList();
        // "年假多少天" → ["员工年假天数", "带薪休假政策", "年假规定"]
    }

    // 技巧2:HyDE(假设文档嵌入)— 先让LLM"猜"答案
    public String hypotheticalDocument(String query) {
        return model.generate("""
            请直接回答以下问题(不需要真实信息,用合理假设):
            %s
            """.formatted(query));
        // 用"假设答案"去检索,比用"问题"检索更准确
        // 因为答案的语义更接近文档内容
    }
}
为什么 HyDE 有效?

用户问题:  "年假多少天?"
假设答案:  "员工入职满一年后享有10天带薪年假,满三年增至15天"

向量相似度:
  "年假多少天" vs 文档    → 0.75(问题 ≠ 陈述句)
  "员工享有10天年假" vs 文档 → 0.93(假设答案 ≈ 文档内容)

HyDE 用答案的向量去检索,比问题的向量更容易匹配到正确文档。

重排序(Re-ranking)

/**
 * 两阶段检索:粗检索 + 精排序
 * 先用向量搜索快速召回候选,再用交叉编码器精确排序
 */
@Service
public class ReRankingService {

    private final EmbeddingStore<TextSegment> store;
    private final EmbeddingModel embeddingModel;
    private final ScoringModel scoringModel;  // 重排序模型

    public List<TextSegment> searchWithReRanking(String query) {
        // 阶段1:粗检索 — 向量搜索召回 Top-20
        Embedding queryEmbedding = embeddingModel.embed(query).content();
        List<EmbeddingMatch<TextSegment>> candidates =
            store.findRelevant(queryEmbedding, 20);

        // 阶段2:精排序 — 用 ScoringModel 对 Top-20 重新打分
        List<TextSegment> segments = candidates.stream()
            .map(m -> m.embedded())
            .toList();

        List<Double> scores = scoringModel.scoreAll(segments, query);

        // 阶段3:按精排分数重新排序,取 Top-5
        return IntStream.range(0, segments.size())
            .boxed()
            .sorted((i, j) -> Double.compare(scores.get(j), scores.get(i)))
            .limit(5)
            .map(segments::get)
            .toList();
    }
}
为什么需要重排序?

向量搜索(快但粗):
  "年假"  → 召回:年假政策(0.92), 年终奖(0.85), 年度总结(0.83)...
  问题:"年终奖"和"年度总结"语义接近但不相关

重排序(慢但精):
  交叉编码器逐一比较 query vs 每个候选
  → 年假政策(0.95), 休假规定(0.88), 考勤制度(0.72)
  → "年终奖"被排到后面

元数据过滤

/**
 * 使用元数据缩小检索范围
 */
public List<TextSegment> searchWithFilter(String query, String department) {
    // 只在指定部门的文档中搜索
    Filter filter = metadataKey("department").isEqualTo(department);

    Embedding queryEmbedding = embeddingModel.embed(query).content();

    return embeddingStore.findRelevant(
        queryEmbedding,
        5,          // Top-5
        0.7,        // 最低分数
        filter      // 元数据过滤
    ).stream()
     .map(EmbeddingMatch::embedded)
     .toList();
}

// 使用示例:
// searchWithFilter("年假政策", "人事部")  → 只在人事部文档中搜索
// searchWithFilter("API文档", "技术部")    → 只在技术部文档中搜索

6️⃣ RAG vs 微调 vs 长上下文

┌──────────┬─────────────────┬────────────────┬──────────────────┐
│          │ RAG             │ 微调(Fine-tune) │ 长上下文窗口       │
├──────────┼─────────────────┼────────────────┼──────────────────┤
│ 原理      │ 检索后注入上下文  │ 修改模型权重      │ 直接放入全部文档    │
│ 数据新鲜度 │ ✅ 实时更新      │ ❌ 训练时固定     │ ✅ 实时           │
│ 成本      │ 中(向量库+检索)│ 高(训练费用)    │ 高(大量Token)    │
│ 准确性    │ 高(基于原文)   │ 中(可能幻觉)    │ 高(全文可见)     │
│ 数据量    │ 无限制          │ 数千-数万条      │ 受窗口限制(128K) │
│ 延迟      │ 中(检索+生成)  │ 低(直接生成)    │ 高(处理长文本)   │
│ 可解释性  │ ✅ 可引用来源    │ ❌ 黑盒         │ ✅ 可引用         │
│ 适合场景  │ 知识库问答       │ 特定任务/风格     │ 少量长文档分析     │
└──────────┴─────────────────┴────────────────┴──────────────────┘

选择建议:
├─ 私有知识库,文档经常更新 → RAG ✅
├─ 需要模型掌握特定领域术语/风格 → 微调
├─ 分析1-2篇长文档 → 长上下文窗口
└─ 大规模知识库 + 高准确性 → RAG + 重排序

7️⃣ 性能优化

分块策略优化

分块大小的影响:

块太小(50字):
  ✅ 检索精确  ❌ 缺少上下文  ❌ 碎片化严重
  例:"10天" — 检索到了,但不知道是年假还是病假

块太大(2000字):
  ✅ 上下文完整  ❌ 检索不精确  ❌ 浪费Token
  例:检索到整章内容,只有一段相关

推荐范围:200-500字/块
  平衡精确性和上下文完整性

检索优化

/**
 * 混合检索:向量搜索 + 关键词搜索
 * 向量搜索擅长语义匹配,关键词搜索擅长精确匹配
 */
@Service
public class HybridSearchService {

    private final EmbeddingStore<TextSegment> vectorStore;
    private final EmbeddingModel embeddingModel;

    public List<TextSegment> hybridSearch(String query) {
        // 1. 向量搜索(语义匹配)
        Embedding queryEmb = embeddingModel.embed(query).content();
        List<EmbeddingMatch<TextSegment>> vectorResults =
            vectorStore.findRelevant(queryEmb, 10);

        // 2. 关键词搜索(精确匹配)
        List<TextSegment> keywordResults = searchByKeyword(query);

        // 3. 合并去重 + 加权排序
        // 向量结果权重0.7,关键词结果权重0.3
        return mergeAndRank(vectorResults, keywordResults, 0.7, 0.3);
    }
}

缓存优化

/**
 * 对高频查询进行缓存
 * 避免重复的向量化和检索操作
 */
@Service
public class CachedRagService {

    @Autowired
    private KnowledgeAssistant assistant;

    // 缓存查询结果(TTL 10分钟)
    @Cacheable(value = "ragAnswers", key = "#question.hashCode()")
    public String answer(String question) {
        return assistant.answer(question);
    }

    // 缓存向量化结果(向量化是耗时操作)
    @Cacheable(value = "embeddings", key = "#text.hashCode()")
    public Embedding getEmbedding(String text) {
        return embeddingModel.embed(text).content();
    }

    // 文档更新时清除缓存
    @CacheEvict(value = {"ragAnswers", "embeddings"}, allEntries = true)
    public void onDocumentUpdated() {
        log.info("文档更新,清除所有RAG缓存");
    }
}

成本优化策略

┌────────────────────────┬──────────────────────┬──────────────┐
│ 优化点                  │ 方法                  │ 效果          │
├────────────────────────┼──────────────────────┼──────────────┤
│ Embedding 模型         │ 用本地模型(不花钱)    │ 成本降至 ¥0   │
│                        │ AllMiniLmL6V2         │              │
├────────────────────────┼──────────────────────┼──────────────┤
│ 减少检索数量            │ maxResults 32       │ Token省 30%   │
├────────────────────────┼──────────────────────┼──────────────┤
│ 压缩检索内容            │ 提取关键句而非全文块   │ Token省 50%   │
├────────────────────────┼──────────────────────┼──────────────┤
│ 缓存高频查询            │ @Cacheable           │ 重复查询 ¥0   │
├────────────────────────┼──────────────────────┼──────────────┤
│ 生成模型选择            │ 用 gpt-4o-mini       │ 成本降低 95%   │
│                        │ 而非 gpt-4o          │              │
└────────────────────────┴──────────────────────┴──────────────┘

💻 实战:完整的企业知识库 RAG 系统

/**
 * 生产级 RAG 系统示例
 * 包含:文档索引、检索、生成、监控
 */

// ===== 1. 文档索引服务 =====
@Service
public class DocumentIndexService {
    private static final Logger log = LoggerFactory.getLogger(DocumentIndexService.class);

    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore<TextSegment> embeddingStore;

    /**
     * 索引文档目录
     */
    public IndexResult indexDirectory(String directoryPath) {
        long start = System.currentTimeMillis();

        // 1. 加载文档
        List<Document> documents = FileSystemDocumentLoader.loadDocuments(
            Paths.get(directoryPath));
        log.info("加载了 {} 个文档", documents.size());

        // 2. 分割文档
        DocumentSplitter splitter = DocumentSplitters.recursive(400, 40);
        int totalSegments = 0;

        for (Document doc : documents) {
            List<TextSegment> segments = splitter.split(doc);
            totalSegments += segments.size();

            // 3. 批量向量化并存储
            EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
                .embeddingModel(embeddingModel)
                .embeddingStore(embeddingStore)
                .build();
            ingestor.ingest(doc);
        }

        long duration = System.currentTimeMillis() - start;
        log.info("索引完成:{} 个文档,{} 个文档块,耗时 {}ms",
            documents.size(), totalSegments, duration);

        return new IndexResult(documents.size(), totalSegments, duration);
    }
}

// ===== 2. RAG 查询服务(带监控)=====
@Service
public class RagQueryService {
    private static final Logger log = LoggerFactory.getLogger(RagQueryService.class);

    private final KnowledgeAssistant assistant;
    private final MeterRegistry metrics;

    @Autowired
    public RagQueryService(KnowledgeAssistant assistant, MeterRegistry metrics) {
        this.assistant = assistant;
        this.metrics = metrics;
    }

    public RagResponse query(String question) {
        long start = System.currentTimeMillis();

        try {
            String answer = assistant.answer(question);
            long duration = System.currentTimeMillis() - start;

            // 记录指标
            metrics.counter("rag.queries.total").increment();
            metrics.timer("rag.query.duration").record(duration, TimeUnit.MILLISECONDS);

            log.info("[RAG] 查询成功 | 问题长度:{} | 耗时:{}ms",
                question.length(), duration);

            return new RagResponse(answer, duration, true);

        } catch (Exception e) {
            metrics.counter("rag.queries.errors").increment();
            log.error("[RAG] 查询失败: {}", e.getMessage());
            return new RagResponse("查询失败:" + e.getMessage(), 0, false);
        }
    }
}

// ===== 3. REST API =====
@RestController
@RequestMapping("/api/knowledge")
public class KnowledgeController {

    @Autowired
    private RagQueryService ragService;

    @Autowired
    private DocumentIndexService indexService;

    @PostMapping("/ask")
    public RagResponse ask(@RequestBody QuestionRequest request) {
        return ragService.query(request.question());
    }

    @PostMapping("/index")
    public IndexResult indexDocuments(@RequestParam String path) {
        return indexService.indexDirectory(path);
    }
}

// ===== 数据类 =====
record RagResponse(String answer, long durationMs, boolean success) {}
record IndexResult(int documentCount, int segmentCount, long durationMs) {}
record QuestionRequest(String question) {}

🔧 最佳实践

✅ RAG 系统设计原则

1. 分块策略
   ├─ 块大小 200-500 字符(根据内容调整)
   ├─ 重叠 10%-20%(防止信息丢失)
   └─ 保持语义完整(不要切断句子)

2. 检索策略
   ├─ Top-K 设置 3-5(太多会引入噪音)
   ├─ 最低分数阈值 0.7(过滤不相关结果)
   ├─ 考虑重排序(提高精度)
   └─ 考虑混合搜索(向量 + 关键词)

3. Prompt 策略
   ├─ 明确指示"只根据文档回答"
   ├─ 要求引用信息来源
   ├─ 处理"文档中没有答案"的情况
   └─ 控制回答的格式和长度

4. 文档管理
   ├─ 定期更新索引
   ├─ 使用元数据标记文档来源和时间
   ├─ 监控检索质量(命中率、用户反馈)
   └─ 删除过时文档

❌ 常见陷阱

陷阱1:块太大导致检索不准
  ✗ 整篇文章作为一个块
  ✓ 分割成 200-500 字的段落

陷阱2:没有设置最低分数阈值
  ✗ 返回所有结果(包括不相关的)
  ✓ 设置 minScore(0.7) 过滤低质量匹配

陷阱3:Prompt 没有限制回答范围
  ✗ "回答用户问题"(LLM可能用自己的知识)
  ✓ "只根据以下文档内容回答,如果文档中没有,请说明"

陷阱4:文档没有更新机制
  ✗ 索引一次就不管了
  ✓ 设置定期重新索引 + 增量更新

陷阱5:忽略 Embedding 模型的选择
  ✗ 中文文档用英文 Embedding 模型
  ✓ 选择支持多语言的 Embedding 模型

学习成果检查

  • 能解释 RAG 的工作原理(索引阶段 + 查询阶段)
  • 能使用 DocumentLoader 和 DocumentSplitter 处理文档
  • 能使用 EmbeddingModel 和 EmbeddingStore 完成向量化和存储
  • 能用 AiServices + ContentRetriever 构建 RAG 应用
  • 能实现查询转换、重排序等进阶优化
  • 能评估 RAG vs 微调 vs 长上下文的选型

下一步:深入学习向量化和 Embedding 的原理,理解 RAG 背后的数学基础。