JOURNAL / 随笔

ShotAI-RAG实现

ShotAI-RAG实现 封面
本文目录
  1. ShotAI RAG(检索增强生成)实现详解
    1. 📚 概述
    2. 🤔 什么是 RAG?
      1. RAG 的核心思想
      2. 为什么需要 RAG?
      3. RAG 在股票助手中的应用
    3. 🏗️ RAG 架构设计
      1. 整体流程图
    4. 💻 核心实现
      1. 1. 向量存储配置
      2. 2. 文档处理服务
      3. 3. RAG 服务实现
      4. 4. RAG Controller
    5. 🌐 前端集成
      1. 1. 文档上传 API
      2. 2. 使用示例
    6. 📊 完整流程示例
      1. 场景:分析公司财报
    7. 🎯 核心技术点
      1. 1. 向量化原理
      2. 2. 相似度计算
      3. 3. 重叠分割
    8. 🔧 高级优化
      1. 1. 混合检索
      2. 2. 重排序(Reranking)
      3. 3. 缓存优化
    9. ⚠️ 常见问题
      1. 1. 检索不到相关文档
      2. 2. 回答不准确
      3. 3. 性能问题
    10. 📊 性能指标
      1. 实测数据
      2. 优化建议
    11. 🎯 总结
    12. 📚 参考资料

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 {

/**
* 配置 Redis 连接池
*/
@Bean
public JedisPooled jedisPooled() {
return new JedisPooled("127.0.0.1", 9379, null, "123456");
}

/**
* 配置向量存储
* 使用 Redis 作为向量数据库,Ollama 作为嵌入模型
*/
@Bean
public VectorStore vectorStore(
@Qualifier("ollamaEmbeddingModel") EmbeddingModel embeddingModel,
JedisPooled jedisPooled) {

return RedisVectorStore.builder(jedisPooled, embeddingModel)
.indexName("shotai-rag-index") // 索引名称
.prefix("shotai:doc:") // Key 前缀
.initializeSchema(true) // 自动初始化 Schema
.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;

/**
* 处理上传的文档
* 1. 解析文档
* 2. 分割文本
* 3. 向量化
* 4. 存储到 Redis
*/
@Override
public int processDocument(MultipartFile file) throws Exception {
log.info("【文档处理】开始: {}, 大小: {} KB",
file.getOriginalFilename(), file.getSize() / 1024);

// 1. 将 MultipartFile 转换为 Resource
Resource resource = new ByteArrayResource(file.getBytes()) {
@Override
public String getFilename() {
return file.getOriginalFilename();
}
};

// 2. 读取文档
List<Document> documents = readDocument(resource, file.getOriginalFilename());

// 3. 分割文档为更小的片段
TokenTextSplitter splitter = new TokenTextSplitter(
500, // 每个片段最大 token 数
100, // 片段之间的重叠 token 数
5, // 最小片段长度
10000, // 最大片段长度
true // 保持分隔符
);

List<Document> splitDocuments = splitter.apply(documents);

// 4. 添加元数据
String fileName = file.getOriginalFilename();
for (Document doc : splitDocuments) {
doc.getMetadata().put("fileName", fileName);
doc.getMetadata().put("uploadTime", System.currentTimeMillis());
}

// 5. 向量化并存储到 Redis
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();

// PDF 文件使用专门的 PDF 读取器
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();
}
// 其他格式使用 Tika 读取器(支持 txt, doc, docx, html 等)
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;

/**
* RAG 提示词模板
*/
private static final String RAG_PROMPT_TEMPLATE = """
你是一个智能助手。请基于以下提供的相关文档内容来回答用户的问题。

相关文档内容:
{context}

用户问题:
{question}

请注意:
1. 如果文档内容中包含答案,请基于文档内容进行详细回答
2. 如果文档内容不足以回答问题,请说明文档中没有相关信息,但可以给出一些建议
3. 保持回答的准确性和相关性
4. 用中文回答

回答:
""";

/**
* 使用 RAG 进行流式对话
*/
@Override
public Flux<String> ragChatStream(String userId, String message) {
// 1. 从向量数据库检索相关文档
String context = retrieveRelevantDocuments(message, 5);

if (context.isEmpty()) {
log.warn("【RAG检索】未找到相关文档,用户: {}", userId);
context = "暂无相关文档内容。";
} else {
log.info("【RAG检索】找到相关文档 {} 字符,用户: {}",
context.length(), userId);
}

// 2. 构建 RAG 提示词
PromptTemplate promptTemplate = new PromptTemplate(RAG_PROMPT_TEMPLATE);
Prompt prompt = promptTemplate.create(Map.of(
"context", context,
"question", message
));

// 3. 使用 ChatClient 生成流式响应
return chatClient.prompt(prompt).stream().content();
}
}

提示词设计要点:

  1. 明确角色:告诉 AI 它是什么
  2. 提供上下文:插入检索到的文档
  3. 清晰指令:说明如何使用文档回答
  4. 处理边界:文档不足时如何应对

3.2 向量检索

/**
* 检索相关文档片段
*/
@Override
public String retrieveRelevantDocuments(String query, int topK) {
try {
// 从向量数据库检索最相关的文档
SearchRequest searchRequest = SearchRequest.builder()
.query(query) // 查询文本
.topK(topK) // 返回前 K 个结果
.similarityThreshold(0.5) // 相似度阈值(0-1)
.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 "";
}
}

检索参数说明:

  • topK:返回最相似的 K 个文档片段

    • 太小:可能遗漏重要信息
    • 太大:引入噪声,浪费 token
    • 推荐:3-5 个
  • similarityThreshold:相似度阈值

    • 0.5:中等严格,平衡召回率和精确率
    • 0.7:严格,只返回高度相关的文档
    • 0.3:宽松,尽可能多返回结果

相似度计算原理:

用户问题向量:[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 {
// 1. 验证文件
if (file.isEmpty()) {
return DocumentUploadResponse.error("上传的文件为空");
}

// 2. 验证文件类型
if (!isSupportedFileType(file.getOriginalFilename())) {
return DocumentUploadResponse.error(
"不支持的文件类型,目前支持: PDF, TXT, DOC, DOCX");
}

// 3. 处理文档(解析、分割、向量化、存储)
int vectorCount = documentService.processDocument(file);

// 4. 生成文档 ID
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

// src/api/rag.js
export const uploadDocument = async (file, onProgress) => {
try {
// 创建 FormData
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} 个向量`);
}
};

// RAG 模式对话
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||)

// 范围:-1 到 1
// 1: 完全相同
// 0: 无关
// -1: 完全相反

3. 重叠分割

原始文本: "ABCDEFGHIJ"

片段1: "ABCDE"
片段2: "CDEFG" ← 重叠 "CDE"
片段3: "EFGHI" ← 重叠 "EFG"
片段4: "GHIJ"

优势:避免关键信息被截断

🔧 高级优化

1. 混合检索

// 结合关键词检索和向量检索
public List<Document> hybridSearch(String query) {
// 1. 向量检索
List<Document> vectorResults = vectorStore.similaritySearch(query);

// 2. 关键词检索(BM25)
List<Document> keywordResults = keywordSearch(query);

// 3. 合并结果,去重
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 提供,点击后连接第三方服务。

搜索文章