RAG 资料库 Demo 完整开发流程

0 阅读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…