从零到一!前端搭建本地轻量化 RAG 问答系统

项目整体架构设计

项目概述

本周实战项目是一个完整的本地轻量化 RAG 问答系统,具备以下能力:

  • ✅ 多格式文档上传(TXT/MD/PDF/CSV)
  • ✅ 文档自动处理(清洗 → 分块 → 向量化)
  • ✅ 语义检索与问答(基于向量数据库)
  • ✅ Vue3.5 可视化界面(文件上传 + 对话交互)

系统架构图

flowchart LR subgraph 本地 RAG 问答系统架构 direction TB subgraph R [ 前端页面 Vue3.5 ] direction LR A[文件上传区] B[问答对话区] C[文档来源区] end subgraph R1 [ Express 后端服务] direction LR A1[文档上传API] B1[文档处理] C1[向量检索] D1[问答生成] end subgraph R2 [ 核心处理层] direction LR A2[文档加载器] B2[文本清洗] C2[文本分块] D2[ Embedding] end E[Chroma 向量数据库 <br/>Docker: localhost:8001] A --> R1 B --> R1 A1 --> A2 B1 --> B2 C1 --> C2 D1 --> D2 R2 --> E end

模块划分

模块 文件 职责
前端页面 src/App.vue Vue 主组件、文件上传、对话交互
后端服务 server.js Express API 路由、请求处理
文档处理 documentProcessor.js 加载、清洗、分块、向量化
RAG 问答 ragEngine.js 检索、提示词构建、生成回答
向量存储 vectorStore.js Chroma 连接、存储、检索

第一步:环境准备与项目初始化

1.1 创建项目

bash 复制代码
# 创建项目文件夹
mkdir local-rag-system
cd local-rag-system

# 初始化 npm 项目
npm init -y

# 安装后端依赖
npm install express multer cors dotenv
npm install @langchain/openai @langchain/community chromadb
npm install @langchain/core @langchain/textsplitters

# 安装 Vue3.5 前端依赖
npm install vue@3.5.13 @vitejs/plugin-vue vite
npm install -D tailwindcss postcss autoprefixer

# 安装开发依赖
npm install -D nodemon

# 创建目录结构
mkdir -p public uploads src components

1.2 环境变量配置

创建 .env 文件:

bash 复制代码
# .env
PORT=3000

# 阿里云百炼配置
BAILIAN_API_KEY=你的API Key
BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

# Embedding 模型配置
EMBEDDING_MODEL=text-embedding-v3
EMBEDDING_DIMENSION=1024

# LLM 模型配置
LLM_MODEL=qwen-plus
LLM_TEMPERATURE=0.3

# Chroma 配置
CHROMA_URL=http://localhost:8001
CHROMA_COLLECTION=rag_knowledge_base

# 文档处理配置
CHUNK_SIZE=800
CHUNK_OVERLAP=120
MAX_FILE_SIZE=10485760  # 10MB

1.3 Vite 配置

javascript 复制代码
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
      }
    }
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    }
  }
})

1.4 启动 Chroma 向量数据库

bash 复制代码
# 使用 Docker 启动 Chroma
docker run -d -p 8001:8000 --name chromadb chromadb/chroma

# 验证服务是否正常
curl http://localhost:8001/api/v2/heartbeat
# 应返回 {"nanosecond heartbeat": ...}

第二步:核心模块代码实现

2.1 向量存储模块

javascript 复制代码
// src/vectorStore.js
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import dotenv from "dotenv";

dotenv.config();

// 初始化 Embedding 模型
const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.BAILIAN_API_KEY,
  configuration: { baseURL: process.env.BAILIAN_BASE_URL },
  model: process.env.EMBEDDING_MODEL,
});

let vectorStore = null;

/**
 * 获取或创建向量存储实例
 */
export async function getVectorStore() {
  if (vectorStore) {
    return vectorStore;
  }

  try {
    vectorStore = await Chroma.fromExistingCollection(embeddings, {
      collectionName: process.env.CHROMA_COLLECTION,
      url: process.env.CHROMA_URL,
    });
    console.log("✅ 已连接到现有向量库");
  } catch (error) {
    console.log("📦 创建新的向量库集合...");
    vectorStore = await Chroma.fromDocuments([], embeddings, {
      collectionName: process.env.CHROMA_COLLECTION,
      url: process.env.CHROMA_URL,
    });
    console.log("✅ 向量库创建成功");
  }

  return vectorStore;
}

/**
 * 添加文档到向量库
 */
export async function addDocuments(documents) {
  const store = await getVectorStore();
  const ids = await store.addDocuments(documents);
  console.log(`📊 已添加 ${ids.length} 个文档块到向量库`);
  return ids;
}

/**
 * 相似度检索
 */
export async function searchSimilar(query, topK = 5) {
  const store = await getVectorStore();
  const results = await store.similaritySearchWithScore(query, topK);
  return results;
}

export default {
  getVectorStore,
  addDocuments,
  searchSimilar,
};

2.2 文档处理模块

javascript 复制代码
// src/documentProcessor.js
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { Document } from "@langchain/core/documents";
import { addDocuments } from "./vectorStore.js";
import fs from "fs/promises";
import path from "path";
import dotenv from "dotenv";

dotenv.config();

// 文本清洗函数
function cleanText(text) {
  let cleaned = text;
  
  cleaned = cleaned.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
  cleaned = cleaned.replace(/\u00A0/g, ' ');
  cleaned = cleaned.replace(/[\u200B-\u200D\uFEFF]/g, '');
  cleaned = cleaned.replace(/[ \t]+/g, ' ');
  cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
  cleaned = cleaned.split('\n').map(line => line.trim()).join('\n');
  cleaned = cleaned.split('\n').filter(line => line.length > 0).join('\n');
  
  return cleaned;
}

// 创建文本分块器
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: parseInt(process.env.CHUNK_SIZE) || 800,
  chunkOverlap: parseInt(process.env.CHUNK_OVERLAP) || 120,
  separators: ["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
});

/**
 * 处理上传的文档
 */
export async function processDocument(filePath, originalName) {
  console.log(`📂 开始处理文档: ${originalName}`);
  
  const content = await fs.readFile(filePath, "utf-8");
  console.log(`   原始大小: ${content.length} 字符`);
  
  const cleanedContent = cleanText(content);
  console.log(`   清洗后: ${cleanedContent.length} 字符`);
  
  const rawDoc = new Document({
    pageContent: cleanedContent,
    metadata: {
      source: originalName,
      processedAt: new Date().toISOString(),
      size: cleanedContent.length,
    },
  });
  
  const chunks = await splitter.splitDocuments([rawDoc]);
  console.log(`   分割后: ${chunks.length} 个文档块`);
  
  const enrichedChunks = chunks.map((chunk, idx) => ({
    ...chunk,
    metadata: {
      ...chunk.metadata,
      chunkIndex: idx,
      totalChunks: chunks.length,
    },
  }));
  
  await addDocuments(enrichedChunks);
  console.log(`✅ 文档处理完成: ${chunks.length} 个块已存储`);
  
  return chunks.length;
}

export default { processDocument, cleanText };

2.3 RAG 问答模块

javascript 复制代码
// src/ragEngine.js
import { ChatOpenAI } from "@langchain/openai";
import { searchSimilar } from "./vectorStore.js";
import dotenv from "dotenv";

dotenv.config();

const llm = new ChatOpenAI({
  apiKey: process.env.BAILIAN_API_KEY,
  configuration: { baseURL: process.env.BAILIAN_BASE_URL },
  model: process.env.LLM_MODEL,
  temperature: parseFloat(process.env.LLM_TEMPERATURE),
});

const RETRIEVAL_CONFIG = {
  topK: 5,
  minRelevanceScore: 0.6,
  maxContextLength: 3000,
};

function optimizeResults(results) {
  let filtered = results.filter(([, score]) => score >= RETRIEVAL_CONFIG.minRelevanceScore);
  let docs = filtered.map(([doc]) => doc);
  
  const seen = new Set();
  docs = docs.filter(doc => {
    const key = doc.pageContent.slice(0, 100);
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
  
  let totalLength = 0;
  const truncated = [];
  for (const doc of docs) {
    if (totalLength + doc.pageContent.length > RETRIEVAL_CONFIG.maxContextLength) {
      const remaining = RETRIEVAL_CONFIG.maxContextLength - totalLength;
      if (remaining > 100) {
        truncated.push({
          ...doc,
          pageContent: doc.pageContent.slice(0, remaining) + "...",
        });
      }
      break;
    }
    truncated.push(doc);
    totalLength += doc.pageContent.length;
  }
  
  return truncated;
}

function buildRagPrompt(question, contexts) {
  const contextText = contexts
    .map((doc, idx) => {
      const source = doc.metadata?.source || "未知来源";
      return `【参考文档 ${idx + 1}】[来源: ${source}]\n${doc.pageContent}`;
    })
    .join("\n\n");
  
  return `你是一个专业的知识问答助手。请基于以下参考文档回答用户问题。

## 重要规则
1. 只使用下面【参考文档】中的信息回答
2. 如果文档中没有相关信息,请明确说"根据现有文档,没有找到相关信息"
3. 不要使用你自己的知识补充答案
4. 回答要简洁、准确、有条理

${contextText}

## 用户问题
${question}

## 回答
`;
}

export async function askQuestion(question) {
  console.log(`\n🔍 查询: ${question}`);
  const startTime = Date.now();
  
  const rawResults = await searchSimilar(question, RETRIEVAL_CONFIG.topK * 2);
  console.log(`   检索到 ${rawResults.length} 个相关文档块`);
  
  const optimizedDocs = optimizeResults(rawResults);
  console.log(`   优化后: ${optimizedDocs.length} 个文档块`);
  
  const prompt = buildRagPrompt(question, optimizedDocs);
  const response = await llm.invoke(prompt);
  
  const elapsed = Date.now() - startTime;
  console.log(`✅ 完成 (耗时 ${elapsed}ms)`);
  
  return {
    answer: response.content,
    sources: optimizedDocs.map(doc => ({
      source: doc.metadata?.source || "未知来源",
      content: doc.pageContent.slice(0, 200) + "...",
    })),
    stats: {
      retrievedCount: rawResults.length,
      usedCount: optimizedDocs.length,
      elapsedMs: elapsed,
    },
  };
}

export default { askQuestion };

2.4 Express 后端服务

javascript 复制代码
// server.js
import express from "express";
import multer from "multer";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import dotenv from "dotenv";
import { processDocument } from "./src/documentProcessor.js";
import { askQuestion } from "./src/ragEngine.js";
import { getVectorStore } from "./src/vectorStore.js";

dotenv.config();

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

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

app.use(cors());
app.use(express.json());
app.use(express.static("dist"));

const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, "uploads/"),
  filename: (req, file, cb) => {
    const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
    cb(null, uniqueSuffix + path.extname(file.originalname));
  },
});

const upload = multer({
  storage,
  limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 10 * 1024 * 1024 },
  fileFilter: (req, file, cb) => {
    const allowedTypes = [".txt", ".md", ".csv", ".json"];
    const ext = path.extname(file.originalname).toLowerCase();
    allowedTypes.includes(ext) ? cb(null, true) : cb(new Error(`不支持的文件类型: ${ext}`));
  },
});

// API 路由
app.get("/api/health", (req, res) => {
  res.json({ status: "ok", timestamp: new Date().toISOString() });
});

app.post("/api/upload", upload.single("file"), async (req, res) => {
  try {
    if (!req.file) return res.status(400).json({ error: "请选择要上传的文件" });
    const chunkCount = await processDocument(req.file.path, req.file.originalname);
    res.json({ success: true, fileName: req.file.originalname, chunkCount });
  } catch (error) {
    console.error("上传失败:", error);
    res.status(500).json({ error: error.message });
  }
});

app.post("/api/ask", async (req, res) => {
  try {
    const { question } = req.body;
    if (!question || question.trim().length === 0) {
      return res.status(400).json({ error: "请输入问题" });
    }
    const result = await askQuestion(question);
    res.json(result);
  } catch (error) {
    console.error("问答失败:", error);
    res.status(500).json({ error: error.message });
  }
});

app.listen(PORT, () => {
  console.log(`
╔══════════════════════════════════════════════════════════╗
║     🚀 本地 RAG 问答系统已启动                           ║
║                                                          ║
║     后端地址: http://localhost:${PORT}                    ║
║     前端地址: http://localhost:5173                      ║
║                                                          ║
║     确保 Chroma 已运行: docker start chromadb           ║
╚══════════════════════════════════════════════════════════╝
  `);
});

2.5 Vue3.5 前端主组件

html 复制代码
<!-- src/App.vue -->
<template>
  <div class="min-h-screen bg-gradient-to-br from-purple-500 to-indigo-600 p-5">
    <div class="max-w-[1400px] mx-auto">
      <!-- 头部 -->
      <header class="text-center text-white mb-8">
        <h1 class="text-4xl mb-2">🤖 本地 RAG 问答系统</h1>
        <p class="opacity-90">上传文档,让 AI 基于你的知识库回答问题</p>
      </header>

      <!-- 双栏布局 -->
      <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <!-- 左侧:文档管理 -->
        <div class="bg-white rounded-2xl shadow-xl overflow-hidden">
          <div class="px-6 py-4 bg-gray-50 border-b border-gray-200 font-semibold">
            📄 文档管理
          </div>
          <div class="p-6">
            <!-- 上传区域 -->
            <div 
              @click="triggerFileInput"
              @dragover.prevent="isDragging = true"
              @dragleave.prevent="isDragging = false"
              @drop.prevent="handleDrop"
              :class="[
                'border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all',
                isDragging ? 'border-purple-500 bg-purple-50' : 'border-gray-300 hover:border-purple-400 hover:bg-gray-50'
              ]"
            >
              <div class="text-5xl mb-4">📁</div>
              <div>点击或拖拽上传文档</div>
              <div class="text-xs text-gray-500 mt-2">支持 TXT、MD、CSV、JSON 格式</div>
              <input 
                ref="fileInputRef"
                type="file" 
                accept=".txt,.md,.csv,.json" 
                multiple
                class="hidden"
                @change="handleFileSelect"
              >
            </div>

            <!-- 文件列表 -->
            <div class="mt-5 max-h-80 overflow-y-auto">
              <div v-if="uploadedFiles.length === 0" class="text-center text-gray-500 py-5">
                暂无上传文件
              </div>
              <div v-for="file in uploadedFiles" :key="file.id" class="flex justify-between items-center p-3 bg-gray-50 rounded-lg mb-2">
                <span class="font-mono text-sm">📄 {{ file.name }}</span>
                <span :class="[
                  'text-xs px-2 py-1 rounded-full',
                  file.status === 'success' ? 'bg-green-100 text-green-700' :
                  file.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
                  'bg-red-100 text-red-700'
                ]">
                  {{ file.status === 'processing' ? '⏳ 处理中' : file.status === 'success' ? `✅ ${file.chunkCount}块` : '❌ 失败' }}
                </span>
              </div>
            </div>
          </div>
        </div>

        <!-- 右侧:问答区域 -->
        <div class="bg-white rounded-2xl shadow-xl overflow-hidden">
          <div class="px-6 py-4 bg-gray-50 border-b border-gray-200 font-semibold">
            💬 智能问答
          </div>
          <div class="p-6">
            <!-- 聊天消息区 -->
            <div class="h-96 overflow-y-auto bg-gray-50 rounded-xl p-4 mb-4" ref="chatContainerRef">
              <div v-for="(msg, idx) in messages" :key="idx" :class="['message', msg.role === 'user' ? 'flex justify-end' : 'flex justify-start']">
                <div :class="[
                  'max-w-[80%] p-3 rounded-2xl text-sm leading-relaxed',
                  msg.role === 'user' ? 'bg-purple-500 text-white rounded-br-none' : 'bg-white border border-gray-200 rounded-bl-none'
                ]">
                  {{ msg.content }}
                </div>
              </div>
              <div v-if="isLoading" class="flex justify-start">
                <div class="bg-white border border-gray-200 rounded-2xl rounded-bl-none p-3">
                  <div class="loading-spinner"></div>
                </div>
              </div>
            </div>

            <!-- 输入区域 -->
            <div class="flex gap-3">
              <input 
                v-model="question"
                type="text" 
                placeholder="输入你的问题..." 
                class="flex-1 px-4 py-3 border border-gray-300 rounded-full focus:outline-none focus:border-purple-400"
                @keypress.enter="askQuestion"
                :disabled="isLoading"
              >
              <button 
                @click="askQuestion"
                :disabled="isLoading || !question.trim()"
                class="px-6 py-3 bg-purple-500 text-white rounded-full hover:bg-purple-600 transition disabled:bg-gray-300 disabled:cursor-not-allowed"
              >
                发送
              </button>
            </div>
          </div>
        </div>
      </div>

      <!-- 第三栏:来源与统计 -->
      <div class="mt-6">
        <div class="bg-white rounded-2xl shadow-xl overflow-hidden">
          <div class="px-6 py-4 bg-gray-50 border-b border-gray-200 font-semibold">
            📚 回答来源
          </div>
          <div class="p-6">
            <div v-if="sources.length === 0" class="text-center text-gray-500 py-5">
              提问后,回答的参考来源将显示在这里
            </div>
            <div v-for="(source, idx) in sources" :key="idx" class="p-3 bg-gray-50 rounded-lg mb-2">
              <div class="font-semibold text-purple-500 mb-1">📄 {{ source.source }}</div>
              <div class="text-gray-600 text-sm">{{ source.content }}</div>
            </div>
            <div v-if="stats" class="mt-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-800">
              📊 检索统计:召回 {{ stats.retrievedCount }} 个文档块 → 使用 {{ stats.usedCount }} 个 | ⏱️ 耗时 {{ stats.elapsedMs }}ms
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, nextTick } from 'vue'

// 响应式状态
const fileInputRef = ref(null)
const chatContainerRef = ref(null)
const isDragging = ref(false)
const isLoading = ref(false)
const question = ref('')
const uploadedFiles = ref([])
const messages = ref([
  { role: 'assistant', content: '你好!我是基于你上传文档的智能问答助手。\n请先上传文档,然后向我提问任何问题~' }
])
const sources = ref([])
const stats = ref(null)

// 滚动到底部
const scrollToBottom = async () => {
  await nextTick()
  if (chatContainerRef.value) {
    chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
  }
}

// 触发文件选择
const triggerFileInput = () => {
  fileInputRef.value?.click()
}

// 处理拖拽上传
const handleDrop = async (e) => {
  isDragging.value = false
  const files = Array.from(e.dataTransfer.files)
  await uploadFiles(files)
}

// 处理文件选择
const handleFileSelect = async (e) => {
  const files = Array.from(e.target.files)
  await uploadFiles(files)
  fileInputRef.value.value = ''
}

// 上传文件
const uploadFiles = async (files) => {
  for (const file of files) {
    const formData = new FormData()
    formData.append('file', file)
    
    const fileId = Date.now() + '-' + file.name
    uploadedFiles.value.push({ id: fileId, name: file.name, status: 'processing' })
    
    try {
      const response = await fetch('/api/upload', { method: 'POST', body: formData })
      const result = await response.json()
      
      if (result.success) {
        const idx = uploadedFiles.value.findIndex(f => f.id === fileId)
        if (idx !== -1) {
          uploadedFiles.value[idx].status = 'success'
          uploadedFiles.value[idx].chunkCount = result.chunkCount
        }
        messages.value.push({ role: 'assistant', content: `✅ 文档《${file.name}》已处理完成,共生成 ${result.chunkCount} 个文档块。` })
        await scrollToBottom()
      }
    } catch (error) {
      const idx = uploadedFiles.value.findIndex(f => f.id === fileId)
      if (idx !== -1) uploadedFiles.value[idx].status = 'error'
      messages.value.push({ role: 'assistant', content: `❌ 文档《${file.name}》处理失败:${error.message}` })
      await scrollToBottom()
    }
  }
}

// 提问
const askQuestion = async () => {
  if (!question.value.trim() || isLoading.value) return
  
  const userQuestion = question.value.trim()
  messages.value.push({ role: 'user', content: userQuestion })
  question.value = ''
  isLoading.value = true
  await scrollToBottom()
  
  try {
    const response = await fetch('/api/ask', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ question: userQuestion })
    })
    
    const result = await response.json()
    
    if (result.error) {
      messages.value.push({ role: 'assistant', content: `❌ 出错了:${result.error}` })
    } else {
      messages.value.push({ role: 'assistant', content: result.answer })
      sources.value = result.sources || []
      stats.value = result.stats
    }
    await scrollToBottom()
  } catch (error) {
    messages.value.push({ role: 'assistant', content: `❌ 网络错误:${error.message}` })
    await scrollToBottom()
  } finally {
    isLoading.value = false
  }
}
</script>

<style scoped>
.loading-spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #e2e8f0;
  border-top-color: #8b5cf6;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.message {
  margin-bottom: 1rem;
}
</style>

2.6 入口文件配置

javascript 复制代码
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'

createApp(App).mount('#app')
css 复制代码
/* src/style.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}

2.7 Tailwind 配置

javascript 复制代码
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{vue,js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

2.8 入口 HTML

html 复制代码
<!-- index.html -->
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>本地 RAG 问答系统</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

第三步:系统部署与使用教程

3.1 package.json 脚本配置

json 复制代码
{
  "name": "local-rag-system",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "dev:frontend": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@langchain/community": "^0.3.0",
    "@langchain/core": "^0.3.0",
    "@langchain/openai": "^0.3.0",
    "@langchain/textsplitters": "^0.1.0",
    "chromadb": "^1.8.0",
    "cors": "^2.8.5",
    "dotenv": "^16.4.5",
    "express": "^4.21.0",
    "multer": "^1.4.5-lts.1",
    "vue": "^3.5.13"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2.1",
    "autoprefixer": "^10.4.20",
    "nodemon": "^3.1.7",
    "postcss": "^8.4.47",
    "tailwindcss": "^3.4.13",
    "vite": "^5.4.8"
  }
}

3.2 启动步骤

bash 复制代码
# 1. 启动 Chroma 向量数据库
docker start chromadb
# 或首次启动
docker run -d -p 8001:8000 --name chromadb chromadb/chroma

# 2. 安装依赖(首次运行)
npm install

# 3. 配置环境变量
# 编辑 .env 填入阿里云百炼 API Key

# 4. 启动后端服务(终端1)
npm run dev

# 5. 启动前端开发服务器(终端2)
npm run dev:frontend

# 6. 访问前端页面
# 打开浏览器访问 http://localhost:5173

第四步:效果演示与测试

测试流程

步骤 操作 预期结果
1 启动 Docker Chroma 服务正常启动,端口 8001 可访问
2 启动后端服务 控制台显示服务启动成功
3 启动前端 Vite 显示 http://localhost:5173
4 访问前端页面 看到完整的 Vue3.5 UI 界面
5 上传测试文档 显示处理进度,文档块数量
6 提问测试 AI 基于文档内容回答,展示来源

测试用例

text 复制代码
📄 测试文档内容(test.md):
RAG(检索增强生成)是一种结合检索和生成的技术方案。
它可以有效解决大模型的幻觉问题,让回答更加准确可靠。

❓ 提问:"什么是 RAG?"

✅ 预期回答:
RAG(检索增强生成)是一种结合检索和生成的技术方案,
可以有效解决大模型的幻觉问题,让回答更加准确可靠。
(来源:test.md)

第五步:项目优化方向

优化方向概览

优化方向 当前状态 优化目标 实施方式
检索精度 基础向量检索 +20% 混合检索、重排序
响应速度 2-4秒 <1.5秒 缓存、连接池
界面体验 基础功能 流式输出 SSE/WebSocket
文档支持 TXT/MD/CSV +PDF/Word 新增加载器
批量处理 单文档上传 批量并行 Promise.all

完整源码结构

perl 复制代码
local-rag-system/
├── .env                      # 环境变量配置
├── index.html                # 入口 HTML
├── package.json
├── vite.config.js            # Vite 配置
├── tailwind.config.js        # Tailwind 配置
├── postcss.config.js
├── server.js                 # Express 后端服务
├── src/
│   ├── main.js               # Vue 入口
│   ├── App.vue               # Vue3.5 主组件
│   ├── style.css             # 全局样式
│   ├── vectorStore.js        # 向量数据库模块
│   ├── documentProcessor.js  # 文档处理模块
│   └── ragEngine.js          # RAG 问答模块
├── public/
├── uploads/                  # 上传文件临时目录
└── dist/                     # 构建输出目录

结语

通过以上步骤,我们从零到一构建了一个完整的本地 RAG 问答系统。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
小时前端1 小时前
AI 辅助编程真的用对了么? agents, commands, memory, rules, skills 都是干什么的
ai编程·cursor
落日漫游1 小时前
代码报错难排查?借助Gemini快速修复
前端
niconicoC1 小时前
让 Three.js 场景更真实:我用高斯泼溅和 SparkJS 做了一个可交互的 3D Demo
前端·webgl
掘金一周1 小时前
走过路过有没有好心人help一下,我有两个问题需要大家帮忙集思广益下 | 沸点周刊 6.11
人工智能·ai编程·沸点
Darling噜啦啦1 小时前
JavaScript 数组深度解析:从纯函数到二维数组陷阱,一文吃透前端数据结构核心
前端·javascript·数据结构
万少1 小时前
一封邮件,让我重新打开了搁置半年的鸿蒙应用
前端·javascript·后端
wjj不想说话1 小时前
你的小程序活动页,可能已经成了后台配置的杂物间
前端
梦想是准点下班1 小时前
androidStudio打包,我又又又忘了
前端
槑有老呆1 小时前
栈队列链表,三个故事就懂了
前端