从零开始搭建本地轻量化RAG问答系统指南

2026-06-13阅读 0热度 0
搭建

项目整体架构设计

先说说这次要做什么。本周的实战项目,是一个完整的本地轻量化 RAG 问答系统——说白了,就是在你本地机器上跑一套可以上传文档、然后让 AI 基于这些文档内容来回答你问题的工具。

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

那这个系统到底能做什么?

  • 支持多种格式的文档上传:TXT、MD、PDF、CSV 都行
  • 上传后自动做文档处理:先清洗、再分块、最后向量化
  • 基于向量数据库做语义检索,然后给出回答
  • 前端用 Vue3.5 搭建可视化界面,文件上传和对话交互都在里面

系统架构图

整个系统怎么分工?来看这几个模块就知道了:

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

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

好,开始动手。先从环境搭建说起。

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 前端主组件




<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 问答系统。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策