构建基于Azure OpenAI的ChatGPT风格应用(含RAG功能)

构建基于Azure OpenAI的ChatGPT风格应用(含RAG功能)

项目概述

这个应用将具备以下核心功能:

  • 使用Azure OpenAI API进行对话生成
  • 允许用户上传文件作为知识库
  • 将上传的文件处理成向量存储(RAG系统)
  • 在用户提问时结合RAG知识库生成回答
  • 提供流畅的聊天界面体验

技术栈选择

前端

  • React/Next.js: 构建用户界面
  • Tailwind CSS: 快速样式设计
  • Axios: API请求处理
  • React Dropzone: 文件上传组件

后端

  • Node.js + Express: 服务器框架
  • Azure OpenAI SDK: 与Azure OpenAI服务交互
  • LangChain.js: RAG实现框架
  • Multer: 文件上传处理

数据存储

  • Vector数据库: Pinecone, Qdrant或Chroma
  • MongoDB/PostgreSQL: 存储用户数据和对话历史

系统架构

  1. 前端应用: 展示聊天界面,处理用户输入和文件上传
  2. API服务: 处理聊天请求和文件处理
  3. 向量数据库: 存储文档嵌入向量
  4. Azure OpenAI服务: 提供LLM能力

详细开发步骤

1. 项目设置

1.1 创建项目目录结构

chatgpt-azure-rag/
├── client/                # 前端React应用
├── server/                # 后端Node.js服务
├── .env                   # 环境变量
└── README.md              # 项目文档

1.2 初始化前端项目

# 创建React应用
npx create-react-app client
# 或使用Next.js (推荐)
npx create-next-app client
cd client

# 安装依赖
npm install axios react-dropzone react-markdown
npm install tailwindcss postcss autoprefixer
npx tailwindcss init -p

1.3 初始化后端项目

mkdir server
cd server
npm init -y
npm install express cors dotenv multer azure-openai langchain @langchain/openai
npm install @pinecone-database/pinecone
npm install nodemon --save-dev

2. Azure OpenAI设置

2.1 创建Azure OpenAI资源

  1. 登录Azure门户
  2. 创建新的OpenAI资源
  3. 选择合适的区域和价格套餐
  4. 部署完成后获取API密钥和终端点URL

2.2 部署模型

  1. 在Azure OpenAI Studio中导航到"部署"
  2. 部署gpt-35-turbo或gpt-4模型
  3. 为嵌入功能部署text-embedding-ada-002模型
  4. 记录模型部署名称

2.3 配置环境变量

创建一个.env文件在根目录:

# Azure OpenAI
AZURE_OPENAI_API_KEY=your_api_key_here
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_API_VERSION=2023-05-15
AZURE_OPENAI_DEPLOYMENT_NAME=your-gpt-deployment
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=your-embedding-deployment

# Pinecone
PINECONE_API_KEY=your_pinecone_api_key
PINECONE_ENVIRONMENT=your_environment
PINECONE_INDEX_NAME=your_index_name

# Server
PORT=5000

3. 后端开发

3.1 创建Express服务器

server/index.js中:

const express = require('express');
const cors = require('cors');
const dotenv = require('dotenv');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

// 加载环境变量
dotenv.config();

const app = express();
const PORT = process.env.PORT || 5000;

// 中间件
app.use(cors());
app.use(express.json());

// 文件上传配置
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    const uploadDir = path.join(__dirname, 'uploads');
    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir);
    }
    cb(null, uploadDir);
  },
  filename: function (req, file, cb) {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

const upload = multer({ storage: storage });

// 路由
app.get('/', (req, res) => {
  res.send('Azure OpenAI ChatGPT API正在运行');
});

// 启动服务器
app.listen(PORT, () => {
  console.log(`服务器运行在端口 ${PORT}`);
});

3.2 实现文件上传与处理

创建server/utils/documentProcessor.js:

const { RecursiveCharacterTextSplitter } = require('langchain/text_splitter');
const { OpenAIEmbeddings } = require('@langchain/openai');
const { PineconeStore } = require('@langchain/pinecone');
const { Pinecone } = require('@pinecone-database/pinecone');
const { Document } = require('langchain/document');
const fs = require('fs');
const path = require('path');
const { PDFLoader } = require("langchain/document_loaders/fs/pdf");
const { DocxLoader } = require("langchain/document_loaders/fs/docx");
const { TextLoader } = require("langchain/document_loaders/fs/text");

// 初始化Pinecone客户端
const pinecone = new Pinecone({
  apiKey: process.env.PINECONE_API_KEY,
  environment: process.env.PINECONE_ENVIRONMENT,
});

// 创建OpenAI嵌入对象
const embeddings = new OpenAIEmbeddings({
  azureOpenAIApiKey: process.env.AZURE_OPENAI_API_KEY,
  azureOpenAIApiVersion: process.env.AZURE_OPENAI_API_VERSION,
  azureOpenAIApiInstanceName: process.env.AZURE_OPENAI_ENDPOINT.split('.')[0].split('//')[1],
  azureOpenAIApiDeploymentName: process.env.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME,
});

// 处理文件函数
async function processFile(filePath) {
  try {
    const fileExtension = path.extname(filePath).toLowerCase();
    let documents = [];

    // 根据文件类型选择加载器
    if (fileExtension === '.pdf') {
      const loader = new PDFLoader(filePath);
      documents = await loader.load();
    } else if (fileExtension === '.docx') {
      const loader = new DocxLoader(filePath);
      documents = await loader.load();
    } else if (fileExtension === '.txt') {
      const loader = new TextLoader(filePath);
      documents = await loader.load();
    } else {
      throw new Error('不支持的文件类型');
    }

    // 文本分割
    const textSplitter = new RecursiveCharacterTextSplitter({
      chunkSize: 1000,
      chunkOverlap: 200,
    });

    const splitDocs = await textSplitter.splitDocuments(documents);

    // 为文档添加元数据
    const fileName = path.basename(filePath);
    const enhancedDocs = splitDocs.map((doc, i) => {
      return new Document({
        pageContent: doc.pageContent,
        metadata: {
          ...doc.metadata,
          fileName,
          chunkId: i,
          fileType: fileExtension.substring(1),
        },
      });
    });

    // 生成唯一的命名空间ID
    const namespaceId = `doc_${Date.now()}`;

    // 获取Pinecone索引
    const index = pinecone.Index(process.env.PINECONE_INDEX_NAME);

    // 创建向量存储
    await PineconeStore.fromDocuments(enhancedDocs, embeddings, {
      pineconeIndex: index,
      namespace: namespaceId,
    });

    return {
      success: true,
      namespaceId,
      documentInfo: {
        fileName,
        chunkCount: enhancedDocs.length,
        fileType: fileExtension.substring(1),
      }
    };
  } catch (error) {
    console.error('处理文件时出错:', error);
    return {
      success: false,
      error: error.message
    };
  }
}

module.exports = { processFile };

3.3 实现聊天和RAG功能

创建server/utils/chatProcessor.js:

const { AzureOpenAI } = require('@azure/openai');
const { PineconeStore } = require('@langchain/pinecone');
const { OpenAIEmbeddings } = require('@langchain/openai');
const { Pinecone } = require('@pinecone-database/pinecone');

// 初始化Pinecone客户端
const pinecone = new Pinecone({
  apiKey: process.env.PINECONE_API_KEY,
  environment: process.env.PINECONE_ENVIRONMENT,
});

// 创建OpenAI嵌入对象
const embeddings = new OpenAIEmbeddings({
  azureOpenAIApiKey: process.env.AZURE_OPENAI_API_KEY,
  azureOpenAIApiVersion: process.env.AZURE_OPENAI_API_VERSION,
  azureOpenAIApiInstanceName: process.env.AZURE_OPENAI_ENDPOINT.split('.')[0].split('//')[1],
  azureOpenAIApiDeploymentName: process.env.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME,
});

// 创建Azure OpenAI客户端
const client = new AzureOpenAI({
  apiKey: process.env.AZURE_OPENAI_API_KEY,
  endpoint: process.env.AZURE_OPENAI_ENDPOINT,
  apiVersion: process.env.AZURE_OPENAI_API_VERSION,
});

async function processChat(messages, namespaceId = null) {
  try {
    let systemMessage = "你是一个有帮助的AI助手。";
    const userMessage = messages[messages.length - 1].content;

    // 如果有命名空间ID,使用RAG
    if (namespaceId) {
      // 获取Pinecone索引
      const index = pinecone.Index(process.env.PINECONE_INDEX_NAME);

      // 创建向量存储
      const vectorStore = await PineconeStore.fromExistingIndex(embeddings, {
        pineconeIndex: index,
        namespace: namespaceId,
      });

      // 执行相似性搜索
      const results = await vectorStore.similaritySearch(userMessage, 3);

      // 合并搜索结果作为上下文
      const context = results.map(item => item.pageContent).join('\n\n');

      // 使用检索到的信息更新系统消息
      systemMessage = `你是一个有帮助的AI助手。请使用以下信息回答用户的问题:

${context}

如果以上信息不足以回答用户的问题,请坦诚地说明你不知道,不要编造信息。`;
    }

    // 准备完整的消息数组
    const completeMessages = [
      { role: 'system', content: systemMessage },
      ...messages.slice(0, -1),  // 之前的消息历史
      { role: 'user', content: userMessage }  // 最新的用户消息
    ];

    // 调用Azure OpenAI API
    const response = await client.chat.completions.create({
      messages: completeMessages,
      model: process.env.AZURE_OPENAI_DEPLOYMENT_NAME,
      temperature: 0.7,
      max_tokens: 800
    });

    return {
      success: true,
      content: response.choices[0].message.content,
    };
  } catch (error) {
    console.error('处理聊天时出错:', error);
    return {
      success: false,
      error: error.message
    };
  }
}

module.exports = { processChat };

3.4 添加API路由

server/index.js中添加路由:

// 引入处理函数
const { processFile } = require('./utils/documentProcessor');
const { processChat } = require('./utils/chatProcessor');

// 文件上传路由
app.post('/api/upload', upload.single('file'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ success: false, message: '没有文件上传' });
    }

    const result = await processFile(req.file.path);
    
    // 处理完成后删除临时文件
    fs.unlinkSync(req.file.path);
    
    if (result.success) {
      res.json({ 
        success: true, 
        namespaceId: result.namespaceId,
        documentInfo: result.documentInfo
      });
    } else {
      res.status(500).json({ success: false, message: result.error });
    }
  } catch (error) {
    console.error('上传处理错误:', error);
    res.status(500).json({ success: false, message: '文件处理失败' });
  }
});

// 聊天路由
app.post('/api/chat', async (req, res) => {
  try {
    const { messages, namespaceId } = req.body;
    
    if (!messages || !Array.isArray(messages) || messages.length === 0) {
      return res.status(400).json({ success: false, message: '消息格式不正确' });
    }
    
    const result = await processChat(messages, namespaceId);
    
    if (result.success) {
      res.json({ success: true, content: result.content });
    } else {
      res.status(500).json({ success: false, message: result.error });
    }
  } catch (error) {
    console.error('聊天处理错误:', error);
    res.status(500).json({ success: false, message: '处理聊天请求失败' });
  }
});

4. 前端开发

4.1 创建聊天界面组件

创建client/src/components/ChatInterface.js:

import React, { useState, useRef, useEffect } from 'react';
import axios from 'axios';
import FileUpload from './FileUpload';

const ChatInterface = () => {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [namespaceId, setNamespaceId] = useState(null);
  const [documentInfo, setDocumentInfo] = useState(null);
  
  const messagesEndRef = useRef(null);
  
  // 滚动到最新消息
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };
  
  useEffect(() => {
    scrollToBottom();
  }, [messages]);
  
  // 处理聊天提交
  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!input.trim()) return;
    
    // 添加用户消息
    const userMessage = { role: 'user', content: input };
    setMessages([...messages, userMessage]);
    setInput('');
    setIsLoading(true);
    
    try {
      // 发送API请求
      const response = await axios.post('http://localhost:5000/api/chat', {
        messages: [...messages, userMessage],
        namespaceId
      });
      
      // 添加AI回复
      if (response.data.success) {
        setMessages(prevMessages => [
          ...prevMessages,
          { role: 'assistant', content: response.data.content }
        ]);
      } else {
        console.error('聊天请求失败:', response.data.message);
        setMessages(prevMessages => [
          ...prevMessages,
          { role: 'assistant', content: '抱歉,处理您的请求时出错了。' }
        ]);
      }
    } catch (error) {
      console.error('聊天请求错误:', error);
      setMessages(prevMessages => [
        ...prevMessages,
        { role: 'assistant', content: '抱歉,无法连接到服务器。' }
      ]);
    } finally {
      setIsLoading(false);
    }
  };
  
  // 处理文件上传成功
  const handleFileProcessed = (data) => {
    setNamespaceId(data.namespaceId);
    setDocumentInfo(data.documentInfo);
    
    // 添加系统消息
    setMessages(prevMessages => [
      ...prevMessages,
      { 
        role: 'assistant', 
        content: `文件 "${data.documentInfo.fileName}" 已成功上传和处理!我现在可以回答关于这个文档的问题了。` 
      }
    ]);
  };
  
  return (
    <div className="flex flex-col h-screen max-w-4xl mx-auto p-4">
      <h1 className="text-2xl font-bold text-center mb-4">AI文档助手</h1>
      
      {/* 文件上传区域 */}
      {!namespaceId && (
        <div className="mb-4">
          <FileUpload onFileProcessed={handleFileProcessed} />
        </div>
      )}
      
      {/* 显示当前文档信息 */}
      {documentInfo && (
        <div className="bg-blue-50 p-3 rounded-lg mb-4 text-sm">
          <p><span className="font-semibold">当前文档:</span> {documentInfo.fileName}</p>
          <p><span className="font-semibold">类型:</span> {documentInfo.fileType}</p>
          <p><span className="font-semibold">内容块数:</span> {documentInfo.chunkCount}</p>
        </div>
      )}
      
      {/* 聊天消息区域 */}
      <div className="flex-1 overflow-auto border border-gray-300 rounded-lg p-4 mb-4">
        {messages.map((message, index) => (
          <div 
            key={index}
            className={`mb-4 p-3 rounded-lg ${
              message.role === 'user' 
                ? 'bg-blue-100 ml-auto max-w-[80%]' 
                : 'bg-gray-100 mr-auto max-w-[80%]'
            }`}
          >
            {message.content}
          </div>
        ))}
        {isLoading && (
          <div className="bg-gray-100 p-3 rounded-lg mr-auto max-w-[80%] mb-4">
            <div className="flex space-x-2">
              <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
              <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100"></div>
              <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200"></div>
            </div>
          </div>
        )}
        <div ref={messagesEndRef} />
      </div>
      
      {/* 消息输入区域 */}
      <form onSubmit={handleSubmit} className="flex">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="输入您的问题..."
          className="flex-1 p-2 border border-gray-300 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
          disabled={isLoading}
        />
        <button
          type="submit"
          className="bg-blue-500 text-white px-4 py-2 rounded-r-lg hover:bg-blue-600 focus:outline-none disabled:bg-blue-300"
          disabled={isLoading || !input.trim()}
        >
          发送
        </button>
      </form>
    </div>
  );
};

export default ChatInterface;

4.2 创建文件上传组件

创建client/src/components/FileUpload.js:

import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import axios from 'axios';

const FileUpload = ({ onFileProcessed }) => {
  const [isUploading, setIsUploading] = useState(false);
  const [uploadProgress, setUploadProgress] = useState(0);
  const [uploadError, setUploadError] = useState('');
  
  const onDrop = useCallback(async (acceptedFiles) => {
    const file = acceptedFiles[0];
    if (!file) return;
    
    // 检查文件类型
    const allowedTypes = ['.pdf', '.docx', '.txt'];
    const fileExt = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
    
    if (!allowedTypes.includes(fileExt)) {
      setUploadError('不支持的文件类型。请上传PDF、DOCX或TXT文件。');
      return;
    }
    
    setIsUploading(true);
    setUploadError('');
    setUploadProgress(0);
    
    // 创建FormData对象
    const formData = new FormData();
    formData.append('file', file);
    
    try {
      // 发送上传请求
      const response = await axios.post('http://localhost:5000/api/upload', formData, {
        headers: {
          'Content-Type': 'multipart/form-data'
        },
        onUploadProgress: (progressEvent) => {
          const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
          setUploadProgress(percentCompleted);
        }
      });
      
      if (response.data.success) {
        onFileProcessed(response.data);
      } else {
        setUploadError(response.data.message || '文件上传失败');
      }
    } catch (error) {
      console.error('上传错误:', error);
      setUploadError(error.response?.data?.message || '服务器错误,请稍后重试');
    } finally {
      setIsUploading(false);
    }
  }, [onFileProcessed]);
  
  const { getRootProps, getInputProps, isDragActive } = useDropzone({ 
    onDrop,
    multiple: false,
    disabled: isUploading
  });
  
  return (
    <div className="w-full">
      <div 
        {...getRootProps()} 
        className={`border-2 border-dashed p-6 rounded-lg text-center cursor-pointer transition-colors ${
          isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400'
        } ${isUploading ? 'opacity-50 pointer-events-none' : ''}`}
      >
        <input {...getInputProps()} />
        
        <svg 
          xmlns="http://www.w3.org/2000/svg" 
          className="h-12 w-12 mx-auto text-gray-400" 
          fill="none" 
          viewBox="0 0 24 24" 
          stroke="currentColor"
        >
          <path 
            strokeLinecap="round" 
            strokeLinejoin="round" 
            strokeWidth={2} 
            d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" 
          />
        </svg>
        
        {isUploading ? (
          <div className="mt-4">
            <p className="text-sm font-medium text-gray-700">正在处理文件...</p>
            <div className="w-full bg-gray-200 rounded-full h-2.5 mt-2">
              <div 
                className="bg-blue-600 h-2.5 rounded-full" 
                style={{ width: `${uploadProgress}%` }}
              ></div>
            </div>
          </div>
        ) : (
          <p className="mt-2 text-sm text-gray-600">
            {isDragActive ? '拖放文件到此处' : '点击或拖放文件到此处上传'}
          </p>
        )}
        
        <p className="mt-1 text-xs text-gray-500">
          支持的文件类型: PDF, DOCX, TXT
        </p>
      </div>
      
      {uploadError && (
        <p className="mt-2 text-sm text-red-600">{uploadError}</p>
      )}
    </div>
  );
};

export default FileUpload;

4.3 更新App.js

更新client/src/App.js:

import React from 'react';
import ChatInterface from './components/ChatInterface';
import './App.css';

function App() {
  return (
    <div className="App bg-gray-50 min-h-screen">
      <ChatInterface />
    </div>
  );
}

export default App;

4.4 添加Tailwind CSS配置

更新client/tailwind.config.js:

module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

更新client/src/index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

5. 启动与测试应用

5.1 运行后端服务

cd server
npm run dev  # 假设你在package.json中设置了"dev": "nodemon index.js"

5.2 运行前端应用

cd client
npm start

5.3 测试功能

  1. 打开浏览器访问 http://localhost:3000
  2. 上传PDF、DOCX或TXT文件
  3. 等待文件处理完成
  4. 开始向AI提问关于文档的问题

6. 生产环境部署

6.1 后端部署到Azure App Service

  1. 在Azure门户中创建新的App Service
  2. 配置应用设置(环境变量)
  3. 使用Azure CLI、GitHub Actions或Azure DevOps进行部署

6.2 前端部署到Azure Static Web Apps或Vercel

  1. 构建React应用: npm run build
  2. 部署到选择的平台
  3. 配置API URL指向后端服务

7. 高级功能扩展

7.1 多文档管理

  • 实现文档列表和切换功能
  • 添加文档标签和分类功能

7.2 对话历史保存

  • 使用数据库存储对话历史
  • 实现历史对话恢复功能

7.3 流式响应

  • 实现服务器发送事件(SSE)或WebSocket
  • 展示打字机效果的回复

7.4 用户认证系统

  • 添加登录/注册功能
  • 实现用户专属知识库

故障排除

常见问题与解决方案

  1. 向量数据库连接失败

    • 检查API密钥和环境配置
    • 确认网络连接和防火墙设置
  2. 文件处理超时

    • 增加服务器超时限制
    • 对大文件实现分批处理
  3. Azure API限额问题

    • 实现请求节流和队列
    • 考虑升级API套餐
  4. 内存占用过大

    • 优化文本分块大小
    • 增加服务器资源或使用无服务器架构

总结

通过以上步骤,你可以构建一个具备以下功能的ChatGPT风格应用:

  1. 使用Azure OpenAI API进行对