《60天AI学习计划启动 | Day 09: RAG(检索增强生成)- 智能文档助手》

Day 09: RAG(检索增强生成)- 智能文档助手

学习目标


核心学习内容

1. RAG 架构原理

什么是 RAG?

  • Retrieval-Augmented Generation(检索增强生成)
  • 结合检索和生成,基于文档回答问题
  • 解决 LLM 知识局限和幻觉问题

工作流程:

用户问题
  ↓
文档检索(向量搜索)
  ↓
找到相关文档片段
  ↓
将文档片段作为上下文
  ↓
AI 基于上下文生成回答
  ↓
返回答案

优势:

  • 基于真实文档,减少幻觉
  • 可更新知识库
  • 支持长文档问答
  • 可追溯来源

2. RAG 核心组件

组件:

  1. 文档存储:向量数据库
  2. 检索器:相似度搜索
  3. 生成器:LLM 生成回答
  4. 上下文组装:合并检索结果

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 = `你是一个智能文档助手。
重要:只基于提供的文档内容回答,不要使用外部知识。
如果文档中没有相关信息,明确说明"文档中没有相关信息"。`;

学习总结

今日收获

  1. ✅ 理解 RAG 架构原理
  2. ✅ 实现文档检索功能
  3. ✅ 结合检索结果生成回答
  4. ✅ 构建智能文档助手
  5. ✅ 优化检索和生成流程

关键知识点

  • RAG 结合检索和生成,基于文档回答问题
  • 向量检索,找到最相关的文档片段
  • 上下文组装,将检索结果作为上下文
  • 流式生成,实时显示回答
  • 来源追溯,显示参考文档

RAG 优势

  • ✅ 基于真实文档,减少幻觉
  • ✅ 可更新知识库
  • ✅ 支持长文档问答
  • ✅ 可追溯来源

明日计划

明天将学习:

期待明天的学习! 🚀


参考资源


代码仓库

项目已更新:

  • ✅ RAG 服务实现
  • ✅ 文档上传功能
  • ✅ 智能问答接口
  • ✅ 前端界面

GitHub 提交: Day 09 - RAG 实现


标签: #AI学习 #RAG #检索增强生成 #文档助手 #学习笔记


写在最后

今天完成了 RAG 的完整实现,这是 AI 应用的重要技术。
通过 RAG,可以让 AI 基于特定文档回答问题,大大提升了实用性。
明天将完善项目,打造一个完整的智能文档助手!

继续加油! 💪


快速检查清单

完成这些,第九天就达标了!

posted @ 2025-12-16 15:21  XiaoZhengTou  阅读(3)  评论(0)    收藏  举报