硬核|RAG检索增强生成实战:从原理到踩坑全解(含源码)

硬核|RAG 检索增强生成实战:从原理到踩坑全解

作者:白晨ovis 首发平台:掘金 项目源码:gitee.com/abao123/bac... 标签:Java / Spring AI / RAG / AI Agent / ChromaDB / 向量数据库


一、前言

RAG(Retrieval-Augmented Generation,检索增强生成)是当前 AI Agent 的核心技术之一。本文基于 Java 后端专家 Agent 项目的真实实现,深入讲解:

  • RAG 的完整技术架构
  • 文档加载 → 文本切分 → 向量化 → 存储 → 检索的全流程
  • 常见问题及解决方案
  • 工程化落地的最佳实践

二、RAG 技术架构

css 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                        RAG 全流程                                │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐       │
│  │  文档加载     │ →  │  文本切分     │ →  │   向量化     │       │
│  │  Markdown    │    │  Chunk       │    │  Embedding   │       │
│  │  Loader      │    │  Splitter    │    │              │       │
│  └──────────────┘    └──────────────┘    └──────────────┘       │
│         ↓                   ↓                   ↓               │
│         └───────────────────┴───────────────────┘               │
│                              ↓                                   │
│                     ┌──────────────┐                             │
│                     │  向量数据库   │                             │
│                     │  ChromaDB    │                             │
│                     └──────────────┘                             │
│                              ↓                                   │
│  ┌──────────────────────────────────────────────────────┐       │
│  │                      检索阶段                          │       │
│  │  用户查询 → 向量化 → Top-K 检索 → 格式化上下文 → LLM  │       │
│  └──────────────────────────────────────────────────────┘       │
└─────────────────────────────────────────────────────────────────┘

三、核心模块实现

3.1 文档加载器(MarkdownDocumentLoader)

职责: 递归扫描知识目录,加载所有 Markdown 文件

java 复制代码
@Component
public class MarkdownDocumentLoader {

    /**
     * 从 classpath 知识目录加载所有 Markdown 文件
     * @param knowledgeDir classpath 下的知识目录(如 "classpath:knowledge/")
     */
    public List<MarkdownDocument> loadDocuments(String knowledgeDir) {
        List<MarkdownDocument> documents = new ArrayList<>();

        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        // 递归匹配所有 .md 文件
        Resource[] resources = resolver.getResources(knowledgeDir + "**/*.md");

        for (Resource resource : resources) {
            String filename = resource.getFilename();
            
            // 跳过 README.md 和空文件
            if (filename == null || filename.equalsIgnoreCase("readme.md")) {
                continue;
            }

            String content = readResource(resource);
            if (content == null || content.isBlank()) {
                continue;
            }

            // 提取相对路径作为 source 和 category
            String source = extractSource(resource.getURI().toString());
            String category = extractCategory(source);

            documents.add(new MarkdownDocument(content, new Metadata(source, filename, category)));
        }

        // 按路径排序,保证加载顺序一致
        documents.sort((a, b) -> 
            a.getMetadata().getSource().compareTo(b.getMetadata().getSource())
        );
        return documents;
    }
}

3.2 文本切分器(MarkdownTextSplitter)

核心策略: 按语义边界优先切分,保证每个 chunk 语义完整

java 复制代码
@Component
public class MarkdownTextSplitter {

    /** 分隔符优先级(从高到低) */
    private static final String[] SEPARATORS = {
        "\n## ",      // 二级标题(最高优先级)
        "\n### ",     // 三级标题
        "\n#### ",    // 四级标题
        "\n\n",       // 段落
        "\n",         // 换行
        "。",         // 中文句号
        ";",         // 中文分号
        ""            // 按字符切分(最低优先级)
    };

    private int chunkSize = 800;      // 块大小(字符数)
    private int chunkOverlap = 200;    // 块重叠(保证跨块上下文)

    /**
     * 将单个文档切分为多个文本块
     */
    public List<MarkdownChunk> split(String content, Metadata metadata) {
        List<MarkdownChunk> chunks = new ArrayList<>();
        List<String> segments = splitBySeparators(content);

        StringBuilder current = new StringBuilder();
        int currentLength = 0;

        for (String segment : segments) {
            if (currentLength + segment.length() > chunkSize && currentLength > 0) {
                // 当前块已满,保存
                chunks.add(createChunk(current.toString(), metadata, chunks.size()));

                // 保留重叠部分(保证跨块上下文不丢失)
                String overlapText = getOverlapText(current.toString());
                current = new StringBuilder(overlapText);
                currentLength = overlapText.length();
            }
            current.append(segment);
            currentLength += segment.length();
        }

        // 保存最后一块
        if (current.length() > 0) {
            chunks.add(createChunk(current.toString(), metadata, chunks.size()));
        }
        return chunks;
    }

    /**
     * 按分隔符优先级递归切分文本
     */
    private List<String> splitRecursive(String text, int separatorIndex) {
        if (separatorIndex >= SEPARATORS.length || text.isEmpty()) {
            return List.of(text);
        }

        String separator = SEPARATORS[separatorIndex];

        // 空字符串分隔符 = 按字符切分
        if (separator.isEmpty()) {
            List<String> result = new ArrayList<>();
            for (int i = 0; i < text.length(); i += chunkSize) {
                result.add(text.substring(i, Math.min(i + chunkSize, text.length())));
            }
            return result;
        }

        String[] parts = text.split(separator.equals("\n\n") ? "\n\n" :
                separator.equals("\n") ? "\n" : java.util.regex.Pattern.quote(separator));

        List<String> result = new ArrayList<>();
        for (String part : parts) {
            if (part.isEmpty()) continue;

            if (part.length() > chunkSize) {
                // 块仍然太大,用下一级分隔符继续切分
                result.addAll(splitRecursive(part, separatorIndex + 1));
            } else {
                // 保留分隔符(保证语义完整性)
                result.add(result.isEmpty() ? part : separator + part);
            }
        }
        return result;
    }
}

3.3 知识库服务(KnowledgeBaseService)

核心职责: 构建向量库 + 检索相关上下文

java 复制代码
@Service
public class KnowledgeBaseService {

    private final VectorStore vectorStore;      // ChromaDB / PGVector
    private final MarkdownDocumentLoader documentLoader;
    private MarkdownTextSplitter textSplitter;

    private boolean loaded = false;
    private int vectorCount = 0;

    /**
     * 构建知识库(全量重建)
     * 流程:加载文档 → 切分 → 向量化 → 存入向量数据库
     */
    public synchronized void build() {
        // Step 1:加载原始 Markdown 文档
        List<MarkdownDocument> documents = documentLoader.loadDocuments(kbProps.getDir());
        documentCount = documents.size();
        log.info("[KB] 加载了 {} 个文档", documentCount);

        // Step 2:切分为 chunk
        List<MarkdownChunk> chunks = textSplitter.splitAll(documents);
        log.info("[KB] 切分为 {} 个文本块", chunks.size());

        // Step 3:转换为 Spring AI Document 并分批存入 VectorStore
        // ⚠️ 注意:DashScope embedding 批量限制为 10 条,需分批调用
        List<Document> aiDocuments = new ArrayList<>();
        for (MarkdownChunk chunk : chunks) {
            Map<String, Object> metadata = new HashMap<>();
            metadata.put("source", chunk.getSource());
            metadata.put("filename", chunk.getFilename());
            metadata.put("category", chunk.getCategory());
            metadata.put("chunk_index", chunk.getChunkIndex());

            aiDocuments.add(new Document(chunk.getContent(), metadata));
        }

        int batchSize = 10; // DashScope embedding 批量上限
        for (int i = 0; i < aiDocuments.size(); i += batchSize) {
            int end = Math.min(i + batchSize, aiDocuments.size());
            List<Document> batch = aiDocuments.subList(i, end);
            log.debug("[KB] 向量化进度: {}/{}", end, aiDocuments.size());
            vectorStore.add(batch);
        }

        vectorCount = chunks.size();
        loaded = true;
        log.info("[KB] 向量库构建完成,共 {} 条向量", vectorCount);
    }

    /**
     * 从知识库检索相关上下文
     */
    public RetrievalResult retrieve(String query) {
        if (!loaded) {
            return new RetrievalResult("", 0);
        }

        try {
            int topK = properties.getKnowledge().getTopK();
            // 相似度搜索
            List<Document> results = vectorStore.similaritySearch(
                SearchRequest.builder()
                    .query(query)
                    .topK(topK)
                    .build()
            );

            String context = formatContext(results);
            return new RetrievalResult(context, results.size());
        } catch (Exception e) {
            log.warn("[KB] 检索失败,降级为无 RAG 模式", e);
            return new RetrievalResult("", 0);
        }
    }

    /**
     * 格式化检索结果为注入 system_prompt 的参考资料文本
     */
    private String formatContext(List<Document> documents) {
        if (documents == null || documents.isEmpty()) {
            return "";
        }

        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < documents.size(); i++) {
            Document doc = documents.get(i);
            String source = doc.getMetadata().getOrDefault("source", "unknown").toString();
            
            sb.append("[").append(source).append("]\n");
            sb.append(doc.getText());
            if (i < documents.size() - 1) {
                sb.append("\n\n---\n\n");
            }
        }
        return sb.toString();
    }
}

四、常见问题及解决方案

❌ 问题 1:检索结果噪声大

原因: 简单向量检索对短文本效果差,Top-K 结果可能包含无关内容

解决方案:混合检索 + 重排序

java 复制代码
// 1. 关键词预检索(BM25 / 全文检索)
List<Document> bm25Results = keywordSearch(query);

// 2. 向量检索
List<Document> vectorResults = vectorStore.similaritySearch(
    SearchRequest.builder().query(query).topK(topK * 2).build()
);

// 3. 取交集作为候选集
List<Document> candidates = intersection(bm25Results, vectorResults);

// 4. 重排序(Rerank)
List<Document> reranked = rerankService.rerank(query, candidates, topK);

❌ 问题 2:Embedding API 批量限制

原因: DashScope embedding 单次最多 10 条,需分批调用

错误写法:

java 复制代码
// ❌ 一次性传入大量文档,会报错
vectorStore.add(allDocuments);  // 超过 10 条会失败

正确写法:

java 复制代码
// ✅ 分批处理
int batchSize = 10;
for (int i = 0; i < allDocuments.size(); i += batchSize) {
    int end = Math.min(i + batchSize, allDocuments.size());
    List<Document> batch = allDocuments.subList(i, end);
    vectorStore.add(batch);
}

❌ 问题 3:语义被切断

原因: 简单按固定长度切分,可能把完整的段落/列表切成两半

解决方案:按语义边界切分

java 复制代码
// 分隔符优先级
private static final String[] SEPARATORS = {
    "\n## ",      // 二级标题(最高)
    "\n### ",     // 三级标题
    "\n\n",       // 段落
    "\n",         // 换行
    ""            // 按字符(最低)
};

❌ 问题 4:跨块上下文丢失

原因: 两相邻 chunk 完全没有重叠,问答时可能丢失关键信息

解决方案:Chunk Overlap

java 复制代码
// 每个块保留 200 字的 overlap
private int chunkOverlap = 200;

// 获取重叠文本(尝试在段落边界截取)
private String getOverlapText(String text) {
    if (text.length() <= chunkOverlap) {
        return text;
    }
    String tail = text.substring(text.length() - chunkOverlap);
    int paragraphBreak = tail.indexOf("\n\n");
    if (paragraphBreak > 0 && paragraphBreak < tail.length() - 50) {
        return tail.substring(paragraphBreak + 2);
    }
    return tail;
}

❌ 问题 5:检索为空时 Agent 回答质量下降

原因: 没有检索到相关内容时,Agent 只能靠自身知识回答,容易产生幻觉

解决方案:降级 + 兜底策略

java 复制代码
public RetrievalResult retrieve(String query) {
    try {
        List<Document> results = vectorStore.similaritySearch(...);
        if (results.isEmpty()) {
            // 检索为空时,返回提示词而非空字符串
            return new RetrievalResult(
                "【提示】知识库中没有找到直接相关的文档,请基于通用知识回答。", 
                0
            );
        }
        return new RetrievalResult(formatContext(results), results.size());
    } catch (Exception e) {
        log.warn("[KB] 检索失败,降级为无 RAG 模式", e);
        // 返回降级提示
        return new RetrievalResult("【提示】知识库暂时不可用,请基于通用知识回答。", 0);
    }
}

❌ 问题 6:向量数据库连接失败

原因: ChromaDB / PGVector 服务未启动或配置错误

解决方案:启动检查 + 懒加载

yaml 复制代码
# application.yml
spring:
  ai:
    vectorstore:
      chroma:
        url: http://localhost:8000
java 复制代码
@PostConstruct
public void init() {
    try {
        // 检查连接
        vectorStore.similaritySearch(
            SearchRequest.builder().query("test").topK(1).build()
        );
        log.info("[KB] ChromaDB 连接正常");
    } catch (Exception e) {
        log.warn("[KB] ChromaDB 连接失败,将在首次检索时重试");
    }
}

五、工程化最佳实践

5.1 配置参数

yaml 复制代码
# application.yml
spring:
  ai:
    vectorstore:
      chroma:
        url: http://localhost:8000
        collection-name: backend_expert_kb

# 知识库配置
agent:
  knowledge:
    dir: classpath:knowledge/
    chunk-size: 800       # 块大小(字符数)
    chunk-overlap: 200    # 块重叠
    top-k: 5              # 检索返回条数

5.2 知识库状态监控

java 复制代码
public Map<String, Object> getStatus() {
    return Map.of(
        "exists", loaded,
        "knowledge_files", documentCount,
        "vector_count", vectorCount,
        "rag_enabled", loaded
    );
}

5.3 知识库热更新

java 复制代码
/**
 * 增量更新知识库(只更新变化的文档)
 */
public synchronized void updateDocument(String source, String content) {
    // 1. 删除旧向量
    vectorStore.delete(collectionName, Filter.eq("source", source));
    
    // 2. 重新切分并添加
    List<MarkdownChunk> chunks = textSplitter.split(content, new Metadata(source));
    for (MarkdownChunk chunk : chunks) {
        vectorStore.add(toDocument(chunk));
    }
}

六、面试高频问题

Q1:RAG 和 Fine-tuning 的区别?

维度 RAG Fine-tuning
更新频率 高(可实时更新知识库) 低(需重新训练)
成本 低(只需更新向量库) 高(GPU 训练成本)
可解释性 高(可追溯来源) 低(知识隐含在权重中)
幻觉问题 轻(基于检索事实) 重(可能产生错误知识)
适用场景 知识问答、文档检索 风格迁移、特定任务

Q2:如何提升 RAG 检索精度?

  1. 文档预处理:去除噪声(HTML 标签、特殊字符)
  2. 语义切分:按标题/段落切分,保留完整语义
  3. 混合检索:关键词 + 向量,取交集
  4. 重排序:用 Rerank 模型重新排序
  5. 上下文压缩:减少 Token 消耗,提高相关性

Q3:向量数据库怎么选?

数据库 优点 缺点 适用场景
ChromaDB 轻量、易用、本地部署 分布式支持弱 中小规模、知识库
PGVector 与 PostgreSQL 集成好 性能一般 需要结构化 + 向量
Milvus 高性能、分布式 运维复杂 大规模生产环境
Qdrant 高性能、 Rust 实现 生态较弱 中等规模

七、项目地址

Java 后端专家 Agent 完整源码:

🔗 gitee.com/abao123/bac...

RAG 核心模块

模块 职责
KnowledgeBaseService 知识库构建与检索
MarkdownDocumentLoader Markdown 文档加载
MarkdownTextSplitter 语义切分
RetrievalResult 检索结果封装

技术栈

  • 向量数据库:ChromaDB(本地部署)
  • Embedding 模型:阿里百炼 text-embedding-v3
  • 切分策略:800 字/块,200 字重叠

八、总结

阶段 关键点
文档加载 递归扫描、跳过 README、路径排序
文本切分 按语义边界(标题 > 段落 > 句子)、保留 Overlap
向量化 分批调用(≤10条)、元数据保留
检索 Top-K 检索、格式化上下文
降级 检索失败时返回提示词而非空字符串

掌握这些核心能力和避坑经验,你也能构建出生产级别的 RAG 系统!


如果对你有帮助,欢迎点赞 + 收藏!