硬核|RAG 检索增强生成实战:从原理到踩坑全解
作者:白晨ovis 首发平台:掘金 项目源码:gitee.com/abao123/bac… 标签:Java / Spring AI / RAG / AI Agent / ChromaDB / 向量数据库
一、前言
RAG(Retrieval-Augmented Generation,检索增强生成)是当前 AI Agent 的核心技术之一。本文基于 Java 后端专家 Agent 项目的真实实现,深入讲解:
- RAG 的完整技术架构
- 文档加载 → 文本切分 → 向量化 → 存储 → 检索的全流程
- 常见问题及解决方案
- 工程化落地的最佳实践
二、RAG 技术架构
┌─────────────────────────────────────────────────────────────────┐
│ RAG 全流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 文档加载 │ → │ 文本切分 │ → │ 向量化 │ │
│ │ Markdown │ │ Chunk │ │ Embedding │ │
│ │ Loader │ │ Splitter │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ↓ ↓ ↓ │
│ └───────────────────┴───────────────────┘ │
│ ↓ │
│ ┌──────────────┐ │
│ │ 向量数据库 │ │
│ │ ChromaDB │ │
│ └──────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 检索阶段 │ │
│ │ 用户查询 → 向量化 → Top-K 检索 → 格式化上下文 → LLM │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
三、核心模块实现
3.1 文档加载器(MarkdownDocumentLoader)
职责: 递归扫描知识目录,加载所有 Markdown 文件
@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 语义完整
@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)
核心职责: 构建向量库 + 检索相关上下文
@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 结果可能包含无关内容
解决方案:混合检索 + 重排序
// 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 条,需分批调用
错误写法:
// ❌ 一次性传入大量文档,会报错
vectorStore.add(allDocuments); // 超过 10 条会失败
正确写法:
// ✅ 分批处理
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:语义被切断
原因: 简单按固定长度切分,可能把完整的段落/列表切成两半
解决方案:按语义边界切分
// 分隔符优先级
private static final String[] SEPARATORS = {
"\n## ", // 二级标题(最高)
"\n### ", // 三级标题
"\n\n", // 段落
"\n", // 换行
"" // 按字符(最低)
};
❌ 问题 4:跨块上下文丢失
原因: 两相邻 chunk 完全没有重叠,问答时可能丢失关键信息
解决方案:Chunk Overlap
// 每个块保留 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 只能靠自身知识回答,容易产生幻觉
解决方案:降级 + 兜底策略
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 服务未启动或配置错误
解决方案:启动检查 + 懒加载
# application.yml
spring:
ai:
vectorstore:
chroma:
url: http://localhost:8000
@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 配置参数
# 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 知识库状态监控
public Map<String, Object> getStatus() {
return Map.of(
"exists", loaded,
"knowledge_files", documentCount,
"vector_count", vectorCount,
"rag_enabled", loaded
);
}
5.3 知识库热更新
/**
* 增量更新知识库(只更新变化的文档)
*/
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 检索精度?
- 文档预处理:去除噪声(HTML 标签、特殊字符)
- 语义切分:按标题/段落切分,保留完整语义
- 混合检索:关键词 + 向量,取交集
- 重排序:用 Rerank 模型重新排序
- 上下文压缩:减少 Token 消耗,提高相关性
Q3:向量数据库怎么选?
| 数据库 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| ChromaDB | 轻量、易用、本地部署 | 分布式支持弱 | 中小规模、知识库 |
| PGVector | 与 PostgreSQL 集成好 | 性能一般 | 需要结构化 + 向量 |
| Milvus | 高性能、分布式 | 运维复杂 | 大规模生产环境 |
| Qdrant | 高性能、 Rust 实现 | 生态较弱 | 中等规模 |
七、项目地址
Java 后端专家 Agent 完整源码:
RAG 核心模块
| 模块 | 职责 |
|---|---|
KnowledgeBaseService | 知识库构建与检索 |
MarkdownDocumentLoader | Markdown 文档加载 |
MarkdownTextSplitter | 语义切分 |
RetrievalResult | 检索结果封装 |
技术栈
- 向量数据库:ChromaDB(本地部署)
- Embedding 模型:阿里百炼 text-embedding-v3
- 切分策略:800 字/块,200 字重叠
八、总结
| 阶段 | 关键点 |
|---|---|
| 文档加载 | 递归扫描、跳过 README、路径排序 |
| 文本切分 | 按语义边界(标题 > 段落 > 句子)、保留 Overlap |
| 向量化 | 分批调用(≤10条)、元数据保留 |
| 检索 | Top-K 检索、格式化上下文 |
| 降级 | 检索失败时返回提示词而非空字符串 |
掌握这些核心能力和避坑经验,你也能构建出生产级别的 RAG 系统!
如果对你有帮助,欢迎点赞 + 收藏!