通过检索增强生成(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 3→2 │ 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 背后的数学基础。