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-49 行
CREATE 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_id | TEXT | 主键,唯一标识一个分块 | "5-3"(第 5 号文档的第 3 块) |
doc_id | INTEGER | 文档归属 ID | 5 |
chunk_index | INTEGER | 分块顺序 | 3 |
title | TEXT | 原始文件名 | "产品手册.md" |
text | TEXT | 实际文本内容 | "第一章:产品介绍..." |
embedding_json | TEXT | 向量嵌入(JSON 数组) | "[0.012, -0.045, ...]" |
created_at | INTEGER | 时间戳 | 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️⃣ 提取 Query | latestUserMessage = ... | 取对话历史中最后一条用户消息 |
| 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
- 🎯 避免低质结果:即使有很多候选,也只返回真正相关的
- 🔧 可调参数:通过
.env的RAG_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
}),
});
关键参数说明:
| 参数 | 值 | 作用 |
|---|---|---|
stream | true | 启用流式输出,实时返回 token |
temperature | RAG 时 0.2,否则 0.5 | RAG 模式下更确定,减少幻觉 |
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 结果 │
└───────────────────┘
优先级逻辑:
- 若
RAG_EMBEDDING=1且OPENAI_API_KEY已配置 → 用语义向量检索 - 若向量检索失败或无 embedding 数据 → 降级到 BM25
- 两种模式都使用相同的阈值过滤和 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 命中的片段与问题不相关
排查步骤:
- 检查分块大小是否合理(过长会包含噪声,过短会丢失上下文)
- 调整
RAG_MIN_RELATIVE_SCORE(降低阈值以纳入更多候选) - 若启用了 Embedding,确认 API 调用成功(查看后端日志)
优化方向:
- 尝试不同的
RAG_CHUNK_SIZE(如 300/500/800) - 增加
RAG_TOP_K到 5,观察是否有改善 - 考虑在上传前对文档进行预处理(去除无关内容)
6.2 回答仍然泛泛而谈?
症状: 即使开启了 RAG,回答还是没有引用文档内容
可能原因:
- 检索到的片段质量不高(BM25 分数低)
- 模型没有遵循 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)是一种经典的关键词匹配算法,核心思想:
其中:
- :词频(term frequency)
- :逆文档频率(inverse document frequency)
- :词频饱和参数(本项目默认 1.5)
- :长度惩罚参数(本项目默认 0.75)
- :当前文档长度
- :平均文档长度
7.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 应用打下坚实基础。