《60天AI学习计划启动 | Day 09: RAG(检索增强生成)- 智能文档助手》
Day 09: RAG(检索增强生成)- 智能文档助手
学习目标
核心学习内容
1. RAG 架构原理
什么是 RAG?
- Retrieval-Augmented Generation(检索增强生成)
- 结合检索和生成,基于文档回答问题
- 解决 LLM 知识局限和幻觉问题
工作流程:
用户问题
↓
文档检索(向量搜索)
↓
找到相关文档片段
↓
将文档片段作为上下文
↓
AI 基于上下文生成回答
↓
返回答案
优势:
- 基于真实文档,减少幻觉
- 可更新知识库
- 支持长文档问答
- 可追溯来源
2. RAG 核心组件
组件:
- 文档存储:向量数据库
- 检索器:相似度搜索
- 生成器:LLM 生成回答
- 上下文组装:合并检索结果
3. RAG 优化策略
策略:
- Top-K 检索:返回最相关的 K 个文档
- 重排序:二次排序提升准确性
- 上下文压缩:只保留关键信息
- 多轮对话:保持上下文连贯
实践作业
作业1:实现 RAG 服务
src/services/rag.js:
import OpenAI from 'openai';
import { vectorDB } from './vector-db.js';
import { documentProcessor } from './document-processor.js';
import { logger } from '../utils/logger.js';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
/**
* RAG 服务
*/
export class RAGService {
constructor() {
this.maxContextLength = 2000; // 最大上下文长度(字符)
this.topK = 5; // 检索文档数量
}
/**
* 基于文档回答问题
*/
async answerQuestion(question, options = {}) {
const {
topK = this.topK,
temperature = 0.7,
includeSources = true
} = options;
try {
// 1. 检索相关文档
logger.info(`检索问题: ${question}`);
const relevantDocs = await vectorDB.search(question, topK);
if (relevantDocs.length === 0) {
return {
answer: '抱歉,我没有找到相关的文档信息。',
sources: [],
retrievedDocs: []
};
}
logger.info(`找到 ${relevantDocs.length} 个相关文档`);
// 2. 组装上下文
const context = this.buildContext(relevantDocs);
logger.info(`上下文长度: ${context.length} 字符`);
// 3. 生成回答
const answer = await this.generateAnswer(question, context, {
temperature,
includeSources
});
return {
answer: answer.content,
sources: includeSources ? this.extractSources(relevantDocs) : [],
retrievedDocs: relevantDocs.map(doc => ({
text: doc.text.substring(0, 200) + '...',
similarity: doc.similarity
})),
usage: answer.usage
};
} catch (error) {
logger.error('RAG 回答失败:', error);
throw error;
}
}
/**
* 组装上下文
*/
buildContext(documents) {
let context = '';
const usedDocs = [];
for (const doc of documents) {
const docText = `文档片段:${doc.text}\n\n`;
// 检查是否会超出长度限制
if (context.length + docText.length > this.maxContextLength) {
break;
}
context += docText;
usedDocs.push(doc);
}
return context.trim();
}
/**
* 生成回答
*/
async generateAnswer(question, context, options = {}) {
const { temperature = 0.7, includeSources = true } = options;
const systemPrompt = `你是一个智能文档助手,基于提供的文档内容回答问题。
回答要求:
1. 只基于提供的文档内容回答,不要编造信息
2. 如果文档中没有相关信息,明确说明
3. 回答要准确、简洁、有条理
4. 可以引用文档中的具体内容
5. 使用 Markdown 格式化回答`;
const userPrompt = `基于以下文档内容回答问题:
${context}
问题:${question}
请基于上述文档内容回答,如果文档中没有相关信息,请说明。`;
const completion = await openai.chat.completions.create({
model: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: temperature,
max_tokens: 1000
});
return {
content: completion.choices[0].message.content,
usage: completion.usage
};
}
/**
* 提取来源信息
*/
extractSources(documents) {
return documents.map(doc => ({
text: doc.text.substring(0, 100) + '...',
similarity: doc.similarity,
metadata: doc.metadata
}));
}
/**
* 流式 RAG 回答
*/
async streamAnswerQuestion(question, callbacks = {}, options = {}) {
const { onChunk, onComplete, onError } = callbacks;
const { topK = this.topK, temperature = 0.7 } = options;
try {
// 1. 检索文档
const relevantDocs = await vectorDB.search(question, topK);
if (relevantDocs.length === 0) {
if (onChunk) {
onChunk('抱歉,我没有找到相关的文档信息。');
}
if (onComplete) {
onComplete({
answer: '抱歉,我没有找到相关的文档信息。',
sources: []
});
}
return;
}
// 2. 组装上下文
const context = this.buildContext(relevantDocs);
// 3. 流式生成回答
const systemPrompt = `你是一个智能文档助手,基于提供的文档内容回答问题。
只基于文档内容回答,不要编造信息。`;
const userPrompt = `基于以下文档内容回答问题:
${context}
问题:${question}`;
const stream = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: temperature,
stream: true
});
let fullContent = '';
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
fullContent += content;
if (onChunk) {
onChunk(content);
}
}
}
if (onComplete) {
onComplete({
answer: fullContent,
sources: this.extractSources(relevantDocs)
});
}
} catch (error) {
logger.error('流式 RAG 失败:', error);
if (onError) {
onError(error);
}
}
}
}
export const ragService = new RAGService();
作业2:创建 RAG API 路由
src/routes/rag.js:
import express from 'express';
import { ragService } from '../services/rag.js';
import { logger } from '../utils/logger.js';
export const ragRouter = express.Router();
// POST /api/rag/answer - 回答问题
ragRouter.post('/answer', async (req, res) => {
try {
const { question, topK = 5, temperature = 0.7 } = req.body;
if (!question) {
return res.status(400).json({
success: false,
error: '问题不能为空'
});
}
const result = await ragService.answerQuestion(question, {
topK,
temperature,
includeSources: true
});
res.json({
success: true,
data: result
});
} catch (error) {
logger.error('RAG 回答错误:', error);
res.status(500).json({
success: false,
error: error.message || '服务异常'
});
}
});
// POST /api/rag/stream - 流式回答问题
ragRouter.post('/stream', async (req, res) => {
try {
const { question, topK = 5, temperature = 0.7 } = req.body;
if (!question) {
return res.status(400).json({
success: false,
error: '问题不能为空'
});
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
await ragService.streamAnswerQuestion(
question,
{
onChunk: (content) => {
res.write(`data: ${JSON.stringify({ content })}\n\n`);
},
onComplete: (result) => {
res.write(`data: ${JSON.stringify({
done: true,
sources: result.sources
})}\n\n`);
res.end();
},
onError: (error) => {
res.write(`data: ${JSON.stringify({
error: error.message
})}\n\n`);
res.end();
}
},
{ topK, temperature }
);
} catch (error) {
logger.error('流式 RAG 错误:', error);
if (!res.headersSent) {
res.status(500).json({
success: false,
error: error.message
});
}
}
});
作业3:实现文档上传功能
src/routes/documents.js:
import express from 'express';
import multer from 'multer';
import { documentProcessor } from '../services/document-processor.js';
import { logger } from '../utils/logger.js';
import fs from 'fs/promises';
const upload = multer({ dest: 'uploads/' });
export const documentsRouter = express.Router();
// POST /api/documents/upload - 上传文档
documentsRouter.post('/upload', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: '请上传文件'
});
}
const filePath = req.file.path;
const fileName = req.file.originalname;
// 读取文件内容
let text = '';
if (fileName.endsWith('.txt') || fileName.endsWith('.md')) {
text = await fs.readFile(filePath, 'utf-8');
} else {
// 可以添加 PDF、Word 等文件解析
return res.status(400).json({
success: false,
error: '暂不支持此文件格式'
});
}
// 处理文档
const result = await documentProcessor.processDocument(text, {
fileName: fileName,
uploadedAt: new Date().toISOString()
});
// 删除临时文件
await fs.unlink(filePath);
res.json({
success: true,
data: {
documentId: result.documentId,
totalChunks: result.totalChunks,
fileName: fileName
}
});
} catch (error) {
logger.error('上传文档错误:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// POST /api/documents/text - 直接添加文本
documentsRouter.post('/text', async (req, res) => {
try {
const { text, metadata = {} } = req.body;
if (!text) {
return res.status(400).json({
success: false,
error: '文本内容不能为空'
});
}
const result = await documentProcessor.processDocument(text, metadata);
res.json({
success: true,
data: {
documentId: result.documentId,
totalChunks: result.totalChunks
}
});
} catch (error) {
logger.error('添加文本错误:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
作业4:前端实现文档助手界面
DocumentAssistant.vue:
<template>
<div class="document-assistant">
<!-- 文档管理区域 -->
<div class="documents-panel">
<h3>文档库</h3>
<div class="upload-section">
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
accept=".txt,.md"
style="display: none" />
<button @click="$refs.fileInput.click()" class="btn-upload">
📄 上传文档
</button>
<button @click="showTextInput = !showTextInput" class="btn-text">
📝 添加文本
</button>
</div>
<!-- 文本输入 -->
<div v-if="showTextInput" class="text-input-area">
<textarea
v-model="textInput"
placeholder="粘贴文档内容..."
rows="5" />
<button @click="addText" :disabled="!textInput.trim()">
添加
</button>
</div>
<!-- 文档列表 -->
<div class="documents-list">
<div v-for="doc in documents" :key="doc.id" class="doc-item">
<span class="doc-name">{{ doc.name || '未命名文档' }}</span>
<span class="doc-chunks">{{ doc.chunks }} 块</span>
<button @click="deleteDocument(doc.id)" class="btn-delete">删除</button>
</div>
</div>
</div>
<!-- 问答区域 -->
<div class="qa-panel">
<h3>智能问答</h3>
<!-- 问题输入 -->
<div class="question-input">
<textarea
v-model="question"
@keydown.enter.exact.prevent="askQuestion"
placeholder="基于文档内容提问..."
rows="2" />
<button
@click="askQuestion"
:disabled="loading || !question.trim()">
{{ loading ? '回答中...' : '提问' }}
</button>
</div>
<!-- 回答显示 -->
<div v-if="answer" class="answer-section">
<div class="answer-content" v-html="formatAnswer(answer)"></div>
<!-- 来源文档 -->
<div v-if="sources.length > 0" class="sources">
<h4>参考来源:</h4>
<div
v-for="(source, index) in sources"
:key="index"
class="source-item">
<div class="source-text">{{ source.text }}</div>
<div class="source-similarity">
相似度: {{ (source.similarity * 100).toFixed(1) }}%
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { streamChatWithRAG } from '@/utils/api';
export default {
name: 'DocumentAssistant',
data() {
return {
documents: [],
question: '',
answer: '',
sources: [],
loading: false,
showTextInput: false,
textInput: ''
};
},
mounted() {
this.loadDocuments();
},
methods: {
async handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('http://localhost:3000/api/documents/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
this.$message.success('文档上传成功');
this.loadDocuments();
}
} catch (error) {
this.$message.error('上传失败');
}
},
async addText() {
if (!this.textInput.trim()) return;
try {
const response = await fetch('http://localhost:3000/api/documents/text', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: this.textInput,
metadata: {
name: '手动添加的文档'
}
})
});
const result = await response.json();
if (result.success) {
this.$message.success('文档添加成功');
this.textInput = '';
this.showTextInput = false;
this.loadDocuments();
}
} catch (error) {
this.$message.error('添加失败');
}
},
async askQuestion() {
if (!this.question.trim() || this.loading) return;
this.loading = true;
this.answer = '';
this.sources = [];
try {
await streamChatWithRAG(
this.question,
{
onChunk: (content) => {
this.answer += content;
},
onComplete: (result) => {
this.sources = result.sources || [];
this.loading = false;
},
onError: (error) => {
this.answer = error.message || '回答失败';
this.loading = false;
}
}
);
} catch (error) {
this.answer = error.message || '回答失败';
this.loading = false;
}
},
async loadDocuments() {
try {
const response = await fetch('http://localhost:3000/api/vector-db/stats');
const result = await response.json();
// 这里可以扩展获取文档列表
} catch (error) {
console.error('加载文档失败:', error);
}
},
formatAnswer(text) {
// 简单的 Markdown 渲染
return text
.replace(/\n/g, '<br>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
}
}
};
</script>
<style scoped>
.document-assistant {
display: grid;
grid-template-columns: 300px 1fr;
gap: 20px;
height: 100vh;
padding: 20px;
}
.documents-panel {
border-right: 1px solid #eee;
padding-right: 20px;
}
.upload-section {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
.btn-upload, .btn-text {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.text-input-area {
margin-bottom: 20px;
}
.text-input-area textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 8px;
}
.qa-panel {
display: flex;
flex-direction: column;
}
.question-input {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
.question-input textarea {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.answer-section {
flex: 1;
overflow-y: auto;
}
.answer-content {
padding: 20px;
background: #f5f5f5;
border-radius: 8px;
margin-bottom: 20px;
line-height: 1.6;
}
.sources {
margin-top: 20px;
}
.source-item {
padding: 12px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 8px;
}
.source-similarity {
font-size: 12px;
color: #999;
margin-top: 4px;
}
</style>
作业5:更新 API 工具
utils/api.js(添加 RAG 方法):
/**
* 流式 RAG 问答
*/
export function streamChatWithRAG(question, callbacks = {}) {
const { onChunk, onComplete, onError } = callbacks;
return new Promise((resolve, reject) => {
fetch('http://localhost:3000/api/rag/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ question })
})
.then(response => {
if (!response.ok) {
throw new Error('请求失败');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
function readStream() {
reader.read().then(({ done, value }) => {
if (done) {
resolve();
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
if (data.error) {
if (onError) onError(new Error(data.error));
reject(new Error(data.error));
return;
}
if (data.done) {
if (onComplete) onComplete(data);
resolve(data);
return;
}
if (data.content && onChunk) {
onChunk(data.content);
}
} catch (e) {
console.error('解析失败:', e);
}
}
}
readStream();
}).catch(error => {
if (onError) onError(error);
reject(error);
});
}
readStream();
})
.catch(error => {
if (onError) onError(error);
reject(error);
});
});
}
作业6:测试 RAG 功能
test-rag.js:
import { vectorDB } from './src/services/vector-db.js';
import { ragService } from './src/services/rag.js';
async function testRAG() {
console.log('=== 测试 RAG 功能 ===\n');
// 1. 添加文档
console.log('1. 添加文档...');
await vectorDB.addDocument(
'Vue.js 是一个用于构建用户界面的渐进式框架。它采用组件化开发模式,支持响应式数据绑定。',
{ category: 'vue' }
);
await vectorDB.addDocument(
'React 是 Facebook 开发的 JavaScript 库,用于构建用户界面。它使用虚拟 DOM 提高性能。',
{ category: 'react' }
);
await vectorDB.addDocument(
'前端开发主要包括 HTML、CSS 和 JavaScript 三种技术。HTML 负责结构,CSS 负责样式,JavaScript 负责交互。',
{ category: 'frontend' }
);
// 2. 测试问答
console.log('\n2. 测试问答...');
const question1 = 'Vue.js 是什么?';
console.log(`问题: ${question1}`);
const result1 = await ragService.answerQuestion(question1);
console.log(`回答: ${result1.answer}`);
console.log(`来源数: ${result1.sources.length}`);
// 3. 测试流式回答
console.log('\n3. 测试流式回答...');
const question2 = '前端开发包括哪些技术?';
console.log(`问题: ${question2}`);
await ragService.streamAnswerQuestion(
question2,
{
onChunk: (content) => {
process.stdout.write(content);
},
onComplete: (result) => {
console.log('\n\n回答完成');
console.log(`来源数: ${result.sources.length}`);
}
}
);
}
testRAG().catch(console.error);
遇到的问题
问题1:上下文过长
解决方案:
// 限制上下文长度
buildContext(documents) {
let context = '';
for (const doc of documents) {
if (context.length + doc.text.length > this.maxContextLength) {
break;
}
context += doc.text + '\n\n';
}
return context;
}
问题2:检索结果不准确
解决方案:
// 提高 topK,增加检索数量
const relevantDocs = await vectorDB.search(question, 10);
// 重排序:使用更复杂的相似度计算
// 或者使用混合检索(关键词 + 向量)
问题3:回答偏离文档
解决方案:
// 在系统提示词中强调
const systemPrompt = `你是一个智能文档助手。
重要:只基于提供的文档内容回答,不要使用外部知识。
如果文档中没有相关信息,明确说明"文档中没有相关信息"。`;
学习总结
今日收获
- ✅ 理解 RAG 架构原理
- ✅ 实现文档检索功能
- ✅ 结合检索结果生成回答
- ✅ 构建智能文档助手
- ✅ 优化检索和生成流程
关键知识点
- RAG 结合检索和生成,基于文档回答问题
- 向量检索,找到最相关的文档片段
- 上下文组装,将检索结果作为上下文
- 流式生成,实时显示回答
- 来源追溯,显示参考文档
RAG 优势
- ✅ 基于真实文档,减少幻觉
- ✅ 可更新知识库
- ✅ 支持长文档问答
- ✅ 可追溯来源
明日计划
明天将学习:
期待明天的学习! 🚀
参考资源
代码仓库
项目已更新:
- ✅ RAG 服务实现
- ✅ 文档上传功能
- ✅ 智能问答接口
- ✅ 前端界面
GitHub 提交: Day 09 - RAG 实现
标签: #AI学习 #RAG #检索增强生成 #文档助手 #学习笔记
写在最后
今天完成了 RAG 的完整实现,这是 AI 应用的重要技术。
通过 RAG,可以让 AI 基于特定文档回答问题,大大提升了实用性。
明天将完善项目,打造一个完整的智能文档助手!
继续加油! 💪
快速检查清单
完成这些,第九天就达标了! ✅

浙公网安备 33010602011771号