cursor实现智能助手 RAG 前后端 实现逻辑

3 阅读4分钟

AI Copilot 项目 RAG 功能实现详解

一、整体架构概览

本项目 RAG(检索增强生成)功能实现了从前端上传文档后端分块存储向量/BM25 检索增强生成回答的完整链路。

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│  前端文件上传   │ →  │  后端接收处理     │ →  │  智能分块 + 存储   │ →  │  检索 + 生成       │
│  ChatSidebar    │    │  POST /api/rag   │    │  rag.js         │    │  /api/chat/stream│
│  App.vue        │    │  index.js:317   │    │  db.js:40-49    │    │  index.js:334   │
└─────────────────┘    └──────────────────┘    └─────────────────┘    └─────────────────┘
                                                                       ↓
                                                              ┌─────────────────┐
                                                              │  SQLite 数据库   │
                                                              │  rag_chunks 表  │
                                                              └─────────────────┘

二、详细实现流程(逐行代码级)

步骤 1:前端 - 选择文件

📍 文件位置: web/src/App.vue
📍 代码行数: 第 148-161 行(模板部分)、第 906-910 行(逻辑部分)

1.1 UI 展示(第 148-161 行)

``vue

知识库文件
    <!-- 上传确认按钮 -->
    <el-button
      type="primary"
      size="small"
      :disabled="!hasSelectedRagFile || uploadingFile"
      :loading="uploadingFile"
      @click="uploadRagFile"
    >
      {{ uploadingFile ? "上传中..." : "上传知识库文件" }}
    </el-button>
  </div>
</div>

<!-- 文件名和状态展示 -->
<div v-if="selectedFileName || ragStatusText" class="rag-kb-meta">
  <span v-if="selectedFileName" class="rag-kb-file-name">
    已选:{{ selectedFileName }}
  </span>
  <el-text v-if="ragStatusText" size="small" type="primary" class="rag-kb-status">
    {{ ragStatusText }}
  </el-text>
</div>

上传 .txt / .md 至后端知识库(持久化存储),勾选「启用 RAG」后参与检索。

```

关键点说明:

  • accept=".txt,.md,text/plain,text/markdown":限制只能上传文本和 Markdown 文件
  • :auto-upload="false":选择文件后不自动上传,需点击"上传知识库文件"按钮才触发
  • @change="onRagUploadChange":文件选择变化时触发事件处理
1.2 文件选择处理(第 906-910 行)

``javascript // web/src/App.vue 第 906-910 行 function onRagUploadChange(uploadFile: UploadFile) { const raw = uploadFile.raw; if (!raw) return; onFileChange({ target: { files: [raw] } }); }


**作用:** Element Plus  Upload 组件包装了原生 File 对象,这里提取 `raw` 属性(原生 File 对象)并传递给 `onFileChange` 函数。

---

### 步骤 2:前端 - 读取文件并发送到后端

**📍 文件位置:** `web/src/App.vue`  
**📍 代码行数:**  912-937 

// web/src/App.vue 第 912-937 行 async function uploadRagFile() { if (!selectedFile.value || uploadingFile.value) return; uploadingFile.value = true; ragStatusText.value = "";

try { // 1️⃣ 使用浏览器原生 API 读取文件内容为文本 const content = await selectedFile.value.text();

// 2️⃣ 发送 POST 请求到后端 RAG 上传接口
const response = await fetch("/api/rag/upload", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    title: selectedFile.value.name,  // 文件名作为标题
    content,                          // 文件全文内容
  }),
});

// 3️⃣ 处理响应
const data = await response.json();
if (!response.ok) {
  ragStatusText.value = `上传失败:${data?.error || "未知错误"}`;
  return;
}

// 4️⃣ 显示上传成功信息
ragStatusText.value = `上传成功:${data.title},切分 ${data.chunkCount} 段(总段数 ${data.totalChunks})`;

} catch (error) { ragStatusText.value = 上传异常:${String(error)}; } finally { uploadingFile.value = false; } }


**关键步骤解析:**

| 步骤 | 代码 | 说明 |
|------|------|------|
| 1️⃣ 读取文件 | `await selectedFile.value.text()` | 使用浏览器 File API`text()` 方法将文件内容读为字符串 |
| 2️⃣ 发送请求 | `fetch("/api/rag/upload", ...)` | POST 请求,JSON 格式,包含 `title``content` 两个字段 |
| 3️⃣ 处理响应 | `const data = await response.json()` | 解析后端返回的 JSON,检查 HTTP 状态码判断是否成功 |
| 4️⃣ 状态反馈 | `ragStatusText.value = ...` | 在界面上显示上传结果(成功时分段数量) |

**请求体示例:**

{ "title": "产品文档.md", "content": "这里是文件的完整内容...\n包含多行文本..." }


---

### 步骤 3:后端 - 接收上传请求

**📍 文件位置:** `server/index.js`  
**📍 代码行数:**  317-332 

// server/index.js 第 317-332 行 app.post("/api/rag/upload", async (req, res) => { try { // 1️⃣ 从请求体中提取 title 和 content const { title = "", content = "" } = req.body || {};

// 2️⃣ 参数校验:content 不能为空
if (!String(content).trim()) {
  return res.status(400).json({ error: "content is required" });
}

// 3️⃣ 调用 rag.js 中的 ingestUpload 函数进行实际处理
const result = await ingestUpload({ title, content });

// 4️⃣ 返回处理结果给前端
res.json({
  ok: true,
  docId: result.docId,           // 文档 ID(内部使用)
  title: result.title,           // 文档标题
  chunkCount: result.chunkCount, // 本次切分的块数
  totalChunks: result.totalChunks, // 累计总块数
});

} catch (error) { res.status(500).json({ error: String(error) }); } });


**关键处理逻辑:**

| 行号 | 操作 | 说明 |
|------|------|------|
| 319 | 解构参数 | 从 `req.body` 中提取 `title``content` |
| 322-324 | 参数校验 | 确保 `content` 不为空,否则返回 400 错误 |
| 327 | 调用核心函数 | `ingestUpload({ title, content })` 执行分块和存储 |
| 330-336 | 返回结果 | 向前端返回分段信息等元数据 |

---

### 步骤 4:后端 - 智能分块处理

**📍 文件位置:** `server/rag.js`  
**📍 代码行数:** 第 272-301 行(`ingestUpload` 函数)

#### 4.1 主入口函数

// server/rag.js 第 272-301 行 export async function ingestUpload({ title = "", content = "" }) { // 1️⃣ 生成新的文档 ID const docId = ragDocId++;

// 2️⃣ 处理标题:去除首尾空格,若无标题则用 docId 生成默认名 const safeTitle = String(title).trim() || doc-${docId}.txt;

// 3️⃣ 调用智能分块函数 const chunks = splitIntoChunksSmart(content);

const now = Date.now();

// 4️⃣ 将每个分块写入数据库 chunks.forEach((text, idx) => { const chunkId = ${docId}-${idx + 1}; insertChunkStmt.run({ chunk_id: chunkId, doc_id: docId, chunk_index: idx + 1, title: safeTitle, text, embedding_json: null, // 初始为空,后续可选生成向量 created_at: now, });

// 5️⃣ 同时写入内存索引(加速检索)
ragChunks.push({
  id: chunkId,
  docId,
  title: safeTitle,
  chunkIndex: idx + 1,
  text,
  embedding: null,
});

});

// 6️⃣ 若启用了向量嵌入且配置了 API Key,批量生成 embedding if (RAG_EMBEDDING_ENABLED && OPENAI_API_KEY && chunks.length) { await embedAndStoreForDoc(docId, chunks); }

// 7️⃣ 返回统计信息 return { docId, title: safeTitle, chunkCount: chunks.length, totalChunks: ragChunks.length, }; }


#### 4.2 智能分块算法

**📍 文件位置:** `server/rag.js`  
**📍 代码行数:** 第 75-104 行

// server/rag.js 第 75-104 行 export function splitIntoChunksSmart(text) { // 1️⃣ 标准化换行符 const clean = String(text || "").replace(/\r\n/g, "\n").trim(); if (!clean) return [];

// 2️⃣ 按空行拆分成段落(保留自然段结构) const rawBlocks = clean.split(/\n\s*\n/u).map((p) => p.trim()).filter(Boolean);

const blocks = [];

// 3️⃣ 处理过长段落:滑窗切割 for (const block of rawBlocks) { if (block.length <= CHUNK_SIZE) { // 不超过阈值,直接保留 blocks.push(block); } else { // 超过阈值,使用滑窗切成多段 let start = 0; while (start < block.length) { const end = Math.min(start + CHUNK_SIZE, block.length); blocks.push(block.slice(start, end)); if (end === block.length) break; // 重叠部分:避免语义断裂 start = end - CHUNK_OVERLAP; } } }

// 4️⃣ 合并短段落:避免碎片化 const merged = []; let buf = ""; for (const piece of blocks) { if (!buf) { buf = piece; continue; } // 如果合并后不超过阈值,就合并 if (buf.length + 1 + piece.length <= CHUNK_SIZE) { buf = ${buf}\n${piece}; } else { // 否则先保存当前缓冲,开启新缓冲 merged.push(buf); buf = piece; } } if (buf) merged.push(buf);

return merged; }


**分块策略详解:**

| 阶段 | 策略 | 目的 | 示意图 |
|------|------|------|--------|
| **段落拆分** | 按 `\n\n` 分割 | 保持自然段落结构 | `段落 1\n\n段落 2` → `["段落 1", "段落 2"]` |
| **滑窗切割** | 超长段落滑动窗口 | 防止单块过大,保持上下文连续性 | `[█████][███░░][░░███]`(重叠部分保证连贯) |
| **短段合并** | 相邻小块合并 | 减少碎片,提高检索质量 | `["小 1", "小 2"]` → `["小 1\n 小 2"]` |

**配置参数(来自 `.env`):**
```javascript
const CHUNK_SIZE = Number(process.env.RAG_CHUNK_SIZE) || 480;        // 默认每块 480 字符
const CHUNK_OVERLAP = Number(process.env.RAG_CHUNK_OVERLAP) || 90;    // 重叠 90 字符

步骤 5:后端 - 存储到 SQLite 数据库

📍 文件位置: server/db.js
📍 代码行数: 第 40-49 行(表定义)

5.1 数据库表结构
// server/db.js 第 40-49CREATE TABLE IF NOT EXISTS rag_chunks (
  chunk_id TEXT PRIMARY KEY,       -- 分块唯一标识:"docId-序号"
  doc_id INTEGER NOT NULL,         -- 文档 ID(同一文档的所有分块共享此 ID)
  chunk_index INTEGER NOT NULL,    -- 分块在文档中的顺序号(从 1 开始)
  title TEXT NOT NULL,             -- 文档标题(文件名)
  text TEXT NOT NULL,              -- 分块的文本内容
  embedding_json TEXT,             -- 向量嵌入的 JSON 表示(可选,用于语义检索)
  created_at INTEGER NOT NULL      -- 创建时间戳
);

-- 为文档 ID 创建索引,加速按文档查询
CREATE INDEX IF NOT EXISTS idx_rag_chunks_doc ON rag_chunks(doc_id);

字段说明:

字段名类型作用示例值
chunk_idTEXT主键,唯一标识一个分块"5-3"(第 5 号文档的第 3 块)
doc_idINTEGER文档归属 ID5
chunk_indexINTEGER分块顺序3
titleTEXT原始文件名"产品手册.md"
textTEXT实际文本内容"第一章:产品介绍..."
embedding_jsonTEXT向量嵌入(JSON 数组)"[0.012, -0.045, ...]"
created_atINTEGER时间戳1711234567890
5.2 插入操作(Prepared Statement)

📍 文件位置: server/rag.js
📍 代码行数: 第 268-270 行

// server/rag.js 第 268-270 行
const insertChunkStmt = db.prepare(
  `INSERT INTO rag_chunks (chunk_id, doc_id, chunk_index, title, text, embedding_json, created_at)
   VALUES (@chunk_id, @doc_id, @chunk_index, @title, @text, @embedding_json, @created_at)`,
);

ingestUpload 中调用(第 283-293 行):

chunks.forEach((text, idx) => {
  const chunkId = `${docId}-${idx + 1}`;
  insertChunkStmt.run({
    chunk_id: chunkId,
    doc_id: docId,
    chunk_index: idx + 1,
    title: safeTitle,
    text,
    embedding_json: null,
    created_at: now,
  });
  // ...
});

优势:

  • 预编译语句:提高性能,避免 SQL 注入
  • 事务安全:better-sqlite3 支持批量插入时自动事务
  • 同步操作:SQLite 是嵌入式数据库,无需异步等待

步骤 6:可选 - 生成向量嵌入(Embedding)

📍 文件位置: server/rag.js
📍 代码行数: 第 223-238 行(批量生成)、第 196-220 行(单个生成)

6.1 批量生成 Embedding
// server/rag.js 第 223-238 行
async function embedAndStoreForDoc(docId, texts) {
  // 1️⃣ 批量调用 OpenAI Embedding API
  const embeddings = await fetchEmbeddingsBatch(texts);
  if (!embeddings || embeddings.length !== texts.length) return;
  
  // 2️⃣ 准备更新语句
  const upd = db.prepare(
    `UPDATE rag_chunks SET embedding_json = ? WHERE chunk_id = ?`,
  );
  
  // 3️⃣ 逐个写入数据库和内存
  for (let i = 0; i < texts.length; i++) {
    const chunkId = `${docId}-${i + 1}`;
    const json = JSON.stringify(embeddings[i]);
    
    // 更新数据库
    upd.run(json, chunkId);
    
    // 更新内存索引
    const item = ragChunks.find((x) => x.id === chunkId);
    if (item) item.embedding = embeddings[i];
  }
}
6.2 调用 OpenAI Embedding API
// server/rag.js 第 207-221 行
async function fetchEmbeddingsBatch(texts) {
  if (!OPENAI_API_KEY || !texts.length) return null;
  
  const res = await fetch(`${OPENAI_BASE_URL}/embeddings`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: EMBEDDING_MODEL,  // 默认:"text-embedding-3-small"
      input: texts.map((t) => String(t).slice(0, 8000)),  // 限制单块长度
    }),
  });
  
  if (!res.ok) {
    console.warn("[RAG] batch embedding 失败", await res.text());
    return null;
  }
  
  const data = await res.json();
  const list = data?.data || [];
  list.sort((x, y) => x.index - y.index);  // 保持顺序
  
  return list.map((x) => x.embedding);
}

触发条件(第 298-300 行):

if (RAG_EMBEDDING_ENABLED && OPENAI_API_KEY && chunks.length) {
  await embedAndStoreForDoc(docId, chunks);
}

环境变量配置:

# .env 文件
RAG_EMBEDDING=1                    # 启用向量嵌入
OPENAI_API_KEY=sk-xxxxx            # 必须配置
RAG_EMBEDDING_MODEL=text-embedding-3-small

Embedding 的作用:

  • 🔹 语义检索:基于向量相似度,而非关键词匹配
  • 🔹 跨词汇匹配:即使 query 和文档用词不同,也能找到相关内容
  • 🔹 回退机制:若向量检索失败或无 embedding,自动降级到 BM25

步骤 7:对话时的 RAG 检索

📍 文件位置: server/index.js
📍 代码行数: 第 367-372 行(聊天接口中的检索逻辑)

7.1 检索触发时机
// server/index.js 第 367-372 行
app.post("/api/chat/stream", async (req, res) => {
  // ...
  const {
    model = DEFAULT_MODEL,
    messages = [],
    systemPrompt = "",
    useRag = false,  // 前端传来的开关
    maxTokens,
  } = req.body || {};

  // ...
  
  // 🔍 找出最后一条用户消息作为检索 query
  const latestUserMessage = [...messages]
    .reverse()
    .find((item) => item?.role === "user" && item?.content)?.content || "";
  
  // 🔍 执行检索(若启用 RAG)
  const sources = useRag ? await searchChunks(latestUserMessage, 3) : [];
  
  // ...
});

检索流程:

步骤代码说明
1️⃣ 提取 QuerylatestUserMessage = ...取对话历史中最后一条用户消息
2️⃣ 判断开关useRag ? ... : []若前端未启用 RAG,返回空数组
3️⃣ 执行检索await searchChunks(..., 3)检索最相关的 3 个片段

步骤 8:检索算法实现

📍 文件位置: server/rag.js
📍 代码行数: 第 241-265 行(searchChunks 函数)

8.1 双模式检索策略
// server/rag.js 第 241-265 行
export async function searchChunks(query, topK = DEFAULT_TOP_K) {
  const k = Math.min(Math.max(Number(topK) || DEFAULT_TOP_K, 1), 20);
  
  // 筛选出有向量的分块
  const withEmb = ragChunks.filter((c) => c.embedding?.length);
  
  // 🔹 模式 1:语义向量检索(优先)
  if (
    RAG_EMBEDDING_ENABLED &&
    OPENAI_API_KEY &&
    withEmb.length > 0
  ) {
    // 1️⃣ 生成 query 的向量
    const qEmb = await fetchEmbeddingSingle(query);
    if (qEmb) {
      // 2️⃣ 计算余弦相似度
      const scored = withEmb.map((c) => ({
        ...c,
        score: cosineSimilarity(qEmb, c.embedding),
      }));
      
      // 3️⃣ 过滤零分并应用阈值
      const filtered = scored.filter((s) => s.score > 0);
      if (filtered.length) {
        return applyTopKRelative(filtered, k).map(toPublicChunk);
      }
    }
  }
  
  // 🔹 模式 2:BM25 关键词检索(回退方案)
  return bm25SearchSync(query, k).map(toPublicChunk);
}
8.2 相对阈值过滤

📍 文件位置: server/rag.js
📍 代码行数: 第 163-168 行

// server/rag.js 第 163-168 行
function applyTopKRelative(scored, topK) {
  if (!scored.length) return [];
  
  // 按分数降序排序
  scored.sort((a, b) => b.score - a.score);
  
  // 取最高分
  const best = scored[0].score;
  
  // 计算绝对阈值(最高分的 22% 作为门槛)
  const minAbs = best * MIN_RELATIVE_SCORE;
  
  // 只保留高于阈值的,并截取前 K 个
  return scored.filter((s) => s.score >= minAbs).slice(0, topK);
}

配置参数:

const MIN_RELATIVE_SCORE = Number(process.env.RAG_MIN_RELATIVE_SCORE) || 0.22;

为什么用相对阈值?

  • 📊 自适应调整:若最高分是 0.9,阈值为 0.198;若最高分仅 0.3,阈值为 0.066
  • 🎯 避免低质结果:即使有很多候选,也只返回真正相关的
  • 🔧 可调参数:通过 .envRAG_MIN_RELATIVE_SCORE 调整灵敏度
8.3 BM25 算法实现

📍 文件位置: server/rag.js
📍 代码行数: 第 106-161 行

// server/rag.js 第 106-161 行(简化版)
function bm25Score(query, chunks) {
  const N = chunks.length;
  if (N === 0) return [];

  // 1️⃣ 对 query 分词(去重)
  const queryTerms = [...new Set(tokenize(query))].filter(Boolean);
  if (!queryTerms.length) return [];

  // 2️⃣ 对每个分块预处理:分词 + 词频统计
  const docs = chunks.map((c) => {
    const terms = tokenize(c.text);
    const tf = new Map();  // 词频映射
    for (const t of terms) {
      tf.set(t, (tf.get(t) || 0) + 1);
    }
    return { chunk: c, tf, len: Math.max(terms.length, 1) };
  });

  // 3️⃣ 计算平均文档长度
  const avgdl = docs.reduce((s, d) => s + d.len, 0) / N;

  // 4️⃣ 计算 IDF(逆文档频率)
  const idfMap = new Map();
  for (const qt of queryTerms) {
    let dfVal = 0;  // 包含该词的文档数
    for (const doc of docs) {
      if (doc.tf.has(qt)) dfVal += 1;
    }
    if (dfVal > 0) {
      idfMap.set(qt, Math.log((N - dfVal + 0.5) / (dfVal + 0.5) + 1));
    }
  }

  // 5️⃣ 计算每个分块的 BM25 分数
  const scored = docs.map((d) => {
    let score = 0;
    for (const qt of queryTerms) {
      const idf = idfMap.get(qt);
      if (idf == null) continue;
      const tf = d.tf.get(qt) || 0;
      if (tf <= 0) continue;
      const denom = tf + BM25_K1 * (1 - BM25_B + BM25_B * (d.len / avgdl));
      score += idf * ((tf * (BM25_K1 + 1)) / denom);
    }
    return { ...d.chunk, score };
  });

  return scored.filter((s) => s.score > 0);
}

BM25 公式参数:

const BM25_K1 = 1.5;  // 控制词频饱和速度
const BM25_B = 0.75;  // 控制文档长度惩罚力度

分词器(支持中英文混合):

// server/rag.js 第 66-73 行
export function tokenize(text) {
  const normalized = String(text).toLowerCase();
  // 提取字母数字序列
  const words = normalized
    .split(/[^\p{L}\p{N}\u4e00-\u9fff]+/u)
    .filter(Boolean);
  // 单独提取中文字符
  const cjkChars = [...normalized].filter((ch) => /[\u4e00-\u9fff]/u.test(ch));
  return [...words, ...cjkChars];
}

步骤 9:构建增强 Prompt

📍 文件位置: server/index.js
📍 代码行数: 第 374-397 行

// server/index.js 第 374-397 行
// 构造发送给模型的消息
const finalMessages = [];

// 1️⃣ 添加系统提示词(若有)
if (systemPrompt?.trim()) {
  finalMessages.push({ role: "system", content: systemPrompt.trim() });
}

if (sources.length > 0) {
  // 2️⃣ 拼接 RAG 上下文
  const ragContext = sources
    .map(
      (item, idx) =>
        `【资料${idx + 1} | ${item.title}#${item.chunkIndex}】\n${item.text}`,
    )
    .join("\n\n");
  
  // 3️⃣ 添加带上下文的系统消息
  finalMessages.push({
    role: "system",
    content:
      "你必须优先根据给定资料回答;若资料不足,请明确说明"资料中未提供完整信息"。\n\n" +
      ragContext,
  });
}

// 4️⃣ 添加用户历史消息
for (const item of messages) {
  if (item?.role && item?.content) {
    finalMessages.push({ role: item.role, content: item.content });
  }
}

最终生成的 finalMessages 结构:

[
  {
    role: "system",
    content: "你是一个专业助手..."  // 用户在 UI 设置的系统提示词
  },
  {
    role: "system",
    content: "你必须优先根据给定资料回答...\n\n【资料 1 | 产品文档.md#1】\n产品特性包括...\n\n【资料 2 | 产品文档.md#2】\n使用方法如下..."
  },
  {
    role: "user",
    content: "这个产品怎么用?"  // 用户最新的问题
  },
  // ... 更多历史对话
]

设计要点:

  • 双重系统消息:第一条定义角色,第二条注入 RAG 上下文和约束
  • 来源标注:每个片段都标明 【资料 X | 文件名#块号】,便于追溯
  • 优先级指令:明确要求模型"优先根据给定资料回答"

步骤 10:调用大模型生成回答

📍 文件位置: server/index.js
📍 代码行数: 第 399-421 行

// server/index.js 第 399-421 行
// 调用 OpenAI 兼容的流式接口
const response = await fetch(`${OPENAI_BASE_URL}/chat/completions`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${OPENAI_API_KEY}`,
  },
  body: JSON.stringify({
    model,
    stream: true,  // 启用流式输出
    messages: finalMessages,
    // RAG 模式下降低温度,提高答案稳定性
    temperature: useRag ? 0.2 : 0.5,
    max_tokens: Number(maxTokens || MAX_TOKENS),  // 默认 400 tokens
  }),
});

关键参数说明:

参数作用
streamtrue启用流式输出,实时返回 token
temperatureRAG 时 0.2,否则 0.5RAG 模式下更确定,减少幻觉
max_tokens默认 400限制单次生成长度,避免超时

步骤 11:流式返回给前端

📍 文件位置: server/index.js
📍 代码行数: 第 423-522 行(流式处理循环)

// server/index.js 第 423-522 行(节选关键部分)
// 设置 SSE 响应头
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");  // 禁用 Nginx 缓冲

// 通知前端:流开始
res.write(`data: ${JSON.stringify({ type: "start" })}\n\n`);

// 若启用 RAG,发送命中状态
if (useRag) {
  res.write(`data: ${JSON.stringify({
    type: "rag_status",
    enabled: true,
    matched: sources.length > 0,
    count: sources.length,
    retrieval: getRagConfigSummary().retrieval,
  })}\n\n`);
}

// 发送引用来源(用于前端展示)
if (sources.length > 0) {
  res.write(`data: ${JSON.stringify({
    type: "sources",
    sources: sources.map((item) => ({
      id: item.id,
      title: item.title,
      chunkIndex: item.chunkIndex,
      text: item.text,
      score: typeof item.score === "number" ? item.score : undefined,
    })),
  })}\n\n`);
}

// 逐块读取上游返回
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let outputTokens = 0;

while (true) {
  const { value, done } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split("\n");
  buffer = lines.pop() || "";

  for (const rawLine of lines) {
    const line = rawLine.trim();
    if (!line.startsWith("data:")) continue;

    const data = line.slice(5).trim();
    if (data === "[DONE]") {
      // 流结束,记录日志
      recordChatLog({ /* ... */ });
      res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
      res.end();
      return;
    }

    try {
      const json = JSON.parse(data);
      const token = json?.choices?.[0]?.delta?.content || "";
      if (token) {
        outputTokens += 1;
        res.write(`data: ${JSON.stringify({ type: "token", token })}\n\n`);
      }
    } catch {
      // 忽略解析错误
    }
  }
}

SSE 事件类型:

事件类型数据格式前端用途
start{ type: "start" }显示"模型思考中"状态
rag_status{ type: "rag_status", matched: true, count: 3 }提示用户 RAG 命中情况
sources{ type: "sources", sources: [...] }展示引用来源框
token{ type: "token", token: "字" }逐字追加到回答中
done{ type: "done", outputTokens: 150, elapsed: 3200 }标记生成结束,记录日志

步骤 12:前端接收流式响应

📍 文件位置: web/src/App.vue
📍 代码行数: 第 939-1060 行(sendMessage 函数核心部分)

// web/src/App.vue 第 939-1060 行(节选关键部分)
async function sendMessage() {
  const userText = inputText.value.trim();
  if (!userText || isGenerating.value) return;

  // 1️⃣ 添加用户消息
  messages.value.push({ role: "user", content: userText });
  
  // 2️⃣ 预置空助手消息(后续逐字填充)
  messages.value.push({ role: "assistant", content: "", sources: [] });
  const assistantIndex = messages.value.length - 1;

  inputText.value = "";
  isGenerating.value = true;
  currentAbortController = new AbortController();

  try {
    const response = await fetch(`${getApiBase()}/api/chat/stream`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model: selectedModel.value,
        systemPrompt: systemPrompt.value,
        useRag: useRag.value,
        messages: messages.value.filter((m) => m.role === "user" || m.role === "assistant"),
      }),
      signal: currentAbortController.signal,
    });

    if (!response.ok || !response.body) {
      // 处理请求失败
      return;
    }

    // 3️⃣ 读取 SSE 流
    const reader = response.body.getReader();
    const decoder = new TextDecoder("utf-8");
    let buffer = "";

    while (true) {
      const { value, done } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop() || "";

      for (const rawLine of lines) {
        const line = rawLine.trim();
        if (!line.startsWith("data:")) continue;

        try {
          const event = JSON.parse(line.slice(5).trim()) as SseDataLine;

          // 🔹 处理 token 事件(逐字显示)
          if (event.type === "token" && event.token) {
            appendStreamToken(assistantIndex, event.token);
          }
          
          // 🔹 处理 RAG 状态事件
          if (event.type === "rag_status" && event.enabled) {
            ragMatchHint.value = event.matched
              ? `RAG 已命中 ${event.count} 个片段,本次回答会更偏向你的文档。`
              : "RAG 未命中文档片段,本次回答可能与未开启 RAG 接近。";
          }
          
          // 🔹 处理来源事件(展示引用)
          if (event.type === "sources" && Array.isArray(event.sources)) {
            setAssistantSources(assistantIndex, event.sources);
          }
          
          // 🔹 处理错误事件
          if (event.type === "error") {
            const m = messages.value[assistantIndex];
            messages.value.splice(assistantIndex, 1, {
              ...m,
              content: (m.content || "") + `\n[错误] ${event.message}`,
            });
          }
        } catch {
          // 解析失败忽略
        }
      }
    }
  } finally {
    isGenerating.value = false;
    persistCurrentSession();  // 保存会话到本地
  }
}

逐字追加实现:

// web/src/App.vue 第 863-870 行
function appendStreamToken(assistantIndex: number, token: string) {
  const list = messages.value;
  const msg = list[assistantIndex];
  if (!msg || msg.role !== "assistant") return;
  
  // 使用 splice 替换整条消息,触发 Vue 响应式更新
  list.splice(assistantIndex, 1, {
    ...msg,
    content: (msg.content || "") + token,
  });
}

为什么用 splice 而不是直接修改?

  • 强制触发更新:Vue 3 对数组下标的响应式有限制,splice 能确保触发视图更新
  • 避免 Bug:直接用 indexOf 找旧对象会失败(第一次 splice 后引用已不在数组中)

三、数据流向全景图

┌──────────────────────────────────────────────────────────────────────┐
│                         用户操作流程                                  │
└──────────────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────────────┐
│  1. 用户上传文档(App.vue:912-937)                                   │
│     - 选择文件 → 读取内容 → POST /api/rag/upload                      │
│     - 请求体:{ title: "文档.md", content: "全文内容" }               │
└──────────────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────────────┐
│  2. 后端接收(index.js:317-332)                                      │
│     - 验证参数 → 调用 ingestUpload                                    │
└──────────────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────────────┐
│  3. 智能分块(rag.js:75-104)                                         │
│     - 按空行拆段落 → 滑窗切长段 → 合并短段                            │
│     - 输出:["段落 1", "段落 2", ...]                                 │
└──────────────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────────────┐
│  4. 存储到 SQLite(rag.js:272-301 + db.js:40-49)                     │
│     - 写入 rag_chunks 表                                              │
│     - 同时加载到内存索引 ragChunks[]                                  │
│     - 可选:调用 OpenAI API 生成 embedding                           │
└──────────────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────────────┐
│  5. 用户发起对话(App.vue:939-1060)                                  │
│     - 勾选「启用 RAG」→ POST /api/chat/stream                         │
│     - 请求体:{ messages: [...], useRag: true }                       │
└──────────────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────────────┐
│  6. 检索相关片段(index.js:367-372 + rag.js:241-265)                │
│     - 提取最后一条用户消息作为 query                                  │
│     - 双模式检索:向量优先 → BM25 回退                                │
│     - 应用相对阈值过滤,返回 topK 个相关片段                           │
└──────────────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────────────┐
│  7. 构建增强 Prompt(index.js:374-397)                               │
│     - 拼接 RAG 上下文到 system 消息                                    │
│     - 添加来源标注:【资料 X | 文件名#块号】                           │
│     - 注入约束指令:"优先根据给定资料回答"                             │
└──────────────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────────────┐
│  8. 调用大模型(index.js:399-421)                                    │
│     - POST /chat/completions(流式)                                  │
│     - temperature: RAG 时 0.2,否则 0.5                                │
│     - max_tokens: 默认 400                                            │
└──────────────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────────────┐
│  9. 流式返回(index.js:423-522)                                      │
│     - SSE 格式:text/event-stream                                    │
│     - 事件类型:start → rag_status → sources → token → done          │
└──────────────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────────────┐
│  10. 前端展示(App.vue:939-1060)                                     │
│      - 逐字追加:appendStreamToken                                   │
│      - 显示 RAG 命中提示                                               │
│      - 展示引用来源框                                                 │
└──────────────────────────────────────────────────────────────────────┘

四、关键技术点总结

4.1 智能分块策略

技术实现方式优势
段落感知先按 \n\n 拆分保持自然语言结构
滑窗切割重叠 90 字符(默认)避免语义断裂
短段合并相邻小块合并至 480 字符减少碎片化
中英混合正则分词 + CJK 字符单独处理支持多语言场景

4.2 双模式检索

                    ┌─────────────┐
                    │  用户 Query   │
                    └──────┬──────┘
                           │
              ┌────────────┴────────────┐
              │                         │
    ┌─────────▼─────────┐     ┌────────▼────────┐
    │   向量检索模式     │     │   BM25 检索模式   │
    │ (需配置 Embedding)│     │  (始终可用)    │
    └─────────┬─────────┘     └────────┬────────┘
              │                         │
              └─────────┬───────────────┘
                        │
              ┌─────────▼─────────┐
              │  相对阈值过滤      │
              │ (最高分的 22%)    │
              └─────────┬─────────┘
                        │
              ┌─────────▼─────────┐
              │  返回 TopK 结果     │
              └───────────────────┘

优先级逻辑:

  1. RAG_EMBEDDING=1OPENAI_API_KEY 已配置 → 用语义向量检索
  2. 若向量检索失败或无 embedding 数据 → 降级到 BM25
  3. 两种模式都使用相同的阈值过滤和 TopK 限制

4.3 持久化设计

双层存储架构:

层级存储介质用途访问速度
热数据内存数组 ragChunks[]检索时直接访问微秒级
冷数据SQLite rag_chunks服务重启后恢复毫秒级

启动流程:

// rag.js:32-57
function loadRagFromDatabase() {
  const rows = db.prepare("SELECT ... FROM rag_chunks").all();
  for (const r of rows) {
    ragChunks.push({
      id: r.chunk_id,
      docId: r.doc_id,
      text: r.text,
      embedding: JSON.parse(r.embedding_json),
    });
  }
}
loadRagFromDatabase();  // 模块加载时立即执行

优势:

  • 快速启动:无需重新上传文档
  • 快速检索:内存操作,无需每次查库
  • 数据可靠:SQLite 保证持久化

4.4 流式体验优化

SSE 事件序列:

时间轴 →
│
├─ 0ms      [start]           → 显示"模型思考中"
├─ 100ms    [rag_status]      → "RAG 已命中 3 个片段"
├─ 150ms    [sources]         → 展示引用来源框
├─ 200ms    [token] "你"      → 逐字显示开始
├─ 400ms    [token] "好"
├─ 600ms    [token] "!"
├─ ...      ...
└─ 3200ms   [done]            → 标记结束,记录日志

前端更新策略:

// ❌ 错误做法:直接修改对象属性(可能不触发响应式)
messages.value[assistantIndex].content += token;

// ✅ 正确做法:splice 替换整个对象
messages.value.splice(assistantIndex, 1, {
  ...msg,
  content: (msg.content || "") + token,
});

五、环境配置说明

5.1 基础配置(必需)

# server/.env
OPENAI_API_KEY=sk-xxxxx              # 你的 API Key
OPENAI_BASE_URL=https://api.openai.com/v1  # 或兼容网关
DEFAULT_MODEL=gpt-4o-mini            # 默认使用的模型
PORT=3000                            # 后端端口

5.2 RAG 专用配置(可选)

# 分块参数
RAG_CHUNK_SIZE=480                   # 每块最大字符数
RAG_CHUNK_OVERLAP=90                 # 滑窗重叠字符数

# 检索参数
RAG_TOP_K=3                          # 返回最相关的 K 个片段
RAG_MIN_RELATIVE_SCORE=0.22          # 相对阈值(最高分的 22%)

# 向量嵌入(需 OPENAI_API_KEY)
RAG_EMBEDDING=1                      # 1=启用,0=禁用
RAG_EMBEDDING_MODEL=text-embedding-3-small

# 数据库路径(默认:server/data/copilot.db)
DATABASE_PATH=./data/copilot.db

5.3 配置影响说明

参数调大效果调小效果推荐范围
RAG_CHUNK_SIZE上下文更完整,但可能包含噪声片段更精确,但可能丢失上下文300-800
RAG_CHUNK_OVERLAP连续性更好,但增加冗余减少重复,但可能断裂50-150
RAG_TOP_K引用更丰富,但可能稀释重点聚焦核心,但可能遗漏2-5
RAG_MIN_RELATIVE_SCORE更多结果(降低门槛)更少结果(提高门槛)0.1-0.3

六、常见问题与优化建议

6.1 检索质量不佳?

症状: RAG 命中的片段与问题不相关

排查步骤:

  1. 检查分块大小是否合理(过长会包含噪声,过短会丢失上下文)
  2. 调整 RAG_MIN_RELATIVE_SCORE(降低阈值以纳入更多候选)
  3. 若启用了 Embedding,确认 API 调用成功(查看后端日志)

优化方向:

  • 尝试不同的 RAG_CHUNK_SIZE(如 300/500/800)
  • 增加 RAG_TOP_K 到 5,观察是否有改善
  • 考虑在上传前对文档进行预处理(去除无关内容)

6.2 回答仍然泛泛而谈?

症状: 即使开启了 RAG,回答还是没有引用文档内容

可能原因:

  1. 检索到的片段质量不高(BM25 分数低)
  2. 模型没有遵循 system 消息中的约束

解决方案:

  • 在 Prompt 模板中加强约束语气
  • 降低 temperature 到 0.1(让模型更确定)
  • 检查检索日志,确认命中的片段确实相关

6.3 性能优化建议

场景 1:上传大文件后检索变慢

  • 💡 优化: 限制单个文件的分块数量(如在 ingestUpload 中设置上限)
  • 💡 替代: 对超长文档进行预分割(如按章节)

场景 2:多用户并发上传导致内存占用高

  • 💡 优化: 定期清理长时间未访问的 ragChunks(LRU 策略)
  • 💡 架构升级: 迁移到专业向量数据库(如 Pinecone、Weaviate)

场景 3:Embedding 生成耗时过长

  • 💡 优化: 异步生成(上传后立即返回,后台批量 embedding)
  • 💡 替代: 使用本地嵌入模型(如 sentence-transformers)

七、扩展阅读

7.1 BM25 算法原理

BM25(Best Matching 25)是一种经典的关键词匹配算法,核心思想:

score(D,Q)=i=1nIDF(qi)TF(qi,D)(k1+1)TF(qi,D)+k1(1b+bDavgdl)\text{score}(D, Q) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{\text{TF}(q_i, D) \cdot (k_1 + 1)}{\text{TF}(q_i, D) + k_1 \cdot (1 - b + b \cdot \frac{|D|}{\text{avgdl}})}

其中:

  • TF(qi,D)\text{TF}(q_i, D):词频(term frequency)
  • IDF(qi)\text{IDF}(q_i):逆文档频率(inverse document frequency)
  • k1k_1:词频饱和参数(本项目默认 1.5)
  • bb:长度惩罚参数(本项目默认 0.75)
  • D|D|:当前文档长度
  • avgdl\text{avgdl}:平均文档长度

7.2 余弦相似度计算

向量检索使用余弦相似度衡量两个向量的方向一致性:

cosine_similarity(A,B)=ABAB=i=1nAiBii=1nAi2i=1nBi2\text{cosine\_similarity}(A, B) = \frac{A \cdot B}{||A|| \cdot ||B||} = \frac{\sum_{i=1}^{n} A_i B_i}{\sqrt{\sum_{i=1}^{n} A_i^2} \cdot \sqrt{\sum_{i=1}^{n} B_i^2}}

取值范围:[-1, 1]

  • 1:完全相同方向
  • 0:正交(无相关性)
  • -1:完全相反方向

本项目只保留正值(score > 0),并应用相对阈值过滤。

7.3 下一步优化方向

短期(P2):

  • 支持更多文件格式(PDF、Word、Excel)
  • 添加文档权限管理(多用户隔离)
  • 实现增量更新(修改文档后重新 embedding)

中期(P3):

  • 接入专业向量数据库(提升检索性能)
  • 实现混合检索加权(向量 + BM25 综合评分)
  • 添加引用质量评估(自动/人工反馈)

长期(P4):

  • 支持多轮对话的上下文检索
  • 集成知识图谱(结构化数据 + 非结构化数据)
  • 实现自动摘要和关键句提取

八、总结

本项目的 RAG 功能实现了完整的文档上传、分块、存储、检索、增强生成链路,具备以下特点:

端到端闭环:从前端 UI 到后端算法,全链路自研
双模检索:向量语义 + BM25 关键词,兼顾准确性与鲁棒性
持久化存储:SQLite + 内存双层架构,平衡性能与可靠性
可观测性强:详细的日志记录、成本估算、命中率统计
可扩展性好:模块化设计,便于后续接入向量数据库、多租户等特性

适合场景:

  • 企业内部知识库问答
  • 产品文档智能客服
  • 个人笔记检索助手
  • 学术论文辅助阅读

技术栈亮点:

  • 前端:Vue 3 + TypeScript + Element Plus
  • 后端:Node.js + Express + better-sqlite3
  • 算法:BM25 + OpenAI Embedding + GPT 系列模型
  • 部署:Docker + PM2 + 国内云服务商

通过本项目,你可以系统学习 RAG 技术的完整实现细节,为构建生产级 AI 应用打下坚实基础。