《猫猫钓游记》可爱+收集+钓鱼游戏试玩
2026-06-30
2026-07-03 0
本周实战项目是一个完整的本地轻量化 RAG 问答系统,具备以下能力:

| 模块 | 文件 | 职责 |
|---|---|---|
| 前端页面 | src/App.vue | Vue 主组件、文件上传、对话交互 |
| 后端服务 | server.js | Express API 路由、请求处理 |
| 文档处理 | documentProcessor.js | 加载、清洗、分块、向量化 |
| RAG 问答 | ragEngine.js | 检索、提示词构建、生成回答 |
| 向量存储 | vectorStore.js | Chroma 连接、存储、检索 |
# 创建项目文件夹
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 [email protected] @vitejs/plugin-vue vite
npm install -D tailwindcss postcss autoprefixer# 安装开发依赖
npm install -D nodemon# 创建目录结构
mkdir -p public uploads src components
创建 .env 文件:
# .env
PORT=3000# 阿里云百炼配置
BAILIAN_API_KEY=你的API Key
BAILIAN_BASE_URL=# Embedding 模型配置
EMBEDDING_MODEL=text-embedding-v3
EMBEDDING_DIMENSION=1024# LLM 模型配置
LLM_MODEL=qwen-plus
LLM_TEMPERATURE=0.3# Chroma 配置
CHROMA_URL=
CHROMA_COLLECTION=rag_knowledge_base# 文档处理配置
CHUNK_SIZE=800
CHUNK_OVERLAP=120
MAX_FILE_SIZE=10485760 # 10MB
// 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'),
}
}
})
# 使用 Docker 启动 Chroma
docker run -d -p 8001:8000 --name chromadb chromadb/chroma# 验证服务是否正常
curl
# 应返回 {"nanosecond heartbeat": ...}
// 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,
};
// 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-x08x0Bx0Cx0E-x1Fx7F]/g, '');
cleaned = cleaned.replace(/u00A0/g, ' ');
cleaned = cleaned.replace(/[u200B-u200DuFEFF]/g, '');
cleaned = cleaned.replace(/[ t]+/g, ' ');
cleaned = cleaned.replace(/n{3,}/g, 'nn');
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: ["nn", "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 };
// 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("nn");
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 };
// 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 问答系统已启动 ║
║ ║
║ 后端地址: :${PORT} ║
║ 前端地址: ║
║ ║
║ 确保 Chroma 已运行: docker start chromadb ║
╚══════════════════════════════════════════════════════════╝
`);
});
<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>
// 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;
}
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
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>
{
"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"
}
}
# 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. 访问前端页面
# 打开浏览器访问
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 启动 Docker Chroma | 服务正常启动,端口 8001 可访问 |
| 2 | 启动后端服务 | 控制台显示服务启动成功 |
| 3 | 启动前端 Vite | 显示 |
| 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 问答系统。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!