从零开始搭建本地轻量化RAG问答系统指南
项目整体架构设计
先说说这次要做什么。本周的实战项目,是一个完整的本地轻量化 RAG 问答系统——说白了,就是在你本地机器上跑一套可以上传文档、然后让 AI 基于这些文档内容来回答你问题的工具。
那这个系统到底能做什么?
- 支持多种格式的文档上传:TXT、MD、PDF、CSV 都行
- 上传后自动做文档处理:先清洗、再分块、最后向量化
- 基于向量数据库做语义检索,然后给出回答
- 前端用 Vue3.5 搭建可视化界面,文件上传和对话交互都在里面
系统架构图
整个系统怎么分工?来看这几个模块就知道了:
| 模块 | 文件 | 职责 |
|---|---|---|
| 前端页面 | src/App.vue | Vue 主组件、文件上传、对话交互 |
| 后端服务 | server.js | Express API 路由、请求处理 |
| 文档处理 | documentProcessor.js | 加载、清洗、分块、向量化 |
| RAG 问答 | ragEngine.js | 检索、提示词构建、生成回答 |
| 向量存储 | vectorStore.js | Chroma 连接、存储、检索 |
第一步:环境准备与项目初始化
好,开始动手。先从环境搭建说起。
1.1 创建项目
# 创建项目文件夹
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 文件,里面放那些不能写死在代码里的配置:
# .env
PORT=3000
# 阿里云百炼配置
BAILIAN_API_KEY=你的API Key
BAILIAN_BASE_URL=你的Base URL
# 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 配置
// 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 向量数据库
# 使用 Docker 启动 Chroma
docker run -d -p 8001:8000 --name chromadb chromadb/chroma
# 验证服务是否正常
curl http://localhost:8001/api/v1/heartbeat
# 应返回 {"nanosecond heartbeat": ...}
第二步:核心模块代码实现
环境搞定,接下来就是真正的编码环节了。整个系统的灵魂,其实就藏在这几个模块里。
2.1 向量存储模块
// 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 文档处理模块
// 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 问答模块
// 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 后端服务
// 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 前端主组件
???? 本地 RAG 问答系统
上传文档,让 AI 基于你的知识库回答问题
???? 文档管理
????
点击或拖拽上传文档
支持 TXT、MD、CSV、JSON 格式
暂无上传文件
???? {{ file.name }}
{{ file.status === 'processing' ? '⏳ 处理中' : file.status === 'success' ? `✅ ${file.chunkCount}块` : '❌ 失败' }}
???? 智能问答
{{ msg.content }}
???? 回答来源
提问后,回答的参考来源将显示在这里
???? {{ source.source }}
{{ source.content }}
???? 检索统计:召回 {{ stats.retrievedCount }} 个文档块 → 使用 {{ stats.usedCount }} 个 | ⏱️ 耗时 {{ stats.elapsedMs }}ms
<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>
2.6 入口文件配置
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
/* 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 配置
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
2.8 入口 HTML
本地 RAG 问答系统
<script type="module" src="/src/main.js"></script>
第三步:系统部署与使用教程
3.1 package.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 启动步骤
# 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 基于文档内容回答,展示来源 |
测试用例
???? 测试文档内容(test.md):
RAG(检索增强生成)是一种结合检索和生成的技术方案。
它可以有效解决大模型的幻觉问题,让回答更加准确可靠。
❓ 提问:"什么是 RAG?"
✅ 预期回答:
RAG(检索增强生成)是一种结合检索和生成的技术方案,
可以有效解决大模型的幻觉问题,让回答更加准确可靠。
(来源:test.md)
第五步:项目优化方向
优化方向概览
| 优化方向 | 当前状态 | 优化目标 | 实施方式 |
|---|---|---|---|
| 检索精度 | 基础向量检索 | +20% | 混合检索、重排序 |
| 响应速度 | 2-4秒 | <1.5秒 | 缓存、连接池 |
| 界面体验 | 基础功能 | 流式输出 | SSE/WebSocket |
| 文档支持 | TXT/MD/CSV | +PDF/Word | 新增加载器 |
| 批量处理 | 单文档上传 | 批量并行 | Promise.all |
完整源码结构
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 问答系统。
