项目整体架构设计
项目概述
本周实战项目是一个完整的本地轻量化 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 问答系统。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!