目录
项目概述
什么是 RAG?
RAG(Retrieval-Augmented Generation) 是一种结合了信息检索和生成式 AI 的技术:
- 检索阶段:从知识库中找到与用户问题最相关的文档片段
- 生成阶段:将这些片段作为上下文,让 LLM 生成准确的回答
项目目标
构建一个完整的 RAG 系统 Demo,支持:
- ✅ 上传 PDF 和 Word 文档
- ✅ 自动分块和向量化
- ✅ 向量数据库存储和检索
- ✅ 基于检索结果的智能问答
- ✅ 展示引用来源
技术选型
后端框架
| 组件 | 选择 | 原因 |
|---|---|---|
| Web 框架 | Koa.js | 轻量、中间件模式清晰、异步友好 |
| 向量数据库 | Qdrant | 高性能、支持云端、API 简洁 |
| Embedding 模型 | 阿里云 DashScope text-embedding-v3 | 中文支持好、成本低、维度 1024 |
| LLM | OpenAI gpt-4o-mini | 性能稳定、成本合理 |
| 文档解析 | pdf-parse + mammoth | 支持 PDF 和 Word |
| 文本分块 | LangChain.js RecursiveCharacterTextSplitter | 按语义分块,保留上下文 |
| 文件上传 | @koa/multer | Koa 官方中间件 |
前端
| 组件 | 选择 | 原因 |
|---|---|---|
| 框架 | 原生 HTML/CSS/JS | 无依赖、快速加载、易于部署 |
| 样式 | CSS 变量 + Flexbox | 响应式、易于主题定制 |
架构设计
系统架构图
┌─────────────────────────────────────────────────────────────┐
│ 前端(浏览器) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 上传区域 问答区域 │ │
│ │ [拖拽上传] [输入框] [发送] │ │
│ │ [文档列表] [对话气泡] │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ HTTP
┌─────────────────────────────────────────────────────────────┐
│ Koa.js 后端服务 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 路由层 │ │
│ │ POST /api/upload POST /api/query GET /api/docs │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 业务逻辑层 │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │ │
│ │ │ 文档解析 │ │ 文本分块 │ │ 向量化 │ │ │
│ │ │ (parser) │ │ (splitter) │ │ (embedder)│ │ │
│ │ └─────────────┘ └──────────────┘ └────────────┘ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │ │
│ │ │ 向量存储 │ │ 相似度检索 │ │ LLM 生成 │ │ │
│ │ │ (vectorStore) │ (search) │ │ (llm) │ │ │
│ │ └─────────────┘ └──────────────┘ └────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ ↕ ↕
┌─────────┐ ┌──────────┐ ┌──────────┐
│ 文件系统 │ │ Qdrant │ │ OpenAI │
│ (uploads) │ (向量库) │ │ (LLM) │
└─────────┘ └──────────┘ └──────────┘
数据流
上传流程:
用户选择文件
↓
前端 FormData 上传
↓
后端接收 → 解析文档(PDF/Word)
↓
文本分块(RecursiveCharacterTextSplitter)
↓
批量向量化(阿里云 Embedding API)
↓
存入 Qdrant(带 metadata)
↓
返回成功信息 → 前端刷新文档列表
查询流程:
用户输入问题
↓
前端发送 JSON 请求
↓
后端向量化问题
↓
Qdrant 相似度检索(Top-K)
↓
提取相关文档片段
↓
拼接 Prompt + 上下文
↓
调用 OpenAI LLM
↓
返回回答 + 引用来源
↓
前端展示对话气泡 + 来源标注
环境准备
系统要求
- Node.js: v18+(推荐 v22+)
- npm/pnpm: 最新版本
- Docker: 用于运行 Qdrant(可选,也支持云端 Qdrant)
必需的 API Key
-
阿里云 DashScope API Key
- 访问:dashscope.console.aliyun.com/
- 开通
text-embedding-v3服务 - 获取 API Key
-
OpenAI API Key
- 访问:platform.openai.com/api-keys
- 创建新 Key
- 或使用任何 OpenAI 兼容接口(如国内代理)
-
Qdrant(选择一种)
- 本地:
docker run -p 6333:6333 qdrant/qdrant - 云端:cloud.qdrant.io/(免费试用)
- 本地:
项目初始化
1. 创建项目目录
mkdir rag-demo
cd rag-demo
2. 初始化 package.json
{
"name": "rag-demo",
"version": "1.0.0",
"description": "RAG 资料库 Demo - Koa.js + Qdrant",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "node --watch src/app.js"
},
"dependencies": {
"@koa/multer": "^3.0.2",
"@koa/router": "^12.0.1",
"@qdrant/js-client-rest": "^1.9.0",
"dotenv": "^16.4.5",
"koa": "^2.15.3",
"koa-body": "^6.0.1",
"koa-static": "^5.0.0",
"mammoth": "^1.7.2",
"multer": "^1.4.5-lts.1",
"openai": "^4.47.1",
"pdf-parse": "^1.1.1",
"uuid": "^10.0.0",
"@langchain/textsplitters": "^0.0.3"
}
}
3. 创建目录结构
mkdir -p src/{routes,services,utils} public uploads
4. 创建 .env 配置文件
# ========== LLM 配置(OpenAI 兼容接口)==========
LLM_API_KEY=your_openai_api_key
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o-mini
# ========== Embedding 配置(阿里云 DashScope)==========
EMBEDDING_API_KEY=your_dashscope_api_key
EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
EMBEDDING_MODEL=text-embedding-v3
# ========== Qdrant 向量数据库 ==========
QDRANT_URL=http://localhost:6333
QDRANT_API_KEY=
QDRANT_COLLECTION=rag_docs_v2
# ========== RAG 参数 ==========
CHUNK_SIZE=500
CHUNK_OVERLAP=50
TOP_K=5
VECTOR_SIZE=1024
# ========== 服务 ==========
PORT=3000
核心功能实现
1. 应用入口(src/app.js)
require('dotenv').config();
const Koa = require('koa');
const Router = require('@koa/router');
const serve = require('koa-static');
const { koaBody } = require('koa-body');
const path = require('path');
const fs = require('fs');
const uploadRouter = require('./routes/upload');
const queryRouter = require('./routes/query');
const { ensureCollection } = require('./services/vectorStore');
const app = new Koa();
const router = new Router();
// 确保 uploads 目录存在
const uploadsDir = path.join(__dirname, '../uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// 错误处理中间件
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
console.error('Server Error:', err);
ctx.status = err.status || 500;
ctx.body = { error: err.message || 'Internal Server Error' };
}
});
// 解析 JSON body(必须在路由之前)
app.use(koaBody({ jsonLimit: '10mb' }));
// 静态文件(前端页面)
app.use(serve(path.join(__dirname, '../public')));
// 路由
router.use('/api', uploadRouter.routes(), uploadRouter.allowedMethods());
router.use('/api', queryRouter.routes(), queryRouter.allowedMethods());
app.use(router.routes());
app.use(router.allowedMethods());
const PORT = process.env.PORT || 3000;
// 启动时确保 Qdrant Collection 存在
ensureCollection()
.then(() => {
app.listen(PORT, () => {
console.log(`🚀 RAG Demo 启动成功: http://localhost:${PORT}`);
console.log(`📦 Qdrant: ${process.env.QDRANT_URL}`);
console.log(`🤖 LLM: ${process.env.LLM_MODEL}`);
});
})
.catch((err) => {
console.error('❌ 启动失败,请检查 Qdrant 是否运行:', err.message);
process.exit(1);
});
关键点:
koaBody中间件必须在路由之前加载,否则 POST 请求无法解析- 错误处理中间件捕获所有异常,返回 JSON 错误信息
- 启动时自动创建 Qdrant Collection
2. 文档解析(src/services/parser.js)
const pdfParse = require('pdf-parse');
const mammoth = require('mammoth');
const fs = require('fs');
async function parseDocument(filePath, mimetype) {
const buffer = fs.readFileSync(filePath);
// PDF
if (mimetype === 'application/pdf' || filePath.endsWith('.pdf')) {
const data = await pdfParse(buffer);
return data.text;
}
// Word (.docx / .doc)
if (
mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
mimetype === 'application/msword' ||
filePath.endsWith('.docx') ||
filePath.endsWith('.doc')
) {
const result = await mammoth.extractRawText({ buffer });
return result.value;
}
throw new Error(`不支持的文件格式: ${mimetype}`);
}
module.exports = { parseDocument };
支持的格式:
- PDF:使用
pdf-parse提取文本 - Word:使用
mammoth提取文本(支持 .docx 和 .doc)
3. 文本分块(src/utils/splitter.js)
const { RecursiveCharacterTextSplitter } = require('@langchain/textsplitters');
const CHUNK_SIZE = parseInt(process.env.CHUNK_SIZE) || 500;
const CHUNK_OVERLAP = parseInt(process.env.CHUNK_OVERLAP) || 50;
async function splitText(text) {
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: CHUNK_SIZE,
chunkOverlap: CHUNK_OVERLAP,
});
const chunks = await splitter.splitText(text);
return chunks;
}
module.exports = { splitText };
分块策略:
chunkSize=500:每个块约 500 个字符chunkOverlap=50:块之间重叠 50 个字符,保留上下文- 使用
RecursiveCharacterTextSplitter按语义边界分块(段落、句子)
4. 向量化(src/services/embedder.js)
const OpenAI = require('openai');
let client = null;
function getClient() {
if (!client) {
client = new OpenAI({
apiKey: process.env.EMBEDDING_API_KEY,
baseURL: process.env.EMBEDDING_BASE_URL || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
});
}
return client;
}
async function embed(input) {
const openai = getClient();
const model = process.env.EMBEDDING_MODEL || 'text-embedding-v3';
const isArray = Array.isArray(input);
const inputs = isArray ? input : [input];
// 过滤空字符串
const filtered = inputs.filter((t) => t && t.trim().length > 0);
if (filtered.length === 0) {
throw new Error('所有文本块为空,无法生成向量');
}
const response = await openai.embeddings.create({
model,
input: filtered,
});
if (!response || !response.data || response.data.length === 0) {
throw new Error('Embedding API 返回空结果,请检查 EMBEDDING_API_KEY 是否正确');
}
const vectors = response.data.map((item) => item.embedding);
// 处理空字符串填充
if (filtered.length < inputs.length) {
const result = [];
let vecIdx = 0;
for (const t of inputs) {
if (t && t.trim().length > 0) {
result.push(vectors[vecIdx++]);
} else {
result.push(new Array(vectors[0].length).fill(0));
}
}
return isArray ? result : result[0];
}
return isArray ? vectors : vectors[0];
}
module.exports = { embed };
关键特性:
- 支持单个文本或批量文本向量化
- 过滤空字符串,避免 API 调用浪费
- 错误处理完善,提示 API Key 问题
5. 向量存储(src/services/vectorStore.js)
const { QdrantClient } = require('@qdrant/js-client-rest');
let client = null;
function getClient() {
if (!client) {
client = new QdrantClient({
url: process.env.QDRANT_URL || 'http://localhost:6333',
apiKey: process.env.QDRANT_API_KEY || undefined,
});
}
return client;
}
const COLLECTION = () => process.env.QDRANT_COLLECTION || 'rag_docs_v2';
const VECTOR_SIZE = parseInt(process.env.VECTOR_SIZE) || 1024;
async function ensureCollection() {
const client = getClient();
const name = COLLECTION();
try {
await client.getCollection(name);
console.log(`✅ Qdrant Collection "${name}" 已存在`);
} catch (e) {
await client.createCollection(name, {
vectors: {
size: VECTOR_SIZE,
distance: 'Cosine',
},
});
console.log(`✅ Qdrant Collection "${name}" 创建成功`);
}
}
async function upsertPoints(points) {
const client = getClient();
await client.upsert(COLLECTION(), {
wait: true,
points: points.map((p) => ({
id: p.id,
vector: p.vector,
payload: p.payload,
})),
});
}
async function search(vector, topK) {
const client = getClient();
const k = topK || parseInt(process.env.TOP_K) || 5;
const results = await client.search(COLLECTION(), {
vector,
limit: k,
with_payload: true,
});
return results;
}
async function listDocuments() {
const client = getClient();
const result = await client.scroll(COLLECTION(), {
limit: 1000,
with_payload: true,
with_vector: false,
});
const docsMap = new Map();
for (const point of result.points) {
const { docId, filename, uploadedAt, totalChunks } = point.payload;
if (!docsMap.has(docId)) {
docsMap.set(docId, { docId, filename, uploadedAt, totalChunks });
}
}
return Array.from(docsMap.values()).sort(
(a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt)
);
}
module.exports = { ensureCollection, upsertPoints, search, listDocuments };
关键设计:
ensureCollection:启动时自动创建 Collection,支持幂等操作upsertPoints:批量插入向量点,带 metadatasearch:相似度检索,返回 Top-K 结果listDocuments:去重获取已索引文档列表
6. LLM 生成(src/services/llm.js)
const OpenAI = require('openai');
let client = null;
function getClient() {
if (!client) {
client = new OpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: process.env.LLM_BASE_URL || 'https://api.openai.com/v1',
});
}
return client;
}
async function generateAnswer(question, contexts) {
const openai = getClient();
const model = process.env.LLM_MODEL || 'gpt-4o-mini';
const contextText = contexts
.map((c, i) => `[来源 ${i + 1}: ${c.filename}]\n${c.text}`)
.join('\n\n---\n\n');
const systemPrompt = `你是一个专业的知识库问答助手。请根据以下提供的参考资料回答用户的问题。
规则:
1. 只根据提供的参考资料回答,不要编造信息
2. 如果参考资料中没有相关信息,请明确告知用户
3. 回答要简洁、准确、有条理
4. 可以适当引用来源文件名
参考资料:
${contextText}`;
const response = await openai.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
],
temperature: 0.3,
});
return response.choices[0].message.content;
}
module.exports = { generateAnswer };
Prompt 设计:
- System Prompt 明确告诉 LLM 只基于提供的资料回答
- 温度设置为 0.3,保证回答的一致性和准确性
- 在 Prompt 中标注来源,便于追溯
7. 上传路由(src/routes/upload.js)
const Router = require('@koa/router');
const multer = require('@koa/multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { parseDocument } = require('../services/parser');
const { splitText } = require('../utils/splitter');
const { embed } = require('../services/embedder');
const { upsertPoints, listDocuments } = require('../services/vectorStore');
const router = new Router();
// 修复中文文件名乱码
function fixFilename(filename) {
try {
if (!/[^\x00-\x7F]/.test(filename) || /[\u4e00-\u9fa5]/.test(filename)) {
return filename;
}
return Buffer.from(filename, 'latin1').toString('utf8');
} catch (e) {
return filename;
}
}
const upload = multer({
storage: multer.diskStorage({
destination: path.join(__dirname, '../../uploads'),
filename: (req, file, cb) => {
const fixedName = fixFilename(file.originalname);
const uniqueName = `${uuidv4()}_${fixedName}`;
cb(null, uniqueName);
},
}),
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const fixedName = fixFilename(file.originalname);
file.originalname = fixedName;
const allowed = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
];
const allowedExt = ['.pdf', '.docx', '.doc'];
const ext = path.extname(fixedName).toLowerCase();
if (allowed.includes(file.mimetype) || allowedExt.includes(ext)) {
cb(null, true);
} else {
cb(new Error('只支持 PDF 和 Word 文档'), false);
}
},
});
router.post('/upload', upload.single('file'), async (ctx) => {
if (!ctx.file) {
ctx.status = 400;
ctx.body = { error: '请上传文件' };
return;
}
const { filename, path: filePath, mimetype, originalname } = ctx.file;
const docId = path.basename(filename, path.extname(filename)).split('_')[0];
console.log(`📄 开始处理文档: ${originalname} (${docId})`);
// 1. 解析文档
let text;
try {
text = await parseDocument(filePath, mimetype);
} catch (err) {
ctx.status = 500;
ctx.body = { error: `文档解析失败: ${err.message}` };
return;
}
if (!text || text.trim().length < 10) {
ctx.status = 400;
ctx.body = { error: '文档内容为空或过短,无法索引' };
return;
}
// 2. 文本分块
const chunks = await splitText(text);
console.log(`✂️ 文档分块完成: ${chunks.length} 个 chunk`);
// 3. 批量向量化
let vectors;
try {
vectors = await embed(chunks);
} catch (err) {
ctx.status = 500;
ctx.body = { error: `向量化失败: ${err.message}` };
return;
}
if (!vectors || vectors.length === 0) {
ctx.status = 500;
ctx.body = { error: '向量化返回空结果,请检查 EMBEDDING_API_KEY' };
return;
}
console.log(`🔢 向量化完成: ${vectors.length} 个向量`);
// 4. 存入 Qdrant(使用整数 ID)
let pointIdCounter = Date.now();
const points = chunks.map((text, idx) => ({
id: pointIdCounter + idx,
vector: vectors[idx],
payload: {
docId,
filename: originalname,
chunkIndex: idx,
text,
uploadedAt: new Date().toISOString(),
totalChunks: chunks.length,
},
}));
await upsertPoints(points);
console.log(`✅ 文档已索引: ${originalname}`);
ctx.body = {
success: true,
docId,
filename: originalname,
chunks: chunks.length,
};
});
router.get('/docs', async (ctx) => {
const docs = await listDocuments();
ctx.body = { docs };
});
module.exports = router;
关键处理:
- 中文文件名修复:从 Latin1 转回 UTF-8
- 完整的错误处理链:解析 → 分块 → 向量化 → 存储
- 使用时间戳 + 索引作为 Point ID,避免 UUID 格式问题
8. 查询路由(src/routes/query.js)
const Router = require('@koa/router');
const { embed } = require('../services/embedder');
const { search } = require('../services/vectorStore');
const { generateAnswer } = require('../services/llm');
const router = new Router();
router.post('/query', async (ctx) => {
const { question, topK } = ctx.request.body;
if (!question || question.trim().length === 0) {
ctx.status = 400;
ctx.body = { error: '请输入问题' };
return;
}
const k = topK || parseInt(process.env.TOP_K) || 5;
console.log(`🔍 问题: ${question}`);
// 1. 问题向量化
const questionVector = await embed(question);
// 2. Qdrant 相似度检索
const results = await search(questionVector, k);
if (results.length === 0) {
ctx.body = {
answer: '当前知识库为空,请先上传文档后再提问。',
sources: [],
};
return;
}
// 3. 提取上下文
const contexts = results.map((r) => ({
text: r.payload.text,
filename: r.payload.filename,
chunkIndex: r.payload.chunkIndex,
score: r.score,
}));
console.log(`📚 检索到 ${contexts.length} 条相关文档片段`);
// 4. LLM 生成回答
const answer = await generateAnswer(question, contexts);
ctx.body = {
answer,
sources: contexts,
};
});
module.exports = router;
前端界面
HTML 结构(public/index.html)
前端采用单页应用设计,包含三个主要区域:
-
上传区域
- 拖拽上传或点击选择
- 实时显示上传状态
- 已上传文档列表
-
问答区域
- 对话气泡展示
- 引用来源标注
- 输入框和发送按钮
-
样式设计
- CSS 变量管理主题色
- Flexbox 响应式布局
- 动画效果(加载、过渡)
关键 JavaScript 功能:
// 文件上传处理
async function handleUpload(file) {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API}/upload`, { method: 'POST', body: formData });
// ...
}
// 问答发送
async function sendQuestion() {
const question = questionInput.value.trim();
const res = await fetch(`${API}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
});
// ...
}
问题排查与解决
问题 1:koaBody 中间件未加载
症状:
Cannot destructure property 'question' of 'ctx.request.body' as it is undefined
原因: 忘记在 app.js 中加载 koaBody 中间件
解决方案:
const { koaBody } = require('koa-body');
app.use(koaBody({ jsonLimit: '10mb' }));
关键: 中间件必须在路由之前加载
问题 2:向量维度不匹配
症状:
Vector dimension error: expected dim: 1536, got 1024
原因:
- 旧 Collection 用 OpenAI Embedding(1536 维)创建
- 新代码用阿里云 Embedding(1024 维)
解决方案:
- 删除旧 Collection:在 Qdrant Dashboard 中删除
- 改用新 Collection 名字:
QDRANT_COLLECTION=rag_docs_v2 - 重启服务自动创建新 Collection
问题 3:Point ID 格式错误
症状:
value 966887ce-746d-4143-8d47-5cbd2076f271_0 is not a valid point ID
原因: Qdrant 要求 Point ID 是整数或标准 UUID,不支持自定义字符串
解决方案:
// ❌ 错误
id: `${docId}_${idx}`
// ✅ 正确
id: Date.now() + idx // 整数 ID
问题 4:中文文件名乱码
症状:
文件名显示为:䏿–‡ (乱码)
原因: multer 默认用 Latin1 编码解析文件名
解决方案:
function fixFilename(filename) {
try {
if (!/[^\x00-\x7F]/.test(filename) || /[\u4e00-\u9fa5]/.test(filename)) {
return filename;
}
return Buffer.from(filename, 'latin1').toString('utf8');
} catch (e) {
return filename;
}
}
问题 5:fetch 请求头拼写错误
症状:
ctx.request.body 为 undefined
原因: 前端 fetch 用了 header 而不是 headers
解决方案:
// ❌ 错误
fetch(url, { header: { 'Content-Type': 'application/json' } })
// ✅ 正确
fetch(url, { headers: { 'Content-Type': 'application/json' } })
启动与使用
1. 启动 Qdrant
本地 Docker:
docker run -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant
云端: 在 cloud.qdrant.io/ 创建实例,获取 URL 和 API Key
2. 配置 .env
LLM_API_KEY=sk-...
EMBEDDING_API_KEY=sk-...
QDRANT_URL=http://localhost:6333
3. 安装依赖
npm install
4. 启动服务
npm start
输出应该显示:
🚀 RAG Demo 启动成功: http://localhost:3000
📦 Qdrant: http://localhost:6333
🤖 LLM: gpt-4o-mini
5. 打开浏览器
6. 使用流程
-
上传文档
- 拖拽或点击选择 PDF/Word 文件
- 等待索引完成(显示 ✅)
- 文档列表会自动刷新
-
提问
- 在输入框输入问题
- 按 Enter 或点击发送
- 等待 LLM 生成回答
- 查看引用来源
扩展与优化
1. 支持更多文档格式
添加 Markdown 支持:
// src/services/parser.js
if (filePath.endsWith('.md')) {
return fs.readFileSync(filePath, 'utf-8');
}
添加 TXT 支持:
if (filePath.endsWith('.txt')) {
return fs.readFileSync(filePath, 'utf-8');
}
2. 使用本地 Embedding 模型
用 Ollama + nomic-embed-text:
# 启动 Ollama
ollama run nomic-embed-text
// src/services/embedder.js
const response = await fetch('http://localhost:11434/api/embeddings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'nomic-embed-text',
prompt: input,
}),
});
const data = await response.json();
return data.embedding;
3. 多知识库隔离
按用户或项目创建不同 Collection:
const COLLECTION = () => {
const userId = ctx.state.userId; // 从认证中获取
return `rag_docs_${userId}`;
};
4. 添加认证
使用 JWT:
npm install jsonwebtoken
// 中间件
app.use(async (ctx, next) => {
const token = ctx.headers.authorization?.split(' ')[1];
if (!token) {
ctx.status = 401;
ctx.body = { error: 'Unauthorized' };
return;
}
// 验证 token...
await next();
});
5. 添加数据库持久化
用 PostgreSQL 存储元数据:
npm install pg
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// 记录上传历史
await pool.query(
'INSERT INTO uploads (docId, filename, uploadedAt) VALUES ($1, $2, $3)',
[docId, originalname, new Date()]
);
6. 性能优化
批量向量化:
// 一次性向量化所有 chunks,而不是逐个
const vectors = await embed(chunks);
缓存 Qdrant 客户端:
let client = null;
function getClient() {
if (!client) {
client = new QdrantClient(...);
}
return client;
}
异步日志:
// 不阻塞主流程
setImmediate(() => {
console.log(`📄 处理文档: ${originalname}`);
});
7. 监控和日志
添加结构化日志:
npm install winston
const logger = require('winston').createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
logger.info('文档上传', { docId, filename, chunks: chunks.length });
8. 前端增强
添加进度条:
// 显示上传进度
xhr.upload.addEventListener('progress', (e) => {
const percent = (e.loaded / e.total) * 100;
progressBar.style.width = percent + '%';
});
添加搜索历史:
// localStorage 保存查询历史
localStorage.setItem('queries', JSON.stringify(queries));
添加主题切换:
// 深色/浅色模式
document.documentElement.setAttribute('data-theme', 'dark');
总结
这个 RAG Demo 展示了一个完整的从文档上传到智能问答的工作流:
- 文档处理:支持多种格式,自动解析和分块
- 向量化:使用专业 Embedding 模型,支持多厂商
- 存储检索:高性能向量数据库,毫秒级检索
- 智能生成:基于检索结果的准确回答,带来源标注
- 用户界面:简洁直观的前端交互
核心优势:
- ✅ 模块化设计,易于扩展
- ✅ 配置灵活,支持多厂商组合
- ✅ 错误处理完善,生产就绪
- ✅ 中文支持完整,无乱码问题
- ✅ 前后端分离,易于部署