RAG 资料库 Demo 完整开发流程

260 阅读9分钟

目录

  1. 项目概述
  2. 技术选型
  3. 架构设计
  4. 环境准备
  5. 项目初始化
  6. 核心功能实现
  7. 前端界面
  8. 问题排查与解决
  9. 启动与使用
  10. 扩展与优化

项目概述

什么是 RAG?

RAG(Retrieval-Augmented Generation) 是一种结合了信息检索和生成式 AI 的技术:

  1. 检索阶段:从知识库中找到与用户问题最相关的文档片段
  2. 生成阶段:将这些片段作为上下文,让 LLM 生成准确的回答

项目目标

构建一个完整的 RAG 系统 Demo,支持:

  • ✅ 上传 PDF 和 Word 文档
  • ✅ 自动分块和向量化
  • ✅ 向量数据库存储和检索
  • ✅ 基于检索结果的智能问答
  • ✅ 展示引用来源

技术选型

后端框架

组件选择原因
Web 框架Koa.js轻量、中间件模式清晰、异步友好
向量数据库Qdrant高性能、支持云端、API 简洁
Embedding 模型阿里云 DashScope text-embedding-v3中文支持好、成本低、维度 1024
LLMOpenAI gpt-4o-mini性能稳定、成本合理
文档解析pdf-parse + mammoth支持 PDF 和 Word
文本分块LangChain.js RecursiveCharacterTextSplitter按语义分块,保留上下文
文件上传@koa/multerKoa 官方中间件

前端

组件选择原因
框架原生 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

  1. 阿里云 DashScope API Key

  2. OpenAI API Key

  3. Qdrant(选择一种)


项目初始化

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:批量插入向量点,带 metadata
  • search:相似度检索,返回 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)

前端采用单页应用设计,包含三个主要区域:

  1. 上传区域

    • 拖拽上传或点击选择
    • 实时显示上传状态
    • 已上传文档列表
  2. 问答区域

    • 对话气泡展示
    • 引用来源标注
    • 输入框和发送按钮
  3. 样式设计

    • 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 维)

解决方案:

  1. 删除旧 Collection:在 Qdrant Dashboard 中删除
  2. 改用新 Collection 名字:QDRANT_COLLECTION=rag_docs_v2
  3. 重启服务自动创建新 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.bodyundefined

原因: 前端 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. 打开浏览器

访问 http://localhost:3000

6. 使用流程

  1. 上传文档

    • 拖拽或点击选择 PDF/Word 文件
    • 等待索引完成(显示 ✅)
    • 文档列表会自动刷新
  2. 提问

    • 在输入框输入问题
    • 按 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 展示了一个完整的从文档上传到智能问答的工作流:

  1. 文档处理:支持多种格式,自动解析和分块
  2. 向量化:使用专业 Embedding 模型,支持多厂商
  3. 存储检索:高性能向量数据库,毫秒级检索
  4. 智能生成:基于检索结果的准确回答,带来源标注
  5. 用户界面:简洁直观的前端交互

核心优势:

  • ✅ 模块化设计,易于扩展
  • ✅ 配置灵活,支持多厂商组合
  • ✅ 错误处理完善,生产就绪
  • ✅ 中文支持完整,无乱码问题
  • ✅ 前后端分离,易于部署

示例代码:

nanmu1.lanzoul.com/iyFoC3lbv0k…