ShotAI RAG(检索增强生成)实现详解
📚 概述
本文详细介绍 ShotAI 项目中 RAG(Retrieval-Augmented Generation)技术的完整实现,包括文档处理、向量存储、语义检索和增强生成的全流程。
🤔 什么是 RAG?
RAG 的核心思想
RAG(检索增强生成)是一种结合了信息检索和文本生成的 AI 技术:
用户问题 → 向量检索 → 找到相关文档 → 构建提示词 → AI 生成回答
|
为什么需要 RAG?
| 问题 |
传统 LLM |
RAG |
| 知识时效性 |
❌ 训练数据有截止日期 |
✅ 实时更新知识库 |
| 专业领域 |
❌ 通用知识,不够深入 |
✅ 基于专业文档回答 |
| 幻觉问题 |
❌ 可能编造不存在的信息 |
✅ 基于真实文档,更可靠 |
| 可追溯性 |
❌ 无法知道信息来源 |
✅ 可标注文档来源 |
RAG 在股票助手中的应用
- 📊 财报分析:上传财报 PDF,快速提取关键指标
- 📈 研报问答:基于研究报告回答投资问题
- 💼 行业研究:构建行业知识库,深度分析
- 🔍 信息检索:从海量文档中精准找到答案
🏗️ RAG 架构设计
整体流程图
┌─────────────────────────────────────────────────────────────┐ │ 1. 文档上传阶段 │ └─────────────────────────────────────────────────────────────┘ ↓ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ 上传文档 │ → │ 文档解析 │ → │ 文本分割 │ │ PDF/TXT/DOC │ │ 提取纯文本 │ │ Token分块 │ └──────────────┘ └──────────────┘ └──────────────┘ ↓ ┌──────────────────────────────┐ │ 向量化 (Embedding) │ │ Ollama bge-m3 模型 │ └──────────────────────────────┘ ↓ ┌──────────────────────────────┐ │ 存储到 Redis 向量数据库 │ │ 索引: shotai-rag-index │ └──────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐ │ 2. 问答阶段 │ └─────────────────────────────────────────────────────────────┘ ↓ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ 用户提问 │ → │ 问题向量化 │ → │ 相似度检索 │ │ "营收增长率" │ │ Embedding │ │ Top-K 文档 │ └──────────────┘ └──────────────┘ └──────────────┘ ↓ ┌──────────────────────────────┐ │ 构建 RAG 提示词 │ │ 问题 + 相关文档上下文 │ └──────────────────────────────┘ ↓ ┌──────────────────────────────┐ │ DeepSeek 生成回答 │ │ 基于文档内容回答 │ └──────────────────────────────┘
|
💻 核心实现
1. 向量存储配置
VectorStoreConfig.java
@Configuration public class VectorStoreConfig {
@Bean public JedisPooled jedisPooled() { return new JedisPooled("127.0.0.1", 9379, null, "123456"); }
@Bean public VectorStore vectorStore( @Qualifier("ollamaEmbeddingModel") EmbeddingModel embeddingModel, JedisPooled jedisPooled) { return RedisVectorStore.builder(jedisPooled, embeddingModel) .indexName("shotai-rag-index") .prefix("shotai:doc:") .initializeSchema(true) .build(); } }
|
配置说明:
- indexName:Redis 中的索引名称,用于向量检索
- prefix:Key 前缀,避免与其他数据冲突
- initializeSchema:自动创建向量索引,无需手动操作
Redis 中的数据结构:
shotai:doc:uuid-1 → { "content": "文档内容", "embedding": [0.1, 0.2, ...], // 768 维向量 "metadata": { "fileName": "财报.pdf", "uploadTime": 1234567890 } }
|
2. 文档处理服务
DocumentServiceImpl.java
@Slf4j @Service public class DocumentServiceImpl implements DocumentService { @Autowired private VectorStore vectorStore;
@Override public int processDocument(MultipartFile file) throws Exception { log.info("【文档处理】开始: {}, 大小: {} KB", file.getOriginalFilename(), file.getSize() / 1024); Resource resource = new ByteArrayResource(file.getBytes()) { @Override public String getFilename() { return file.getOriginalFilename(); } }; List<Document> documents = readDocument(resource, file.getOriginalFilename()); TokenTextSplitter splitter = new TokenTextSplitter( 500, 100, 5, 10000, true ); List<Document> splitDocuments = splitter.apply(documents); String fileName = file.getOriginalFilename(); for (Document doc : splitDocuments) { doc.getMetadata().put("fileName", fileName); doc.getMetadata().put("uploadTime", System.currentTimeMillis()); } vectorStore.add(splitDocuments); log.info("【文档处理】完成: {}, 生成 {} 个向量", fileName, splitDocuments.size()); return splitDocuments.size(); } }
|
关键参数说明:
| 参数 |
值 |
说明 |
| maxTokens |
500 |
每个片段最大 token 数,太大会影响检索精度 |
| overlap |
100 |
重叠部分,避免语义被截断 |
| minLength |
5 |
过滤掉太短的片段 |
| maxLength |
10000 |
防止超长文本 |
为什么要分割文档?
原始文档(10000 tokens) ↓ 分割成 20 个片段(每个 500 tokens) ↓ 优势: 1. 检索更精准(只返回相关段落) 2. 提示词更短(节省 token) 3. 回答更聚焦(避免信息过载)
|
2.2 文档读取器
private List<Document> readDocument(Resource resource, String filename) { String lowerCaseFilename = filename.toLowerCase(); if (lowerCaseFilename.endsWith(".pdf")) { PdfDocumentReaderConfig config = PdfDocumentReaderConfig.builder() .withPageTopMargin(0) .withPageBottomMargin(0) .withPageExtractedTextFormatter(ExtractedTextFormatter.builder() .withNumberOfTopTextLinesToDelete(0) .build()) .withPagesPerDocument(1) .build(); PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(resource, config); return pdfReader.get(); } else { TikaDocumentReader tikaReader = new TikaDocumentReader(resource); return tikaReader.get(); } }
|
支持的文档格式:
| 格式 |
读取器 |
特点 |
| PDF |
PagePdfDocumentReader |
保留页面结构 |
| TXT |
TikaDocumentReader |
纯文本 |
| DOC/DOCX |
TikaDocumentReader |
提取文本和表格 |
| HTML |
TikaDocumentReader |
去除标签 |
3. RAG 服务实现
RagServiceImpl.java
@Slf4j @Service public class RagServiceImpl implements RagService { @Autowired private VectorStore vectorStore; @Autowired @Qualifier("deepSeekChatModel") private ChatModel chatModel; private ChatClient chatClient;
private static final String RAG_PROMPT_TEMPLATE = """ 你是一个智能助手。请基于以下提供的相关文档内容来回答用户的问题。 相关文档内容: {context} 用户问题: {question} 请注意: 1. 如果文档内容中包含答案,请基于文档内容进行详细回答 2. 如果文档内容不足以回答问题,请说明文档中没有相关信息,但可以给出一些建议 3. 保持回答的准确性和相关性 4. 用中文回答 回答: """;
@Override public Flux<String> ragChatStream(String userId, String message) { String context = retrieveRelevantDocuments(message, 5); if (context.isEmpty()) { log.warn("【RAG检索】未找到相关文档,用户: {}", userId); context = "暂无相关文档内容。"; } else { log.info("【RAG检索】找到相关文档 {} 字符,用户: {}", context.length(), userId); } PromptTemplate promptTemplate = new PromptTemplate(RAG_PROMPT_TEMPLATE); Prompt prompt = promptTemplate.create(Map.of( "context", context, "question", message )); return chatClient.prompt(prompt).stream().content(); } }
|
提示词设计要点:
- 明确角色:告诉 AI 它是什么
- 提供上下文:插入检索到的文档
- 清晰指令:说明如何使用文档回答
- 处理边界:文档不足时如何应对
3.2 向量检索
@Override public String retrieveRelevantDocuments(String query, int topK) { try { SearchRequest searchRequest = SearchRequest.builder() .query(query) .topK(topK) .similarityThreshold(0.5) .build(); List<Document> relevantDocuments = vectorStore.similaritySearch(searchRequest); return relevantDocuments.stream() .map(doc -> { String content = doc.getText(); String fileName = (String) doc.getMetadata().get("fileName"); return String.format("【来源: %s】\n%s", fileName, content); }) .collect(Collectors.joining("\n\n---\n\n")); } catch (Exception e) { log.error("【向量检索失败】错误: {}", e.getMessage()); return ""; } }
|
检索参数说明:
相似度计算原理:
用户问题向量:[0.1, 0.2, 0.3, ...] (768 维) ↓ 计算余弦相似度 ↓ 文档1: 0.85 ✅ (高度相关) 文档2: 0.72 ✅ (相关) 文档3: 0.45 ❌ (低于阈值)
|
4. RAG Controller
@Slf4j @RestController @RequestMapping("/api/rag") public class RagController { @Autowired private DocumentService documentService;
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public DocumentUploadResponse uploadDocument(@RequestParam("file") MultipartFile file) { try { if (file.isEmpty()) { return DocumentUploadResponse.error("上传的文件为空"); } if (!isSupportedFileType(file.getOriginalFilename())) { return DocumentUploadResponse.error( "不支持的文件类型,目前支持: PDF, TXT, DOC, DOCX"); } int vectorCount = documentService.processDocument(file); String documentId = UUID.randomUUID().toString(); log.info("文档处理成功: {}, 生成 {} 个向量", file.getOriginalFilename(), vectorCount); return DocumentUploadResponse.success( documentId, file.getOriginalFilename(), vectorCount); } catch (Exception e) { log.error("处理文档上传时发生错误", e); return DocumentUploadResponse.error("文档处理失败: " + e.getMessage()); } }
private boolean isSupportedFileType(String filename) { if (filename == null) return false; String lowerCaseFilename = filename.toLowerCase(); return lowerCaseFilename.endsWith(".pdf") || lowerCaseFilename.endsWith(".txt") || lowerCaseFilename.endsWith(".doc") || lowerCaseFilename.endsWith(".docx"); } }
|
🌐 前端集成
1. 文档上传 API
export const uploadDocument = async (file, onProgress) => { try { const formData = new FormData(); formData.append('file', file);
const config = { headers: { 'Content-Type': 'multipart/form-data', }, };
if (onProgress) { config.onUploadProgress = (progressEvent) => { const percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); onProgress(percentCompleted); }; }
const response = await api.post('/rag/upload', formData, config);
if (response.status === 200 && response.data.status === 200) { return { error: false, data: { documentId: response.data.documentId, filename: response.data.fileName, vectorCount: response.data.vectorCount, }, message: response.data.msg || '文档上传成功', }; } else { return { error: true, message: response.data.msg || '文档上传失败', }; } } catch (error) { console.error('文档上传失败:', error); return { error: true, message: '文档上传失败,请检查网络连接和文件格式' }; } };
|
2. 使用示例
const handleUpload = async (file) => { const result = await uploadDocument(file, (progress) => { console.log(`上传进度: ${progress}%`); }); if (!result.error) { console.log(`文档上传成功,生成 ${result.data.vectorCount} 个向量`); } };
const sendRagMessage = async (message) => { await sendMessage(userId, message, 'rag'); };
|
📊 完整流程示例
场景:分析公司财报
步骤 1:上传财报
用户:上传 "某公司2024Q3财报.pdf" 系统: 1. 解析 PDF → 提取文本 2. 分割文本 → 42 个片段 3. 向量化 → 42 个 768 维向量 4. 存储到 Redis 结果:✅ 上传成功,生成 42 个向量
|
步骤 2:提问
用户:这家公司的营收增长率是多少? 系统: 1. 问题向量化 2. 检索相关片段(Top 5) - 片段1: "营业收入同比增长25.6%..." (相似度: 0.89) - 片段2: "Q3营收达到..." (相似度: 0.82) - ... 3. 构建提示词 4. AI 生成回答
|
步骤 3:AI 回答
AI:根据财报显示,该公司 Q3 营收同比增长 25.6%, 环比增长 12.3%。主要增长来源于... 【来源: 某公司2024Q3财报.pdf】
|
🎯 核心技术点
1. 向量化原理
文本: "营业收入同比增长25.6%" ↓ Ollama bge-m3 模型 向量: [0.123, -0.456, 0.789, ..., 0.234] (768 维)
|
为什么是 768 维?
- bge-m3 模型的输出维度
- 足够表达语义信息
- 平衡精度和性能
2. 相似度计算
similarity = (A · B) / (||A|| * ||B||)
|
3. 重叠分割
原始文本: "ABCDEFGHIJ" 片段1: "ABCDE" 片段2: "CDEFG" ← 重叠 "CDE" 片段3: "EFGHI" ← 重叠 "EFG" 片段4: "GHIJ"
优势:避免关键信息被截断
|
🔧 高级优化
1. 混合检索
public List<Document> hybridSearch(String query) { List<Document> vectorResults = vectorStore.similaritySearch(query); List<Document> keywordResults = keywordSearch(query); return mergeAndRank(vectorResults, keywordResults); }
|
2. 重排序(Reranking)
public List<Document> rerank(List<Document> documents, String query) { return documents.stream() .sorted((d1, d2) -> { double score1 = rerankModel.score(query, d1.getText()); double score2 = rerankModel.score(query, d2.getText()); return Double.compare(score2, score1); }) .collect(Collectors.toList()); }
|
3. 缓存优化
@Cacheable(value = "rag-results", key = "#query") public String retrieveRelevantDocuments(String query, int topK) { }
|
⚠️ 常见问题
1. 检索不到相关文档
原因:
解决:
.similarityThreshold(0.3)
new TokenTextSplitter(300, 50, ...)
|
2. 回答不准确
原因:
- 检索到的文档不相关
- 提示词设计不合理
- topK 设置不当
解决:
.topK(10)
""" 请严格基于以下文档回答,不要编造信息: {context} """
|
3. 性能问题
原因:
- 文档太大,分割片段太多
- Redis 查询慢
- 向量维度太高
解决:
if (file.getSize() > 10 * 1024 * 1024) { throw new RuntimeException("文件太大,请上传小于 10MB 的文件"); }
RedisVectorStore.builder() .indexName("shotai-rag-index") .build();
|
📊 性能指标
实测数据
| 操作 |
耗时 |
说明 |
| 上传 1MB PDF |
2-3 秒 |
包括解析、分割、向量化 |
| 向量检索 |
50-100 ms |
Redis 查询 |
| 生成回答 |
2-5 秒 |
取决于回答长度 |
优化建议
- ✅ 使用 Redis 索引加速检索
- ✅ 异步处理文档上传
- ✅ 缓存常见问题的检索结果
- ✅ 批量处理多个文档
🎯 总结
通过 RAG 技术,我们实现了:
- ✅ 知识增强:基于真实文档回答,避免幻觉
- ✅ 实时更新:随时上传新文档,立即生效
- ✅ 可追溯性:标注文档来源,答案可验证
- ✅ 专业深度:针对特定领域,提供专业回答
RAG 是构建企业级 AI 应用的核心技术,让 AI 真正具备了领域知识和实时学习能力。
📚 参考资料
系列完结!感谢阅读!
如果这个系列对你有帮助,欢迎 Star ⭐️ 项目!
分享ShotAI-RAG实现 
微信扫码打开,再通过右上角菜单分享。
交流与讨论
评论由 Valine / LeanCloud 提供,点击后连接第三方服务。